跳到主要内容
IM智引科技

研究 / 外贸与 B2B 增长

AI Skill:GEO 覆盖审计——多语言与 AI 可见性缺口分析

GEO 优化做了没做、做到什么程度,肉眼看不出来。这个 Skill 把第 40–44 篇的所有 GEO 规范转成可自动检查的规则:FAQ 是否自包含、参数表是否语义化、hreflang 是否双向、AI 爬虫是否被放行,并输出优先级排序的缺口清单。

BLKTECH 编辑部2026年8月4日12 分钟难度 实战免费Node.jsAI Agent

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 搜索:词与问的三层错位

NEXT ACTION / 下一步

继续系列:AI 外贸站建设全系列

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

继续

RELATED / 相关推荐

接着读这些

按同一分类、系列与标签为你挑选。