Cómo Extraer Datos Schema.org y JSON-LD de Cualquier Página Web
Escribes selectores CSS para una página de producto. Funcionan durante dos semanas. Luego la web lanza un rediseño, el precio se mueve a un nuevo <span> y tu scraper devuelve None en silencio para cada fila.
Mientras tanto, esa misma página llevaba todo el tiempo su nombre, precio, moneda, disponibilidad y valoración en un bloque JSON limpio. Está ahí para Google, no para ti, pero nada te impide leerlo. Esta guía te enseña a encontrar esos datos Schema.org, extraerlos con Python, convertirlos en un registro limpio y gestionar los casos en los que fallan.
Qué Son los Datos Estructurados y Por Qué los Publican las Webs
Schema.org es un vocabulario compartido para describir cosas en la web: Product, Offer, JobPosting, Recipe, Article, Event, LocalBusiness y cientos más. Los buscadores lo leen para construir resultados enriquecidos, las estrellas, precios y etiquetas de stock que ves bajo algunos resultados. Las webs que se preocupan por el tráfico orgánico lo publican en sus páginas más importantes.
Existen tres sintaxis:
- JSON-LD. Un bloque
<script type="application/ld+json">con un documento JSON dentro. Es el formato más común hoy y el que recomienda Google. - Microdata. Atributos como
itemscope,itemtypeeitemproprepartidos por el HTML visible. Habitual en tiendas y plantillas más antiguas. - RDFa. La misma idea con atributos
vocab,typeofyproperty. Menos común.
Muchas páginas también incluyen meta etiquetas Open Graph (og:title, og:image, og:price:amount). No son Schema.org, pero sirven como alternativa para títulos e imágenes.
¿Por qué importa esto para el scraping? Los datos estructurados son la parte más estable de una página. Un rediseño cambia los nombres de clases, pero el equipo de SEO rara vez rompe el marcado que alimenta sus resultados enriquecidos. Un parser basado en él sobrevive a cambios de diseño que acaban con los scrapers basados en selectores.
Cómo Encontrarlos en una Página
Abre la página, mira el código fuente (no el árbol de elementos de DevTools, que muestra el DOM después de ejecutar JavaScript) y busca:
application/ld+jsonpara bloques JSON-LDitemprop=para Microdataproperty="og:para etiquetas Open Graph
Una página suele tener varios bloques JSON-LD: uno para la organización, otro para las migas de pan y otro para el producto. Para ver todo lo que publica una página de un vistazo, pega la URL en el Schema Markup Validator. Lista cada elemento que encuentra, en todas las sintaxis.
Extraer JSON-LD con Python
Necesitas requests y beautifulsoup4. La función siguiente recoge todos los nodos JSON-LD de una página y gestiona dos formas que hacen fallar los primeros intentos: un bloque que contiene una lista de objetos y un bloque que lo envuelve todo en un array @graph.
import json
import requests
from bs4 import BeautifulSoup
def jsonld_nodes(html):
soup = BeautifulSoup(html, "html.parser")
nodes = []
for tag in soup.find_all("script", type="application/ld+json"):
raw = tag.string or tag.get_text()
if not raw or not raw.strip():
continue
try:
# strict=False acepta saltos de línea dentro de strings, un error habitual
doc = json.loads(raw, strict=False)
except json.JSONDecodeError:
continue
for item in doc if isinstance(doc, list) else [doc]:
if isinstance(item, dict) and "@graph" in item:
nodes.extend(item["@graph"])
else:
nodes.append(item)
return [n for n in nodes if isinstance(n, dict)]
def has_type(node, wanted):
types = node.get("@type", [])
return wanted in (types if isinstance(types, list) else [types])
Ten en cuenta que @type puede ser un string o una lista. "@type": ["Product", "Car"] es válido, así que comprueba siempre la pertenencia en lugar de comparar strings.
O Lee Todas las Sintaxis a la Vez con extruct
Si haces scraping de muchas webs distintas, algunas solo usarán Microdata o RDFa. La librería de código abierto extruct las lee todas en una sola llamada:
import extruct
from w3lib.html import get_base_url
def all_structured_data(html, url):
return extruct.extract(
html,
base_url=get_base_url(html, url),
syntaxes=["json-ld", "microdata", "rdfa", "opengraph"],
uniform=True, # Microdata y Open Graph con la misma forma que JSON-LD
errors="ignore",
)
Con uniform=True, los elementos Microdata llegan con @type y los mismos nombres de propiedad que JSON-LD, así que el mismo código posterior puede tratar ambos.
Un detalle: extruct parsea el JSON-LD por página, no por bloque. Si un bloque de la página está roto, errors="ignore" descarta todo el JSON-LD de esa página, incluido el bloque de producto válido. Un buen reparto es usar la función jsonld_nodes de arriba para JSON-LD, y extruct para Microdata, RDFa y Open Graph.
Convertir un Nodo Product en un Registro Limpio
Encontrar el nodo Product es la parte fácil. La especificación de Schema.org permite mucha variación, y las webs reales la usan toda:
offerspuede ser un únicoOffer, una lista de ofertas o unAggregateOfferconlowPriceyhighPrice.pricepuede ser un número, un string como"1299.00"o un string con separador de miles. A veces se esconde en unpriceSpecificationanidado.availabilitysuele ser una URL comohttps://schema.org/InStock, a veces conhttp, a veces soloInStock.brandpuede ser un string o un objeto conname.imagepuede ser un string, una lista o unImageObjectconurl.
Un pequeño normalizador absorbe todo eso:
import re
def first(value):
if isinstance(value, list):
return value[0] if value else None
return value
def name_of(value):
value = first(value)
return value.get("name") if isinstance(value, dict) else value
def to_number(value):
if isinstance(value, (int, float)):
return float(value)
text = re.sub(r"[^\d.,]", "", str(value or ""))
if "," in text and "." in text:
# El separador que aparece al final es el decimal
if text.rfind(",") > text.rfind("."):
text = text.replace(".", "").replace(",", ".")
else:
text = text.replace(",", "")
elif "," in text:
decimals = text.split(",")[-1]
text = text.replace(",", ".") if len(decimals) <= 2 else text.replace(",", "")
try:
return float(text)
except ValueError:
return None
def product_record(node):
offer = first(node.get("offers")) or {}
spec = first(offer.get("priceSpecification")) or {}
if has_type(offer, "AggregateOffer"):
price = offer.get("lowPrice") or offer.get("price")
else:
price = offer.get("price") or spec.get("price")
image = first(node.get("image"))
rating = node.get("aggregateRating") or {}
return {
"name": node.get("name"),
"sku": node.get("sku"),
"brand": name_of(node.get("brand")),
"price": to_number(price),
"currency": offer.get("priceCurrency") or spec.get("priceCurrency"),
"availability": str(offer.get("availability") or "").rstrip("/").split("/")[-1] or None,
"image": image.get("url") if isinstance(image, dict) else image,
"rating": to_number(rating.get("ratingValue")),
"review_count": to_number(rating.get("reviewCount") or rating.get("ratingCount")),
}
url = "https://www.example.com/product/123"
html = requests.get(url, timeout=30).text
products = [product_record(n) for n in jsonld_nodes(html) if has_type(n, "Product")]
print(products)
El mismo patrón sirve para otros tipos. Un JobPosting tiene title, hiringOrganization, jobLocation y baseSalary. Un Recipe tiene recipeIngredient y recipeInstructions. Un RealEstateListing o un Car traen sus propias propiedades. Escribe una pequeña función de mapeo por cada tipo que te interese, no por cada web.
Dónde Te Fallan los Datos Estructurados
Los datos estructurados son el mejor primer paso, no una respuesta completa. Prepárate para estos casos.
La Ficha Técnica No Está en el Marcado
Las webs publican lo que premian los buscadores: nombre, precio, valoración, disponibilidad. Las especificaciones detalladas que lee un comprador, como dimensiones, materiales, potencia del motor o superficie, suelen vivir solo en una tabla HTML o en una lista de definiciones. Puedes leerlas de forma genérica, sin selectores por web, recogiendo pares de etiqueta y valor:
def spec_table(html):
soup = BeautifulSoup(html, "html.parser")
specs = {}
for row in soup.select("table tr"):
cells = row.find_all(["th", "td"])
if len(cells) == 2:
specs[cells[0].get_text(" ", strip=True)] = cells[1].get_text(" ", strip=True)
for dt in soup.find_all("dt"):
dd = dt.find_next_sibling("dd")
if dd:
specs[dt.get_text(" ", strip=True)] = dd.get_text(" ", strip=True)
return specs
Conserva las etiquetas tal como las muestra la página. Si extraes la misma web en varios idiomas, asigna después las etiquetas a tus propios nombres de campo (“Kilometerstand” y “Mileage” son el mismo campo).
El JSON-LD Lo Añade JavaScript
Algunas webs insertan sus datos estructurados en el lado del cliente, por ejemplo con un gestor de etiquetas. El HTML que devuelve requests no tiene entonces ningún JSON-LD, mientras que el validador, que renderiza la página, lo muestra sin problema. Renderiza la página con un navegador y lee los scripts desde el DOM:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(url, wait_until="networkidle")
blocks = page.locator('script[type="application/ld+json"]').all_text_contents()
browser.close()
Los Datos Están Desactualizados o Son de Otra Variante
Las directrices de Google exigen que el marcado coincida con el contenido visible, pero las plantillas y las cachés se desvían. En un producto con tallas o colores, el JSON-LD puede describir la variante por defecto o listar todas las variantes bajo un ProductGroup con hasVariant. Comprueba una muestra de páginas contra lo que muestra el navegador antes de fiarte del campo de precio a escala.
El JSON Está Roto
El JSON-LD escrito a mano contiene comas finales, comillas sin escapar y comentarios HTML. strict=False resuelve los saltos de línea. Para lo demás, un parser tolerante como chompjs recupera muchos bloques que json.loads rechaza. Si un bloque sigue fallando, regístralo y pasa a la siguiente fuente.
No Hay Nada Porque Te Han Bloqueado
Un resultado vacío no siempre significa que la página no tenga marcado. Una página de desafío anti-bot o un “403 disfrazado de 200” tampoco tienen JSON-LD. Busca señales de bloqueo antes de concluir que una web no publica datos estructurados. Nuestra guía sobre cómo detectar bloqueos silenciosos repasa las señales habituales.
Un Orden Práctico de Extracción
Para cada página, prueba las fuentes de la más estable a la menos estable y para cuando tengas los campos que necesitas:
- JSON-LD
- Microdata y RDFa
- Estado incrustado del framework, como
__NEXT_DATA__(consulta cómo encontrar el JSON oculto detrás de una web con JavaScript) - Meta etiquetas Open Graph para título, descripción e imagen
- Tablas de especificaciones y valores etiquetados
- Selectores CSS o XPath, como último recurso
Anota de qué fuente procede cada campo. Cuando una web cambie, sabrás qué capa se ha movido.
Preguntas Frecuentes
¿Qué diferencia hay entre JSON-LD, Microdata y RDFa?
Son tres formas de escribir el mismo vocabulario Schema.org. JSON-LD es un bloque JSON aparte dentro de una etiqueta script. Microdata y RDFa son atributos sobre los elementos HTML visibles. El modelo de datos es el mismo, por eso extruct puede devolver los tres con la misma forma.
¿El precio del JSON-LD es siempre el que aparece en la página?
Normalmente sí, porque los buscadores esperan que el marcado coincida con el contenido visible. Puede ir con retraso tras un cambio de precio o describir otra variante distinta de la seleccionada. Valídalo con una muestra antes de confiar en él.
¿Necesito un navegador headless para leer datos estructurados?
Solo cuando la web los inserta con JavaScript o bloquea los clientes HTTP simples. La mayor parte del JSON-LD está en el HTML renderizado en el servidor, así que basta con una petición simple.
¿Puedo combinar datos estructurados con mis propios selectores?
Sí, y es la configuración más robusta. Toma todos los campos que puedas de los datos estructurados y usa selectores solo para los pocos campos que no cubren.
Deja que una API Haga el Parseo
Si prefieres no mantener código de extracción para cada tipo de página, ScrapeUnblocker puede devolver JSON estructurado en lugar de HTML. Añade parsed_data=true a una petición /getPageSource:
import requests
r = requests.post(
"https://api.scrapeunblocker.com/getPageSource",
params={"url": "https://www.example.com/product/123", "parsed_data": True},
headers={"x-scrapeunblocker-key": "YOUR_API_KEY"},
timeout=120,
)
print(r.json()["data"])
La respuesta indica qué tipo de página era (product_detail, listing_detail, search_list, etc.) y devuelve campos en inglés y en snake_case, con los precios normalizados a un valor y una moneda. Las páginas de detalle ahora también incluyen la ficha técnica completa como specifications, tal como la muestra la página, junto a los datos Schema.org. Cuando una página no tiene nada que extraer, recibes el HTML renderizado, así que el parser de esta guía sigue teniendo con qué trabajar. La guía de datos parseados muestra la forma completa de la respuesta.
Prueba ScrapeUnblocker gratis
Tasa de éxito del 95%+ · desde 0,55 € por cada 1000 llamadas (puede aplicarse IVA) · 500 solicitudes gratis al registrarte.