导语:三个 Astro 静态站把 JSON-LD 期望类型写进一张表,部署后 60 秒内跑一遍线上页,首跑就抓出 3 个构建、部署、浏览器全部看不见的错——全部属于"标记活着,但内容错了"。
背景:标记坏掉,没有任何东西会报错
作者(Dev.to 上的 morinaga)在维护三个程序化 SEO 目录站,全部是 Astro 5 SSG 静态构建,部署在 Cloudflare Pages:
| 站点 | 内容 | 详情页用的 @type |
|---|---|---|
| aiappdex.com | HuggingFace 开源模型目录 | SoftwareApplication + BreadcrumbList |
| findindiegame.com | Steam / itch.io 独立游戏库 | VideoGame + BreadcrumbList(部分页另有 FAQPage) |
| ossfind.com | SaaS 的开源替代品 | 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.com | alternatives 页缺 ItemList | 该 layout 还没加,纯遗漏 | 当时的 Backlog |
| findindiegame.com | WebSite.@id 写成了 http://findindiegame.com | 复制模板没改协议,与 canonical 不一致 | 已修 |
| aiappdex.com | SoftwareApplication.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.com | 1 块 / WebSite | SoftwareApplication + BreadcrumbList + WebSite | 首页 WebSite 另挂 SearchAction(/search?q={search_term_string}) |
| findindiegame.com | 1 块 / WebSite | VideoGame + BreadcrumbList + FAQPage + WebSite | 首页 WebSite 已无 http:// 的 @id,该项闭环 |
| ossfind.com | 1 块 / WebSite | ItemList + BreadcrumbList + WebSite | ItemList.numberOfItems 实测为 3 / 4,与列表项数量一致;原 Backlog 已补 |
全部 6 个详情页 URL 解析 parseErr = 0,无 _PARSE_ERROR。抽检 aiappdex 一页,SoftwareApplication.name 与页面 <h1>(xlm-roberta_punctuation_fullstop_truecase)完全一致——该项修复在线上生效(该模型本身没有花哨展示名,故仍是下划线形态,属正常)。
口径说明:以上为本次复检的实抓结果,不是作者提供的数字;文章未包含任何排名、CTR、曝光量对比——原作者也没有给出,属于纯机制性复盘,不做效果承诺。
审计能抓的和抓不到的,边界要写清楚:
只抽每站 2 个详情页 + 首页,是烟雾测试,不是全量校验;
抓不到只在特定页型出现的问题、数据层的边角值、以及不在 sitemap 抽样里的页面;
跑的是线上而不是构建产物——好处是能抓到 CDN 边缘返回旧页面、缓存不一致,坏处是问题已经在线上活了几分钟。
复盘
3 条可直接抄的配置动作
把期望写成表,别写成断言散落在脚本里。
host + pathRegex + expectedTypes三元组让"新增一类页型"变成加一行配置,而不是改一处逻辑。跑线上、从 sitemap 取样。 硬编码详情页 URL 会在内容更新后失效;从
sitemap-0.xml按正则抽 2 条,永远测的是当前生产环境里真实存在的页。先非致命,再收紧。
|| echo兜底让检查先当哨兵不当门卫;等误报清零、连续全绿,再把ItemList缺失设成 fail。
2 条红线(含一条"别这么做")
别用 HEAD 或浏览器肉眼验收结构化数据。 它不在渲染树里,浏览器怎么看都正常;测法只能是抓 HTML 文本、正则抽
<script type="application/ld+json">、逐块JSON.parse。别让审计脚本"失败时静默通过"。 上面
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、目录站