关闭

Tryaivo™技术

MusicEvent 的 @id 悬空,validator 全绿

2026-11-07 03:51:01 浏览:

导语:一个约 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 表示有问题:

  1. 必填字段检查:按 @type 对照期望字段表,缺项立即报。

  2. @id 解析检查(两遍):第一遍收集所有"定义"——带 @type 且带 @id 的节点;第二遍检查所有"引用"——只有一个 @id 键的对象。引用不在定义集合里,就是悬空。

  3. 解析错误检查:逐块 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(艺人)602431WebPage.name
/martingarrix(艺人)602431WebPage.name
/artists/(集合)308401—
/venues/(集合)309400BreadcrumbList.itemListElement、WebPage.name
/faq/city/amsterdam207500—
/(首页)105400—
合计210—25632 类

结论很清楚:解析层是干净的(21 个块零语法错,必填项基本齐),实体层仍有 6 条悬空引用和 3 处类型冲突。具体两处:

  1. 同一 URI 两套类型。 艺人页把 https://thedjcalendar.com/davidguetta 同时声明为 MusicGroup(主实体块)和 WebPage(第五个块),一个 URI 两个身份;集合页则是 #webpage 同时被声明为 WebPage 与 CollectionPage。

  2. #artist 只被引用、从未被定义。 页面壳的 about 和第五块的 mainEntity 都指向 https://thedjcalendar.com/davidguetta#artist,但全部 155 个节点里,没有任何一个以 #artist 为 @id 承载 MusicGroup——主实体的 @id 是裸 URL,不带片段。引用悬空。

定性观察(无量化,不给数字):这类问题不会让富媒体结果立刻消失,也不会在 GSC 里冒红;它的代价是自动化消费方的实体合并会做错——同一页的 WebPage 与 MusicGroup 抢一个 @id 时,谁覆盖谁取决于解析器实现。这种"取决于实现"的标记,正是 AI 侧引用时灵时不灵的常见来源。

复盘

3 条配置动作

  1. 定义与引用分开判定。 只把"带 @type + 带 @id"的节点算定义,只有 @id 一个键的对象才算引用。我第一版脚本没区分(把所有出现过的字符串 @id 都算定义),结果是"引用即定义",报 0 条悬空;改成严格判定后,同一批页面立刻报出 6 条。这是这类审计器最容易写错的一行。

  2. 一个 URI 只承载一种类型。 约定好:页面级实体用 #webpage,主业务实体用裸 URL,子实体用 #breadcrumb / #artist 这类片段。并且主实体的定义必须真的落在约定的那个 URI 上——不能引用 #artist,定义却是裸 URL。

  3. CI 里必须放 broken fixture。 正向用例只能证明"当前线上没问题",反向用例才能证明"检查器还活着"。作者的 CI 就是这么做的:一份好样本、一份故意断链的样本,后者不报错就判失败。

2 条红线

  1. 别把 validator 绿灯当完成。 schema.org validator 与 Google Rich Results Test 只查语法和结构性必填,不查跨块 @id 引用完整性。本次 6 条悬空引用全部能通过这两类校验器——它们的绿灯只说明"标记合法",不说明"图是对的"。

  2. 别让每个模板各自拼 @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

标签:schema