← Todos los artículos

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, itemtype e itemprop repartidos por el HTML visible. Habitual en tiendas y plantillas más antiguas.
  • RDFa. La misma idea con atributos vocab, typeof y property. 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+json para bloques JSON-LD
  • itemprop= para Microdata
  • property="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:

  • offers puede ser un único Offer, una lista de ofertas o un AggregateOffer con lowPrice y highPrice.
  • price puede ser un número, un string como "1299.00" o un string con separador de miles. A veces se esconde en un priceSpecification anidado.
  • availability suele ser una URL como https://schema.org/InStock, a veces con http, a veces solo InStock.
  • brand puede ser un string o un objeto con name.
  • image puede ser un string, una lista o un ImageObject con url.

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:

  1. JSON-LD
  2. Microdata y RDFa
  3. Estado incrustado del framework, como __NEXT_DATA__ (consulta cómo encontrar el JSON oculto detrás de una web con JavaScript)
  4. Meta etiquetas Open Graph para título, descripción e imagen
  5. Tablas de especificaciones y valores etiquetados
  6. 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.

Pruébalo gratis → Ver precios