Orquestando subagentes y LLMs locales para validar datos estructurados

Publicado por Lino Uruñuela el 2026-04-17

 

Si habéis leído el artículo que publiqué sobre la arquitectura de agentes para SEO, sabréis que estoy creando una estructura de skills donde cada skill resuelve una tarea SEO concreta. Hoy quería enseñaros, paso a paso, cómo funciona una de esas skills por dentro: auditoria-schemas-seo.

Vamos a ver la ejecución real, el flujo de decisiones que toma; para ello le voy a pasar al agente esta URL:

https://www.runnea.com/zapatillas-running/adidas/adizero-boston-13/1041450/

Y vamos a ver cada comando que ejecuta, cada prompt que construye, cada subagente que delega, hasta escupir el informe final. Pensad que toda la lógica del workflow ya está dentro del SKILL.md; yo solo le paso la URL, y el agente orquesta todo.

 

Skill para analizar datos estructurados

 


Qué hace este skill y por qué no es un simple validador

Aquí están, a grandes rasgos, las cuatro tareas que realiza este SOP de auditoria-schemas-seo:

  1. Extrae los datos estructurados de la URL con JavaScript activado (porque muchos CMS los inyectan por JS y sin eso no los ves), también comprobará si se visualiza al acceder con JS desactivado; si no lo detecta, avisa.
  2. Valida los datos estructurados en tres capas:
    1. sintaxis JSON: validador que ejecuta en local y que comprueba el formato comprobando la documentación de Google sobre datos estructurados para verificar que cumple todos los requisitos de Google
    2. Google Rich Results Test: que realiza la comprobación utilizando la herramienta de comprobación de fragmentos destacados
    3. Evalúa los resultados usando dos LLM en local de manera independiente.
  3. Compara con los datos estructurados de los competidores: para ello primero realiza una o varias búsquedas en Google y obtiene el marcado de datos de cada uno de los resultados en el TOP 3 de la SERP. Que nos sirve para detectar si hay algún tipo de marcado de datos que tienen los competidores y que no tiene la URL procesada.
  4. Genera un informe con snippets en formato diff listos para pegar en producción.

 

Bajo la ruta Proyectos/STATE_OF_THE_ART/skills/auditoria-schemas/SKILL.md está la definición de este skill, que está en formato YAML para que Claude Code la indexe:

---
name: auditoria-schemas-seo
description: >
  WHEN: auditoría y optimización de datos estructurados (JSON-LD / Microdata / RDFa)
  de una o varias URLs — extracción con JS activado, validación sintáctica +
  requisitos Google + Google Rich Results Test, análisis LLM con dos modelos
  independientes, comparación vs TOP-3 competidores, recomendaciones con snippets
  de código en formato diff.
  NOT WHEN: solo quieres un parse rápido sin validación (usa parse_schemas.py
  directamente).
---

El WHEN y el NOT WHEN son lo que le permite a Claude Code decidir si debe usar este skill o si debería ejecutar otra herramienta, comando, subagente, etc. El indicar al agente cuándo sí y cuándo no usar este skill, aunque parece una chorrada, hace que el agente no utilice un montón de recursos para preguntas tontas.


Herramientas que usa este skill

Este skill tiene declarado un buen arsenal de herramientas concretas. Si alguna no existe o no responde, tiene definidos fallbacks que son bastante más deterministas y así no me pregunta nada y no se bloquea si no estoy:

Herramienta Ruta Para qué
render_url.py ./venv/bin/python scripts/render_url.py HTML renderizado con Chrome headless (JS ejecutado)
shot-scraper ./venv/bin/shot-scraper Fallback ligero si render_url falla
parse_schemas.py ./venv/bin/python3 scripts/parse_schemas.py Lista todos los bloques JSON-LD y Microdata de un HTML
validate_jsonld.py ./venv/bin/python3 scripts/validate_jsonld.py Valida campos requeridos por Google sin LLM ni internet
check_rich_results_authenticated.py ./venv/bin/python scripts/check_rich_results_authenticated.py Rich Results Test con sesión persistente
check_rich_results_test.py ./venv/bin/python scripts/check_rich_results_test.py Fallback a validator.schema.org sin login
google_search_simple.py ./venv/bin/python scripts/google_search_simple.py SERP completa con orgánicos, PAA y AI Overview
llm ./venv/bin/llm Modelos LLM locales vía Ollama (GPU)

 

También utiliza tres subagentes para que la ejecución, que puede ser redundante y con mucho código y/o dato, se aísle del contexto principal de la tarea, así no se satura su ventana de contexto con datos innecesarios:

  • schema-auditor: fetch + parse + validación de una o varias URLs.
  • rich-results-auditor: valida contra Google Rich Results Test con sesión persistente.
  • serp-explorer: obtiene las SERP (una a una, nunca en paralelo para que no me baneen) y extrae lo que se le indique en el prompt.

Delegar a un subagente es importante porque el HTML renderizado puede ocupar 0,5 MB, y si tienes que mirar 4 URLs seguidas añadiendo el contenido al mismo prompt / tarea te comerás la ventana de contexto en la primera página.


Cada URL procesada por un subagente

El prompt inicial es sencillo:

audita los schemas de https://www.runnea.com/zapatillas-running/adidas/adizero-boston-13/1041450/

El agente activará auditoria-schemas-seo e irá ejecutando los pasos del SOP en orden.

Paso 1: Extracción del contenido de una URL con JavaScript activado

Primero renderiza la URL con Chrome headless. Con JS activado porque muchos sites inyectan parte de los datos estructurados usando JS.

./venv/bin/python scripts/render_url.py \
  "https://www.runnea.com/zapatillas-running/adidas/adizero-boston-13/1041450/" \
  > Proyectos/STATE_OF_THE_ART/codigo-usado/runnea_boston13.html

 

En este ejemplo el HTML renderizado pesa 544 KB. El agente ejecuta un comando grep -c 'application/ld+json' que en este caso devuelve 2 coincidencias para el patrón '<script>', y aunque esto no sea del todo correcto al parsear el HTML por parte del agente usando diferentes regex, encuentra 5 bloques JSON-LD.

El script de extracción que ejecuta el agente es este (lo genera en tiempo real usando python3 -c, este no necesita guardarlo):

import re, json
html = open('Proyectos/STATE_OF_THE_ART/codigo-usado/runnea_boston13.html').read()
blocks = re.findall(
    r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
    html, re.DOTALL
)
for i, b in enumerate(blocks):
    fname = f'Proyectos/STATE_OF_THE_ART/codigo-usado/jsonld_block_{i}.json'
    with open(fname, 'w') as f:
        f.write(b.strip())

Y obtiene:

Guardado: jsonld_block_0.json (444 bytes)  — @type: Organization
Guardado: jsonld_block_1.json (97 bytes)   — @type: WebSite
Guardado: jsonld_block_2.json (468 bytes)  — @type: BreadcrumbList
Guardado: jsonld_block_3.json (2629 bytes) — @type: Product
Guardado: jsonld_block_4.json (1910 bytes) — @type: FAQPage
Total bloques: 5

 

Paso 2: Validación

Validar un JSON-LD no es solo validar el formato JSON y decir "es válido":

La validación NO puede basarse únicamente en el conocimiento del propio LLM, sino que usa herramientas deterministas y en un orden concreto:

  1. Sintaxis JSON
  2. Validador que se ejecuta en local y que lee los requisitos que Google indica en su documentación (la cual he descargado previamente en markdown)
  3. Comprobar errores usando la herramienta oficial para comprobar datos estructurados de Google.

2.1. Sintaxis

La primera comprobación es fácil y sencilla:

for i in 0 1 2 3 4; do
  python3 -m json.tool Proyectos/STATE_OF_THE_ART/codigo-usado/jsonld_block_$i.json \
    > /dev/null && echo "Bloque $i OK" || echo "Bloque $i ERROR sintaxis"
done

Que comprueba los 5 bloques de datos estructurados JSON.

 

2.2. Validador local contra requisitos de Google

Es un script propio, que guardo en scripts/validate_jsonld.py, y que comprueba cada campo requerido y recomendado por Google para cada @type que haya en los datos estructurados; no utiliza ni LLM ni necesita acceder a internet.

Se lo pasas:

python3 scripts/validate_jsonld.py Proyectos/STATE_OF_THE_ART/codigo-usado/jsonld_block_3.json

 y te dice (ejemplo para el bloque Product):

── Bloque #1: @type = Product ✓ (Google soportado)
   ⚠️  WARNING  — Propiedad recomendada ausente: 'offers'
   ⚠️  WARNING  — Propiedad recomendada ausente: 'aggregateRating'
   ⚠️  WARNING  — Propiedad recomendada ausente: 'sku'

RESUMEN: 1 bloque(s) | 0 errores | 3 advertencias

 

El mismo script sobre los otros 4 bloques encontrados en el código:

  • Organization: 3 warnings (contactPoint, sameAs, legalName ausentes).
  • WebSite: no está en la lista de tipos soportados para rich results (Sitelinks SearchBox está deprecado). No es bloqueante.
  • BreadcrumbList: limpio.
  • FAQPage: limpio.

 

2.3. Google Rich Results Test — el dictamen autoritativo

Aunque el script de validación local contrasta los datos con lo que hay en la documentación de Google, para poder comprobar cómo Google lo interpreta debemos usar la herramienta Rich Results Test

He creado un script que utiliza un perfil de Chrome persistente en scripts/perfil_rich_results/. El flujo es:

  1. La primera vez de todas, ejecuto check_rich_results_authenticated.py --login en modo visible y hago login a mano con una cuenta de Google.
  2. A partir de ahí, el agente puede ejecutar el script en headless reutilizando esa sesión, sin volver a pedir nada.

 

Ahora es cuando el agente principal delega al subagente rich-results-auditor la siguiente tarea, así:

"Valida en Google Rich Results Test el bloque jsonld_block_3.json. Guarda el screenshot como rich-results-product.png. Ejecuta headless con --no-visible-fallback. Devuélveme items elegibles, errores, warnings y el exit code."

El subagente ejecuta este comando en segundo plano:

./venv/bin/python scripts/check_rich_results_authenticated.py \
  --file Proyectos/STATE_OF_THE_ART/codigo-usado/jsonld_block_3.json \
  --screenshot-dir Proyectos/STATE_OF_THE_ART/codigo-usado/ \
  --no-visible-fallback

 

Y el subagente devuelve:

Items elegibles: 1 Product válido + 1 Review válido (2 elementos aptos para rich results).
Errores reportados por Google: ninguno.
Advertencias: ninguna.
Screenshot: codigo-usado/rich-results-product.png

Fijaos en la diferencia que hay entre el validador local, que indicaba 3 warnings, en comparación con la herramienta para datos estructurados de Google, que indica 0. Esto es porque se están evaluando cosas diferentes:

  • El script local pregunta: "¿tienes todas las propiedades que Google recomienda?"
  • La herramienta de Google pregunta: "¿este schema es elegible para un rich result?"

 

2.4. Análisis semántico con dos LLM ejecutados en local de manera independiente

Utilizo la evaluación de dos LLM distintos porque es la forma más barata que he encontrado de pillar falsos positivos. Un solo modelo te puede inventar un error que no existe, o pasarte por alto uno real, pero siendo dos es mucho más difícil que suceda.

Primer LLM: qwen3.5:9b genera el prompt al vuelo

timeout 60 /home/lino/Programas/venv/bin/llm -m qwen3.5:9b - << 'ENDPROMPT'
Eres SEO técnico. Dado este JSON-LD de ficha de producto (zapatilla running)
en runnea.com, indica:
1) ¿Es @type:Product el más adecuado? ¿Debería ser Review, ProductReview
   o Product con review embebido?
2) ¿Son coherentes los valores con lo que Google espera?
3) ¿Qué propiedades añadirías sí o sí para elegibilidad de rich result de
   producto en un comparador editorial?

NO valides sintaxis. Sé conciso, máximo 200 palabras.

=== JSON-LD ===
[...contenido del jsonld_block_3.json...]
ENDPROMPT

 

Respuesta:

@type:Product es adecuado para fichas de catálogo con review embebido. Sin offers/price es válido en editorial; sin aggregateRating no hay estrellas en SERP. sku es opcional. Añadir: aggregateRating para estrellas, publisher recomendable.

Segundo LLM: gemma4:e4b, con el análisis del primero en el prompt, pidiéndole que sea crítico:

ANALISIS1="[respuesta literal de qwen]"
JSON_LD=$(cat Proyectos/STATE_OF_THE_ART/codigo-usado/jsonld_block_3.json)

timeout 120 /home/lino/Programas/venv/bin/llm -m gemma4:e4b - << ENDPROMPT
Eres SEO técnico. Revisa de forma independiente este JSON-LD y el análisis
previo de otro modelo. Tu misión:
1) Confirmar o refutar cada hallazgo del análisis previo.
2) Identificar cualquier problema adicional omitido.
3) Verificar que los errores reportados existen realmente en el JSON
   (especialmente '@type' vs 'type' — leer el JSON literal).

Sé crítico y preciso.

=== JSON-LD a analizar ===
$JSON_LD

=== Análisis previo a revisar ===
$ANALISIS1
ENDPROMPT

Vemos cómo Gemma confirma casi todo lo que había dicho el LLM de Qwen, pero añade cosas que el primer LLM no vio:

Nesting de ImageGallery: Hay un error estructural. El array associatedMedia contiene elementos que usan @type:ImageGallery (con URLs) en lugar de los tipos de media adecuados (@type:ImageObject). Esto invalida el bloque.

Si miro el JSON original:

"hasPart": {
  "@type": "ImageGallery",
  "associatedMedia": [
    { "@type": "ImageGallery", "url": "...", "name": "..." },
    { "@type": "ImageGallery", "url": "...", "name": "..." },
    ...
  ]
}

El contenedor es una ImageGallery, vale, pero cada ítem dentro también es una ImageGallery

Por este tipo de cosas creo que es recomendable validar con dos modelos.

Paso 3: Comparación de datos estructurados que tiene la competencia

El agente lanza una monitorización de las SERP usando una KW acorde al contenido de la URL. Sabe de qué trata porque la ha procesado unas cuantas veces entre obtener HTML, JSON, etc. La ejecución nunca la hago en paralelo porque si no te cae un baneo en cero coma.

El agente llama al subagente serp-explorer usando:

"Lanza SERP con google_search_simple.py "adidas adizero boston 13 review" --hl es, guarda el JSON crudo en codigo-usado/serp_boston13.json, devuélveme los 5 primeros orgánicos, si hay AI Overview / PAA / shopping, y rich snippets visibles. Excluye runnea de la lista de competidores pero anota su posición."

Respuesta del subagente:

Pos Dominio Título
1 roadrunningreview.com Adidas Adizero Boston 13, análisis
2 runnea.com Adidas Adizero Boston 13 review (URL objetivo)
3 runrepeat.com Cut in half: Adidas Adizero Boston 13 Review (2025)
4 atmosferasport.es adidas Adizero Boston 13: la mixta que te hará volar
5 magazine.365rider.com Confort y velocidad equilibrados

Sin AI Overview. Sin PAA. Sin carrusel de shopping. Vídeos de YouTube Shorts en posiciones 6 y 10.

 

Ahora el agente llama al otro subagente, schema-auditor, que se encarga de extraer los schemas de los tres competidores en paralelo, pero internamente, aislado del contexto principal:

"Extrae JSON-LD de estas 3 URLs con render_url.py + parse_schemas.py, guarda los bloques en competidor_{1|2|3}_jsonld_block_N.json y dime: por cada URL, qué @type presenta y si tiene Product/Review con aggregateRating/offers/sku/brand."

El subagente devuelve:

  • roadrunningreview (pos 1): Organization + NewsArticle + VideoObject. Sin Product, sin Review estructurado, sin aggregateRating, sin offers. Solo NewsArticle editorial.
  • runrepeat (pos 3): WebPage + BreadcrumbList + Product (x2). Claves en Product: name, brand, image, description, review. Tiene Product y brand, pero sin aggregateRating, sin offers, sin sku.
  • atmosferasport (pos 4): solo WebPage. Microdata adicional genérico. Ni Product ni Review estructurado.

 

Cuando el agente construye la tabla comparativa sale algo muy revelador:

Campo / Dominio runnea.com roadrunning (p1) runrepeat (p3) atmosfera (p4)
@type:Product ✅ (x2)
@type:Review embebido
reviewRating individual ✅ (8.9/10)
aggregateRating
offers / price
sku / gtin / mpn
brand
BreadcrumbList
FAQPage

Runnea es, objetivamente, quien tiene la implementación más completa del TOP-5: el único con FAQPage estructurado, y uno de los dos con Product + Review + Breadcrumb. Pero hay un dato brutal: ninguno de los cuatro tiene aggregateRating. Eso significa que el primero que lo ponga se lleva las estrellas en la SERP solo. Diferenciación visual a coste de 6 líneas de JSON.

 

Vale, igual es demasiado optimista, pero no está mal.

Paso 4: Generación del informe final y recomendaciones resaltadas con diff

El agente recopila todo lo anterior y genera el informe final. El formato es obligatorio según el output_schema del SKILL.md:

  • 7 secciones fijas (resumen, keyword/competidores, validación, competencia, tabla comparativa, recomendaciones, ideas).
  • Las recomendaciones tienen que estar en formato diff, con + para lo añadido y - para lo eliminado, como si lo abrieras en VSCode.
  • Cada diff lleva al pie una línea que indica qué validaciones hay que volver a pasar cuando se implemente.

Os pongo un extracto real del informe que ha generado el agente, con la recomendación que más me gusta — corregir el bug del ImageGallery que pilló el segundo LLM:

   "hasPart": {
     "@type": "ImageGallery",
     "associatedMedia": [
-      {
-        "@type": "ImageGallery",
-        "url": "https://static.runnea.com/images/.../boston-13-zapatillas-running.jpg",
-        "name": "Adidas Adizero Boston 13"
-      }
+      {
+        "@type": "ImageObject",
+        "contentUrl": "https://static.runnea.com/images/.../boston-13-zapatillas-running.jpg",
+        "name": "Adidas Adizero Boston 13"
+      }
     ]
   }

Y la de aggregateRating que le regala estrellas en la SERP:

   "brand": { "@type": "Brand", "name": "Adidas" },
   "url": "https://www.runnea.com/zapatillas-running/adidas/adizero-boston-13/1041450/",
+  "aggregateRating": {
+    "@type": "AggregateRating",
+    "ratingValue": "8.9",
+    "bestRating": "10",
+    "worstRating": "1",
+    "ratingCount": "1",
+    "reviewCount": "1"
+  },
   "review": { ... }

El fichero final (ver resultado) se guarda con nombre canónico:

Proyectos/STATE_OF_THE_ART/informes/StructuredData-2026-04-17-15-35-adidas-adizero-boston-13-runnea.com.md

Nombre fijo, fecha, hora, keyword y dominio. Así puedo comparar luego entre revisiones (diff informes/StructuredData-2026-04-17.md informes/StructuredData-2026-06-01.md) o listarlos por tipo (ls informes/StructuredData-*).

 

Ver el documento final que ha generado

Hasta la próxima

 




Lea otros artículos de Arquitectura de agentes para SEO

Últimos posts

Últimos comentarios


Lino
@Emil8ano, no son tokens, son caracteres... Pero estoy casi seguro que el limite de texto en cada llamada aumentará rápidamente.
Post: aNótame: extensión para guardar notas y generar resumenes usando Gemini de manera local

Emiliano
Gran idea! Pregunta. Los 8000 caracteres no son tokens no? Si es así ojo que sin 8000 entre entrada y salida. O sea si te comes 6000 de ent
Post: aNótame: extensión para guardar notas y generar resumenes usando Gemini de manera local

Lino
@spamloco a tí r hacerme ver que no soy al único que le importa :p A ver si nos vemos!
Post: ¿Cómo decide Google que URL debe rastrear?

Alejandro
Gracias Lino, siempre investigando un poco más allá.
Post: ¿Cómo decide Google que URL debe rastrear?

Lino
3,2,1... Gracias a ti Pedro!! y sí, parece que los humanos somos expertos en haciendo ruido cuando intentamos que alguien nos escuche... :p
Post: ¿Cómo decide Google que URL debe rastrear?

Pedro
1,2...1,2... probando. Gracias por el artículo, verdaderamente interesante ver cómo no paramos de generar ruido :)
Post: ¿Cómo decide Google que URL debe rastrear?

Lino
Funcionan!! Ahora solo tengo que generar engagement :D A ver si quito lo de avisar por Twitter... no sé cuántos años llevará sin funcio
Post: ¿Cómo decide Google que URL debe rastrear?

Juanan Carapapa
Yo también vengo a probar los comentarios, probando probando xD
Post: ¿Cómo decide Google que URL debe rastrear?

Lino2
Hola @errioxa que tal
Post: ¿Cómo decide Google que URL debe rastrear?

Lino2
Hola
Post: ¿Cómo decide Google que URL debe rastrear?