跳到主要内容
BLKBLKTECH

指南 / ai

AI Skills 实战:结构化数据校验 Skill——语法对不代表标记对

JSON-LD 校验分语法、类型、一致性三层,校验器只覆盖第一层。用本地规则表查必填字段与类型选择,让模型比对标记内容与页面可见内容,堵住真正会丢富媒体资格的问题。

作者 BLKTECH 编辑部更新 2026年8月1日19 分钟难度 进阶免费Node.jsBunAI AgentSchema.orgGitHub Actions

结构化数据校验要分三层:语法层(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 的有效类型、nameurl 齐全。零错误零警告。

但它有三个实打实的问题:

  1. 这是一篇文章,却声明自己是 WebSite——类型根本就选错了
  2. WebSite 本该只出现在首页,现在 47 篇文章各有一份,等于告诉搜索引擎这个域名下有 47 个网站
  3. 里面的 namedescription站点级文案,和页面自己的 <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 有帮助

两条纪律:

  1. WebSiteOrganization 只能出现在首页。 每页都输出一份,是本文开头那个 bug 的本质。
  2. 文章页必须是 ArticleBlogPosting 二选一即可,BlogPostingArticle 的子类型,教程类内容用 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 工具箱」系列:

  1. 死链与断链审计
  2. Meta 审计:title、description、H1
  3. 当前:结构化数据与 JSON-LD 校验
  4. Sitemap 与实际路由的差异审计、孤儿页发现
  5. 内链结构审计:权重分布、内容孤岛与锚文本
  6. 内置 seo:audit 与 Skill 封装
  7. 配套资源:说明书、规则注册表与检查清单

三篇下来,系列的原则已经完整了:

  • 确定性交给脚本,判断交给模型(①)
  • 能量化的写成阈值表,不能量化的给判断依据(②)
  • CI 的阻断线画在「有唯一正确答案」的地方(②③)
  • 报告要给根因,不是给症状列表(③)

最后一条是本篇的新增,也是最容易被低估的一条:同一个 bug 在 64 个页面上的 111 处投影,报告成 111 行问题还是 1 个根因,决定了这份报告会被执行还是被无视。

相关阅读:

NEXT ACTION / 下一步

回看系列第二篇:Meta 审计

把读到的方法变成一个小行动,完成后再回来迭代。

继续

RELATED / 相关推荐

接着读这些

按同一栏目、标签与技术栈为你挑选。