导语:一个约 1,000 页的演出日历站,用 178 行单文件脚本把 JSON-LD 审计接进 CI,把"解析错误"清零;但我按同一套思路复核 6 个线上页,仍查出 6 条悬空 @id 引用和 3 处 @id 类型冲突——这些错 schema 校验器不会报,Rich Results 也不会报。
背景:合法的标记,断掉的实体图
The DJ Calendar 是按艺人、场馆、城市、流派组织的 EDM 演出日历。页面类型多,标记也就多:一个艺人页要同时承载 MusicGroup(艺人实体本身)、十几场 MusicEvent(巡演场次)、FAQPage("下一场在哪")、BreadcrumbList、VideoObject(官方 aftermovie),加上 WebPage 做页面壳。实测 /davidguetta 一页有 6 个独立的 application/ld+json 脚本块、17 种 @type。
这种结构下,页面不是"一个 JSON-LD 对象",而是多个块拼成的一张图,块与块之间靠 @id 引用串起来。于是失败模式变了:
不是 JSON 语法错(
JSON.parse一跑就知道);也不是必填字段缺失(validator 会提示);
而是引用指向了一个不存在的 URI,或者同一个 URI 被声明成两种类型。
这类错在浏览器里没感觉,在 schema.org validator 里是绿灯,在 GSC 富媒体报告里也不出现,因为严格来说它都不是"错误",只是图不完整。作者原文的做法是:把"每种页面该出哪些类型"写成一张期望表,部署后抓线上实页逐块审计,让 CI 在退化时报红。
落地清单
1. 三个检查,按顺序做
作者的审计器是单文件 PHP CLI(约 178 行),做三件事,退出码 0 表示干净、1 表示有问题:
必填字段检查:按
@type对照期望字段表,缺项立即报。@id解析检查(两遍):第一遍收集所有"定义"——带@type且带@id的节点;第二遍检查所有"引用"——只有一个@id键的对象。引用不在定义集合里,就是悬空。解析错误检查:逐块
JSON.parse,语法错单独归类(不能和缺字段混在一起报,否则没人看得懂)。
第 2 步是全部价值所在,也是下面复盘第 1 条要展开的坑:定义与引用必须分开判定。混在一起算,每个引用都成了自己的定义,悬空永远查不出来。
2. 我用来复核的脚本(真实可跑)
下面这段是我在 2026-11-07 复核该站时实际运行的版本,Python 标准库,无第三方依赖,直接喂线上抓下来的 HTML:
import re, json
REQ = {
'MusicEvent': ['name', 'startDate', 'location', 'performer'],
'MusicGroup': ['name'],
'Organization': ['name', 'url'],
'WebPage': ['name'],
'FAQPage': ['mainEntity'],
'BreadcrumbList': ['itemListElement'],
'VideoObject': ['name', 'thumbnailUrl', 'uploadDate'],
}
def flat(node, acc):
if isinstance(node, dict):
acc.append(node)
for k, v in node.items():
if k != '@id': # 不把 @id 字符串当子节点递归
flat(v, acc)
elif isinstance(node, list):
for v in node:
flat(v, acc)
def audit(html):
blocks = [m.group(1) for m in re.finditer(
r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
html, re.S | re.I)]
nodes, parse_err = [], 0
for raw in blocks:
try:
d = json.loads(raw.strip())
except Exception:
parse_err += 1 # 语法错单独计数
continue
flat(d, nodes)
# 第一遍:只要同时有 @type 和 @id,才算「定义」
defs, conflicts = {}, []
for n in nodes:
if isinstance(n.get('@id'), str) and n.get('@type'):
t = n['@type'] if isinstance(n['@type'], str) else ','.join(n['@type'])
if n['@id'] in defs and defs[n['@id']] != t:
conflicts.append((n['@id'], defs[n['@id']], t))
defs.setdefault(n['@id'], t)
# 第二遍:只有一个 @id 键的对象才是「引用」
refs = [n['@id'] for n in nodes if set(n.keys()) == {'@id'}]
dangling = sorted({r for r in refs if r not in defs})
missing = sorted({
(n['@type'], p) for n in nodes
if isinstance(n.get('@type'), str) and n['@type'] in REQ
for p in REQ[n['@type']]
if '@id' in n and (p not in n or n[p] in ('', [], None))
})
return dict(blocks=len(blocks), parse_err=parse_err, defs=len(defs),
refs=len(refs), dangling=dangling, conflicts=conflicts,
missing=missing)抓页那一步用 Invoke-WebRequest 或任何 HTTP 客户端都行,关键是抓线上真实页,不是抓构建产物——构建产物和上线页之间的差异(模板变量没渲染、缓存了旧版本)正是这类审计要抓的东西。
3. 真实 JSON-LD 片段(线上原文,2026-11-07)
艺人页第一个块,页面壳:
{"@context":"https://schema.org","@type":"WebPage","@id":"https://thedjcalendar.com/davidguetta#webpage","url":"https://thedjcalendar.com/davidguetta","inLanguage":"en-US","isPartOf":{"@id":"https://thedjcalendar.com/#website"},"breadcrumb":{"@id":"https://thedjcalendar.com/davidguetta#breadcrumb"},"about":{"@id":"https://thedjcalendar.com/davidguetta#artist"},"dateModified":"2026-11-06"}同页某一场演出(共 13 场,此处截取结构与关键字段):
{"@type":"MusicEvent","name":"David Guetta @ EDC Orlando 2026","eventStatus":"https://schema.org/EventScheduled","eventAttendanceMode":"https://schema.org/OfflineEventAttendanceMode","typicalAgeRange":"18+","performer":[{"@type":"MusicGroup","name":"David Guetta","@id":"https://thedjcalendar.com/davidguetta"}],"startDate":"2026-11-06","offers":{"@type":"Offer","availability":"https://schema.org/InStock","validFrom":"2026-11-06","seller":{"@type":"Organization","name":"..."}}}值得注意的写法细节:
eventStatus与eventAttendanceMode用的是https://schema.org/EventScheduled/OfflineEventAttendanceMode的完整 URI,不是字符串枚举——这是正确写法,枚举值必须写成 schema.org 的绝对 URI。performer用{"@type":"MusicGroup","name":...,"@id":...}内联一份精简定义 + 引用主实体,既保证单块可独立解析,也保证整图能合并。13 场演出里这个对象重复 13 次。offers.availability同样用完整 URI(https://schema.org/InStock),不是"InStock"。
4. 插在哪个节点
该站的做法是 SSR 直接输出多个独立 script 块(不是一个大 @graph),分散在每个页面模板的对应位置:页面壳一块、主实体一块、面包屑一块、FAQ 一块、视频一块。好处是模板各自维护互不干扰,代价是 @id 约定必须全站统一——这就是后面复核查出问题的地方。
5. 验证方式
不用 "看起来能解析" 当验证。逐块
JSON.parse只覆盖语法。要用 两遍
@id解析(定义 / 引用分离)+ 类型冲突检测 + 必填字段表。作者的 CI 做法值得抄:除了"线上页应无问题"的正向用例,还放了一份故意断链的 fixture(broken),检查器必须在它上面报错并
exit 1。只跑正向用例是假绿——检查器自己坏掉时,正向用例照样全过。
结果
作者自述(非第三方复现,口径:作者文章自述):约 1,000 个页面,从"数百条富媒体警告"到 0;手段是部署后抓线上实页做审计,而不是在 UI 里逐页点验证工具。
我的独立复核(2026-11-07,抽样 6 个线上页,共 21 个 JSON-LD 块):
| 页面 | 块数 | 解析错误 | @id 定义 | 内部引用 | 悬空引用 | @id 类型冲突 | 必填项缺失 |
|---|---|---|---|---|---|---|---|
/davidguetta(艺人) | 6 | 0 | 2 | 4 | 3 | 1 | WebPage.name |
/martingarrix(艺人) | 6 | 0 | 2 | 4 | 3 | 1 | WebPage.name |
/artists/(集合) | 3 | 0 | 8 | 4 | 0 | 1 | — |
/venues/(集合) | 3 | 0 | 9 | 4 | 0 | 0 | BreadcrumbList.itemListElement、WebPage.name |
/faq/city/amsterdam | 2 | 0 | 7 | 5 | 0 | 0 | — |
/(首页) | 1 | 0 | 5 | 4 | 0 | 0 | — |
| 合计 | 21 | 0 | — | 25 | 6 | 3 | 2 类 |
结论很清楚:解析层是干净的(21 个块零语法错,必填项基本齐),实体层仍有 6 条悬空引用和 3 处类型冲突。具体两处:
同一 URI 两套类型。 艺人页把
https://thedjcalendar.com/davidguetta同时声明为MusicGroup(主实体块)和WebPage(第五个块),一个 URI 两个身份;集合页则是#webpage同时被声明为WebPage与CollectionPage。#artist只被引用、从未被定义。 页面壳的about和第五块的mainEntity都指向https://thedjcalendar.com/davidguetta#artist,但全部 155 个节点里,没有任何一个以#artist为@id承载MusicGroup——主实体的@id是裸 URL,不带片段。引用悬空。
定性观察(无量化,不给数字):这类问题不会让富媒体结果立刻消失,也不会在 GSC 里冒红;它的代价是自动化消费方的实体合并会做错——同一页的 WebPage 与 MusicGroup 抢一个 @id 时,谁覆盖谁取决于解析器实现。这种"取决于实现"的标记,正是 AI 侧引用时灵时不灵的常见来源。
复盘
3 条配置动作
定义与引用分开判定。 只把"带
@type+ 带@id"的节点算定义,只有@id一个键的对象才算引用。我第一版脚本没区分(把所有出现过的字符串@id都算定义),结果是"引用即定义",报 0 条悬空;改成严格判定后,同一批页面立刻报出 6 条。这是这类审计器最容易写错的一行。一个 URI 只承载一种类型。 约定好:页面级实体用
#webpage,主业务实体用裸 URL,子实体用#breadcrumb/#artist这类片段。并且主实体的定义必须真的落在约定的那个 URI 上——不能引用#artist,定义却是裸 URL。CI 里必须放 broken fixture。 正向用例只能证明"当前线上没问题",反向用例才能证明"检查器还活着"。作者的 CI 就是这么做的:一份好样本、一份故意断链的样本,后者不报错就判失败。
2 条红线
别把 validator 绿灯当完成。 schema.org validator 与 Google Rich Results Test 只查语法和结构性必填,不查跨块
@id引用完整性。本次 6 条悬空引用全部能通过这两类校验器——它们的绿灯只说明"标记合法",不说明"图是对的"。别让每个模板各自拼
@id后缀。 多块部署时,@id的片段约定必须由一处函数统一生成、全站引用同一个。各模板手写后缀,改一次 URL 规则就是全站悬空;而悬空不报错,只会安静地让实体对不上。
标注
Schema.org 版本:Schema.org 现行版,查询于 2026-11-07(本机访问 schema.org 超时,未取到具体版本号,按惯例标注为"现行版";文中所有
@type与字段名均取自该站线上页实际输出,可复现核对)。涉及
@type:MusicEvent、MusicGroup、PerformingArtsTheater、Place、Offer、Organization、VideoObject、FAQPage、Question、Answer、BreadcrumbList、ListItem、WebPage、WebSite、CollectionPage、SpeakableSpecification。验证工具:自建 Python 脚本(本机运行,逐块
json.loads+ 两遍@id解析 + 类型冲突检测 + 必填字段表);作者原文用 178 行 PHP CLI 接入 CI,未在本机执行其脚本。数据口径:抽样 6 页 / 21 块,非全站 1,000 页全量;作者"数百条警告 → 0"为其自述,非第三方复现。
来源:
https://thedjcalendar.com/davidguetta、/martingarrix、/artists/、/venues/、/faq/city/amsterdam、/,以及作者复盘文章(dev.to/bryanhamiltondev/how-i-took-1000-pages-from-hundreds-of-rich-results-warnings-to-zero-2mkl)。复盘时间:2026-11-07。
标签:Schema.org、结构化数据、JSON-LD、AI可见度、GEO、MusicEvent