Back to projects
L3 · pSEO Live

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.

0

Service lines

Web · RRSS · Gráfico · Interiorismo

0

Cities seeded

Castellón province dataset

0

Max landings

Combinatorial pSEO surface

0

Live now

Top-5 cities × 4 lines

0

Slots per page

Hero · 3 bloques · CTA

0

Schema layers

LocalBusiness · Service · Breadcrumb

0

Banned phrases

Anti-AI-slop guardrails

0

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.

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 type

undf_servicio_local

  • · title, editor, excerpt
  • · thumbnail, custom-fields
  • · show_in_rest: true
  • · hierarchical: false
  • · rewrite: false (custom rule)

Taxonomy

4 terms

undf_linea_servicio

  • · desarrollo-web
  • · gestion-rrss
  • · diseno-grafico
  • · diseno-interiores
  • → 4 service hubs linked

Taxonomy

15 terms

undf_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 sanitised
hero_kicker
3–6 words · no punctuation
Line + comarca/city tag
hero_lead
1–2 <p>, ≤220ch each
Lead with local context
block_1_title
4–8 words · nominal
First angle on the service
block_1_body
1–2 <p>, ≤420ch total
Develops angle 1
block_2_title
4–8 words · nominal
Second angle
block_2_body
1–2 <p>, ≤420ch total
Develops angle 2
block_3_title
4–8 words · nominal
Local-arrival angle
block_3_body
1–2 <p>, ≤420ch total
How we work in their city
cta_label
2–4 words · imperative
Primary action button

AI pipeline

From command to indexed page.

Six idempotent stages. Run twice — segunda ejecución es un no-op salvo --force.

  1. 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) y ensure_post() crea el post draft con título "{Línea} en {Ciudad}".

  2. 02 stage

    Build prompt

    Carga system.base.md + lineas/{slug}.md y 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.

  3. 03 stage

    Call model

    Anthropic o Gemini según --model. Wrapped en http_with_retry(): backoff exponencial 5s → 15s → 45s → 90s en 429/5xx. Rate-limit configurable (default 5s entre llamadas para Gemini free tier 15rpm).

  4. 04 stage

    Parse + validate

    Strip code-fences si llegan. json_decode. Validar todas las expected_keys presentes y no vacías. Sanitizar HTML con wp_kses al subset permitido.

  5. 05 stage

    Persist

    Si existe contenido humano previo y no hay backup → guardar en undf_local_slots_human antes de sobrescribir. Persistir slots en undf_local_slots. Marcar generated_by con el modelo + timestamp.

  6. 06 stage

    Publish + index

    Si --publish: post pasa a publish y 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.

01 rule

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.

02 rule

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.

03 rule

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.

04 rule

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".

05 rule

HTML whitelist

Salida sanitizada con wp_kses: solo <p>, <strong>, <em>. Prohibidos <h*>, <ul>, <a>, <img>, <br>. La maquetación queda intacta.

06 rule

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