站点 bug 大多不刺激:少了个 alt、改 slug 之后某条内链断了、一篇 180 字的页面根本不该发出去。它们都不报错,于是悄悄堆积,直到 Search Console 把问题甩到你脸上。让一个 AI Agent 按一份很窄的 checklist 去读你构建出来的 dist/,大约十分钟就能扫完。诀窍有两个:每个 prompt 只问一件事;它给出的结果,都用一条你比模型更信得过的 shell 命令交叉验证。
TL;DR
build 站点,把 AI Agent 指向 dist/,问六个窄问题(死链、缺 alt、薄页、孤儿、title、frontmatter),每条发现都用一行 shell 复核,再把两个关键闸门(内部死链 + 缺 frontmatter)接进 CI,回归直接挂构建。AI 负责第一遍扫描,脚本负责守住底线。
什么时候值得做这一遍
- 改过路由、重命名了 slug、迁移过内容,之后还没重新爬过。
- Search Console 的 Page indexing 报告(2026 年由原”Coverage”改名而来)里”Crawled - currently not indexed”或”Not found (404)“在涨。
- 短时间内批量发了一批新页面,怀疑有些带着 bug 上线了。
- 一个在持续维护的站,已经 60+ 天没做过完整爬取。
你需要什么
- 本地已构建的产物
dist/(本文按 Astro 静态输出来写;shell 部分对任何一堆index.html的静态目录都适用)。 - 一个能读本地文件的 AI Agent:Codex CLI(跑 GPT-5.5)或 Claude Code(跑 Claude Opus 4.7 / Sonnet 4.6)。两者读几百个 HTML 文件都很轻松;站特别大时,一次只把 prompt 限定在一个路由前缀上。
- 装好
grep、linkinator(截至 2026 年 6 月为 v7.6.1)和jq。如果还想查外链,再加上lychee。
第 1 步:build,然后 QA 产物(不是源码)
npm run build
ls dist/ # 确认页面在
QA 一定针对 dist/,绝不针对 src/。很多 bug 只在渲染之后才出现:frontmatter 为 null 时 meta 标签变空、:key 插值断掉、某个模板把 <title> 漏掉了。源码看着干净,渲染出来的页面是坏的。
第 2 步:内部死链
Prompt:
[CONTEXT] 构建产物在 dist/(Astro 静态)。内部链接形如
/en/articles/<slug>/ 或 /zh/articles/<slug>/。
[TASK] 列出 dist/**/*.html 里所有 href 指向「dist/ 中没有对应 index.html」的内部链接。
输出表格:source_file, broken_href
shell 验证(这才是真理来源,不是模型):
# 真正能解析到的 URL 集合
find dist -name 'index.html' | sed 's|dist||;s|/index.html|/|' | sort > /tmp/live-urls.txt
# 每页用到的内链
grep -RhoE 'href="(/[a-z]+/articles/[a-z0-9-]+/)"' dist | sed 's/href="//;s/"//' \
| sort -u > /tmp/used-urls.txt
# 用到的 - 活的 = 死链
comm -23 /tmp/used-urls.txt /tmp/live-urls.txt | head
想要顺着页面继续往下爬的完整检查,直接对构建目录跑 linkinator:
# --recurse 跟随站内链接;--skip 用正则跳过外部 http(s)
npx linkinator dist --recurse --skip '^https?://'
linkinator 在任何链接失败时退出码非零,可以直接当 CI 闸门用。注意它没有 --silent:只想要退出码就用 --verbosity none,想导出结果就用 --format csv。
第 3 步:缺 alt
Prompt:
[TASK] dist/**/*.html 里列出无 alt 或 alt="" 的 <img>。
已经标了 alt="" + role="presentation" 的装饰性图片忽略。
输出表格:file, line_excerpt
验证:
grep -RHn '<img[^>]*>' dist | grep -v 'alt="[^"]\+"' | head
缺 alt 不会直接掉排名,但它会破坏读屏软件和图片搜索,而且这是性价比极高的无障碍改进,顺手就能把 Lighthouse 推到 100。
第 4 步:薄页
Prompt:
[TASK] dist/**/index.html 每页提取可见正文(剥掉 nav/header/footer/scripts)并计字数。
列出正文字数 < 400 的页面,从最少的排起。
输出表格:file, word_count
字数只是起点标记,不是结论。一篇 200 字、把一个具体问题答清楚的页面完全没问题。用这份清单找出「又薄、又在抢竞争激烈关键词」的页面,要么补厚,要么并进更强的那篇。
第 5 步:孤儿页
Prompt:
[TASK] dist/ 里每篇文章页,统计来自其它页面的入链数量(排除自链和分页)。
列出 0 入链的页面(孤儿)。
输出表格:file, incoming_count
验证:
# 从未被任何其它页面链作目标的页面
for url in $(cat /tmp/live-urls.txt); do
count=$(grep -RlE "href=\"$url\"" dist | grep -v "dist$url" | wc -l)
[ "$count" -eq 0 ] && echo "ORPHAN: $url"
done | head
孤儿是重构之后最隐形的杀手:Google 还能通过 sitemap 找到它们,但没有内链,爬得就稀、排得就弱。每篇文章都应至少有两条来自相关页面的入链。
第 6 步:title 体检
Prompt:
[TASK] dist/**/*.html 列出 <title> 满足以下任一的页面:
- 缺失或为空
- 超过 60 字符(Google 大约在 580px / ~60 字符处截断)
- 跨页面重复
输出表格:file, issue, title_text
重复 title 要最先修:两个同名页面在搜索里互相竞争,还会让 Google 搞不清该排哪一个。
第 7 步:frontmatter 一致性(这一项跑源码)
唯独这项要跑源码,因为缺 frontmatter 正是你在 dist/ 里看到的空 meta 标签的根源。脚本保持精简:
// scripts/frontmatter-consistency.mjs
import { readdirSync, readFileSync } from 'node:fs';
import matter from 'gray-matter';
const REQUIRED = ['title', 'description', 'urlSlug', 'category', 'tags',
'publishedAt', 'lang', 'translationKey'];
for (const lang of ['en', 'zh']) {
for (const cat of readdirSync(`src/content/articles/${lang}`)) {
for (const f of readdirSync(`src/content/articles/${lang}/${cat}`)) {
if (!f.endsWith('.mdx')) continue;
const { data } = matter(readFileSync(`src/content/articles/${lang}/${cat}/${f}`, 'utf8'));
const missing = REQUIRED.filter((k) => data[k] === undefined || data[k] === '');
if (missing.length) console.log(`${lang}/${cat}/${f}: 缺 ${missing.join(',')}`);
}
}
}
第 8 步:把关键闸门接进 CI
按类别开 issue,不要按文件开,否则待办会把你淹掉。然后把两个绝不能回归的类别接成闸门:内部死链和缺 frontmatter。
# .github/workflows/qa.yml(节选)
- name: QA gates
run: |
node scripts/frontmatter-consistency.mjs # 缺字段时退出码非零
npx linkinator dist --recurse --skip '^https?://' # 内链闸门
node scripts/audit-pillars.mjs # 孤儿闸门
外链单独放一个定时任务,用 lychee 跑,这样第三方站点抽风不会拦住你的部署:
- name: 外链检查(每晚,PR 上不阻塞)
uses: lycheeverse/lychee-action@v2
with:
args: --base . 'dist/**/*.html'
fail: true # PR 上只想要报告就设为 false
lychee 在链接失败时返回非零退出码(2),action 还会把 steps.<id>.outputs.exit_code 暴露出来,于是你可以开一个跟踪 issue,而不是直接挂构建。
“做完了”长什么样
| 类别 | 验证方式 | 是否闸门 |
|---|---|---|
| 内部死链 | comm 差集 + linkinator | 是,挂构建 |
| 缺 alt | grep 过滤 <img> | 否,每周报告 |
| 薄页(<400 字) | AI 正文字数 | 否,人工分诊 |
| 孤儿(0 入链) | grep 目标循环 | 是,挂构建 |
| title 问题 | AI 扫描 | 否,每周报告 |
| 缺 frontmatter | frontmatter-consistency.mjs | 是,挂构建 |
修完后重跑每个 prompt:关键类别应返回 0。预计 4-8 周内 Search Console 的 Page indexing 数字会改善(Google 按自己的节奏重爬,不是立刻),抽样的 Lighthouse SEO + Accessibility 也会到 100。
容易踩的坑
- QA 跑
src/而不是dist/。 只在渲染后才出现的 bug 会漏掉:nullfrontmatter 导致 meta 为空、<title>丢失、插值断掉。 - 相信模型一句”看起来没问题”。 永远要它给出明确的列表或表格。没列表,就没东西可验证。
- 拿 AI 的计数当闸门。 模型是非确定性的;它说 23 条死链、你的
comm差集说 18 条,信脚本,AI 列表只用来查那 5 条差在哪。 - 改完不记账。 按类别开 issue,否则下个月又会把同样的 bug 引回来。
- 跳过孤儿检测。 这是路由重构之后最常见、也最隐形的回归。
FAQ
- 该用哪个模型来扫? 都行。Claude Code 跑 Sonnet 4.6(截至 2026 年 6 月为 1M token 上下文)能一次读完一大批 HTML,是更便宜的主力;想让它顺便把验证脚本也写出来,Codex 上的 GPT-5.5 在 shell 推理上更强。模型只做第一遍,真正当闸门的是 shell 脚本。
- 能在 CI 里完全自动化吗? 确定性的闸门可以(linkinator 查内部死链、脚本查缺 frontmatter、pillar 审计查孤儿)。模糊的类别(薄页、title 质量)留成人工每周复核的报告,它们需要判断,不该硬挂。
- 外链失效怎么办? 用专门的工具。
lychee(Rust、异步)或 linkinator 处理上千条外链都比 AI Agent 快得多,而且懂重定向和限流。 - 多久做一次完整 QA? 在维护中的站每月一次,否则每季度一次,且每次改路由/slug 或迁移内容后必做。
- 为什么 AI 报的数和我的 shell 验证对不上? 模型是估算,shell 命令是精确的。把差额对清楚(常见原因是它把
<head>里的链接或你本想排除的外链也数了进去),收紧 prompt,最后以脚本为准来设闸门。