把 SEO 检查内置进项目的正确结构是:一个规则注册表统一定义 24 项检查及其 error/warn 分级,一个 runner 负责加载产物与遍历规则,输出机器可读的 JSON 加人类可读的摘要;validate 命令串起类型检查、构建和审计;Skill 只负责解读 JSON、定位根因和给修复方案,不重新实现检测逻辑。
这一篇要解决什么
系列前五篇各自造了一个 Skill,每个带一两个脚本。真拿到项目里用会立刻遇到三个问题:
- 五个脚本各自遍历一遍
dist/,重复读取几百个文件 - 五份判定规则散在五个地方,改一个阈值要翻半天
- 发布流程要记住跑五条命令,漏一条不会有任何提示
所以最后一步是收口:把五类检查合并成项目内置的一条命令,再用 Skill 包住它。
目标形态是这样的分工:
npm run validate
├─ 类型与构建检查 → astro check
├─ 生成最新产物 → astro build
└─ npm run seo:audit → 24 项检查,error 时退出码 1
↓
.audit/seo.json
↓
SEO 审计 Skill
├─ 读 JSON,不重新检测
├─ 归纳根因
└─ 给出定位到文件行号的修复方案
关键在最后那块的一句话:
脚本负责发现问题,Skill 负责解释问题。两者之间用 JSON 交接,职责不重叠。
很多人做到「写个检测脚本」就停了,结果是控制台刷出 200 行报错没人看。也有人走另一个极端,让 Agent 直接去读 HTML 找问题——慢、贵、还不稳定。分开做,各自都能做到最好。
一、规则注册表:一切的中心
五篇里每篇都在讲「判定规则要写死」。合并之后,这些规则必须收进一个注册表,而不是散在各个脚本里。
// seo/rules.js
export const RULES = [
// ── HTML 基础 ──
{ id: 'html-lang', level: 'error', scope: 'page', desc: '中文路径 lang="zh",其余 lang="en"' },
{ id: 'h1-exactly-one', level: 'error', scope: 'page', desc: '每页恰好一个 <h1>' },
{ id: 'title-present', level: 'error', scope: 'page', desc: 'title 存在且非空' },
{ id: 'title-duplicate', level: 'warn', scope: 'site', desc: 'title 跨页面重复' },
{ id: 'desc-exactly-one', level: 'error', scope: 'page', desc: 'meta description 恰好一个且非空' },
{ id: 'desc-duplicate', level: 'warn', scope: 'site', desc: 'description 跨页面重复' },
{ id: 'img-alt', level: 'error', scope: 'page', desc: '每个 <img> 有非空 alt' },
// ── 索引指令 ──
{ id: 'canonical-one', level: 'error', scope: 'page', desc: 'canonical 恰好一个' },
{ id: 'canonical-self', level: 'error', scope: 'page', desc: 'canonical 等于当前正式 URL' },
{ id: 'canonical-noquery',level: 'error', scope: 'page', desc: 'canonical 不带查询参数' },
{ id: 'robots-meta-one', level: 'error', scope: 'page', desc: 'robots meta 恰好一个且无冲突指令' },
{ id: 'noindex-sitemap', level: 'error', scope: 'cross', desc: 'noindex 页面不得进入 sitemap' },
// ── 多语言(单语站可关闭)──
{ id: 'hreflang-set', level: 'error', scope: 'page', desc: '含 en / zh-CN / x-default', when: 'i18n' },
{ id: 'hreflang-target', level: 'error', scope: 'cross', desc: 'hreflang 目标页面存在', when: 'i18n' },
// ── 链接 ──
{ id: 'internal-404', level: 'error', scope: 'cross', desc: '站内链接对应真实产物' },
{ id: 'no-param-link', level: 'error', scope: 'page', desc: '禁止遗留 ?from= 内链' },
// ── robots.txt ──
{ id: 'robots-exists', level: 'error', scope: 'file', desc: 'robots.txt 存在且非全站 Disallow' },
{ id: 'robots-sitemap', level: 'error', scope: 'file', desc: 'robots.txt 声明 sitemap 且目标存在' },
// ── sitemap ──
{ id: 'sitemap-index', level: 'error', scope: 'file', desc: 'sitemap-index 存在、命名空间正确' },
{ id: 'sitemap-clean', level: 'error', scope: 'file', desc: 'URL 不重复、不带参数或锚点' },
{ id: 'sitemap-sync', level: 'error', scope: 'cross', desc: 'sitemap 与产物双向一致' },
{ id: 'sitemap-lastmod', level: 'error', scope: 'file', desc: 'lastmod 恰好一个、格式正确、非未来时间' },
// ── 图片 sitemap(无图片 sitemap 时关闭)──
{ id: 'imgmap-ns', level: 'error', scope: 'file', desc: '图片 sitemap 命名空间正确', when: 'imageSitemap' },
{ id: 'imgmap-count', level: 'error', scope: 'file', desc: '每条目 1–1000 张图片', when: 'imageSitemap' },
{ id: 'imgmap-url', level: 'error', scope: 'file', desc: '图片 URL 为 http(s)、不重复无参数', when: 'imageSitemap' },
{ id: 'imgmap-file', level: 'error', scope: 'cross', desc: '本地图片文件真实存在', when: 'imageSitemap' },
{ id: 'imgmap-onpage', level: 'error', scope: 'cross', desc: '图片实际出现在所属页面中', when: 'imageSitemap' },
{ id: 'imgmap-lastmod', level: 'error', scope: 'cross', desc: '图片 lastmod 与页面 sitemap 一致', when: 'imageSitemap' },
{ id: 'imgmap-legacy', level: 'warn', scope: 'file', desc: 'image:title / image:caption 已弃用', when: 'imageSitemap' },
// ── 结构化数据 ──
{ id: 'jsonld-valid', level: 'error', scope: 'page', desc: '每个 JSON-LD 块是合法 JSON' },
{ id: 'no-searchaction', level: 'warn', scope: 'page', desc: '不声明不存在的 SearchAction' },
];
四个字段各有用处:
| 字段 | 作用 |
|---|---|
id |
报告里的稳定标识,也是白名单的键 |
level |
error 阻断构建,warn 只报告 |
scope |
决定这条规则在哪个阶段执行(见下节) |
when |
特性开关,单语站关掉 i18n,无图站关掉 imageSitemap |
when 这个字段值得强调。上面 30 条规则里有 8 条只对双语站或有图片 sitemap 的站成立——硬塞给单语站会产生 8 类必然失败,人的第一反应就是把整个检查关掉。用开关声明适用条件,比让人去注释代码强得多。
// seo/config.js
export const FEATURES = { i18n: false, imageSitemap: false }; // 本站为单语、无图片 sitemap
export const ALLOWLIST = { 'title-duplicate': ['/search/'] }; // 已知可接受的例外
二、四种 scope:只遍历一次产物
五个独立脚本最大的浪费是各自遍历 dist/。合并后按 scope 组织成一次遍历:
| scope | 含义 | 何时执行 |
|---|---|---|
page |
只看单个页面自身 | 遍历时逐页执行 |
site |
需要全站聚合(跨页重复) | 遍历完成后 |
file |
只看 sitemap / robots.txt 等文件 | 遍历前独立执行 |
cross |
需要页面集合与文件互相比对 | 最后执行 |
这个顺序不是随意的——cross 必须最后跑,因为它依赖前面所有阶段的产出。noindex-sitemap、sitemap-sync、internal-404 都属于这一类。
// seo/runner.js
import { RULES } from './rules.js';
import { FEATURES, ALLOWLIST } from './config.js';
import { loadPages, loadFiles } from './load.js';
import * as checks from './checks/index.js';
const active = RULES.filter((r) => !r.when || FEATURES[r.when]);
const findings = [];
const report = (rule, target, detail) => {
if (ALLOWLIST[rule.id]?.includes(target)) return; // 白名单在此统一生效
findings.push({ id: rule.id, level: rule.level, target, detail, desc: rule.desc });
};
// 1) 一次性加载:产物页面 + sitemap/robots
const pages = await loadPages('dist'); // [{ url, html, head, title, ... }]
const files = await loadFiles('dist'); // { sitemap, imageSitemap, robots }
// 2) file → page → site → cross
for (const scope of ['file', 'page', 'site', 'cross']) {
for (const rule of active.filter((r) => r.scope === scope)) {
const fn = checks[rule.id.replace(/-./g, (m) => m[1].toUpperCase())];
if (!fn) { console.warn(`规则 ${rule.id} 未实现`); continue; }
await fn({ rule, pages, files, report });
}
}
const errors = findings.filter((f) => f.level === 'error');
const warns = findings.filter((f) => f.level === 'warn');
await Bun.write('.audit/seo.json', JSON.stringify({
summary: { pages: pages.length, rules: active.length, errors: errors.length, warns: warns.length },
findings,
}, null, 2));
console.log(`${pages.length} 页 / ${active.length} 条规则 → ${errors.length} error, ${warns.length} warn`);
for (const f of errors.slice(0, 10)) console.log(` ✗ [${f.id}] ${f.target} — ${f.detail}`);
if (errors.length > 10) console.log(` …另有 ${errors.length - 10} 条,详见 .audit/seo.json`);
process.exit(errors.length ? 1 : 0);
三个设计决定值得说明:
控制台只打印前 10 条。 完整结果在 JSON 里。控制台刷 200 行没人会看,而且 CI 日志里根本翻不动。
report() 是唯一的写入口。 白名单只在这一个地方生效,不会出现「A 规则认白名单、B 规则不认」的情况。
规则未实现只警告不崩溃。 允许注册表先行、实现逐步补齐,否则加一条规则就得同时改两处才能跑。
三、检查函数长什么样
每条规则一个函数,输入统一、输出统一。以三条为例:
// seo/checks/index.js
// scope: page —— 只看单页
export function h1ExactlyOne({ rule, pages, report }) {
for (const p of pages) {
const n = (p.html.match(/<h1[\s>]/gi) ?? []).length;
if (n !== 1) report(rule, p.url, `找到 ${n} 个 <h1>,应为 1 个`);
}
}
// scope: site —— 需要全站聚合
export function titleDuplicate({ rule, pages, report }) {
const byTitle = new Map();
for (const p of pages) {
if (!p.title) continue;
if (!byTitle.has(p.title)) byTitle.set(p.title, []);
byTitle.get(p.title).push(p.url);
}
for (const [title, urls] of byTitle) {
if (urls.length > 1) report(rule, urls[0], `与 ${urls.length - 1} 个页面重复:「${title}」→ ${urls.join(', ')}`);
}
}
// scope: cross —— 页面集合与 sitemap 互相比对
export function noindexSitemap({ rule, pages, files, report }) {
const noindexed = new Set(pages.filter((p) => p.noindex).map((p) => p.url));
for (const url of files.sitemap.urls) {
if (noindexed.has(url)) report(rule, url, '页面声明 noindex,却出现在 sitemap 中');
}
}
注意 p.noindex 和 p.title 是在 loadPages 阶段一次性解析好的,不是每条规则各自再解析一遍 HTML。加载器是整套东西里最该写扎实的部分——第四篇那三个陷阱(百分号编码、尾部斜杠、只在 <head> 里找 robots)全部要在这里一次处理干净:
// seo/load.js(节选)
export async function loadPages(dist) {
const pages = [];
for (const file of await walk(dist)) {
const html = await readFile(file, 'utf8');
const head = html.split('</head>')[0]; // 陷阱:正文提到 noindex 不算
pages.push({
url: normalize('/' + relative(dist, file)), // 陷阱:解码 + 统一尾斜杠
html, head,
title: decode(head.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ''),
canonical: head.match(/<link\s+rel="canonical"\s+href="([^"]*)"/i)?.[1] ?? null,
noindex: /noindex/i.test(head.match(/<meta\s+name="robots"[^>]*content="([^"]*)"/i)?.[1] ?? ''),
lang: html.match(/<html[^>]+lang="([^"]*)"/i)?.[1] ?? null,
});
}
return pages;
}
归一化只在这一处做,所有规则拿到的都是已经干净的数据。 这是合并带来的最大收益——五个独立脚本时,同一个坑要踩五遍。
四、串起 validate
// package.json
{
"scripts": {
"build": "astro check && astro build",
"seo:audit": "bun seo/runner.js",
"validate": "npm run build && npm run seo:audit"
}
}
validate 的语义是**「这份产物能不能发」**,所以顺序不能变:类型检查 → 构建 → 审计。审计必须在真实产物上跑,因为很多问题只在渲染后才出现(模板拼出来的 title、组件里误加的第二个 <h1>)。
# .github/workflows/validate.yml
name: validate
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run validate
- uses: actions/upload-artifact@v4
if: always() # 失败时更需要这份 JSON
with: { name: seo-audit, path: .audit/seo.json }
if: always() 不能省。构建失败时上传产物才有意义——成功的时候你根本不需要看。
五、Skill 层:只解释,不检测
到这里,纯脚本方案已经完整了。Skill 要加的不是检测能力,是解释能力。
先看纯脚本的输出长什么样:
66 页 / 22 条规则 → 3 error, 5 warn
✗ [canonical-self] /guides/foo/ — canonical 指向 /guides/foo 但当前 URL 为 /guides/foo/
✗ [canonical-self] /guides/bar/ — canonical 指向 /guides/bar 但当前 URL 为 /guides/bar/
✗ [canonical-self] /guides/baz/ — canonical 指向 /guides/baz 但当前 URL 为 /guides/baz/
信息完全正确,但读的人得自己想明白:这三条是同一个 bug 吗?改哪个文件?为什么只有这三页?
Skill 说明书要做的就是把这段变成可执行的东西:
---
name: seo-audit
description: 解读 SEO 审计结果,定位根因并给出修复方案。当用户运行 validate
或 seo:audit 后出现失败、询问 SEO 检查报错含义、或需要修复审计问题时使用。
---
## 执行步骤
1. 若 `.audit/seo.json` 不存在或早于最近一次构建,先执行 `npm run validate`
2. 读取 `.audit/seo.json`,**不要自己重新扫描 HTML**——检测已经做完了
3. 按 `id` 分组,识别哪些 finding 属于同一根因
4. 对每个根因,定位到源码位置(不是产物位置)
5. 按「输出格式」给出报告
## 根因定位规则
产物中的问题必须回溯到源码。常见映射:
| 症状 | 通常的源码位置 |
|---|---|
| 全部页面同一问题 | 布局或模板文件 |
| 某个栏目全部出问题 | 该栏目的页面模板或 content 配置 |
| 单页问题 | 该页的 Markdown / frontmatter |
| sitemap 相关 | 构建配置(如 astro.config.mjs) |
**判断依据是受影响页面的分布,不是问题本身。** 3 页出问题和 60 页
出问题,即使 id 相同,根因位置也完全不同。
## 输出格式
按根因分组,每组给出:
1. 一句话说明问题是什么、影响几个页面
2. 根因所在的文件与行号
3. 具体改法(能给 diff 就给 diff)
4. 验证方式(改完跑什么命令确认)
不要逐条罗列 finding——JSON 里已经有了,人要的是分组后的结论。
## 边界
- 不修改代码,除非用户明确要求
- warn 级别不主动提修复方案,除非用户问起
- 拿不准根因时,宁可说"影响这 3 个页面,需要确认是否共用模板",
不要猜一个文件路径
最后那条边界很重要。猜错文件路径的报告,比不给路径的报告更糟——人会照着去改,改完发现不对,然后就再也不信这个 Skill 了。
六、效果对比
同一批问题,三种形态的输出:
| 纯脚本 | 直接让 Agent 查 | 脚本 + Skill | |
|---|---|---|---|
| 速度 | 秒级 | 分钟级 | 秒级 + 一次解读 |
| 稳定性 | 每次结果一致 | 每次可能不同 | 检测一致,解读稳定 |
| 覆盖完整性 | 100%(规则内) | 可能漏 | 100% |
| 输出可读性 | 差 | 好 | 好 |
| 给出根因 | ❌ | 有时 | ✅ |
| 给出修复 | ❌ | ✅ | ✅ |
| 成本 | 几乎为 0 | 高 | 低 |
中间那列是很多人的第一直觉——「让 AI 读一遍我的网站找 SEO 问题」。它慢、贵、且无法保证覆盖:模型不会保证真的看了全部 66 个页面。而检测这件事恰恰要求 100% 覆盖。
凡是能用规则表达的判断,都不该交给模型执行;模型的位置在规则之后,不在规则之内。
这句话是整个系列的总结。
Skill 版本的完整输出:
## 3 个 error,归为 1 个根因
**问题**:3 个页面的 canonical 缺少尾部斜杠,与实际 URL 不一致。
搜索引擎会认为 canonical 指向了另一个(不存在的)URL。
**根因**:`src/layouts/BaseLayout.astro:18`
```diff
- const canonical = new URL(Astro.url.pathname, SITE.url).toString();
+ const path = Astro.url.pathname.endsWith('/') ? Astro.url.pathname : Astro.url.pathname + '/';
+ const canonical = new URL(path, SITE.url).toString();
```
**为什么只有 3 页**:这 3 页由动态路由生成,`Astro.url.pathname`
不带尾斜杠;静态页面走的是另一条路径,因此未受影响。
**验证**:`npm run validate`,canonical-self 应降为 0。
---
## 5 个 warn(未展开,如需修复请说明)
- title-duplicate ×2:分页页面标题相同,属预期行为
- no-searchaction ×3:可在 BaseLayout 中移除
「为什么只有 3 页」这一段是纯脚本永远给不出的——它需要理解代码结构。而这恰好是最能建立信任的部分:读的人一旦明白问题为什么发生,才会相信这个修复方案是对的。
七、落地顺序
不要一次上 30 条规则。实测比较顺的顺序:
| 阶段 | 做什么 | 规则数 |
|---|---|---|
| 1 | 先跑通 runner + 3 条最确定的规则(h1、title、internal-404) | 3 |
| 2 | 把现有问题全部修掉或全部加白名单,让基线归零 | 3 |
| 3 | 接进 CI,此时 CI 是绿的 | 3 |
| 4 | 每次加 2–3 条规则,修完再加下一批 | 逐步到 30 |
第 2 步是关键。基线不归零就接 CI,等于 CI 从第一天起就是红的,之后所有人都会习惯性忽略它。宁可先把一批已知问题加进白名单、标上 TODO,也不要让 CI 带着一屏红色上线。
// 白名单要写清原因和日期,而不是一个裸数组
export const ALLOWLIST = {
'title-duplicate': [
'/search/', // 2026-08-01 搜索页与首页同标题,已 noindex,可接受
],
'img-alt': [
'/about/', // 2026-08-01 装饰性图片待补 alt,跟踪于 #42
],
};
带日期和原因的白名单,半年后还能判断哪些该清理。裸数组三个月后就没人知道为什么在里面了。
八、系列总结
「SEO Skills 工具箱」全系列:
- 死链与断链审计
- Meta 审计:title、description、H1
- 结构化数据与 JSON-LD 校验
- Sitemap 差异审计与孤儿页发现
- 内链结构审计
- 当前:内置 seo:audit 与 Skill 封装
- 配套资源:说明书、规则注册表与检查清单
七条原则:
- 确定性交给脚本,判断交给模型(①)
- 能量化的写成阈值表,不能量化的给判断依据(②)
- CI 的阻断线画在「有唯一正确答案」的地方(②③④⑤)
- 报告要给根因,不是给症状列表(③)
- 集合运算前先做归一化——脚本不会报错,只会安静地全错(④)
- 算图指标前先剔除模板化链接(⑤)
- 凡是能用规则表达的判断,都不该交给模型执行(⑥)
最后一条是前六条的归纳。做这类工具时最大的诱惑,是觉得「模型这么聪明,让它直接看就好了」。但检测要的是确定性和完整覆盖,这正是模型的弱项、脚本的强项。模型的价值在规则之后——把 200 行机器输出变成 3 段人能行动的结论。
分清这条界线,工具才好用。
相关阅读:
- Astro 内容站搭建指南——本系列所依赖的站点结构
- 内容发布自动化——把 validate 接进发布流程
RELATED / 相关推荐
接着读这些
按同一栏目、标签与技术栈为你挑选。
AI Skills 实战:把网站死链与断链审计做成一个可复用 Skill
把死链检查的判定规则和输出格式固化成一份 Skill 说明书,配合两个确定性脚本,让 AI Agent 每次都按同一套 SOP 产出可执行的审计报告,并接入 CI 持续拦截。
AI Skills 实战:Sitemap 差异审计 Skill——六个信号源打架时,谁说了算
把实际路由、sitemap、内链图、robots.txt、meta robots 和 canonical 当作六个独立信号源,用集合运算查出它们之间的互相矛盾与孤儿页,并绕开百分号编码等三个实现陷阱。
AI Skills 实战:Meta 审计 Skill——查 title、description 与 H1 的缺失、重复与超长
把 title、description、H1 的判定阈值和改写依据固化成 Skill 说明书,用半角当量宽度替代字符数解决中文截断误判,让模型直接给出改写候选而不只是报告超长。