大多数爬虫的构建方式都是一样的:打开页面,找到包含价格的元素,写一个 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,再回退到选择器,并记录下每条记录来自哪个来源。你的提取器会更少出故障,而当它们真的出故障时,也会告诉你原因。
来源与参考资料
- HTTP Archive,Web Almanac 2024: Structured Data,2024 年 11 月 11 日。
- Schema.org,Product、ProductGroup、Offer 和 AggregateOffer。
- Shifter,Web Scraping API 提取规则与渲染 JavaScript文档。