Schema 生成用单一实体配置文件(entities.yaml)作为事实来源,脚本按 page_type 自动选择 Schema 类型并生成 @id 引用链。模型只负责 description、knowsAbout 等表达型字段。生成后跑校验脚本检查 @id 引用完整性和必填字段,再用 Rich Results Test 验证。
AI Skill:Author 与 Organization Schema 批量生成
为什么 Schema 不该手写
第 38 篇给了完整的 Schema 代码模板。但手动往每个页面复制粘贴会出三类问题:
@id引用断裂:文章 Schema 的author字段引用了#person,但作者主页的 Person Schema 的@id写成了别的值 → Google 无法关联两个实体- 字段遗漏:Organization Schema 在首页写全了,在 About 页少了
hasCredential - 信息不一致:公司名在首页写
AquaFlow Industrial,在 Schema 里写Aquaflow Industrial Ltd→ 违反第 39 篇的 NAP 一致性要求
这三类问题都是结构性的,脚本可以 100% 避免。
一、单一事实来源:entities.yaml
所有实体信息集中在一个文件。这份文件同时也是第 39 篇要求的“品牌事实表”。
# src/data/entities.yaml
organization:
id: "organization" # 用于生成 @id
name: "AquaFlow Industrial"
legalName: "AquaFlow Industrial Co., Ltd."
foundingDate: "2003"
numberOfEmployees: 180
description: >
Manufacturer of industrial centrifugal, submersible and magnetic
drive pumps for chemical processing, water treatment and food
manufacturing applications.
logo:
url: "/logo.png"
width: 512
height: 512
address:
streetAddress: "No. 88 Industrial Road, Nanhai District"
addressLocality: "Foshan"
addressRegion: "Guangdong"
postalCode: "528200"
addressCountry: "CN"
contactPoints:
- telephone: "+86-757-1234-5678"
contactType: "sales"
email: "info@aquaflowpumps.com"
availableLanguage: ["English", "Chinese"]
areaServed: ["US", "GB", "DE", "AU", "CA"]
credentials:
- name: "ISO 9001:2015 Quality Management System"
category: "certification"
recognizedBy: "TÜV Rheinland"
- name: "CE Marking (EN 809)"
category: "certification"
recognizedBy: "European Union"
sameAs:
- "https://www.linkedin.com/company/aquaflow-industrial"
- "https://www.thomasnet.com/profile/aquaflow-industrial"
- "https://www.europages.co.uk/AQUAFLOW-INDUSTRIAL"
persons:
- slug: "michael-chen"
name: "Michael Chen"
givenName: "Michael"
familyName: "Chen"
jobTitle: "Senior Pump Systems Engineer"
image: "/authors/michael-chen.jpg"
email: "michael.chen@aquaflowpumps.com"
description: >
Pump systems engineer with 15 years of experience in centrifugal
pump hydraulic design, NPSH optimization, and corrosion-resistant
material selection for chemical service applications.
alumniOf: "South China University of Technology"
credentials:
- name: "B.Eng in Mechanical Engineering"
category: "degree"
- name: "Hydraulic Institute Member"
category: "membership"
knowsAbout:
- "Centrifugal pump hydraulic design"
- "NPSH analysis and cavitation prevention"
- "Corrosion-resistant material selection"
- "API 610 compliance"
- "Magnetic drive pump applications"
sameAs:
- "https://www.linkedin.com/in/michaelchen-pumps"
site:
url: "https://www.aquaflowpumps.com"
defaultLocale: "en-US"
这份文件的三个作用:
- Schema 生成的数据源
- 第 39 篇要求的 NAP 标准(所有平台复制这里的值)
- 页面模板里显示公司信息时也读这份文件(保证前端显示和 Schema 一致)
二、按 page_type 自动选择 Schema 类型
映射规则写死在脚本里,不需要每次判断:
| page_type | Schema 类型 | 关联实体 |
|---|---|---|
| 首页 | Organization + WebSite | — |
| about | Organization | — |
| pillar | Organization + BreadcrumbList | Organization |
| cluster-sku | Product + BreadcrumbList | Organization(manufacturer) |
| cluster-app | TechArticle + BreadcrumbList | Person(author)+ Organization |
| cluster-resource | TechArticle + BreadcrumbList | Person(author)+ Organization |
| author 主页 | Person | Organization(worksFor) |
| 含 FAQ 的任意页 | 追加 FAQPage | — |
三、生成脚本
// scripts/schema-generate.mjs
import fs from 'fs/promises';
import yaml from 'js-yaml';
const entities = yaml.load(
await fs.readFile('src/data/entities.yaml', 'utf8')
);
const registry = JSON.parse(await fs.readFile('registry.json', 'utf8'));
const SITE = entities.site.url;
const ORG_ID = `${SITE}/#organization`;
const personId = slug => `${SITE}/authors/${slug}/#person`;
// ── Organization Schema ──
function buildOrganization() {
const o = entities.organization;
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": ORG_ID,
"name": o.name,
"legalName": o.legalName,
"url": SITE,
"description": o.description.trim(),
"foundingDate": o.foundingDate,
"logo": {
"@type": "ImageObject",
"url": SITE + o.logo.url,
"width": o.logo.width,
"height": o.logo.height,
},
...(o.numberOfEmployees && {
"numberOfEmployees": {
"@type": "QuantitativeValue",
"value": o.numberOfEmployees,
},
}),
"address": { "@type": "PostalAddress", ...o.address },
"contactPoint": (o.contactPoints ?? []).map(c => ({
"@type": "ContactPoint",
...c,
})),
"hasCredential": (o.credentials ?? []).map(c => ({
"@type": "EducationalOccupationalCredential",
"credentialCategory": c.category,
"name": c.name,
...(c.recognizedBy && {
"recognizedBy": { "@type": "Organization", "name": c.recognizedBy },
}),
})),
"sameAs": o.sameAs ?? [],
};
}
// ── Person Schema ──
function buildPerson(p) {
return {
"@context": "https://schema.org",
"@type": "Person",
"@id": personId(p.slug),
"name": p.name,
"givenName": p.givenName,
"familyName": p.familyName,
"jobTitle": p.jobTitle,
"url": `${SITE}/authors/${p.slug}/`,
"image": SITE + p.image,
"email": p.email,
"description": p.description.trim(),
"worksFor": { "@id": ORG_ID }, // 引用 Organization
...(p.alumniOf && {
"alumniOf": { "@type": "CollegeOrUniversity", "name": p.alumniOf },
}),
"hasCredential": (p.credentials ?? []).map(c => ({
"@type": "EducationalOccupationalCredential",
"credentialCategory": c.category,
"name": c.name,
})),
"knowsAbout": p.knowsAbout ?? [],
"sameAs": p.sameAs ?? [],
};
}
// ── Product Schema(SKU 页) ──
function buildProduct(row) {
return {
"@context": "https://schema.org",
"@type": "Product",
"name": row.product_name ?? row.pkw,
"description": row.description,
...(row.model && { "model": row.model }),
"brand": { "@type": "Brand", "name": entities.organization.name },
"manufacturer": { "@id": ORG_ID },
...(row.specs && {
"additionalProperty": Object.entries(row.specs).map(([k, v]) => ({
"@type": "PropertyValue",
"name": k,
"value": String(v),
})),
}),
"offers": {
"@type": "Offer",
"availability": "https://schema.org/InStock",
"seller": { "@id": ORG_ID },
},
};
}
// ── TechArticle Schema ──
function buildTechArticle(row) {
return {
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": row.title ?? row.pkw,
"description": row.description,
"datePublished": row.publishedAt,
"dateModified": row.updatedAt ?? row.publishedAt,
"author": { "@id": personId(row.author_slug ?? entities.persons[0].slug) },
"publisher": { "@id": ORG_ID },
"mainEntityOfPage": {
"@type": "WebPage",
"@id": SITE + row.page_slug,
},
...(row.about && {
"about": row.about.map(t => ({ "@type": "Thing", "name": t })),
}),
...(row.citations && {
"citation": row.citations.map(c => ({
"@type": "CreativeWork",
"name": c,
})),
}),
};
}
// ── BreadcrumbList ──
function buildBreadcrumb(row) {
const parts = row.page_slug.split('/').filter(Boolean);
const items = [{ name: 'Home', url: SITE + '/' }];
let acc = '';
for (const part of parts) {
acc += '/' + part;
items.push({
name: part.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
url: SITE + acc + '/',
});
}
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": items.map((it, i) => ({
"@type": "ListItem",
"position": i + 1,
"name": it.name,
...(i < items.length - 1 && { "item": it.url }),
})),
};
}
// ── FAQPage(从页面 FAQ 数据构建) ──
function buildFaqPage(faqs) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(f => ({
"@type": "Question",
"name": f.question,
"acceptedAnswer": { "@type": "Answer", "text": f.answer },
})),
};
}
// ── 按 page_type 分发 ──
const output = {};
// Organization(首页 + about)
output['/'] = [buildOrganization(), {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${SITE}/#website`,
"url": SITE,
"name": entities.organization.name,
"publisher": { "@id": ORG_ID },
}];
output['/about/'] = [buildOrganization()];
// Person(每个作者主页)
for (const p of entities.persons) {
output[`/authors/${p.slug}/`] = [buildPerson(p)];
}
// 注册表页面
const seen = new Set();
for (const row of registry) {
if (row.status === 'deprecated') continue;
if (seen.has(row.page_slug)) continue;
seen.add(row.page_slug);
const schemas = [];
switch (row.page_type) {
case 'pillar':
schemas.push(buildOrganization(), buildBreadcrumb(row));
break;
case 'cluster-sku':
schemas.push(buildProduct(row), buildBreadcrumb(row));
break;
case 'cluster-app':
case 'cluster-resource':
schemas.push(buildTechArticle(row), buildBreadcrumb(row));
break;
}
if (row.faqs?.length) schemas.push(buildFaqPage(row.faqs));
if (schemas.length) output[row.page_slug] = schemas;
}
await fs.mkdir('src/data/generated', { recursive: true });
await fs.writeFile(
'src/data/generated/schemas.json',
JSON.stringify(output, null, 2)
);
console.log(JSON.stringify({
summary: {
pagesWithSchema: Object.keys(output).length,
organizationSchemas: Object.values(output)
.flat().filter(s => s['@type'] === 'Organization').length,
personSchemas: entities.persons.length,
productSchemas: Object.values(output)
.flat().filter(s => s['@type'] === 'Product').length,
techArticleSchemas: Object.values(output)
.flat().filter(s => s['@type'] === 'TechArticle').length,
},
outputFile: 'src/data/generated/schemas.json',
}, null, 2));
四、引用完整性校验脚本
生成后必须验证 @id 引用链完整——这是最容易出问题也最容易被忽略的部分。
// scripts/schema-validate.mjs
import fs from 'fs/promises';
const schemas = JSON.parse(
await fs.readFile('src/data/generated/schemas.json', 'utf8')
);
// 收集所有已定义的 @id
const definedIds = new Set();
for (const list of Object.values(schemas)) {
for (const s of list) {
if (s['@id']) definedIds.add(s['@id']);
}
}
// 递归查找所有 @id 引用(形如 { "@id": "..." } 但自身没有 @type)
function findReferences(obj, path = '', refs = []) {
if (Array.isArray(obj)) {
obj.forEach((v, i) => findReferences(v, `${path}[${i}]`, refs));
return refs;
}
if (obj && typeof obj === 'object') {
const keys = Object.keys(obj);
// 纯引用节点:只有 @id,没有 @type
if (keys.length === 1 && keys[0] === '@id') {
refs.push({ id: obj['@id'], path });
return refs;
}
for (const [k, v] of Object.entries(obj)) {
if (k === '@id') continue;
findReferences(v, path ? `${path}.${k}` : k, refs);
}
}
return refs;
}
const errors = [];
const warns = [];
// 检查 1:引用的 @id 是否已定义
for (const [page, list] of Object.entries(schemas)) {
for (const s of list) {
const refs = findReferences(s);
for (const ref of refs) {
if (!definedIds.has(ref.id)) {
errors.push({
type: 'dangling_reference',
page,
schemaType: s['@type'],
missingId: ref.id,
fieldPath: ref.path,
});
}
}
}
}
// 检查 2:必填字段
const REQUIRED_FIELDS = {
Organization: ['name', 'url', 'address'],
Person: ['name', 'jobTitle', 'worksFor'],
Product: ['name', 'description', 'brand'],
TechArticle: ['headline', 'author', 'publisher', 'datePublished'],
FAQPage: ['mainEntity'],
BreadcrumbList: ['itemListElement'],
};
for (const [page, list] of Object.entries(schemas)) {
for (const s of list) {
const required = REQUIRED_FIELDS[s['@type']] ?? [];
const missing = required.filter(f => !s[f]);
if (missing.length) {
errors.push({
type: 'missing_required_fields',
page,
schemaType: s['@type'],
missing,
});
}
}
}
// 检查 3:EEAT 建议字段(缺失不致命但影响效果)
const RECOMMENDED = {
Organization: ['sameAs', 'hasCredential', 'foundingDate', 'logo'],
Person: ['sameAs', 'knowsAbout', 'hasCredential', 'image'],
Product: ['additionalProperty', 'manufacturer'],
TechArticle: ['citation', 'dateModified'],
};
for (const [page, list] of Object.entries(schemas)) {
for (const s of list) {
const rec = RECOMMENDED[s['@type']] ?? [];
const missing = rec.filter(f =>
!s[f] || (Array.isArray(s[f]) && s[f].length === 0)
);
if (missing.length) {
warns.push({
type: 'missing_recommended_fields',
page,
schemaType: s['@type'],
missing,
impact: 'EEAT 信号减弱',
});
}
}
}
// 检查 4:NAP 一致性(Organization 的 name 在所有实例中必须相同)
const orgNames = new Set(
Object.values(schemas).flat()
.filter(s => s['@type'] === 'Organization')
.map(s => s.name)
);
if (orgNames.size > 1) {
errors.push({
type: 'inconsistent_org_name',
found: [...orgNames],
note: '违反 NAP 一致性要求(第 39 篇)',
});
}
console.log(JSON.stringify({
summary: {
pagesChecked: Object.keys(schemas).length,
definedIds: [...definedIds],
errorCount: errors.length,
warnCount: warns.length,
verdict: errors.length === 0 ? 'PASS' : 'FAIL',
},
errors,
warns,
}, null, 2));
process.exit(errors.length > 0 ? 1 : 0);
五、Skill 说明书
# Skill:Author 与 Organization Schema 批量生成
## 何时使用
- 首次为站点配置 Schema
- 新增作者或页面后同步 Schema
- 修改公司信息后全站更新
## 输入
- `src/data/entities.yaml`(实体配置,需先建好)
- `registry.json`
## 执行步骤
### Step 1:检查 entities.yaml 完整性(你来做)
必填检查:
□ organization.legalName 是法定注册名(不是简称)
□ organization.address 完整(含邮编和国家代码)
□ organization.sameAs 至少包含 LinkedIn 公司页
□ 每个 person 有 jobTitle(具体职位,不是 "Engineer")
□ 每个 person 的 sameAs 至少包含 LinkedIn 个人档案
□ 每个 person 的 knowsAbout 与站点内容主题对应
如 sameAs 为空 → 提示用户先完成第 39 篇的实体绑定,
Schema 里的 sameAs 是跨来源验证的入口,为空则 Schema 的
EEAT 价值大幅降低。
### Step 2:补全需要表达的字段(你来写)
脚本无法生成的字段:
- `organization.description`:60–100 词,含主营产品和服务行业
- `person.description`:50–80 词,含工作年限、专长、经验规模
- `person.knowsAbout`:3–6 项,必须与该作者实际写的内容主题一致
**knowsAbout 的写法要求**:
用具体的技术领域,不用泛化描述
✅ "NPSH analysis and cavitation prevention"
❌ "Pump engineering"
❌ "Industrial equipment"
### Step 3:运行生成脚本
执行 `scripts/schema-generate.mjs`
### Step 4:运行校验脚本
执行 `scripts/schema-validate.mjs`
- verdict = PASS → 进入 Step 5
- verdict = FAIL → 按 errors 修复 entities.yaml 或注册表,重新生成
**dangling_reference 错误的处理**:
说明某个 Schema 引用了不存在的 @id,通常是:
- TechArticle 的 author_slug 在 entities.yaml 的 persons 里没有对应项
- 修复:在 entities.yaml 添加该作者,或修正注册表的 author_slug
### Step 5:线上验证
部署后,用 Google Rich Results Test 验证 3–5 个代表性页面:
- 首页(Organization + WebSite)
- 一个 SKU 页(Product)
- 一个作者主页(Person)
- 一篇技术文章(TechArticle + FAQPage)
## 输出格式
Schema 批量生成报告
实体配置:[N] 个组织,[N] 位作者 生成页面:[N] 个 校验结果:[PASS/FAIL]
Schema 分布
| 类型 | 数量 |
|---|---|
| Organization | 12 |
| Person | 2 |
| Product | 8 |
| TechArticle | 24 |
| FAQPage | 18 |
| BreadcrumbList | 44 |
ERROR([N] 项)
E1. 引用悬空
- 页面:/blog/npsh-explained/
- Schema:TechArticle
- 字段:author
- 引用的 @id:https://…/authors/sarah-liu/#person
- 问题:entities.yaml 的 persons 中无 sarah-liu
- 修复:在 entities.yaml 添加该作者,或修正注册表的 author_slug
WARN([N] 项)
W1. 缺少 EEAT 建议字段
- 页面:/authors/michael-chen/
- Schema:Person
- 缺少:sameAs
- 影响:无外部档案链接,Google 无法跨来源验证该人物实体
- 建议:补充 LinkedIn 个人档案 URL
线上验证清单
□ 首页 Rich Results Test 无 Error □ SKU 页 Product Schema 被识别 □ 作者主页 Person Schema 被识别 □ 文章页 TechArticle + FAQPage 均被识别
## 禁止行为
- 不要手工编辑 generated/schemas.json(改 entities.yaml 后重新生成)
- sameAs 为空时不要跳过警告——这是 Schema 的核心价值所在
- knowsAbout 不要写与站点内容无关的领域(Google 会核对一致性)
- 不要为不存在的人物创建 Person Schema(第 37 篇的原则)
六、在 Astro 中消费生成结果
生成的 schemas.json 由 SEOHead 组件读取:
---
// src/components/seo/SchemaFromRegistry.astro
import schemas from '../../data/generated/schemas.json';
const pathname = Astro.url.pathname;
const pageSchemas = schemas[pathname] ?? [];
---
{pageSchemas.map(schema => (
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
))}
为什么用生成文件而不是运行时构建:生成阶段可以跑校验,运行时构建无法验证引用完整性。生成 + 校验 + 消费三步分离,问题在部署前就能发现。
七、与第 38 篇的关系
| 第 38 篇 | 本篇 | |
|---|---|---|
| 内容 | Schema 字段含义和完整代码示例 | 批量生成的工程化方案 |
| 适用 | 理解 Schema 该写什么 | 站点有 20+ 页面时的规模化 |
| 数据源 | 手写在页面里 | entities.yaml 单一事实来源 |
| 校验 | 靠 Rich Results Test 逐页查 | 脚本批量校验引用完整性 |
先读第 38 篇理解 Schema 结构,再用本篇的方案规模化。
→ AI Skill:竞品关键词差距分析
RELATED / 相关推荐
接着读这些
按同一分类、系列与标签为你挑选。
Author Schema + Organization Schema JSON-LD 注入实战
结构化数据是告诉 Google"我们是谁"的官方语言。这篇给出可直接复制的 Organization、Person、Article Schema 完整代码,讲清楚各字段的作用、sameAs 属性如何绑定外部实体、如何在 Astro 中封装可复用的 Schema 组件,以及用 Rich Results Test 验证。
GEO 审计升级:从「结构合规」到「有没有被引用」
之前那个 GEO 覆盖审计 Skill 只能查结构,查不了覆盖——因为它没有期望值可比。补上意图注册表之后,审计从一层变三层:结构、覆盖与一致性、引用实测。其中复述块与主答块的一致性检查是纯确定性的,脚本能百分之百判定,这是升级带来的最大收益。
AI Skill:Cannibalization 诊断——同站竞争页面识别
注册表审计查的是规划层面的冲突,Cannibalization 诊断查的是已发布内容的实际竞争。这个 Skill 用 GSC 数据找出同一查询下多个页面轮流排名的证据,再用页面正文比对确认原因,最后给出合并、差异化或降权的处置决策。