Back to projects
L3 · pSEO Live

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.

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}/.

Every landing is a Custom Post Type resolved via a rewrite rule from two crossed taxonomies.

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 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 — the second run is a no-op unless you pass --force.

  1. 01 stage

    Resolve targets

    cities × lineas matrix. For each combination, ensure_term() creates the location term if it doesn't exist (with geo term_meta) and ensure_post() creates the draft post with title "{Line} en {City}".

  2. 02 stage

    Build prompt

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

  3. 03 stage

    Call model

    Anthropic or Gemini per --model. Wrapped in http_with_retry(): exponential backoff 5s → 15s → 45s → 90s on 429/5xx. Configurable rate-limit (default 5s between calls for Gemini free tier 15rpm).

  4. 04 stage

    Parse + validate

    Strip code fences if they arrive. json_decode. Check every expected_key is present and non-empty. Sanitise HTML with wp_kses down to the allowed subset.

  5. 05 stage

    Persist

    If prior human content exists and there is no backup → save it to undf_local_slots_human before overwriting. Persist slots to undf_local_slots. Stamp generated_by with the model + timestamp.

  6. 06 stage

    Publish + index

    With --publish: post moves to publish and 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.

01 rule

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.

02 rule

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.

03 rule

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.

04 rule

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

05 rule

HTML whitelist

Output sanitised with wp_kses: only <p>, <strong>, <em>. Banned: <h*>, <ul>, <a>, <img>, <br>. Layout stays intact.

06 rule

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