页面正文之外还有一层机器可读的声明:主体名称、地址、营业时间、产品参数、资质有效期。这一层通常由 JSON-LD、Microdata 与 Open Graph 三套写法承载,其中 JSON-LD 因为不改视觉结构、可以独立放在
里,成了实际落地中最常见的一种。
问题是它经常缺失,或者写了一半:只有 WebPage 没有 Organization;有 name 没有 address;地址写了但只写到市;@type 写成自造词。这类缺陷不会导致页面报错,浏览器与搜索引擎都不会提醒,只有抽取端在需要那个字段的时候才发现------那时已经体现为回答里缺一段话。
巡检这一层的成本很低,脚本不到一百行,难的地方在于判据设计:什么算缺、缺到哪一级要报警。
一、三套写法的读取顺序
先明确一个工程判断:读取顺序按 JSON-LD → Microdata → Open Graph 排,前一层拿到就用,拿不到才降级。原因是三者的语义强度不同。
· JSON-LD :显式声明类型与属性,最接近「页面自己说清楚这是什么」,支持多对象嵌套与图引用(通过 @id)。
· Microdata :属性挂在视觉 DOM 上(itemtype、itemprop),改动风险高,落地率明显低于 JSON-LD,但老站点常见。
· Open Graph:面向社交分享设计,字段集固定且很窄(标题、描述、图片、类型),不能当成结构化数据的替代。
一个页面只有 Open Graph 而无前两者,在抽取视角下等于没做标注------OG 提供的是摘要,不是字段。这一条在巡检报表里要单列,因为把它算成「已标注」会让整份结果失真。
二、解析与字段齐备度计算
主体逻辑是抽取所有 JSON-LD 块,合并成一个节点表,然后按类型检查必查字段。JSON 解析失败率不低(尾逗号、单引号、把 JSON 包在 HTML 注释里),必须给每一块单独 try,不能让一块坏数据毁掉整页判断。
python
import json, re
from collections import defaultdict
LD_BLOCK = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.S | re.I)
def parse_blocks(html):
nodes, broken = [], 0
for raw in LD_BLOCK.findall(html):
txt = raw.strip()
if txt.startswith("<!--"):
txt = txt.strip("<!-")
try:
data = json.loads(txt)
except Exception:
broken += 1
continue
nodes.extend(flatten(data))
return nodes, broken
def flatten(data):
"""@graph 展开、列表摊平,返回带 @type 的节点列表。"""
out = []
if isinstance(data, list):
for d in data:
out.extend(flatten(d))
elif isinstance(data, dict):
if "@graph" in data:
out.extend(flatten(data["@graph"]))
if data.get("@type"):
out.append(data)
return out
broken 计数必须保留并进入报表。一块解析不了的 JSON-LD 比完全没有更糟------页面作者以为做了标注,读取端拿到的却是空。巡检报表里把这类标成「声明存在但不可解析」,与「未声明」分开列,处置方式完全不同:前者是修语法,后者是补内容。
三、必查字段表与判据分级
必查字段按页面类型定,不按站点定。一份可用的初始表:
| 页面类型 | @type | 必查字段 | 缺失级别 |
| --- | --- | --- | --- |
| 机构主页 | Organization | name、url、address、sameAs | 硬缺失 |
| 文章页 | Article / NewsArticle | headline、datePublished、dateModified、author | 硬缺失 |
| 产品页 | Product | name、description、brand、offers | 硬缺失 |
| 联系方式页 | Organization | telephone、email、contactPoint | 软缺失 |
| 面包屑 | BreadcrumbList | itemListElement(≥2 项) | 提示 |
分级不是为了好看。「硬缺失」= 该类型页面缺了关键字段,抽取端会退回正文猜测,报错概率高;「软缺失」= 有替代来源(正文里也写着),影响可控;「提示」= 不做也不影响主流程,做了改善展示。三档在日报里分别对应不同的处理时限,否则所有告警都变成「下周再看」。
地址字段还要额外做一层颗粒度检查:addressLocality 为空或只到省级,抽取端读出来的地址就不具备可核对性。这一项常见但不显眼,值得单独写一条断言。
四、同一主体多处声明的冲突检测
比缺失更麻烦的是冲突:机构主页声明的地址与联系页地址不一致;sameAs 列了三个外部主页,其中一个已经改版换名;文章页 dateModified 早于 datePublished。这类问题不会因为字段齐全而被发现,只能靠跨页比对。
python
def conflicts_by_key(nodes, key):
bucket = defaultdict(set)
for n in nodes:
v = n.get(key)
if isinstance(v, str) and v.strip():
bucket[n.get("@type", "?")].add(v.strip())
return {t: sorted(vs) for t, vs in bucket.items() if len(vs) > 1}
# 用法:把整站抓回来的节点合成一份大表,按字段名分组看唯一值数
# 期望同一类型下同一字段只有一个值;多值即候选冲突。
冲突判据要留人工确认口子。同一 Organization 下出现两个 telephone 是合理的(售前与售后各一条),出现两个 address 就值得追一下。简单起见,脚本对电话只统计数量、对地址与 url 判唯一,后续按实际误报率再放宽或收紧。
五、每日任务怎么落地
一次全站扫描在几百到几千页的量级上,用两路并发加超时控制可以在十几分钟内完成,足够每天跑。任务设计上有三点值得坚持:
· 快照而不是覆盖 :每天的解析结果按 URL + 抓取日期 存一份,不覆盖历史。字段消失这件事只有对比历史才能发现,单看当天报表看不出来。
· 报表按类型分组,不按 URL 分组 :一个模板缺 dateModified,会同时影响几百篇文章页。按 URL 出报表会得到一个很大的数字,按模板分组只有几行。
· 变更项单列:与前一天相比新增、消失、改值的字段单独一节。内容改错往往在改错当天最容易判断,两天之后就没人记得当时为什么改。
调度侧不需要额外依赖,cron 或任务计划程序每天一次即可,抓取失败重试两次。真正要注意的是 UA 与限速------巡检脚本被站点当成攻击,是这类内部工具最常见的失效方式。
六、几类常见误判
第一类是模板占位符未替换。页面上留的是 {organization_name} 或空字符串,脚本读到一个「已声明但无意义」的值。对策是加一层最小可用性判断:长度低于 2 的字符串、含花括号的值,一律视为缺失。
第二类是把 Open Graph 当 JSON-LD。OG 的 og:title 覆盖不了 Article.headline 缺失,报表里如果按「页面有标题声明」就算通过,会漏掉整类问题。判据要按字段名而不是按是否有任何元信息。
第三类是嵌套对象被摊平后丢失归属。contactPoint 是数组,telephone 在每个元素里;摊平成一张大表之后,很难判断某个电话属于哪个联络方式。上面的 flatten 只处理 @graph 与顶层列表,属性内部的嵌套结构保留原样,就是为这一点。
第四类是缓存导致的旧声明仍在返回。改过 JSON-LD 之后,CDN 边缘节点上的 HTML 可能仍是旧版,脚本连续几天报同一个缺失。对策是在巡检结果里同时记录响应头里的缓存信息与页面 dateModified,两条一起看才判得准。
七、小结
结构化标注的巡检,价值不在「补上多少个字段」,在于让这一层变成一个可回归的对象。字段缺失、语法错误、跨页冲突这三类问题,一旦有每天跑一次的脚本盯着,就不会以「客户问到才发现」的形式暴露。判据要保守、报表要分组、历史要留快照,这三点比解析实现本身更决定这套脚本能用多久。
(本文由一支长期做网页结构化数据与检索工程的技术团队整理,欢迎同行交流指正。)