跳到主要内容
BLKBLKTECH

指南 / ai

AI Skills 实战:Sitemap 差异审计 Skill——六个信号源打架时,谁说了算

把实际路由、sitemap、内链图、robots.txt、meta robots 和 canonical 当作六个独立信号源,用集合运算查出它们之间的互相矛盾与孤儿页,并绕开百分号编码等三个实现陷阱。

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

Sitemap 审计的本质是集合运算:把实际路由、sitemap、内链图、robots.txt、meta robots、canonical 当作六个独立信号源,两两求差集找矛盾。自相矛盾的信号(在 sitemap 里却被 noindex)是客观错误应阻断构建,缺失类问题(漏收录、孤儿页)需要人判断只做报告。

先说结论

前三篇都是逐页检查:这条链接通不通(①)、这页字段好不好(②)、这页标记对不对(③)。视角始终在单个页面上。

这一篇的视角要升一层。看一个真实例子——本站的 /search/ 页面:

信号源 它说了什么
sitemap.xml 收录了 https://blktech.cn/search/,即「请索引这一页」
页面 <meta name="robots"> noindex, follow,即「别索引这一页」

两个信号直接打架。 而单独看任何一个都挑不出毛病:sitemap 语法完全正确,noindex 也确实是搜索页该有的设置。前三篇的任何一个 Skill 都发现不了这个问题——它不在任何单个页面里,它在页面之间、清单之间

所以第四篇要做的是:

不问「这一页对不对」,问「这几份清单说的是不是同一件事」。

方法是集合运算。


一、先划清和第一篇的边界

第一篇的检查清单里已经有「sitemap 与实际不一致」和「孤儿页」两条,容易和本篇混淆。区别在提问方式

① 死链审计 ④ Sitemap 差异审计
单位 一条链接 一个集合
提问 这条链接通不通? 这两份清单的差集是什么?
能发现 sitemap 里指向 404 的条目 sitemap 漏收录、信号互相矛盾
发现不了 页面存在但 sitemap 没收 单条链接本身的连通性

具体说,①逐条访问 sitemap 里的 URL,所以能发现「sitemap 有、页面没有」(死链)。但它永远发现不了反方向——「页面有、sitemap 没有」,因为那个 URL 压根不在它的检查列表里。

逐条遍历只能验证清单里的东西,验证不了清单本身是否完整。 这是①的结构性盲区,也是④存在的理由。


二、六个信号源

一个站点其实同时在向搜索引擎发六种声明,它们互相独立、各自可能出错:

# 信号源 它声明了什么
1 实际路由(构建产物) 事实:这些页面真的存在
2 sitemap.xml 意图:请索引这些
3 站内链接图 权重:我认为这些重要
4 robots.txt 边界:这些不许爬
5 meta robots 指令:这页不许索引
6 canonical 归属:这页的正主是谁

只有第 1 个是事实,其余五个都是你的声明。声明和事实可以不符,声明和声明之间也可以互相矛盾。

搜索引擎遇到矛盾信号时的处理方式是不确定的——有时选一个信任,有时两个都打折。你无法预测结果,这本身就是问题。


三、矛盾矩阵

把六个信号源两两组合,会产生问题的组合有九种。这张表是整个 Skill 的知识内核:

# 矛盾 含义 级别
1 sitemap ∩ noindex 求索引,又拒绝索引 🔴
2 sitemap ∩ robots.txt disallow 求索引,又不让爬 🔴
3 sitemap ∩ canonical 指向别处 求索引一个自称非正主的页面 🔴
4 sitemap − 实际路由 sitemap 里躺着死链 🔴
5 canonical → 404 声明的正主不存在 🔴
6 实际路由 − sitemap 页面漏收录 🟡
7 实际路由 − 内链图 孤儿页 🟡
8 内链图 ∩ noindex 内链权重流向不索引的页面 🟢
9 robots.txt disallow ∩ 有内链 爬虫顺着内链撞墙 🟢

第 2 条值得单独说,因为它是最反直觉的一个:

robots.txt 屏蔽的页面,仍然可能被索引。

robots.txt 管的是爬取不是索引。搜索引擎不能爬,但如果有大量外链指向它,仍可能凭链接信息把这个 URL 收进索引——只是显示的摘要会是空的或「由于该网站的 robots.txt,我们无法提供说明」。

更要命的是连锁反应:想让一个页面不被索引,正确做法是让它可爬 + noindex;如果你同时 disallow 了它,搜索引擎爬不到那个页面,也就永远读不到上面的 noindex。屏蔽反而阻止了你的排除指令生效。

第 1 条就是本站 /search/ 的情况。


四、孤儿页:定义比想象的复杂

「没有任何内链指向的页面」——这个定义写成代码只有一行差集,但直接拿它出报告会有大量噪音。

因为存在伪孤儿页

类型 为什么看着像孤儿 该不该报
分页第 2 页及以后 只有第 1 页有固定入口 ❌ 不该报
标签 / 分类聚合页 入口在聚合索引里,静态抓取可能拿不到 ❌ 不该报
有外部流量的落地页 刻意不放站内入口 ❌ 不该报
已下线但未删除的旧页 内链早就撤了 ✅ 该报,且该删
新发布但忘了挂入口的页 真的漏了 这才是要找的

所以孤儿页检测分两步:

  1. 脚本算出 实际路由 − 内链图 的差集(纯集合运算,确定性)
  2. 模型判断每一个是不是伪孤儿,以及真孤儿该从哪里补内链

第 2 步没法用规则写死——「这个页面该不该有内链入口」取决于它在整站结构里的角色,这是判断题。这也再次印证系列第一条原则:确定性交给脚本,判断交给模型。


五、Skill 设计:脚本占比最高的一篇

四篇里,这一篇脚本承担的比例最大,因为集合运算是纯确定性的

任务 归属
解析 sitemap(含 sitemap-index 多文件) 脚本
遍历构建产物得到实际路由集合 脚本
构建站内链接图 脚本(复用①)
解析 robots.txt 的 disallow 规则 脚本
提取 meta robots 与 canonical 脚本
九种矛盾的集合运算 脚本
判断孤儿页是真是伪 模型
判断矛盾该往哪个方向修 模型
归纳根因、生成报告 模型

「判断矛盾往哪个方向修」比听起来重要。发现 /search/ 既在 sitemap 又是 noindex,有两种修法:

  • 从 sitemap 移除 → 保留 noindex
  • 去掉 noindex → 让它被索引

哪种对,取决于这个页面该不该被索引,而这需要理解页面用途。搜索结果页显然该保持 noindex、从 sitemap 移除;但如果是个被误标 noindex 的产品页,那就该反过来修。脚本只能报告矛盾,报不了方向。


六、脚本:集合运算

// scripts/sitemap-diff.mjs
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, relative } from 'node:path';

const DIST = process.argv[2] ?? 'dist';
const SITE = (process.env.SITE_URL ?? 'https://example.com').replace(/\/$/, '');

// ---- 归一化:所有集合运算前必须统一格式,否则差集全是假的 ----
const norm = (u) => {
  let p = u.startsWith('http') ? new URL(u).pathname : u;
  p = decodeURIComponent(p);              // 陷阱①:中文 slug 在 sitemap 里是编码的
  p = p.replace(/index\.html$/, '');
  if (!p.endsWith('/')) p += '/';         // 陷阱②:尾部斜杠必须统一
  return p;
};

// ---- 信号源 1:实际路由 ----
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 files = await walk(DIST);
const routes = new Set(files.map((f) => norm('/' + relative(DIST, f).replace(/\\/g, '/'))));

// ---- 信号源 2:sitemap(处理 sitemap-index 多文件)----
async function readSitemap(name) {
  const xml = await readFile(join(DIST, name), 'utf8').catch(() => '');
  if (!xml) return new Set();
  const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
  if (xml.includes('<sitemapindex')) {
    const sets = await Promise.all(
      locs.map((l) => readSitemap(l.split('/').pop()))    // 递归子 sitemap
    );
    return new Set(sets.flatMap((s) => [...s]));
  }
  return new Set(locs.map(norm));
}
const sitemap = await readSitemap('sitemap-index.xml');

// ---- 信号源 4:robots.txt ----
const robotsTxt = await readFile(join(DIST, 'robots.txt'), 'utf8').catch(() => '');
const disallow = [...robotsTxt.matchAll(/^\s*Disallow:\s*(\S+)/gim)]
  .map((m) => m[1]).filter((p) => p !== '/');
const isBlocked = (p) => disallow.some((d) => p.startsWith(d));

// ---- 信号源 3/5/6:链接图、meta robots、canonical ----
const linked = new Set();
const meta = new Map();

for (const file of files) {
  const html = await readFile(file, 'utf8');
  const url = norm('/' + relative(DIST, file).replace(/\\/g, '/'));

  // 陷阱③:只在 <head> 里找 robots,正文提到 "noindex" 不算
  const head = html.split('</head>')[0];
  const robots = head.match(/<meta\s+name="robots"\s+content="([^"]*)"/i)?.[1] ?? '';
  const canonical = head.match(/<link\s+rel="canonical"\s+href="([^"]*)"/i)?.[1] ?? '';
  meta.set(url, { noindex: /noindex/i.test(robots), canonical: canonical ? norm(canonical) : null });

  for (const [, href] of html.matchAll(/<a[^>]+href="([^"]+)"/gi)) {
    if (/^(https?:|mailto:|tel:|#)/.test(href) && !href.startsWith(SITE)) continue;
    linked.add(norm(new URL(href, SITE + url).pathname));
  }
}

// ---- 九种矛盾 ----
const diff = (a, b) => [...a].filter((x) => !b.has(x)).sort();
const inSitemap = (fn) => [...sitemap].filter(fn).sort();

const findings = {
  'sitemap∩noindex':       inSitemap((u) => meta.get(u)?.noindex),
  'sitemap∩disallow':      inSitemap(isBlocked),
  'sitemap∩canonical异位':  inSitemap((u) => meta.get(u)?.canonical && meta.get(u).canonical !== u),
  'sitemap−路由':           diff(sitemap, routes),
  'canonical→404':          [...meta].filter(([, m]) => m.canonical && !routes.has(m.canonical)).map(([u]) => u),
  '路由−sitemap':           diff(routes, sitemap),
  '孤儿页':                 diff(routes, linked).filter((u) => u !== '/'),
  '内链∩noindex':           [...linked].filter((u) => meta.get(u)?.noindex).sort(),
  'disallow∩有内链':        [...linked].filter(isBlocked).sort(),
};

await mkdir('.audit', { recursive: true });
await writeFile('.audit/sitemap.json', JSON.stringify(
  { counts: { routes: routes.size, sitemap: sitemap.size, linked: linked.size }, findings }, null, 2));

// 矛盾类阻断,缺失类只报告
const BLOCKING = ['sitemap∩noindex', 'sitemap∩disallow', 'sitemap∩canonical异位', 'sitemap−路由', 'canonical→404'];
const blockers = BLOCKING.flatMap((k) => findings[k]);
for (const [k, v] of Object.entries(findings)) if (v.length) console.log(`${k}: ${v.length}`);
console.log(`路由 ${routes.size} / sitemap ${sitemap.size} / 被内链 ${linked.size}`);
process.exit(blockers.length ? 1 : 0);

七、三个真实踩到的陷阱

集合运算看着简单,但每一步归一化没做对,差集就全是假的。下面三个是实测中真实踩到的,全部会产生大批假阳性:

陷阱一:百分号编码

中文 slug 在 sitemap 里是编码存储的:

sitemap 里:/guides/%E5%B1%95%E7%A4%BA%E5%9E%8B.../
文件系统里:/guides/展示型独立站进阶优化/

不做 decodeURIComponent 直接求差集,每一个中文 slug 页面都会同时出现在「漏收录」和「sitemap 死链」两个列表里。第一次跑本站时,7 个中文 slug 页面全部误报。

陷阱二:grep -c 数的是行数

想快速数 sitemap 条目时:

grep -c "<loc>" dist/sitemap-0.xml     # 输出 1 ❌
grep -o "<loc>" dist/sitemap-0.xml | wc -l   # 输出 65 ✅

XML sitemap 是压缩成单行的,grep -c 统计的是「包含匹配的行数」,永远返回 1。这个误差会让你以为 sitemap 几乎是空的。

陷阱三:全文搜 noindex

这条最隐蔽。用「HTML 里是否包含字符串 noindex」判断页面是否被排除,结果是:

⚠️ 在 sitemap 中却被 noindex:
  /guides/ai-skills-seo-link-audit/      ← 误报
  /guides/ai-skills-seo-meta-audit/      ← 误报
  /guides/astro-content-site-guide/      ← 误报
  /search/                               ← 真的

前三个都是正文里讨论了 noindex 这个词的 SEO 文章。任何写 SEO 内容的站,用全文匹配都会踩这个坑。

正确做法是只解析 <head> 里的 <meta name="robots"> 标签——上面脚本里 html.split('</head>')[0] 那一行就是为此。

三个陷阱有个共同点:它们不会让脚本报错,只会让结果安静地全错。 集合运算的可怕之处在于它永远给得出答案,不管输入的格式对不对。所以归一化函数应该是这个 Skill 里最先写、也最该测的部分。


八、报告样例

本站的真实扫描结果:

# Sitemap 差异审计报告

实际路由 66 / sitemap 66 / 孤儿页 0

## ✅ 覆盖率

sitemap − 路由:0    (无死链条目)
路由 − sitemap:0    (无漏收录)

Astro 的 sitemap 集成按构建产物自动生成,两个方向的差集都为空。

## 🔴 信号矛盾(1 处)

| 页面 | sitemap | meta robots | 矛盾 |
|---|---|---|---|
| /search/ | ✅ 已收录 | `noindex, follow` | 求索引又拒索引 |

**根因**`astro.config.mjs``sitemap()` 未配置 filter,
默认收录全部页面,无法感知页面级的 noindex 声明。

**修复方向**:搜索结果页本就不应被索引,保留 noindex,从 sitemap 排除。

```js
// astro.config.mjs
sitemap({ filter: (page) => !page.includes('/search') })
```

## 🟡 孤儿页(0 处)

路由 − 内链图 = ∅,全部 66 个页面都至少有一处站内入口。

> 本站的导航与系列互链较密,这一项为空。差集非空时,每一条都要交模型
> 判断是真孤儿还是伪孤儿(见「孤儿页」一节),脚本不做这个判断。

注意报告里那句只有模型能给的判断:「搜索结果页本就不应被索引,保留 noindex,从 sitemap 排除」。脚本只能报告「这两个信号打架了」,报不出该往哪边修——那需要理解 /search/ 这个页面是干什么的。

另外值得注意的是它定位到了根因:111 个页面级症状也好、1 处矛盾也好,问题都出在 astro.config.mjs 的一行配置上。报告直接给出改哪个文件、改成什么样,比列出问题清单有用得多。


九、修复策略

矛盾 修复方向
sitemap ∩ noindex 先定这页该不该索引,再决定去掉哪一边
sitemap ∩ disallow 想排除就用 noindex 而非 disallow;想索引就放开 robots.txt
sitemap ∩ canonical 异位 从 sitemap 移除非正主页面
sitemap 里是死链 从 sitemap 移除;有外链的另加 301
canonical → 404 改成实际存在的 URL;拿不准就自指
页面漏收录 检查 sitemap 生成逻辑,通常是配置漏了某个 collection
真孤儿页 从相关页面补内链;确实无价值就删或 noindex
内链 → noindex 页 可保留(导航需要),但别放在正文重点位置
disallow 的页有内链 内链加 nofollow,或干脆撤掉链接

有一条通用原则:

遇到信号矛盾,先回答「这个页面到底该不该被索引」,再决定改哪一边。

大多数人的本能是「让报错消失」——哪边好改改哪边。但把 noindex 删掉确实能让矛盾消失,代价是搜索结果页被收录进索引,这比原来的问题更糟。矛盾是症状,页面定位才是病根。


十、CI 策略:矛盾阻断,缺失放行

沿用系列的一贯原则(CI 只拦有唯一正确答案的事),这一篇的分界线格外清晰:

类别 例子 有无标准答案 CI
矛盾类 sitemap ∩ noindex、canonical→404 ✅ 有——自相矛盾就是错 阻断
缺失类 漏收录、孤儿页 ❌ 没有——有些页本就不该收录 报告

一句话:

自相矛盾是客观错误,缺失是主观判断。

/search/ 不在 sitemap 里,不是缺陷,是正确设计。但它同时在 sitemap 里又被 noindex,无论这页该不该索引,这个组合都是错的——这就是「有唯一正确答案」的含义。

# .github/workflows/sitemap-audit.yml
name: sitemap-audit
on: [push]
jobs:
  sitemap:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install && bun run build
      - run: SITE_URL=https://blktech.cn bun scripts/sitemap-diff.mjs dist
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: sitemap-audit, path: .audit/sitemap.json }

至此四篇的阻断线可以并排看了:

系列 阻断 放行
① 死链 内链 404 外链失效
② Meta title 缺失、跨页重复 长度、措辞
③ 结构化数据 L1 语法 + L2 类型 + 客观 L3 语义一致性
④ Sitemap 信号矛盾 漏收录、孤儿页

四条线的位置各不相同,但判据是同一条:这件事有没有唯一正确答案。


十一、系列导航

「SEO Skills 工具箱」系列:

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

系列原则至此五条:

  • 确定性交给脚本,判断交给模型(①)
  • 能量化的写成阈值表,不能量化的给判断依据(②)
  • CI 的阻断线画在「有唯一正确答案」的地方(②③④)
  • 报告要给根因,不是给症状列表(③)
  • 集合运算前先做归一化——脚本不会因格式不一致报错,只会安静地全错(④)

最后一条是本篇的新增。前四篇的错误大多会以「报错」的形式暴露出来,而集合运算的错误不会——它永远给得出一个看起来很像样的答案。这类静默失败,比崩溃危险得多。

相关阅读:

NEXT ACTION / 下一步

回看系列第三篇:结构化数据校验

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

继续

RELATED / 相关推荐

接着读这些

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