003 · Technical case study
Agency theme
· pSEO.
Tema custom para estudio de diseño con un motor de landings programáticas que escala combinatoriamente servicio × ciudad, generadas por IA con guardrails estrictos contra el SEO-slop genérico.
- Role
- Solo engineer · Studio
- Year
- 2026
- Status
- Live · undf.studio
- 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}/.
Cada landing es un Custom Post Type resuelto vía rewrite rule a partir de dos taxonomías cruzadas.
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.
Cada landing es un post del CPT undf_servicio_local, identificado por la intersección de dos taxonomías + 9 slots tipados.
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 — segunda ejecución es un no-op salvo --force.
- 01 stage
Resolve targets
Matriz
cities × lineas. Para cada combinación,ensure_term()crea el término de localización si no existe (con term_meta de geo) yensure_post()crea el post draft con título"{Línea} en {Ciudad}". - 02 stage
Build prompt
Carga
system.base.md+lineas/{slug}.mdy los compone. Construye payload usuario con brief JSON: línea, localidad (slug, nombre, región, comarca, población, CP, distancia, es_sede), título actual, canonical, expected_keys. - 03 stage
Call model
Anthropic o Gemini según
--model. Wrapped enhttp_with_retry(): backoff exponencial5s → 15s → 45s → 90sen 429/5xx. Rate-limit configurable (default 5s entre llamadas para Gemini free tier 15rpm). - 04 stage
Parse + validate
Strip code-fences si llegan.
json_decode. Validar todas las expected_keys presentes y no vacías. Sanitizar HTML conwp_ksesal subset permitido. - 05 stage
Persist
Si existe contenido humano previo y no hay backup → guardar en
undf_local_slots_humanantes de sobrescribir. Persistir slots enundf_local_slots. Marcargenerated_bycon el modelo + timestamp. - 06 stage
Publish + index
Si
--publish: post pasa apublishy entra en sitemap.undf_reviewed = 1. Sitemap se regenera por request, sin caché. URL inspection en GSC para acelerar descubrimiento.
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.
La parte "pSEO" es trivial; la parte difícil es que las páginas no suenen a copy genérico que Google penaliza. Seis reglas duras en el system prompt.
Banned generic copy
El system prompt veta 8+ frases típicas de SEO-slop: "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" y todas sus variantes.
Veracity rule
Solo se permiten datos del brief + facts del municipio (población, comarca, código postal, distancia a sede). Prohibido inventar: nombres de calles, plazas, eventos, polígonos, empresas, estadísticas regionales, referencias a clientes concretos.
Distance-aware copy
El prompt clasifica la distancia a sede en bandas: 0 km (sede) → menciona presencia local. <30 km → reuniones en persona. 30–100 km → remoto + visitas puntuales. >100 km → remoto con visita técnica concertada.
No fake metrics
Vetadas las promesas numéricas no respaldadas: "+40% ventas", "doblarás reservas", "Core Web Vitals verdes". Solo marcos de trabajo: "medimos antes y después", "ciclos de 2 semanas".
HTML whitelist
Salida sanitizada con wp_kses: solo <p>, <strong>, <em>. Prohibidos <h*>, <ul>, <a>, <img>, <br>. La maquetación queda intacta.
Strict JSON output
Modelo configurado con response_mime_type: application/json. Sin code-fences, sin texto antes/después. Si falta cualquier slot esperado → throw RuntimeException("Slots faltantes"). La calidad es determinista o falla.
Schema.org
Three JSON-LD layers.
Cada landing emite un @graph con tres tipos vinculados por @id.
@type
LocalBusiness
Sede UNDF · areaServed = ciudad → containedInPlace = región. Geo coords del término. Address con addressLocality + addressRegion + addressCountry.
@type
Service
Nombre = "{Línea} — {Ciudad}". Provider apunta a LocalBusiness por @id. ServiceType = nombre de la línea. AreaServed = ciudad.
@type
BreadcrumbList
Home → Hub de servicio (/consultoria-software, /diseno-grafico, etc.) → Landing local. 3 niveles, IDs canónicos.
// real output — wp_head action
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "LocalBusiness",
"@id": "https://undf.studio/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.
Toda la operación pasa por una única CLI. Idempotente, con dry-run y restore.
Vall d'Uixó + 3 ciudades próximas
4 ciudades × 4 líneas = 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 municipios de Castellón
5 × 4 = 20 landings publicadas · ~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 para auditar prompts antes
Escribe los prompts a logs/prompts/ sin llamar API
php tools/generate-local-landings.php \ --cities=vall-duixo \ --lineas=all \ --dry-run
Idempotent
Re-ejecutar la misma matriz sin --force es no-op para landings ya generadas con backup humano.
Audit-first
--dry-run escribe los prompts exactos a logs/prompts/ sin tocar la API. Útil antes de gastar tokens.
Reversible
--restore revierte slots al backup humano (undf_local_slots_human) si la generación IA no convence.
Editorial shell
One template, four signatures.
Las 60 landings usan el mismo shell editorial. La línea de servicio inyecta su propia "signature" tipográfica y de copy.
Shell común
editorial-shell.php
- · header con kicker + edición numérica + región/CP
- · hero h1 con Fraunces variable (opsz, SOFT, WONK)
- · lead con border-l accent
- · ficha local en aside (poblacion, comarca, distancia)
- · 3 bloques título/cuerpo
- · signature partial (per línea)
- · CTA + breadcrumbs
Signatures (per line)
- › signature-desarrollo-web.php
- › signature-gestion-rrss.php
- › signature-diseno-grafico.php
- › signature-diseno-interiores.php
Cada signature aporta su intro de tono + bloque visual propio sin duplicar el 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