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 页有固定入口 | ❌ 不该报 |
| 标签 / 分类聚合页 | 入口在聚合索引里,静态抓取可能拿不到 | ❌ 不该报 |
| 有外部流量的落地页 | 刻意不放站内入口 | ❌ 不该报 |
| 已下线但未删除的旧页 | 内链早就撤了 | ✅ 该报,且该删 |
| 新发布但忘了挂入口的页 | 真的漏了 | ✅ 这才是要找的 |
所以孤儿页检测分两步:
- 脚本算出
实际路由 − 内链图的差集(纯集合运算,确定性) - 模型判断每一个是不是伪孤儿,以及真孤儿该从哪里补内链
第 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 工具箱」系列:
- 死链与断链审计
- Meta 审计:title、description、H1
- 结构化数据与 JSON-LD 校验
- 当前:Sitemap 差异审计与孤儿页发现
- 内链结构审计:权重分布、内容孤岛与锚文本
- 内置 seo:audit 与 Skill 封装
- 配套资源:说明书、规则注册表与检查清单
系列原则至此五条:
- 确定性交给脚本,判断交给模型(①)
- 能量化的写成阈值表,不能量化的给判断依据(②)
- CI 的阻断线画在「有唯一正确答案」的地方(②③④)
- 报告要给根因,不是给症状列表(③)
- 集合运算前先做归一化——脚本不会因格式不一致报错,只会安静地全错(④)
最后一条是本篇的新增。前四篇的错误大多会以「报错」的形式暴露出来,而集合运算的错误不会——它永远给得出一个看起来很像样的答案。这类静默失败,比崩溃危险得多。
相关阅读:
- Astro 内容站搭建指南——sitemap、RSS 与 robots.txt 的基础配置
- 域名与 DNS 基础——URL 结构与站点边界
RELATED / 相关推荐
接着读这些
按同一栏目、标签与技术栈为你挑选。
AI Skills 高阶:把 SEO 检查内置进项目,做成一条命令 + 一个 Skill
把分散的五类 SEO 检查合并成项目内置的 seo:audit 脚本,用统一的规则注册表管理 24 项检查与 error/warn 分级,再封装成 Skill,让检测、解析和修复方案一次给全。
AI Skills 实战:把网站死链与断链审计做成一个可复用 Skill
把死链检查的判定规则和输出格式固化成一份 Skill 说明书,配合两个确定性脚本,让 AI Agent 每次都按同一套 SOP 产出可执行的审计报告,并接入 CI 持续拦截。
AI Skills 实战:Meta 审计 Skill——查 title、description 与 H1 的缺失、重复与超长
把 title、description、H1 的判定阈值和改写依据固化成 Skill 说明书,用半角当量宽度替代字符数解决中文截断误判,让模型直接给出改写候选而不只是报告超长。