GEO 覆盖审计检查六类项:robots.txt 是否放行 AI 爬虫、内容是否服务端渲染、FAQ 是否用标题标签且自包含、参数表是否用原生 table+caption、hreflang 是否双向自引用、Schema 是否完整。脚本做结构检查,模型判断 FAQ 答案的自包含程度。
AI Skill:GEO 覆盖审计——多语言与 AI 可见性缺口分析
审计的六类检查项
第 40–44 篇讲了 GEO 的全部规范。这个 Skill 把它们转成可执行的检查规则,按优先级排列(对 AI 可见性影响从大到小):
| # | 检查项 | 数据来源 | 影响 |
|---|---|---|---|
| 1 | robots.txt 是否放行 AI 爬虫 | robots.txt | 致命:屏蔽了则完全不可见 |
| 2 | 内容是否服务端渲染 | HTML 源码 | 致命:JS 渲染则 AI 抓不到 |
| 3 | FAQ 是否用标题标签且自包含 | HTML 结构 + 语义 | 高 |
| 4 | 参数表是否原生 table + caption | HTML 结构 | 高 |
| 5 | hreflang 是否双向自引用 | HTML head | 中(仅多语言站) |
| 6 | Schema 完整性 | JSON-LD | 中 |
前两项是致命项——不通过的话,后面四项做得再好都没用。所以审计必须按这个顺序执行。
这个 Skill 只查结构,查不了覆盖。
把六项检查排在一起看会发现:它们全都在问“格式对不对”,没有一项在问“该答的问题答了没有”。 原因是没有期望值可比——SEO 侧的每个审计都拿关键词注册表当期望值算差集,GEO 侧当时没有那张表。
补上 GEO 意图注册表之后,审计才能加第二层:登记未落地、 落地未登记、主答块缺失、复述块与主答块文本分叉、fact_refs 悬空。 这一层全部是确定性判定,脚本能直接判。见 GEO 审计升级:从「结构合规」到「有没有被引用」—— 那篇的第一层直接调用本篇的脚本,两者不重复。
这篇是 GEO 审计的结构层。下一组文章会在此基础上补上站级期望值:先建立意图注册表,再检查登记、落地和复述是否一致;最后由健康度报告汇总新的 GEO 指标。
Skill 说明书
# Skill:GEO 覆盖审计
## 何时使用
- 定期(每季度)GEO 健康检查
- 在 Perplexity/ChatGPT 测试发现未被引用时
- 上线新一批页面后
## 输入
- 构建产物目录(如 `dist/`)
- 站点 URL(用于抓取线上 robots.txt 验证)
## 执行步骤
### Step 1:致命项检查(先跑,不通过则停止后续)
执行 `scripts/geo-audit.mjs --critical`
**检查 1:robots.txt AI 爬虫放行**
必须检查以下 User-Agent 是否被 Disallow:
GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-Web,
PerplexityBot, Google-Extended
判定:
- 任一被 `Disallow: /` → error(致命)
- 未显式提及但 `User-agent: *` 允许 → 通过(默认允许)
- 显式 `Allow: /` → 通过(最优)
**检查 2:服务端渲染验证**
对每个页面的 HTML,检查 `<main>` 或 `<body>` 内的纯文本长度。
判定:
- 纯文本 < 200 字符 → error(疑似客户端渲染)
- 纯文本 ≥ 200 字符 → 通过
如果任一致命项不通过,输出 error 报告并停止,
不要继续检查后续项——先解决致命问题。
### Step 2:结构项检查
执行 `scripts/geo-audit.mjs --structure`
脚本检查:
- FAQ 区域是否用 h2/h3 标签(而非 strong/b)
- 是否存在 `<table>` 元素,是否有 `<caption>`
- hreflang 标签的双向性和自引用
- JSON-LD 的存在性和类型
### Step 3:FAQ 自包含性判断(你来做)
脚本会提取所有 FAQ 的问题标题和答案文本。
对每条 FAQ,按第 41 篇的自包含原则判断:
**问题标题检查**:
- 是否包含完整主体名(型号名/产品名)
✅ "What is the maximum temperature of the CP-100 centrifugal pump?"
❌ "最高温度是多少?"(缺主体)
❌ "What is its operating range?"(用 its 指代)
**答案首句检查**:
- 首句是否包含主体名(不用 it/this/该产品 指代)
- 首句是否给出核心结论或数值
✅ "The CP-100 operates from -20°C to 120°C."
❌ "It depends on the configuration."(无实质信息)
❌ "该泵的温度范围较宽。"(无具体数值)
**答案长度检查**:
- 80–150 词 → 通过
- < 50 词 → warn(信息可能不足)
- > 200 词 → warn(可能被 chunk 切断,建议拆分)
**双单位检查**:
- 技术参数是否同时给出公制和英制
✅ "-20°C to 120°C (-4°F to 248°F)"
⚠️ "-20°C to 120°C"(缺英制,影响北美搜索匹配)
### Step 4:输出报告
## 输出格式
GEO 覆盖审计报告
生成时间:[YYYY-MM-DD] 检查页面数:[N] GEO 就绪度:[N]%
致命项([通过/不通过])
✅ robots.txt AI 爬虫放行
- GPTBot: Allow
- ClaudeBot: Allow
- PerplexityBot: Allow
- OAI-SearchBot: Allow
- Google-Extended: 未显式提及(默认允许)
- 建议:显式添加 Allow 规则,更明确
✅ 服务端渲染
- [N]/[N] 页面纯文本内容 ≥ 200 字符
- 无客户端渲染问题
ERROR([N] 项)
E1. FAQ 使用加粗文字而非标题标签
- 文件:/applications/centrifugal-pump-mining/
- 当前:
<strong>What flow rate is needed for mine dewatering?</strong> - 应为:
<h3>What flow rate is needed for mine dewatering?</h3> - 影响:RAG 系统无法在标题边界切片,该问答可能与前后内容混在 一个 chunk 里,稀释语义相关性
E2. 参数表缺少 caption
- 文件:/products/centrifugal-pumps/cp-100/
- 当前:
<table>无<caption>子元素 - 应添加:
<caption>CP-100 Centrifugal Pump Technical Specifications</caption> - 影响:LLM 提取参数时无法确认该表属于哪个型号,引用率下降
WARN([N] 项)
W1. FAQ 答案首句使用指代词
- 文件:/products/centrifugal-pumps/cp-200s/
- 问题:“What materials is the CP-200S available in?”
- 当前答案首句:“It is available in SS316 and duplex steel.”
- 建议改为:“The CP-200S is available in SS316 stainless steel and duplex steel casing options.”
- 原因:chunk 被单独提取后,“It” 无指代对象
W2. 技术参数缺少英制单位
- 文件:/products/centrifugal-pumps/cp-100/
- 参数:“Flow rate: 5–120 m³/h”
- 建议:“Flow rate: 5–120 m³/h (22–528 GPM)”
W3. hreflang 缺少自引用
- 文件:/de/produkte/kreiselpumpen/
- 当前 hreflang:en, fr, x-default
- 缺少:de(指向自己)
- 影响:hreflang 组不完整,Google 可能忽略整组
通过项
- [N] 个 FAQ 符合自包含规范
- [N] 个参数表使用原生 table + caption
- [N] 个页面有完整的 Schema(Product/FAQPage/TechArticle)
## 禁止行为
- 致命项不通过时,不要继续输出结构项的细节
(先解决致命问题,否则其他改动都是白做)
- 不要建议添加 llms.txt 作为高优先级项
(它的确定性回报低于前四项)
确定性脚本
// scripts/geo-audit.mjs
import fs from 'fs/promises';
import path from 'path';
import { parse } from 'node-html-parser';
const DIST = 'dist';
const AI_CRAWLERS = [
'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
'ClaudeBot', 'Claude-Web', 'PerplexityBot',
'Google-Extended', 'Applebot-Extended',
];
// ── 致命项 1:robots.txt 检查 ──
async function checkRobots() {
let robots;
try {
robots = await fs.readFile(path.join(DIST, 'robots.txt'), 'utf8');
} catch {
return { pass: false, reason: 'robots.txt not found' };
}
const results = {};
for (const crawler of AI_CRAWLERS) {
// 匹配该 crawler 的段落
const re = new RegExp(
`User-agent:\\s*${crawler}[\\s\\S]*?(?=User-agent:|$)`, 'i'
);
const section = robots.match(re)?.[0];
if (!section) {
results[crawler] = 'not_specified'; // 遵循 User-agent: * 规则
} else if (/Disallow:\s*\/\s*$/m.test(section)) {
results[crawler] = 'BLOCKED';
} else {
results[crawler] = 'allowed';
}
}
const blocked = Object.entries(results)
.filter(([, v]) => v === 'BLOCKED')
.map(([k]) => k);
return { pass: blocked.length === 0, results, blocked };
}
// ── 致命项 2:服务端渲染检查 ──
async function checkSSR(htmlFiles) {
const failures = [];
for (const file of htmlFiles) {
const html = await fs.readFile(file, 'utf8');
const root = parse(html);
const main = root.querySelector('main') || root.querySelector('body');
const text = (main?.text ?? '').replace(/\s+/g, ' ').trim();
if (text.length < 200) {
failures.push({ file, textLength: text.length });
}
}
return { pass: failures.length === 0, failures };
}
// ── 结构项检查 ──
async function checkStructure(htmlFiles) {
const issues = { faqHeading: [], tableCaption: [], hreflang: [], schema: [] };
const faqData = [];
for (const file of htmlFiles) {
const html = await fs.readFile(file, 'utf8');
const root = parse(html);
const urlPath = '/' + path.relative(DIST, file)
.replace(/index\.html$/, '').replace(/\\/g, '/');
// FAQ:查找疑似问句的 strong/b 元素(应该用标题标签)
root.querySelectorAll('strong, b').forEach(el => {
const text = el.text.trim();
if (/^(what|how|when|why|can|is|does|are|do)\b.*\?$/i.test(text)) {
issues.faqHeading.push({ file: urlPath, text });
}
});
// 提取实际的 FAQ(h2/h3 疑问句 + 后续段落),供模型判断自包含性
root.querySelectorAll('h2, h3').forEach(h => {
const q = h.text.trim();
if (!q.endsWith('?')) return;
let node = h.nextElementSibling;
const answerParts = [];
while (node && !['H2', 'H3'].includes(node.tagName)) {
if (node.tagName === 'P') answerParts.push(node.text.trim());
node = node.nextElementSibling;
}
const answer = answerParts.join(' ');
faqData.push({
file: urlPath,
question: q,
answer,
wordCount: answer.split(/\s+/).length,
});
});
// 参数表:检查 caption
root.querySelectorAll('table').forEach((t, i) => {
if (!t.querySelector('caption')) {
issues.tableCaption.push({ file: urlPath, tableIndex: i });
}
});
// hreflang:检查自引用
const hreflangs = root.querySelectorAll('link[rel="alternate"][hreflang]')
.map(l => l.getAttribute('hreflang'));
if (hreflangs.length > 0) {
// 从路径推断当前语言(/de/... → de)
const langMatch = urlPath.match(/^\/([a-z]{2})\//);
const currentLang = langMatch?.[1] ?? 'en';
if (!hreflangs.includes(currentLang)) {
issues.hreflang.push({
file: urlPath, currentLang, declared: hreflangs,
});
}
if (!hreflangs.includes('x-default')) {
issues.hreflang.push({ file: urlPath, missing: 'x-default' });
}
}
// Schema:检查 JSON-LD 存在性和类型
const schemas = root.querySelectorAll('script[type="application/ld+json"]')
.map(s => {
try { return JSON.parse(s.text)['@type']; } catch { return 'INVALID'; }
});
if (schemas.length === 0) {
issues.schema.push({ file: urlPath, missing: 'no JSON-LD found' });
}
if (schemas.includes('INVALID')) {
issues.schema.push({ file: urlPath, error: 'invalid JSON-LD syntax' });
}
}
return { issues, faqData };
}
// ── 主流程 ──
async function collectHtml(dir) {
const out = [];
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...await collectHtml(full));
else if (e.name.endsWith('.html')) out.push(full);
}
return out;
}
const htmlFiles = await collectHtml(DIST);
const robotsResult = await checkRobots();
const ssrResult = await checkSSR(htmlFiles);
// 致命项不通过 → 只输出致命项
if (!robotsResult.pass || !ssrResult.pass) {
console.log(JSON.stringify({
criticalFailure: true,
robots: robotsResult,
ssr: ssrResult,
}, null, 2));
process.exit(1);
}
const { issues, faqData } = await checkStructure(htmlFiles);
console.log(JSON.stringify({
criticalFailure: false,
summary: {
pagesChecked: htmlFiles.length,
faqCount: faqData.length,
faqHeadingIssues: issues.faqHeading.length,
tableCaptionIssues: issues.tableCaption.length,
hreflangIssues: issues.hreflang.length,
schemaIssues: issues.schema.length,
},
robots: robotsResult,
ssr: { pass: true },
issues,
// FAQ 原文,供模型判断自包含性
faqData,
}, null, 2));
手动补充项:AI 引用实测
脚本无法验证的一项:你的内容实际上是否被 AI 引用。
这需要手动测试,建议每月一次,记录在一张表里:
| 测试问题 | Perplexity | ChatGPT | 引用页面 | 信息准确性 |
|---|---|---|---|---|
| “CP-100 pump max temperature” | ✅ | ✅ | /products/…/cp-100/ | 准确 |
| “pump material for 60% sulfuric acid” | ✅ | ❌ | /applications/chemical-transfer/ | 准确 |
| “best centrifugal pump manufacturers” | ❌ | ❌ | — | — |
如果 AI 引用了但信息不准确:说明该页面的 FAQ 或参数表表述不清,回去按自包含原则重写。这比“完全没被引用”更需要优先处理——错误信息会损害品牌。
→ 为什么关键词注册表管不了 AI 搜索:词与问的三层错位
RELATED / 相关推荐
接着读这些
按同一分类、系列与标签为你挑选。
GEO 审计升级:从「结构合规」到「有没有被引用」
之前那个 GEO 覆盖审计 Skill 只能查结构,查不了覆盖——因为它没有期望值可比。补上意图注册表之后,审计从一层变三层:结构、覆盖与一致性、引用实测。其中复述块与主答块的一致性检查是纯确定性的,脚本能百分之百判定,这是升级带来的最大收益。
AI Skill:Cannibalization 诊断——同站竞争页面识别
注册表审计查的是规划层面的冲突,Cannibalization 诊断查的是已发布内容的实际竞争。这个 Skill 用 GSC 数据找出同一查询下多个页面轮流排名的证据,再用页面正文比对确认原因,最后给出合并、差异化或降权的处置决策。
AI Skill:页面覆盖度审计——找出注册表中无对应页面的词
注册表里躺着一批词,但对应的页面还没建——这是最容易被遗忘的增长机会。这个 Skill 用脚本做注册表与实际路由的差集比对,模型负责按业务价值排序并输出可执行的建页清单。