跳到主要内容
BLKBLKTECH

指南 / ai

AI Skills 高阶:把 SEO 检查内置进项目,做成一条命令 + 一个 Skill

把分散的五类 SEO 检查合并成项目内置的 seo:audit 脚本,用统一的规则注册表管理 24 项检查与 error/warn 分级,再封装成 Skill,让检测、解析和修复方案一次给全。

作者 BLKTECH 编辑部更新 2026年8月1日22 分钟难度 实战免费Node.jsBunAI AgentGitHub ActionsAstro

把 SEO 检查内置进项目的正确结构是:一个规则注册表统一定义 24 项检查及其 error/warn 分级,一个 runner 负责加载产物与遍历规则,输出机器可读的 JSON 加人类可读的摘要;validate 命令串起类型检查、构建和审计;Skill 只负责解读 JSON、定位根因和给修复方案,不重新实现检测逻辑。

这一篇要解决什么

系列前五篇各自造了一个 Skill,每个带一两个脚本。真拿到项目里用会立刻遇到三个问题:

  1. 五个脚本各自遍历一遍 dist/,重复读取几百个文件
  2. 五份判定规则散在五个地方,改一个阈值要翻半天
  3. 发布流程要记住跑五条命令,漏一条不会有任何提示

所以最后一步是收口:把五类检查合并成项目内置的一条命令,再用 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-sitemapsitemap-syncinternal-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.noindexp.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 工具箱」全系列:

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

七条原则:

  • 确定性交给脚本,判断交给模型(①)
  • 能量化的写成阈值表,不能量化的给判断依据(②)
  • CI 的阻断线画在「有唯一正确答案」的地方(②③④⑤)
  • 报告要给根因,不是给症状列表(③)
  • 集合运算前先做归一化——脚本不会报错,只会安静地全错(④)
  • 算图指标前先剔除模板化链接(⑤)
  • 凡是能用规则表达的判断,都不该交给模型执行(⑥)

最后一条是前六条的归纳。做这类工具时最大的诱惑,是觉得「模型这么聪明,让它直接看就好了」。但检测要的是确定性和完整覆盖,这正是模型的弱项、脚本的强项。模型的价值在规则之后——把 200 行机器输出变成 3 段人能行动的结论。

分清这条界线,工具才好用。

相关阅读:

NEXT ACTION / 下一步

回看系列第一篇:死链审计

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

继续

RELATED / 相关推荐

接着读这些

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