数据抓取

停止解析HTML:从JSON-LD和嵌入数据中提取结构化数据

大多数页面已经携带机器可读的数据。如何提取JSON-LD和嵌入式JSON,而不是使用脆弱的选择器,以及当数据不完整时该怎么做。

Matt Brown

Matt Brown

2026年9月26日 · 4 分钟阅读

大多数爬虫的构建方式都是一样的:打开页面,找到包含价格的元素,写一个 CSS 选择器,然后对每个字段重复这个过程。这种方法在网站进行改版、重命名某个类名或者用新组件包裹价格之前都能正常运作。之后选择器就会返回空值,或者更糟,返回错误的内容,而流水线仍在继续运行。

许多页面其实已经以面向机器的形式发布了同样的数据。搜索引擎要求这样做,网站也提供了,而这些数据一直就藏在页面源代码里。本教程介绍如何找到它、用几十行代码提取它,以及如何处理数据缺失或不完整的情况,正如下面这个真实的例子所示,这种情况比文档中说明的要常见得多。

核心要点

  • 在 2024 年 Web Almanac 中,JSON-LD 出现在 41% 的页面上,高于 2022 年的 34%。对于产品、文章、活动和组织信息而言,它通常是页面上最稳定的数据来源。
  • 结构化数据的变化频率远低于页面布局,因为网站依赖它来获得搜索结果展示。选择器会在改版时失效,而结构化数据通常能挺过去。
  • 它并不总是完整的。一个产品页面可能只为屏幕上显示的那个变体发布完整记录,而对其他所有变体只提供简单的链接。要验证每一条记录。
  • 最稳健的提取器会先尝试结构化数据,再尝试内嵌 JSON,最后才用 CSS 选择器,并记录下究竟用了哪一种方式。

网页上”结构化数据”指的是什么

机器可读数据通常存在于四个地方:

来源长什么样典型内容
JSON-LD<script type="application/ld+json"> 代码块schema.org 对象:Product、Offer、NewsArticle、Organization、Event、BreadcrumbList
Microdata可见元素上的 itemprop 属性同样的 schema.org 词汇表,分散在标记之中
Open Graph 和 meta 标签<meta property="og:...">标题、描述、图片,有时还有价格
内嵌应用状态页面的 JavaScript 读取的大型 JSON 对象,例如 __NEXT_DATA__通常包含页面展示的一切,甚至更多

HTTP Archive 的 2024 年 Web Almanac 测量了它们在网络上的分布情况:JSON-LD “从 2022 年的 34% 增长到 2024 年的 41%“,microdata 稳定在 26%,而 RDFa 和 Open Graph(包括大多数网站添加的社交分享标签)分别出现在 66% 和 64% 的页面上。JSON-LD 是首先应该尝试的对象,因为它是一个独立完整的数据块,而不是散布在布局各处的属性。

为什么它比选择器更可靠

CSS 选择器依赖于页面看起来是什么样。结构化数据依赖于页面表达的意思是什么。网站会不断改变页面的外观,但很少改变结构化数据所表达的内容,因为它为搜索引擎中的富媒体结果提供支撑,一旦破坏它,网站就会付出可见的代价。

它失败的方式也更诚实。一个不再匹配的选择器可能会悄悄匹配到另一个元素,并返回一个看似合理但错误的值,这正是静默失败率一文中描述的那种错误。而一个 JSON-LD 对象要么包含 price 字段,要么不包含,这使得验证变得直截了当。

用 Python 提取 JSON-LD

标准库就足够了。这个提取器会收集每一个 JSON-LD 代码块,容忍格式错误的数据,并遍历网站用来嵌套对象的容器:列表、@graph,以及 schema.org 用于产品变体的 hasVariant。

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)]

在一个实际的 Guardian 文章页面上,jsonld_objects(html, "NewsArticle") 无需任何选择器就能返回标题、发布与修改时间戳以及作者信息。仅这些时间戳就值得付出这份努力:它们精确、机器可读,并且在网站的每篇文章中都保持一致。

规范化产品数据

产品数据需要更细心一些处理,因为价格嵌套在 offers 内部,有时是单个 Offer,有时是带有价格区间的 AggregateOffer。

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

对一个具有标准产品和 offer 的页面运行这段代码,它会返回干净的记录,例如 {"name": "Trail Runner", "sku": "TR-01", "brand": "Acme", "price": 89.0, "currency": "EUR", "availability": "InStock"},无论页面样式如何。

当结构化数据不完整时:一个真实案例

文档中的示例会让这一切看起来很容易。真实的页面则更混乱,值得展示一个例子。

我们在一家大型 Shopify 商店的一款热门鞋子的产品页面上运行了这个提取器。该页面的 JSON-LD 描述了一个 ProductGroup,这是 schema.org 中用于表示按变体销售的产品的类型,其中包含产品名称、品牌、描述、图片,以及 49 个变体。其中只有 7 个,也就是屏幕上显示的那个颜色的各个尺码,是完整的产品记录,价格为 $100.00 并带有库存状态。其余 42 个,也就是所有其他颜色和尺码,只是简单的引用:一个类型和一个 URL,别无其他。评价评分则位于另一个单独的 JSON-LD 代码块中。

因此,这份结构化数据描述的是页面,而不是整个目录。一个假设”所有变体都在 JSON-LD 中”的流水线,会悄无声息地只为一种颜色记录了价格,而对其余的什么都没记录。

同一家商店还公开了每个产品的公共 JSON 表示形式,这份数据是吻合的:该颜色有七个变体,每个都带有价格和库存标志。但在那里,价格是以 10000 的形式出现的,以最小货币单位表示。一个不加规范化就混合使用这两种数据来源的流水线,会把同一双鞋记录为 $100,又记录为一万美元。

由此可以得出三条经验,它们的适用范围远不止 Shopify:

  • 验证,而不是假设。 一个能被解析的 JSON-LD 代码块并不等于一条完整的记录。要检查你所需要的每个字段是否都存在,并将实际获得的数量与预期数量进行比对。
  • 当结构化数据不完整时,跟随其中的引用。 变体的 URL、内嵌 JSON 和公共产品接口通常能补全缺口,而且它们通常比页面本身更干净。
  • 显式地规范化单位。 最小货币单位、价格区间、含税与不含税价格,以及货币种类,都需要在代码中处理,而不能凭假设行事。

一个会记录数据来源的回退链

把这些部分组合成一条链:先尝试结构化数据,再尝试内嵌 JSON,最后才用选择器,并记录下每条记录究竟是由哪一种方式产生的。

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

source 字段很快就能证明它的价值。当一个原本总是产出 json-ld 记录的网站开始产出 css 记录时,说明它的结构化数据已经发生变化或消失了,而你希望在选择器这道回退方案也失效之前就知道这一点。这也是目标健康评分的一项有用输入。

用 Web Scraping API 来实现

如果你通过 Shifter 的 Web Scraping API 获取页面,同样的方法不需要你自己运行浏览器就能奏效。该 API 的 extract_rules 参数可以把 CSS 选择器映射到 JSON 字段,而它的 html 输出会返回某个元素的内部 HTML,因此一条选中 JSON-LD script 标签的规则,会把原始代码块返回给你去解析:

{
  "jsonld": { "selector": "script[type='application/ld+json']", "output": "html" }
}

单条规则只会返回第一个匹配的元素,所以对于包含多个 JSON-LD 代码块的页面,需要请求完整的 HTML,再对其运行上面的提取器。对于用 JavaScript 注入结构化数据的页面,可以把这两种方式与 render_js=1 结合使用;而当你直接抓取一个 JSON 接口,例如产品的 JSON URL 时,使用 auto_parser=1 可以获得解析后的响应体。缺失的字段会以 null 的形式返回,而不是导致请求失败,这与上文所说的”先验证、再回退”模式相契合。完整语法参见提取规则文档。

结构化数据无法给你的东西

结构化数据描述的是网站选择为搜索引擎发布的内容。它可能滞后于可见页面,省略网站不打算暴露的字段,或者描述的是默认变体而非屏幕上显示的那一个。尤其是价格,应当按样本抽查与可见页面进行对比,因为一个过期的 JSON-LD 价格和一个当前的页面显示价格,从不同的角度看都”正确”。而对于按访问者所在地区提供不同内容的网站,结构化数据也会随之变化,因此要从你所关心的市场采集数据,详见抓取机票和酒店价格一文。

结论

在写下一个选择器之前,先打开页面源代码,搜索 application/ld+json。在网络上相当大一部分页面中,你想要的数据其实已经在那里了,并用一套共享的词汇表进行了标注,而且远不像其周围的布局那样容易变化。

先提取它,验证它,在它不完整时回退到内嵌 JSON,再回退到选择器,并记录下每条记录来自哪个来源。你的提取器会更少出故障,而当它们真的出故障时,也会告诉你原因。

来源与参考资料

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始