用 AI 做站点 QA:死链、缺 alt、薄页一次扫清

可复现的站点 QA 流程:可粘贴的 AI prompt、精确的 shell 验证脚本,以及在内部死链和缺字段时直接挂构建的 CI 闸门。

站点 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 限定在一个路由前缀上。
  • 装好 greplinkinator(截至 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是,挂构建
缺 altgrep 过滤 <img>否,每周报告
薄页(<400 字)AI 正文字数否,人工分诊
孤儿(0 入链)grep 目标循环是,挂构建
title 问题AI 扫描否,每周报告
缺 frontmatterfrontmatter-consistency.mjs是,挂构建

修完后重跑每个 prompt:关键类别应返回 0。预计 4-8 周内 Search Console 的 Page indexing 数字会改善(Google 按自己的节奏重爬,不是立刻),抽样的 Lighthouse SEO + Accessibility 也会到 100。

容易踩的坑

  • QA 跑 src/ 而不是 dist/ 只在渲染后才出现的 bug 会漏掉:null frontmatter 导致 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,最后以脚本为准来设闸门。

相关阅读

标签: #独立开发 #AI 辅助建站 #工作流 #Technical SEO