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.

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:
- 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.
- Valida los datos estructurados en tres capas:
- 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
- Google Rich Results Test: que realiza la comprobación utilizando la herramienta de comprobación de fragmentos destacados
- Evalúa los resultados usando dos LLM en local de manera independiente.
- 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.
- 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:
- Sintaxis JSON
- 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)
- 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,legalNameausentes).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:
- La primera vez de todas, ejecuto
check_rich_results_authenticated.py --loginen modo visible y hago login a mano con una cuenta de Google. - 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 comorich-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:Productes adecuado para fichas de catálogo con review embebido. Sinoffers/pricees válido en editorial; sinaggregateRatingno hay estrellas en SERP.skues opcional. Añadir:aggregateRatingpara estrellas,publisherrecomendable.
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 arrayassociatedMediacontiene 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 encodigo-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 encompetidor_{1|2|3}_jsonld_block_N.jsony 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