关闭

Tryaivo™技术

三站 JSON-LD 接进 CI,首跑揪出 3 个静默错

2026-11-06 04:47:37 浏览:

导语:三个 Astro 静态站把 JSON-LD 期望类型写进一张表,部署后 60 秒内跑一遍线上页,首跑就抓出 3 个构建、部署、浏览器全部看不见的错——全部属于"标记活着,但内容错了"。

背景:标记坏掉,没有任何东西会报错

作者(Dev.to 上的 morinaga)在维护三个程序化 SEO 目录站,全部是 Astro 5 SSG 静态构建,部署在 Cloudflare Pages:

站点内容详情页用的 @type
aiappdex.comHuggingFace 开源模型目录SoftwareApplication + BreadcrumbList
findindiegame.comSteam / itch.io 独立游戏库VideoGame + BreadcrumbList(部分页另有 FAQPage)
ossfind.comSaaS 的开源替代品ItemList + BreadcrumbList

结构化数据全部由 Astro layout 组件注入 <head>,没有 SSR,也没有运行时注入。问题就出在这里:

  • Astro 编译器不校验 JSON-LD;

  • 构建步骤不检查它;

  • 部署日志一片绿色,页面在浏览器里完全正常;

  • 改模板(新增一个 slot、重排 <head>、抽公共 layout)时,某类页的 JSON-LD 块可以整块消失。

静态站尤其致命:构建期是唯一一次校验机会。等到 Search Console 在「增强功能」里标红,往往是几周以后。作者自述前期只是怀疑"有些页的 FAQ JSON-LD 格式不对",但没有任何机制能证实——于是写了个部署后审计脚本。

落地清单

1. 选用的 @type 与字段

按 Google 目前支持富结果的类型挑,不追冷门类型:

  • SoftwareApplication:模型详情页。必填 name、offers(含 price、priceCurrency);推荐 applicationCategory、operatingSystem、url、description。

  • VideoGame:游戏详情页。必填 name、description、offers;推荐 genre、publisher、author、datePublished、image。

  • ItemList:替代品聚合页。必填 numberOfItems、itemListElement(每个 ListItem 带 position、url、name)。

  • BreadcrumbList:全站详情页。必填 itemListElement(position + name + item)。

  • WebSite:所有首页;potentialAction 挂 SearchAction 可争取站内搜索框。

版本口径:Schema.org 现行版,查询于 2026-11-06。当日 schema.org/version/latest 与发布页在本机网络不可达,因此不写具体版本号,只按官网现行词汇表校验字段名与层级。

2. 完整 JSON-LD 片段(线上实抓,未改写)

findindiegame.com 一个游戏详情页 <head> 内的块(外层是数组,两个实体并列):

[
  {
    "@context": "https://schema.org",
    "@type": "VideoGame",
    "name": "A Short Hike",
    "description": "A Short Hike is a compact open-world exploration game set in a provincial park...",
    "url": "https://findindiegame.com/games/a-short-hike-1055540/",
    "image": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1055540/header.jpg",
    "genre": ["Adventure", "Indie"],
    "publisher": { "@type": "Organization", "name": "adamgryu" },
    "author": { "@type": "Organization", "name": "adamgryu" },
    "datePublished": "Jul 30, 2019",
    "offers": {
      "@type": "Offer",
      "price": "$7.99",
      "priceCurrency": "USD",
      "availability": "https://schema.org/InStock",
      "url": "https://store.steampowered.com/app/1055540/"
    }
  },
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://findindiegame.com" },
      { "@type": "ListItem", "position": 2, "name": "Games", "item": "https://findindiegame.com/games/" }
    ]
  }
]

注意两个细节:offers.price 用了带货币符号的 "$7.99" 而非数字 7.99——Google 的 Offer 文档要求数字或符合格式的字符串,混用两种写法在批量生成时最容易踩;datePublished 用的是 "Jul 30, 2019" 这种非 ISO 8601 形态,能解析但不推荐,批量站点应统一成 2019-07-30。

3. 插在哪个节点

Astro layout 组件的 <head> 内,按页面类型条件渲染;SSG 站没有水合错位问题,但代价是模板一改就静默丢块——这正是要审计的原因。

4. 审计脚本:一张期望表 + 线上实抓

scripts/audit-jsonld.mjs 把"哪类页该有哪些类型"写成表:

const SITES = [
  {
    host: "aiappdex.com",
    homepage: { path: "/", expectedTypes: ["WebSite"] },
    detail: {
      pathRegex: /\/models\//,
      expectedTypes: ["SoftwareApplication", "BreadcrumbList"],
    },
  },
  {
    host: "findindiegame.com",
    homepage: { path: "/", expectedTypes: ["WebSite"] },
    detail: {
      pathRegex: /\/games\//,
      expectedTypes: ["VideoGame", "BreadcrumbList"],
    },
  },
  {
    host: "ossfind.com",
    homepage: { path: "/", expectedTypes: ["WebSite"] },
    detail: {
      pathRegex: /\/alternatives\//,
      expectedTypes: ["ItemList", "BreadcrumbList"],
    },
  },
];

详情页路径不写死,从线上 sitemap 里挑真实存在的页:

async function discoverDetailPaths(host, regex, count = 2) {
  try {
    const sitemap = await fetch(`https://${host}/sitemap-0.xml`).then(r => r.text());
    const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
    return urls.filter(u => regex.test(u)).slice(0, count).map(u => new URL(u).pathname);
  } catch {
    return [];
  }
}

sitemap-0.xml 这个文件名是有意的:@astrojs/sitemap 在千页以下的小站输出 /sitemap-0.xml,不是 /sitemap-index.xml。写错文件名会静默失败——catch 返回空数组,脚本"通过",什么都没检查。

抽取时处理 @graph,并把解析失败单独记成一种结果:

function extractJsonLd(html) {
  const matches = [...html.matchAll(
    /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
  )];
  const items = [];
  for (const m of matches) {
    try {
      const parsed = JSON.parse(m[1].trim());
      const arr = Array.isArray(parsed) ? parsed : [parsed];
      for (const node of arr) {
        if (!node) continue;
        if (node["@graph"]) {
          for (const sub of node["@graph"]) if (sub && sub["@type"]) items.push(sub);
        } else if (node["@type"]) items.push(node);
      }
    } catch (e) {
      items.push({ "@type": "_PARSE_ERROR", error: String(e).slice(0, 80) });
    }
  }
  return items;
}

接入 CI 时故意设成非致命:

- name: Audit JSON-LD (non-fatal)
  run: node scripts/audit-jsonld.mjs || echo "JSON-LD audit reported issues (non-fatal)"

|| 是关键设计:新检查一上线就设成阻断,等于在发布最紧的时候给自己造一个阻塞。先让它报,观察真实条件下的误报率,再收紧。作者的路线是等三站连续全绿后去掉 ||。

结果:首跑抓到的 3 个问题

站点问题性质处理
ossfind.comalternatives 页缺 ItemList该 layout 还没加,纯遗漏当时的 Backlog
findindiegame.comWebSite.@id 写成了 http://findindiegame.com复制模板没改协议,与 canonical 不一致已修
aiappdex.comSoftwareApplication.name 填了原始 HuggingFace model ID(如 meta-llama/Llama-3.1-8B-Instruct)而非展示名组件取了错误字段,两值都在上下文里已修

后两个是"标记合法、内容错误"的典型:JSON 合法、@type 正确、富结果测试也不会报错,但 Googlebot 读到的是与 canonical 不一致的 @id、与 <h1> 不一致的应用名。这类错只有把结构化数据当文本读一遍才会暴露——浏览器不渲染它。

我方独立复检(2026-11-06)

对三个站点的首页与详情页做了实抓复检(每站首页 1 页、详情页 2 页,从各自 sitemap-0.xml 随机取),结果:

站点首页 blocks / types详情页 types(实测)备注
aiappdex.com1 块 / WebSiteSoftwareApplication + BreadcrumbList + WebSite首页 WebSite 另挂 SearchAction(/search?q={search_term_string})
findindiegame.com1 块 / WebSiteVideoGame + BreadcrumbList + FAQPage + WebSite首页 WebSite 已无 http:// 的 @id,该项闭环
ossfind.com1 块 / WebSiteItemList + BreadcrumbList + WebSiteItemList.numberOfItems 实测为 3 / 4,与列表项数量一致;原 Backlog 已补

全部 6 个详情页 URL 解析 parseErr = 0,无 _PARSE_ERROR。抽检 aiappdex 一页,SoftwareApplication.name 与页面 <h1>(xlm-roberta_punctuation_fullstop_truecase)完全一致——该项修复在线上生效(该模型本身没有花哨展示名,故仍是下划线形态,属正常)。

口径说明:以上为本次复检的实抓结果,不是作者提供的数字;文章未包含任何排名、CTR、曝光量对比——原作者也没有给出,属于纯机制性复盘,不做效果承诺。

审计能抓的和抓不到的,边界要写清楚:

  • 只抽每站 2 个详情页 + 首页,是烟雾测试,不是全量校验;

  • 抓不到只在特定页型出现的问题、数据层的边角值、以及不在 sitemap 抽样里的页面;

  • 跑的是线上而不是构建产物——好处是能抓到 CDN 边缘返回旧页面、缓存不一致,坏处是问题已经在线上活了几分钟。

复盘

3 条可直接抄的配置动作

  1. 把期望写成表,别写成断言散落在脚本里。 host + pathRegex + expectedTypes 三元组让"新增一类页型"变成加一行配置,而不是改一处逻辑。

  2. 跑线上、从 sitemap 取样。 硬编码详情页 URL 会在内容更新后失效;从 sitemap-0.xml 按正则抽 2 条,永远测的是当前生产环境里真实存在的页。

  3. 先非致命,再收紧。 || echo 兜底让检查先当哨兵不当门卫;等误报清零、连续全绿,再把 ItemList 缺失设成 fail。

2 条红线(含一条"别这么做")

  1. 别用 HEAD 或浏览器肉眼验收结构化数据。 它不在渲染树里,浏览器怎么看都正常;测法只能是抓 HTML 文本、正则抽 <script type="application/ld+json">、逐块 JSON.parse。

  2. 别让审计脚本"失败时静默通过"。 上面 discoverDetailPaths 的 catch { return [] } 是必要的容错,但也意味着"抓不到页"和"全部通过"在输出上长一个样。至少要打印抽到了几条样本——样本数为 0 就是失败,不是通过。

另外一条可以顺手补进这套脚本的规则:name 与页面 <h1>、url/@id 与 canonical 的一致性断言。上面 3 个问题里有 2 个都属于这一类,靠人看报告才发现,写成断言就能自动拦。

标注

  • Schema.org 版本:现行版,查询于 2026-11-06(当日 schema.org 站点从本机不可达,故不标具体版本号)

  • 验证工具:作者自建 scripts/audit-jsonld.mjs(正则抽取 + JSON.parse + @graph 展开 + 类型比对),接入 GitHub Actions 非致命步骤;本文复检使用同样的抽取逻辑对线上页实抓

  • 验证时间:2026-11-06(我方复检);原文发布 2026 年内,作者自述为 6 个月三站实验的一部分

  • 来源:https://dev.to/morinaga/what-i-learned-wiring-json-ld-structured-data-audits-into-a-post-deploy-ci-step-3ga8(原文公开,代码片段按原文结构引用)

  • 数据口径:问题清单与处理状态来自作者自述;站点现状为我方独立实抓,非第三方复现;无排名与流量数据

标签:Schema.org、结构化数据、JSON-LD、AI可见度、GEO、目录站

标签:schema const sitemap