结构化数据校验要分三层:语法层(JSON 可解析、必填字段齐全)和类型层(页面类型与 @type 是否匹配)用本地规则表脚本化,一致性层(标记内容与页面可见内容是否相符)只能由模型比对。第三层才是导致富媒体资格丢失的主因,而所有在线校验器都查不出来。
先说结论
先看一段真实的 JSON-LD。它来自本站每一个页面,包括你正在读的这篇:
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "BLKTECH",
"url": "https://blktech.cn",
"description": "面向个人创业者和小团队的 AI 工具、自动化工作流…",
"publisher": { "@type": "Organization", "name": "BLKTECH", "url": "https://blktech.cn" }
}
把它扔进任何一个在线校验器,结果都是通过:JSON 合法、@context 正确、@type 是 schema.org 的有效类型、name 和 url 齐全。零错误零警告。
但它有三个实打实的问题:
- 这是一篇文章,却声明自己是
WebSite——类型根本就选错了 WebSite本该只出现在首页,现在 47 篇文章各有一份,等于告诉搜索引擎这个域名下有 47 个网站- 里面的
name和description是站点级文案,和页面自己的<title>、<meta description>直接矛盾
一个校验器全绿的标记,可以同时犯这三个错。所以:
校验器只能告诉你 JSON-LD 语法对不对,告诉不了你标记的内容和页面是不是一回事。而后者才是真正丢富媒体资格的原因。
这一篇讲怎么把后者也自动查出来。
一、为什么不能只依赖在线校验器
两个原因,一个是工程上的,一个是根本性的。
工程上:没有能用的 API。
Google 在 2020 年弃用了 Structured Data Testing Tool,现在官方工具是 Rich Results Test 和 schema.org 的 validator。问题是这两个都没有公开的 API——你没法把它们接进 CI,只能人工打开网页一个个粘。47 个页面粘一遍,谁也坚持不了两周。
根本上:它们只查语法。
开头那段 JSON-LD 就是证明。校验器的职责是「这段标记符合 schema.org 规范吗」,不是「这段标记描述的是这个页面吗」。后者需要同时理解标记和页面内容——这正好是模型擅长而校验器做不到的事。
所以本 Skill 的路线:
| 层次 | 谁来查 | 能否自动化 |
|---|---|---|
| 语法是否合法 | 本地脚本 + 规则表 | ✅ 完全可以 |
| 类型选得对不对 | 规则表 + 模型 | ✅ 可以 |
| 标记与页面是否一致 | 模型 | ✅ 可以 |
| 最终富媒体效果确认 | 人工跑 Rich Results Test | ❌ 抽查即可 |
在线校验器降级为上线前的抽查手段,而不是日常流程的一环。
二、三层问题模型
这是整个 Skill 的骨架:
| 层 | 查什么 | 典型问题 | 谁能查出来 |
|---|---|---|---|
| L1 语法层 | 标记本身是否合法 | JSON 解析失败、@context 缺失、必填字段没填 |
脚本 ✅ 校验器 ✅ |
| L2 类型层 | 类型选得对不对 | 文章页标成 WebSite、@graph 嵌套错误 |
脚本(规则表)✅ 校验器 ❌ |
| L3 一致性层 | 标记内容是否等于页面内容 | author 和页面署名不符、标了 FAQPage 但页面没问答 |
只有模型 ✅ |
开头那个例子:L1 完美通过,L2 和 L3 全错。
大部分团队的结构化数据工作只做到 L1,因为那是校验器唯一能给出的反馈。而处罚风险几乎全部集中在 L3。
三、L3 为什么最要命
Google 的结构化数据政策里有一条明确要求:标记的内容必须对用户可见,且能代表页面的实际内容。
违反它的后果不是「标记没效果」这么温和,而是:
- 该页面失去富媒体结果资格
- 严重或成规模的,会收到针对结构化数据的手动操作处罚,整站富媒体展示一起没
也就是说,L3 问题的期望收益是负的——标错了比不标更糟。
常见的 L3 问题:
| 问题 | 说明 |
|---|---|
author 与页面署名不符 |
标记写 A,页面显示 B;或页面根本没有署名 |
datePublished 与页面显示日期不符 |
常见于模板取错字段 |
headline 与 <h1> 内容不符 |
标记里塞了关键词版标题 |
标了 FAQPage,页面无问答区 |
为拿富媒体位硬加,典型违规 |
标了 aggregateRating,页面无可见评分 |
高危,容易触发手动操作 |
image 指向 404 或不存在的图 |
直接失去带图展示 |
@type 与页面实际用途不符 |
开头那个例子 |
| 标记里有页面上根本没有的内容 | 「不可见内容标记」,明确违规 |
判断这些全部需要同时看标记和页面。脚本可以提取两边的值,但「这两个作者名算不算同一个人」「这个 headline 和 h1 意思一样吗」只能交给模型。
各家搜索引擎对结构化数据的政策会变——这些规则建议每年复核一次,写进 Skill 的参考资料里并注明复核日期。
四、可以直接不做的两类标记
技术 SEO 里做减法常常比做加法收益高。有两类标记,普通内容站现在写了也拿不到富媒体展示:
HowTo:Google 自 2023 年起逐步废弃 HowTo 富媒体结果,先是移动端,随后桌面端也不再展示。现在标了不会有分步富媒体卡片。
FAQPage:Google 自 2023 年 8 月起,把 FAQ 富媒体结果限制为仅对权威政府和医疗类网站展示。普通内容站、企业站、博客标了都不会显示。
很多 2022 年前写的 SEO 教程还在教你给每篇文章加 FAQPage 提高曝光——那个窗口已经关了。
但要诚实说明另一面:这两类标记作为语义信息仍有一点残值。AI 抓取器在解析页面时会读结构化数据,清晰的问答结构有助于它准确提取。所以结论不是「删掉」,而是:
别再为了富媒体展示去写 FAQPage 和 HowTo,也别花力气优化它们。已有的留着无害,没有的不用补。
把省下的力气放到下一节真正有用的类型上。
五、内容站真正该标的类型
| 类型 | 用在哪 | 价值 |
|---|---|---|
Article / BlogPosting |
每个文章页 | 🔴 核心,影响文章类富媒体与作者信息展示 |
BreadcrumbList |
有层级的页面 | 🟡 SERP 显示面包屑路径,提升点击率 |
Organization |
仅首页 | 🟡 知识面板、品牌信息 |
WebSite |
仅首页 | 🟡 站内搜索框(sitelinks searchbox) |
Person |
作者页 | 🟢 建立作者实体,对 E-E-A-T 有帮助 |
两条纪律:
WebSite和Organization只能出现在首页。 每页都输出一份,是本文开头那个 bug 的本质。- 文章页必须是
Article或BlogPosting。 二选一即可,BlogPosting是Article的子类型,教程类内容用Article更贴切。
关于必填字段有个容易误解的点:Google 对 Article 没有列出任何硬性必填属性,全部标为「推荐」。但推荐字段缺失会直接影响富媒体资格,所以实践中要按必填对待:
Article 实践必填:headline / image / datePublished / author
Article 推荐补充:dateModified / publisher / mainEntityOfPage / description
headline 另有一条长度限制:超过 110 字符可能被忽略。这里可以直接复用上一篇的半角当量函数——110 这个数字同样是按英文字符给的,中文站要按当量算,实际上限约 55 个汉字。
六、Skill 设计:规则表是核心资产
三层架构沿用系列前两篇,本篇的重点全在 references/ 里的两张表。
表一:类型必填字段
// references/schema-rules.json
{
"Article": {
"required": ["headline", "image", "datePublished", "author"],
"recommended": ["dateModified", "publisher", "mainEntityOfPage", "description"],
"constraints": { "headline": { "maxWidth": 110 } }
},
"BlogPosting": { "extends": "Article" },
"BreadcrumbList": {
"required": ["itemListElement"],
"itemRequired": ["position", "name"]
},
"Organization": {
"required": ["name", "url"],
"recommended": ["logo", "sameAs"],
"onlyOn": ["/"]
},
"WebSite": {
"required": ["name", "url"],
"recommended": ["potentialAction"],
"onlyOn": ["/"]
},
"Person": { "required": ["name"], "recommended": ["url", "sameAs"] }
}
onlyOn 字段是专门为这次发现的问题加的——声明某类型只允许出现在哪些路径,脚本直接能查。
表二:一致性对照
规定标记里的每个字段该和页面上的什么比:
// references/consistency-map.json
{
"headline": { "compareWith": "h1", "match": "semantic" },
"author.name": { "compareWith": "[rel=author]", "match": "exact" },
"datePublished": { "compareWith": "time[datetime]", "match": "date" },
"image": { "compareWith": "reachable", "match": "http" },
"description": { "compareWith": "meta[description]", "match": "semantic" }
}
match 的三种模式决定了谁来判:
| 模式 | 含义 | 执行者 |
|---|---|---|
exact |
字符串完全相同 | 脚本 |
date |
日期归一化后相同 | 脚本 |
http |
资源可访问 | 脚本 |
semantic |
意思是否一致 | 模型 |
semantic 是这张表存在的理由。headline 是「Meta 审计 Skill 实战」而 h1 是「Meta 审计 Skill——查 title、description 与 H1」,字符串不同但意思一致,这该算通过。只有模型能做这个判断。
七、脚本:提取与 L1/L2 检查
// scripts/extract-jsonld.mjs
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
const DIST = process.argv[2] ?? 'dist';
const RULES = JSON.parse(await readFile('references/schema-rules.json', 'utf8'));
// 解析 extends 继承
const ruleFor = (type) => {
const r = RULES[type];
if (!r) return null;
return r.extends ? { ...RULES[r.extends], ...r } : r;
};
async function walk(dir) {
const out = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...await walk(p));
else if (e.name.endsWith('.html')) out.push(p);
}
return out;
}
const strip = (s) => s.replace(/<[^>]+>/g, '').trim();
const pages = [];
for (const file of await walk(DIST)) {
const html = await readFile(file, 'utf8');
const url = '/' + relative(DIST, file).replace(/index\.html$/, '').replace(/\\/g, '/');
const issues = [];
const nodes = [];
const blocks = [...html.matchAll(
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi
)];
if (blocks.length === 0) {
issues.push({ layer: 'L2', type: 'jsonld-missing' });
}
for (const [, raw] of blocks) {
let data;
try {
data = JSON.parse(raw);
} catch (err) {
issues.push({ layer: 'L1', type: 'json-parse-error', detail: err.message });
continue; // 解析失败就没法往下查了
}
// 三种常见组织形式:单对象、数组、@graph
const list = Array.isArray(data) ? data : data['@graph'] ?? [data];
for (const node of list) {
if (!node || typeof node !== 'object') continue;
nodes.push(node);
if (!data['@context'] && !node['@context']) {
issues.push({ layer: 'L1', type: 'context-missing' });
}
const type = Array.isArray(node['@type']) ? node['@type'][0] : node['@type'];
if (!type) { issues.push({ layer: 'L1', type: 'type-missing' }); continue; }
const rule = ruleFor(type);
if (!rule) { issues.push({ layer: 'L2', type: 'type-unknown', schemaType: type }); continue; }
// L1:必填与推荐字段
for (const f of rule.required ?? []) {
if (node[f] == null) issues.push({ layer: 'L1', type: 'required-missing', schemaType: type, field: f });
}
for (const f of rule.recommended ?? []) {
if (node[f] == null) issues.push({ layer: 'L1', type: 'recommended-missing', schemaType: type, field: f });
}
// L2:类型是否被限制在特定路径
if (rule.onlyOn && !rule.onlyOn.includes(url)) {
issues.push({ layer: 'L2', type: 'type-wrong-scope', schemaType: type, allowed: rule.onlyOn });
}
}
}
// L2:文章页必须有 Article 或 BlogPosting
const types = nodes.map((n) => n['@type']).flat();
const isArticlePage = /^\/(guides|reviews|practice|resources)\/[^/]+\/$/.test(url);
if (isArticlePage && !types.some((t) => t === 'Article' || t === 'BlogPosting')) {
issues.push({ layer: 'L2', type: 'article-type-missing', found: types });
}
// 抽出页面侧的对照值,交给模型做 L3
const observed = {
h1: strip(html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? ''),
title: strip(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ''),
description: html.match(/<meta\s+name="description"\s+content="([^"]*)"/i)?.[1] ?? '',
dates: [...html.matchAll(/<time[^>]*datetime="([^"]*)"/gi)].map((m) => m[1]),
};
pages.push({ url, nodes, observed, issues });
}
await mkdir('.audit', { recursive: true });
await writeFile('.audit/schema.json', JSON.stringify(pages, null, 2));
const l1 = pages.flatMap((p) => p.issues).filter((i) => i.layer === 'L1' && i.type !== 'recommended-missing');
const l2 = pages.flatMap((p) => p.issues).filter((i) => i.layer === 'L2');
console.log(`扫描 ${pages.length} 页:L1 语法问题 ${l1.length},L2 类型问题 ${l2.length}`);
process.exit(l1.length + l2.length ? 1 : 0);
三个实现细节值得注意:
- 必须处理
@graph:真实站点常把多个类型塞进一个<script>的@graph数组里,只取顶层对象会漏掉全部内容 @type可能是数组:"@type": ["Article", "TechArticle"]是合法写法observed是给模型准备的:脚本不做 L3 判断,只负责把页面侧的对照值抽出来一起交出去
八、报告样例
用本站的真实扫描结果:
# 结构化数据审计报告
扫描 64 页 / L1 语法 0 / L2 类型 111 / L3 待判定 47
## 🔴 L2 类型层(111 处,同一根因)
### 全部 47 个文章页:缺少 Article 标记
| 页面 | 实际 @type | 应为 |
|---|---|---|
| /guides/ai-skills-seo-link-audit/ | WebSite | Article |
| /guides/hyperframes-how-it-works/ | WebSite | Article |
| …(共 47 页) | WebSite | Article |
### 全部 64 页:WebSite / Organization 超出允许范围
| 类型 | 出现页数 | 允许范围 |
|---|---|---|
| WebSite | 64 | 仅 / |
| Organization | 64 | 仅 / |
**根因**:`src/layouts/BaseLayout.astro` 中 jsonLd 为硬编码常量,
所有页面共用同一份,未按页面类型分支。
**建议**:改为按 props 生成——首页输出 WebSite + Organization,
文章页输出 Article(headline / image / datePublished / author 取自 frontmatter)。
## 🟡 L3 一致性层(47 处)
| 页面 | 字段 | 标记值 | 页面实际值 | 判定 |
|---|---|---|---|---|
| /guides/ai-skills-seo-link-audit/ | name | BLKTECH | AI Skills 实战:把网站死链… | ❌ 不符 |
| /guides/ai-skills-seo-link-audit/ | description | 面向个人创业者和小团队… | 把死链检查的判定规则… | ❌ 不符 |
**说明**:因类型本身就错(WebSite 而非 Article),一致性问题会随 L2 一并修复。
这份报告最有价值的一行是 「根因」——111 处 L2 问题不是 111 个 bug,是一个 bug 在 64 个页面上的投影。报告如果只列 111 行问题,读的人会以为工作量巨大;指出根因在哪个文件的哪一段,实际改动是几十行。
能从一堆症状里归纳出根因,是模型相比纯脚本最大的增量。 脚本只会老实吐 111 行。
九、修复策略
| 问题 | 动作 |
|---|---|
| JSON 解析失败 | 立即修,这段标记等于完全无效 |
缺 @context |
补 https://schema.org |
文章页无 Article |
按 frontmatter 生成,别硬编码 |
WebSite 出现在非首页 |
改为按页面类型分支输出 |
| 必填字段缺失 | 补;确实拿不到值就别输出该字段,不要填空字符串 |
headline 超 110 当量 |
缩短,或用 alternativeHeadline 放长版 |
image 404 |
修图或去掉该字段 |
author 与页面不符 |
以页面可见内容为准改标记,不是反过来 |
| 标了 FAQPage 但无问答 | 删标记(而不是给页面硬加问答区) |
两条原则:
拿不到值就别输出这个字段。 空字符串、
"undefined"、占位符比字段缺失更糟——缺失只是信息不全,错值是错误信息。
不一致时以页面可见内容为准。 标记是对页面的描述,不是对页面的补充。改标记去贴合页面,而不是改页面去迁就标记。
十、CI 策略:原则不变,结论不同
第二篇定下的原则是:CI 只拦客观错误,不拦主观质量。
这一篇沿用同一条原则,但结论比②激进得多——因为结构化数据里客观错误的比例远高于 Meta:
| 层 | 是否客观 | CI 策略 |
|---|---|---|
| L1 语法 | ✅ 完全客观 | 阻断 |
| L2 类型 | ✅ 基本客观(类型选错就是错) | 阻断 |
| L3 一致性 · exact/date/http | ✅ 客观 | 阻断 |
| L3 一致性 · semantic | ❌ 需要判断 | 只报告 |
对比三篇的阻断线:
| 系列 | 阻断什么 | 放行什么 |
|---|---|---|
| ① 死链 | 内链 404 | 外链失效 |
| ② Meta | title 缺失、跨页重复 | 长度、措辞质量 |
| ③ 结构化数据 | L1 + L2 + 客观 L3 | 语义一致性 |
同一条原则,三种不同的落点。决定阻断线位置的从来不是「这件事重不重要」,而是「这件事有没有唯一正确答案」。 结构化数据的阻断线可以画得很靠前,正因为它的对错大部分是规范定义好的。
# .github/workflows/schema-audit.yml
name: schema-audit
on: [push]
jobs:
schema:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install && bun run build
- run: bun scripts/extract-jsonld.mjs dist # L1/L2 非零退出
- uses: actions/upload-artifact@v4
if: always()
with: { name: schema-audit, path: .audit/schema.json }
十一、系列导航
「SEO Skills 工具箱」系列:
- 死链与断链审计
- Meta 审计:title、description、H1
- 当前:结构化数据与 JSON-LD 校验
- Sitemap 与实际路由的差异审计、孤儿页发现
- 内链结构审计:权重分布、内容孤岛与锚文本
- 内置 seo:audit 与 Skill 封装
- 配套资源:说明书、规则注册表与检查清单
三篇下来,系列的原则已经完整了:
- 确定性交给脚本,判断交给模型(①)
- 能量化的写成阈值表,不能量化的给判断依据(②)
- CI 的阻断线画在「有唯一正确答案」的地方(②③)
- 报告要给根因,不是给症状列表(③)
最后一条是本篇的新增,也是最容易被低估的一条:同一个 bug 在 64 个页面上的 111 处投影,报告成 111 行问题还是 1 个根因,决定了这份报告会被执行还是被无视。
相关阅读:
- Astro 内容站搭建指南——内容模型的字段设计直接决定 JSON-LD 能填出什么
- 展示型独立站进阶优化——GEO 与搜索意图
RELATED / 相关推荐
接着读这些
按同一栏目、标签与技术栈为你挑选。
AI Skills 高阶:把 SEO 检查内置进项目,做成一条命令 + 一个 Skill
把分散的五类 SEO 检查合并成项目内置的 seo:audit 脚本,用统一的规则注册表管理 24 项检查与 error/warn 分级,再封装成 Skill,让检测、解析和修复方案一次给全。
AI Skills 实战:把网站死链与断链审计做成一个可复用 Skill
把死链检查的判定规则和输出格式固化成一份 Skill 说明书,配合两个确定性脚本,让 AI Agent 每次都按同一套 SOP 产出可执行的审计报告,并接入 CI 持续拦截。
AI Skills 实战:Meta 审计 Skill——查 title、description 与 H1 的缺失、重复与超长
把 title、description、H1 的判定阈值和改写依据固化成 Skill 说明书,用半角当量宽度替代字符数解决中文截断误判,让模型直接给出改写候选而不只是报告超长。