003 · Technical case study
Agency theme
· pSEO.
Custom theme for a design studio with a programmatic landings engine that scales combinatorially service × city, AI-generated with strict guardrails against generic SEO slop.
- Role
- Solo engineer · Studio
- Year
- 2026
- Status
- Live · undf.agency
- Scope
- Theme · pSEO · AI pipeline
- Stack
- WordPress · PHP · Tailwind · GSAP
- Surface
- 4 lines × 15 cities = 60 max landings
By the numbers
The pSEO surface, quantified.
Counted from the live system — dataset, posts, prompts, schema layers.
Service lines
Web · RRSS · Gráfico · Interiorismo
Cities seeded
Castellón province dataset
Max landings
Combinatorial pSEO surface
Live now
Top-5 cities × 4 lines
Slots per page
Hero · 3 bloques · CTA
Schema layers
LocalBusiness · Service · Breadcrumb
Banned phrases
Anti-AI-slop guardrails
AI providers
Gemini default · Claude opt-in
URL architecture
/servicios/{line}/{city}/.
Every landing is a Custom Post Type resolved via a rewrite rule from two crossed taxonomies.
La Vall d'Uixó
31.900 · Sede
SEDECastellón de la Plana
170.888 · 24 km
Vila-real
51.367 · 17 km
Borriana
36.063 · 14 km
Vinaròs
29.390 · 94 km
Desarrollo de Software/Web
/desarrollo-web
/desarrollo-web/vall-duixo/
Live/desarrollo-web/castellon-de-la-plana/
Live/desarrollo-web/vila-real/
Live/desarrollo-web/borriana/
Live/desarrollo-web/vinaros/
Gestión de RRSS
/gestion-rrss
/gestion-rrss/vall-duixo/
Live/gestion-rrss/castellon-de-la-plana/
Live/gestion-rrss/vila-real/
Live/gestion-rrss/borriana/
Live/gestion-rrss/vinaros/
Diseño Gráfico
/diseno-grafico
/diseno-grafico/vall-duixo/
Live/diseno-grafico/castellon-de-la-plana/
Live/diseno-grafico/vila-real/
Live/diseno-grafico/borriana/
Live/diseno-grafico/vinaros/
Diseño de Interiores
/diseno-interiores
/diseno-interiores/vall-duixo/
Live/diseno-interiores/castellon-de-la-plana/
Live/diseno-interiores/vila-real/
Live/diseno-interiores/borriana/
Live/diseno-interiores/vinaros/
5 cities × 4 lines = 20 published. Dataset has 15 cities — combinatorial ceiling at 60.
Data model
CPT + 2 taxonomías + 9 slots.
Each landing is a post in the CPT undf_servicio_local, identified by the intersection of two taxonomies + 9 typed slots.
CPT
post typeundf_servicio_local
- · title, editor, excerpt
- · thumbnail, custom-fields
- · show_in_rest: true
- · hierarchical: false
- · rewrite: false (custom rule)
Taxonomy
4 termsundf_linea_servicio
- · desarrollo-web
- · gestion-rrss
- · diseno-grafico
- · diseno-interiores
- → 4 service hubs linked
Taxonomy
15 termsundf_localizacion
- · lat / lng
- · region (Castellón)
- · comarca
- · poblacion (INE)
- · codigos_postales
- · distancia_sede_km
- · es_sede (boolean)
- → all REST-exposed
undf_local_slots — typed JSON meta
9 keys · wp_kses sanitisedAI pipeline
From command to indexed page.
Six idempotent stages. Run twice — the second run is a no-op unless you pass --force.
- 01 stage
Resolve targets
cities × lineasmatrix. For each combination,ensure_term()creates the location term if it doesn't exist (with geo term_meta) andensure_post()creates the draft post with title"{Line} en {City}". - 02 stage
Build prompt
Loads
system.base.md+lineas/{slug}.mdand composes them. Builds a user payload with a JSON brief: line, locality (slug, name, region, comarca, population, postcode, distance, is_hq), current title, canonical, expected_keys. - 03 stage
Call model
Anthropic or Gemini per
--model. Wrapped inhttp_with_retry(): exponential backoff5s → 15s → 45s → 90son 429/5xx. Configurable rate-limit (default 5s between calls for Gemini free tier 15rpm). - 04 stage
Parse + validate
Strip code fences if they arrive.
json_decode. Check every expected_key is present and non-empty. Sanitise HTML withwp_ksesdown to the allowed subset. - 05 stage
Persist
If prior human content exists and there is no backup → save it to
undf_local_slots_humanbefore overwriting. Persist slots toundf_local_slots. Stampgenerated_bywith the model + timestamp. - 06 stage
Publish + index
With
--publish: post moves topublishand joins the sitemap.undf_reviewed = 1. Sitemap regenerates per request, no cache. URL inspection in GSC to accelerate discovery.
Flow
Data path.
From CSV to indexed JSON-LD
data/castellon-municipios.json ◀── INE dataset (15 cities)
│
▼
┌──────────────────────────────┐
│ ensure_term + ensure_post │ crea taxonomy term + draft CPT
│ (idempotent) │ con term_meta de geo
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ system.base.md │ voice rules · banned phrases
│ + lineas/<slug>.md │ line-specific guidance
│ + brief JSON (city facts) │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Gemini Flash | Claude Sonnet │ ◀── retry 5/15/45/90s · rate-limit 5s
│ JSON-only response │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ extract + validate + sanitize│ wp_kses · expected_keys check
│ (RuntimeException si falta) │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ undf_local_slots (post_meta) │ + backup undf_local_slots_human
│ undf_generated_by, _at │
└──────────────┬───────────────┘
│ --publish
▼
┌──────────────────────────────┐
│ post_status = publish │ ◀── entra en sitemap
│ wp_head: JSON-LD │ LocalBusiness · Service · Breadcrumb
└──────────────────────────────┘ Guardrails
Anti-AI-slop, by design.
The "pSEO" part is trivial; the hard part is making the pages not sound like the generic copy Google now penalises. Six hard rules in the system prompt.
Banned generic copy
The system prompt bans 8+ classic SEO-slop phrases: "soluciones a medida", "expertos apasionados", "impulsamos tu negocio", "transformación digital", "equipo multidisciplinar", "llevar al siguiente nivel", "marcar la diferencia", "pasión por lo que hacemos" and every variant.
Veracity rule
Only brief data + town facts are allowed (population, comarca, postcode, distance to HQ). No invention: no street names, plazas, events, industrial parks, companies, regional stats or references to specific clients.
Distance-aware copy
The prompt classifies distance to HQ in bands: 0 km (HQ) → mentions local presence. <30 km → in-person meetings. 30–100 km → remote + occasional visits. >100 km → remote with scheduled technical visits.
No fake metrics
Unbacked numeric promises are banned: "+40% ventas", "doblarás reservas", "Core Web Vitals verdes". Only process frames: "medimos antes y después", "ciclos de 2 semanas".
HTML whitelist
Output sanitised with wp_kses: only <p>, <strong>, <em>. Banned: <h*>, <ul>, <a>, <img>, <br>. Layout stays intact.
Strict JSON output
Model configured with response_mime_type: application/json. No code fences, no text before/after. If any expected slot is missing → throw RuntimeException("Slots faltantes"). Quality is deterministic or it fails.
Schema.org
Three JSON-LD layers.
Every landing emits an @graph with three types linked by @id.
@type
LocalBusiness
UNDF HQ · areaServed = city → containedInPlace = region. Geo coords from the term. Address with addressLocality + addressRegion + addressCountry.
@type
Service
Name = "{Line} — {City}". Provider points to LocalBusiness by @id. ServiceType = line name. AreaServed = city.
@type
BreadcrumbList
Home → Service hub (/consultoria-software, /diseno-grafico, etc.) → Local landing. 3 levels, canonical IDs.
// real output — wp_head action
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "LocalBusiness",
"@id": "https://undf.agency/servicios/desarrollo-web/vall-duixo/#localbusiness",
"name": "UNDF — Ingeniería Digital",
"areaServed": {
"@type": "City",
"name": "La Vall d'Uixó",
"containedInPlace": { "@type": "AdministrativeArea", "name": "Castellón" }
},
"geo": { "@type": "GeoCoordinates", "latitude": 39.8245, "longitude": -0.23 }
},
{
"@type": "Service",
"name": "Desarrollo de Software/Web — La Vall d'Uixó",
"provider": { "@id": "...#localbusiness" },
"areaServed": { "@type": "City", "name": "La Vall d'Uixó" }
},
{
"@type": "BreadcrumbList",
"itemListElement": [ /* Home → Hub → Landing */ ]
}
]
} CLI
Production commands.
The whole operation runs through one CLI. Idempotent, with dry-run and restore.
Vall d'Uixó + 3 nearby cities
4 cities × 4 lines = 16 landings · ~2-3 min
php tools/generate-local-landings.php \ --cities=vall-duixo,castellon-de-la-plana,borriana,benicassim \ --lineas=all \ --model=gemini-flash-latest \ --publish \ --rate-limit=5 \ --force
Top 5 Castellón municipalities
5 × 4 = 20 landings published · ~3-4 min
php tools/generate-local-landings.php \ --cities=castellon-de-la-plana,vila-real,borriana,vall-duixo,vinaros \ --lineas=all \ --publish
Dry-run to audit prompts first
Writes prompts to logs/prompts/ without hitting the API
php tools/generate-local-landings.php \ --cities=vall-duixo \ --lineas=all \ --dry-run
Idempotent
Re-running the same matrix without --force is a no-op for landings already generated with a human backup.
Audit-first
--dry-run writes the exact prompts to logs/prompts/ without touching the API. Useful before spending tokens.
Reversible
--restore reverts slots to the human backup (undf_local_slots_human) if the AI output doesn't convince.
Editorial shell
One template, four signatures.
All 60 landings use the same editorial shell. The service line injects its own typographic and copy "signature".
Shared shell
editorial-shell.php
- · header with kicker + numbered edition + region/postcode
- · hero h1 in Fraunces variable (opsz, SOFT, WONK)
- · lead with accent border-l
- · local fact-sheet aside (population, comarca, distance)
- · 3 title/body blocks
- · signature partial (per line)
- · CTA + breadcrumbs
Signatures (per line)
- › signature-desarrollo-web.php
- › signature-gestion-rrss.php
- › signature-diseno-grafico.php
- › signature-diseno-interiores.php
Each signature contributes its own tone-intro and visual block without duplicating the shell.
Stack
All in-tree.
Zero plugins · curated dependencies
Core
- WordPress
- PHP 8+
- Tailwind CSS 3.4
- PostCSS
- Autoprefixer
Content model
- Custom Post Type
- 2 taxonomies
- Term meta REST
- 9 typed slots
pSEO pipeline
- Bootstrap CLI
- Idempotent matrix
- JSON dataset
- Daily logs
AI
- Gemini Flash (default)
- Claude Sonnet (opt-in)
- Strict JSON mode
- HTTP retry + backoff
SEO surface
- JSON-LD schema.org
- LocalBusiness markup
- Dynamic sitemap
- Geo coordinates
Frontend
- Editorial shell
- GSAP 3.12 + ScrollTrigger
- Fraunces variable
- Per-line signatures