La mayoría de los scrapers se construyen de la misma manera: abrir la página, encontrar el elemento que contiene el precio, escribir un selector CSS y repetir para cada campo. Funciona hasta que el sitio publica un rediseño, renombra una clase o envuelve el precio en un nuevo componente. Entonces el selector no devuelve nada, o peor, devuelve algo incorrecto, y el pipeline sigue funcionando.
Muchas páginas ya publican los mismos datos en un formato pensado para máquinas. Los motores de búsqueda lo pidieron, los sitios lo proporcionaron, y ha estado ahí, en el código fuente de la página, todo este tiempo. Este tutorial muestra cómo encontrarlo, extraerlo con unas pocas docenas de líneas de código y gestionar los casos en los que falta o está incompleto, algo que, como muestra un ejemplo real más abajo, ocurre con más frecuencia de lo que sugiere la documentación.
Ideas clave
- JSON-LD apareció en el 41% de las páginas en el Web Almanac 2024, frente al 34% en 2022. Para productos, artículos, eventos y organizaciones, suele ser la fuente más estable de la página.
- Los datos estructurados cambian mucho menos que el diseño de la página, porque los sitios dependen de ellos para los resultados de búsqueda. Los selectores se rompen con los rediseños; los datos estructurados normalmente sobreviven a ellos.
- No siempre están completos. Una página de producto puede publicar registros completos para la variante en pantalla y solo enlaces básicos para el resto de variantes. Valida cada registro.
- El extractor más robusto prueba primero los datos estructurados, luego el JSON incrustado y, por último, los selectores CSS, y registra cuál de ellos utilizó.
Qué significa “datos estructurados” en una página web
Hay cuatro lugares donde suelen residir datos legibles por máquinas:
| Fuente | Qué aspecto tiene | Contenido típico |
|---|---|---|
| JSON-LD | Bloques <script type="application/ld+json"> | Objetos de schema.org: Product, Offer, NewsArticle, Organization, Event, BreadcrumbList |
| Microdata | Atributos itemprop en elementos visibles | El mismo vocabulario de schema.org, repartido por el marcado |
| Open Graph y meta tags | <meta property="og:..."> | Título, descripción, imagen, a veces precio |
| Estado de aplicación incrustado | Un gran objeto JSON que el JavaScript de la página lee, como __NEXT_DATA__ | A menudo todo lo que muestra la página, y más |
El Web Almanac 2024 del HTTP Archive midió su presencia en la web: JSON-LD creció “del 34% en 2022 al 41% en 2024”, microdata se mantuvo estable en el 26%, y RDFa y Open Graph, que incluyen las etiquetas de compartición social que la mayoría de sitios añaden, aparecieron en el 66% y el 64% de las páginas. JSON-LD es el primero al que recurrir, porque es un bloque de datos autocontenido en lugar de atributos dispersos por el diseño.
Por qué es más fiable que los selectores
Un selector CSS depende de cómo se ve una página. Los datos estructurados dependen de qué significa una página. Los sitios cambian constantemente cómo se ven sus páginas. Cambian mucho menos lo que dicen sus datos estructurados, porque alimentan resultados enriquecidos en los motores de búsqueda, y romperlos tiene un coste visible para el sitio.
También falla de forma más honesta. Un selector que deja de coincidir puede coincidir silenciosamente con un elemento distinto y devolver un valor incorrecto pero plausible, el tipo de error descrito en la tasa de fallo silencioso. Un objeto JSON-LD, o bien contiene un campo price, o bien no lo contiene, lo que hace que la validación sea sencilla.
Extracción de JSON-LD en Python
La biblioteca estándar es suficiente. Este extractor recopila cada bloque JSON-LD, tolera los que están rotos, y recorre los contenedores que los sitios usan para anidar objetos: listas, @graph y hasVariant, que schema.org utiliza para variantes de producto.
import json
from html.parser import HTMLParser
class _Collector(HTMLParser):
"""Collect JSON-LD blocks and embedded JSON state from an HTML page."""
def __init__(self):
super().__init__()
self.blocks, self._buf, self._kind = [], None, None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "script" and a.get("type", "").lower() == "application/ld+json":
self._buf, self._kind = [], "json-ld"
elif tag == "script" and a.get("id") == "__NEXT_DATA__":
self._buf, self._kind = [], "next-data"
def handle_data(self, data):
if self._buf is not None:
self._buf.append(data)
def handle_endtag(self, tag):
if tag == "script" and self._buf is not None:
raw = "".join(self._buf).strip()
try:
self.blocks.append((self._kind, json.loads(raw)))
except json.JSONDecodeError:
self.blocks.append((self._kind + "-invalid", raw[:200]))
self._buf = self._kind = None
def _walk(node):
"""Yield every JSON-LD object, flattening lists and nested containers."""
if isinstance(node, list):
for item in node:
yield from _walk(item)
elif isinstance(node, dict):
yield node
for key in ("@graph", "mainEntity", "itemListElement", "hasVariant"):
if key in node:
yield from _walk(node[key])
def _types(obj):
t = obj.get("@type", [])
return {t} if isinstance(t, str) else set(t)
def jsonld_objects(html, wanted_type=None):
"""Every JSON-LD object on the page, optionally filtered by schema.org type."""
collector = _Collector()
collector.feed(html)
objs = [o for kind, data in collector.blocks if kind == "json-ld" for o in _walk(data)]
return [o for o in objs if wanted_type is None or wanted_type in _types(o)]
En un artículo real de The Guardian, jsonld_objects(html, "NewsArticle") devuelve el titular, las marcas de tiempo de publicación y modificación, y el autor, sin selectores de ningún tipo. Solo esas marcas de tiempo ya justifican el esfuerzo: son exactas, legibles por máquina y consistentes en todos los artículos del sitio.
Normalizar productos
Los productos requieren un poco más de cuidado, porque los precios viven dentro de offers anidados, a veces como un único Offer y a veces como un AggregateOffer con un rango de precios.
def products(html):
"""Return normalised product records found in a page's JSON-LD."""
out = []
for obj in jsonld_objects(html, "Product"):
offers = obj.get("offers") or {}
offer = offers[0] if isinstance(offers, list) and offers else offers
if isinstance(offer, dict) and "AggregateOffer" in _types(offer):
price = offer.get("lowPrice")
else:
price = offer.get("price") if isinstance(offer, dict) else None
brand = obj.get("brand")
out.append({
"name": obj.get("name"),
"sku": obj.get("sku") or obj.get("gtin13") or obj.get("mpn"),
"brand": brand.get("name") if isinstance(brand, dict) else brand,
"price": float(price) if price not in (None, "") else None,
"currency": offer.get("priceCurrency") if isinstance(offer, dict) else None,
"availability": (offer.get("availability") or "").rsplit("/", 1)[-1] if isinstance(offer, dict) else None,
})
return out
Ejecutado contra una página con un producto y una oferta estándar, devuelve registros limpios como {"name": "Trail Runner", "sku": "TR-01", "brand": "Acme", "price": 89.0, "currency": "EUR", "availability": "InStock"}, sin importar cómo esté estilizada la página.
Cuando los datos estructurados están incompletos: un ejemplo real
Los ejemplos de la documentación hacen que esto parezca fácil. Las páginas reales son más desordenadas, y merece la pena mostrar una.
Ejecutamos el extractor contra la página de producto de una zapatilla popular en una gran tienda de Shopify. El JSON-LD de la página describía un ProductGroup, el tipo de schema.org para un producto vendido en variantes, con el nombre del producto, la marca, la descripción y las imágenes, y 49 variantes. Solo 7 de ellas, las tallas del color en pantalla, eran productos completos con un precio de $100.00 y un estado de disponibilidad. Las otras 42, cada uno de los demás colores y tallas, eran referencias básicas: un tipo y una URL, nada más. La valoración de reseñas estaba en un segundo bloque JSON-LD, separado.
De modo que los datos estructurados describían la página, no el catálogo. Un pipeline que asumiera que “todas las variantes están en el JSON-LD” habría fijado el precio de un solo color en silencio, y no habría registrado nada para el resto.
La misma tienda también expone una representación JSON pública de cada producto, que sí coincidía: siete variantes para ese color, cada una con un precio y un indicador de disponibilidad. Pero ahí el precio llegaba como 10000, en unidades menores. Un pipeline que mezclara ambas fuentes sin normalizar habría registrado una zapatilla a $100 y la misma zapatilla a diez mil dólares.
De aquí se derivan tres lecciones, y se aplican mucho más allá de Shopify:
- Valida, no asumas. Un bloque JSON-LD que se puede parsear no es un registro completo. Comprueba que están presentes todos los campos que necesitas, y compara lo que obtuviste con lo que esperabas.
- Sigue las referencias cuando los datos estructurados sean parciales. Las URL de variantes, el JSON incrustado y los endpoints públicos de producto suelen llenar los huecos, y normalmente son más limpios que la página.
- Normaliza las unidades explícitamente. Las unidades menores, los rangos de precios, los precios con y sin impuestos, y la moneda, todo necesita tratamiento en el código, no por suposición.
Una cadena de fallback que registra su fuente
Junta las piezas en una cadena: prueba primero los datos estructurados, luego el JSON incrustado, luego los selectores, y guarda una nota de cuál produjo cada registro.
REQUIRED = ("name", "price", "currency")
def extract_product(html, embedded=None, css_fallback=None):
for source, candidates in (
("json-ld", products(html)),
("embedded-json", embedded(html) if embedded else []),
("css", css_fallback(html) if css_fallback else []),
):
for record in candidates:
if all(record.get(f) not in (None, "") for f in REQUIRED):
return {**record, "source": source}
return None
El campo source demuestra rápidamente su utilidad. Cuando un sitio que siempre producía registros json-ld empieza a producir registros css, sus datos estructurados han cambiado o han desaparecido, y conviene saberlo antes de que el fallback de selectores también se rompa. También es una entrada útil para una puntuación de salud del objetivo.
Hacerlo con la Web Scraping API
Si obtienes páginas a través de la Web Scraping API de Shifter, el mismo enfoque funciona sin que tengas que ejecutar un navegador tú mismo. El parámetro extract_rules de la API asigna selectores CSS a campos JSON, y su salida html devuelve el HTML interno de un elemento, de modo que una regla que seleccione el script JSON-LD te devuelve el bloque en bruto para que lo parsees:
{
"jsonld": { "selector": "script[type='application/ld+json']", "output": "html" }
}
Una única regla devuelve el primer elemento que coincide, así que para páginas con varios bloques JSON-LD, solicita el HTML completo y ejecuta el extractor anterior sobre él. Combina cualquiera de los dos enfoques con render_js=1 para páginas que inyectan sus datos estructurados con JavaScript, y usa auto_parser=1 cuando estés obteniendo directamente un endpoint JSON, como una URL de JSON de producto, para recuperar el cuerpo ya parseado. Los campos ausentes vuelven como null en lugar de hacer fallar la solicitud, lo cual encaja con el patrón de validar-y-luego-recurrir-al-fallback descrito antes. La sintaxis completa está en la documentación de reglas de extracción.
Lo que los datos estructurados no te darán
Los datos estructurados describen lo que el sitio decidió publicar para los motores de búsqueda. Pueden ir por detrás de la página visible, omitir campos que al sitio no le interesa exponer, o describir la variante por defecto en lugar de la que está en pantalla. Para los precios en particular, compáralos con la página visible sobre una base de muestreo, porque un precio JSON-LD desactualizado y un precio actual en la página son ambos “correctos” desde puntos de vista distintos. Y para los sitios que varían el contenido según la ubicación del visitante, los datos estructurados también varían, así que captúralos desde el mercado que te interesa, tal como se trata en scraping de precios de vuelos y hoteles.
Conclusión
Antes de escribir otro selector, abre el código fuente de la página y busca application/ld+json. En una gran parte de la web, los datos que quieres ya están ahí, etiquetados con un vocabulario compartido, y con muchas menos probabilidades de cambiar que el diseño que los rodea.
Extráelo primero, valídalo, recurre al JSON incrustado y luego a los selectores cuando esté incompleto, y registra de qué fuente proviene cada registro. Tus extractores se romperán menos a menudo, y cuando lo hagan, te lo dirán.
Fuentes y referencias
- HTTP Archive, Web Almanac 2024: Structured Data, 11 de noviembre de 2024.
- Schema.org, Product, ProductGroup, Offer y AggregateOffer.
- Shifter, documentación de reglas de extracción de la Web Scraping API y de renderizado de JavaScript.