跳到主要内容
BLKBLKTECH

指南 / ai

AI Skills 实战:把网站死链与断链审计做成一个可复用 Skill

把死链检查的判定规则和输出格式固化成一份 Skill 说明书,配合两个确定性脚本,让 AI Agent 每次都按同一套 SOP 产出可执行的审计报告,并接入 CI 持续拦截。

作者 BLKTECH 编辑部更新 2026年8月1日20 分钟难度 进阶免费Node.jsBunAI AgentGitHub ActionsSitemap

把死链审计做成 Skill 的正确拆法是:抓取链接和采集状态码交给确定性脚本,判断误报、分类和给修复动作交给模型,中间用一份写清判定规则与输出格式的说明书连接。内链断链应阻断构建,外链失效只定期报告。

先说结论

死链检查是典型的「重复执行、判定标准明确、输出格式固定」的活。这类活不该每次重新写提示词,应该固化成一个 Skill。

但真正决定这个 Skill 好不好用的,不是它跑在哪个平台上:

Skill 的价值 90% 在那份「判定规则 + 输出格式」的说明书里,平台只是投放位置。

同一份死链审计说明书,放进 Claude Code 是 SKILL.md,放进 Cursor 是一条 rule,放进 Codex 是 AGENTS.md 的一节,放进自建 Agent 是一段系统提示。换平台要重写的只有文件名。

这篇按这个思路,从零搭出一个能用的死链审计 Skill:说明书怎么写、哪两个脚本必须自己实现、报告长什么样、怎么接进 CI,以及实践中最常踩的误报坑。


一、死链为什么值得系统治理

先排掉一个站不住脚的理由:抓取预算

很多 SEO 文章一上来就说死链浪费抓取预算。这对几十万页的电商站成立,对几百页的内容站基本是伪命题——Googlebot 不差你这点配额。用一个不成立的理由说服自己做一件事,做不了几次就会放弃。

真正的三个理由是:

1. 内链权重断流。 内链是站内权重传递的通道。一条指向 404 的内链,权重传过去就没了,且目标页面拿不到任何提升。站内互链越密,这个损失越大。

2. sitemap 与实际不一致会降低信任度。 sitemap 里躺着一批已经 404 的 URL,等于反复告诉搜索引擎「这些页面很重要请来抓」,抓到的却是 404。这比单纯的死链更糟。

3. AI 抓取器遇到 404 不会重试。 这是最近两年新增的成本。传统搜索引擎有重访机制,一次抓失败还会再来。而 AI 搜索的抓取行为更接近「一次性取用」——抓到 404,这一页在这轮回答里就等于不存在。你做的 GEO 优化再好,链接是断的,内容就进不了答案。

第三条也解释了为什么死链治理的优先级近两年在上升。


二、现有做法卡在哪

做法 问题
Screaming Frog 免费版 500 URL 上限,站稍大就不够;结果要人工逐条点开判断
在线检测工具 只爬得到能从首页点到的页面,孤儿页和 sitemap 差异查不出来
自己写脚本 每个项目重写一次;跑完输出一堆 URL,没有人真的会去读
直接让 AI「帮我查死链」 每次结果格式都不一样,判定标准全凭当次发挥,403 和 404 混为一谈

归纳成三个病根:

  • 不可复用:知识留在个人脑子里或某次对话里,换项目、换人就归零
  • 判定标准不统一:这次把 403 算死链,下次不算,两份报告没法比较
  • 结果不可执行:给你一张 404 列表,然后呢?谁去改、改成什么、哪些可以忽略

Skill 要解决的正是这三条,尤其是第三条。


三、AI Skills 到底是什么

抛开各家规范的差异,一个 Skill 就是能力单元的三件套

组成 作用 谁来执行
说明书 什么时候用、按什么步骤做、怎么判定、输出成什么格式 模型读
确定性脚本 抓取、解析、发请求、采集状态码——不需要判断的部分 机器跑
参考资料 状态码判定表、误报白名单、修复动作对照 模型按需查

三件套里,脚本和参考资料是跨平台通用的纯资产,说明书也只有承载文件不同。落地位置对照:

平台 说明书放哪 触发方式
Claude Code / Claude 应用 .claude/skills/<name>/SKILL.md 按 frontmatter 的 description 自动匹配
Cursor .cursor/rules/*.mdc glob 匹配或手动 @ 引用
Codex AGENTS.md 中的一节 进入目录自动加载
自建 Agent / SDK 系统提示片段或工具描述 自己路由

所以本文后面给出的说明书内容,你复制到哪个文件里都能用。

什么时候值得做成 Skill

不是所有事都该封装。判断标准:

特征 一句提示词就够 值得做成 Skill
执行频率 一次性 每周/每次发布都跑
判定标准 当场想就行 有明确规则,且规则会积累
输出格式 看懂就行 需要跨时间对比、要能交接给别人
步骤数 1–2 步 多步且有先后依赖
误报处理 无所谓 需要维护白名单

死链审计五条全中,所以它是个特别标准的例子。反过来,「帮我把这段文案改短」就不该做成 Skill。


四、死链审计到底要查什么

这一节是整个 Skill 的知识内核。大部分人只查第 1 条,而实际会伤到 SEO 的有 11 类:

# 类型 说明 严重度
1 内链 404 / 5xx 站内指向不存在页面的链接 🔴 高
2 外链失效 引用的外部资源已下线 🟡 中
3 重定向链 / 循环 A→B→C 多跳,或 A→B→A 死循环 🟡 中
4 软 404 返回 200,但页面内容是「页面不存在」 🔴 高
5 锚点失效 #section 在目标页面找不到对应 id 🟢 低
6 图片 / 静态资源 404 <img src>、CSS、JS 加载失败 🟡 中
7 sitemap 与实际不一致 sitemap 里有已删页面,或漏了已上线页面 🔴 高
8 孤儿页 已上线但站内没有任何链接指向 🟡 中
9 canonical 异常 canonical 指向 404 或指向一个重定向 🔴 高
10 混合内容 HTTPS 页面里嵌 http:// 资源 🟡 中
11 尾部斜杠不一致 站内一半写 /guides/foo,一半写 /guides/foo/ 🟡 中

第 4、11 两条值得单独说,因为它们最隐蔽:

软 404 骗过所有只看状态码的工具。很多 SPA 或配置不当的服务器,访问不存在的路径时返回 200 加一个「页面不存在」的壳。搜索引擎会把它当正常页面收录,于是你的索引里多出一堆空内容页。只有把状态码和页面内容一起看才能发现——这正是模型比脚本强的地方

尾部斜杠在 Astro、Next.js 这类静态站上高发。构建产物是 /guides/foo/index.html,托管平台通常会把 /guides/foo 301 到 /guides/foo/。功能上没问题,但每条写错的内链都多走一跳重定向。几百条内链累积起来,就是几百次无谓的往返。


五、Skill 的三层设计

目录结构:

seo-link-audit/
├── SKILL.md              # 说明书:流程、判定规则、输出格式
├── scripts/
│   ├── crawl-local.mjs   # 构建产物离线检查(内链、锚点、资源)
│   └── check-external.mjs # 外链联网探测(并发、重试、限流)
└── references/
    ├── status-rules.md   # 状态码判定表
    └── allowlist.txt     # 误报白名单

分工的原则只有一句,也是整个设计的核心:

确定性的事交给脚本,需要判断的事交给模型。

具体到这个 Skill:

任务 归属 理由
遍历 HTML、提取链接 脚本 纯字符串处理,模型做既慢又可能漏
发 HTTP 请求、拿状态码 脚本 需要并发和重试控制,模型做不了
判断 403 是真挂了还是被反爬拦了 模型 要结合域名、响应体、历史记录判断
识别软 404 模型 要读页面内容语义
决定「改链接」还是「加 301」 模型 要看这条链接在什么位置、被谁引用
生成人能读的报告 模型 脚本只能吐 JSON

让模型去遍历文件提取链接,是最常见的误用。 那是脚本三秒钟能做完且不会出错的事,交给模型既慢、又贵、还可能漏掉。


六、说明书怎么写:六块骨架

一份能用的 Skill 说明书,必须包含这六块。下面每块都给出可直接填入的真实内容——放进 SKILL.md.mdc 还是 AGENTS.md 都一样。

第 1 块:触发条件

写清「什么时候该用我」,这决定 Agent 能不能在对的时机想起它。写得含糊,Skill 就永远不会被触发。

---
name: seo-link-audit
description: 审计网站的死链、断链、重定向链、软 404 和 sitemap 一致性,
  产出带修复动作的报告。当用户提到死链检查、断链、404 排查、上线前链接
  检查、SEO 技术审计时使用。
---

对比一下反面写法:description: 检查链接——这种描述几乎不可能被正确匹配。描述里要出现用户真实会说的词,「死链」「404」「上线前检查」都是。

第 2 块:检查范围

明确边界,尤其是不查什么——这能避免每次跑出一堆无关噪音。

## 检查范围

查:
- 站内 HTML 中的 `<a href>``<img src>``<link href>``<script src>`
- sitemap.xml 中列出的所有 URL
- canonical 与 og:url 指向的地址

不查:
- `mailto:``tel:``javascript:` 协议
- allowlist.txt 中列出的域名
- 需要登录态才能访问的后台路径

第 3 块:执行步骤

按顺序写,并明确每步调用哪个脚本、产出什么中间文件。

## 执行步骤

1. 确认检查目标:本地构建产物 `dist/` 还是线上站点。默认先跑本地。
2. 运行 `scripts/crawl-local.mjs`,产出 `.audit/internal.json`
3. 读取 internal.json,若存在内链 404,立即标记为阻断级问题
4. 运行 `scripts/check-external.mjs`,产出 `.audit/external.json`
5. 对所有 403 / 429 / 超时项,按「误报处理」一节复核
6. 对所有返回 200 的可疑页面,抓取正文前 200 字,判断是否为软 404
7. 按「输出格式」生成报告,写入 `.audit/report.md`

第 4 块:判定规则表

这是全篇最有复用价值的部分,也是每次跑结果都一致的原因。

## 判定规则

| 状态 | 结论 | 动作 |
|---|---|---|
| 200 + 正常内容 | 通过 | 无 |
| 200 + 内容为"页面不存在" | 软 404 | 修复目标页或改链接 |
| 301 / 308 | 永久重定向 | 内链直接改成最终 URL |
| 302 / 307 | 临时重定向 | 确认是否本该是 301 |
| 400 | URL 格式错误 | 检查是否有非法字符 |
| 401 / 403 | 疑似反爬 | 转人工复核,不直接判死 |
| 404 | 死链 | 按修复策略处理 |
| 410 | 已明确删除 | 移除链接 |
| 429 | 触发限流 | 降速重试,不判死 |
| 5xx | 服务端异常 | 间隔重试 3 次仍失败才判死 |
| 超时 / DNS 失败 | 不确定 | 换时间点重试,连续 2 次失败才判死 |

注意几条故意不判死的规则:401/403/429/5xx 都不直接进死链列表。宁可漏报也不误报——一份混着误报的报告,人看两次就不看了。

第 5 块:误报处理

## 误报处理

以下情况标记为「需人工确认」,不计入死链总数:

- 域名在 allowlist.txt 中(已知强反爬:知乎、微信公众号、部分政府站)
- 返回 403 但换 User-Agent 后返回 200
- 返回 429,且同域名其他链接正常
- 链接只在 JS 执行后才出现(静态抓取拿不到上下文)

新发现的稳定误报,追加到 allowlist.txt 并注明日期和原因。

最后一句是关键:让白名单随使用积累。这是 Skill 比一次性脚本更值钱的地方——规则会越用越准。

第 6 块:输出格式

固定格式,才能跨时间对比。

## 输出格式

报告分四段:

1. **摘要**:扫描页面数 / 链接总数 / 阻断级问题数 / 待确认数
2. **阻断级问题**:内链 404、canonical 异常、sitemap 不一致
3. **建议修复**:外链失效、重定向链、尾部斜杠不一致
4. **需人工确认**:403 / 429 / 超时

每条包含:目标 URL、来源页面、状态、类型、**建议动作**
按「来源页面」分组,方便一次改完一个文件。

「按来源页面分组」是个小但重要的细节。按目标 URL 排序的报告,修的时候要在文件间反复横跳;按来源页分组,可以一个文件一次改完。


七、两个脚本

脚本是这个 Skill 里唯一需要自己写的部分,也是跨平台完全通用的资产。

脚本一:构建产物离线检查

不联网,直接查构建产物,几秒钟跑完。内链检查完全不需要发网络请求——目标页面存不存在,看文件系统就知道。

// scripts/crawl-local.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';

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;
}

// 文件路径 → URL 路径:dist/guides/foo/index.html → /guides/foo/
const toUrlPath = (file) =>
  '/' + relative(DIST, file).replace(/index\.html$/, '').replace(/\\/g, '/');

const files = await walk(DIST);
const pages = new Map();   // urlPath → { ids:Set }
const links = [];          // 所有待检查链接

for (const file of files) {
  const html = await readFile(file, 'utf8');
  const from = toUrlPath(file);
  // 收集本页所有锚点 id,供锚点检查用
  const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]));
  pages.set(from, { ids });

  const attrs = /(?:href|src)="([^"#][^"]*)?(#[^"]*)?"/g;
  for (const [, raw = '', hash = ''] of html.matchAll(attrs)) {
    if (!raw && !hash) continue;
    if (/^(mailto:|tel:|javascript:|data:)/.test(raw)) continue;
    links.push({ from, raw, hash: hash.slice(1) });
  }
}

const internal = [];
const external = new Set();

for (const { from, raw, hash } of links) {
  const isAbs = /^https?:\/\//.test(raw);
  if (isAbs && !raw.startsWith(SITE)) { external.add(raw); continue; }

  // 站内:统一解析成绝对路径
  const path = new URL(raw || from, SITE + from).pathname;
  const norm = path.endsWith('/') ? path : path + '/';
  const exists = pages.has(norm) || pages.has(path);

  if (!exists) {
    internal.push({ from, to: path, type: 'internal-404' });
  } else if (path !== norm && pages.has(norm)) {
    // 写成 /guides/foo,实际是 /guides/foo/ → 多一跳重定向
    internal.push({ from, to: path, type: 'trailing-slash' });
  } else if (hash && !pages.get(norm)?.ids.has(hash)) {
    internal.push({ from, to: path + '#' + hash, type: 'anchor-missing' });
  }
}

await mkdir('.audit', { recursive: true });
await writeFile('.audit/internal.json',
  JSON.stringify({ pages: pages.size, internal, external: [...external] }, null, 2));

console.log(`扫描 ${pages.size} 页,内链问题 ${internal.length} 条,外链 ${external.size} 个`);
process.exit(internal.some((i) => i.type === 'internal-404') ? 1 : 0);

用正则解析 HTML 有已知局限(注释里的链接、属性顺序异常会漏),但对构建产物这种格式规整的输出足够了。在意准确率可以换成 linkedom 解析,代价是多一个依赖。

最后一行的 process.exit(1) 是为后面接 CI 埋的:有内链 404 就以非零码退出

脚本二:外链联网探测

外链必须联网查,重点是并发控制、重试和限流。

// scripts/check-external.mjs
import { readFile, writeFile } from 'node:fs/promises';

const UA = 'Mozilla/5.0 (compatible; LinkAudit/1.0)';
const CONCURRENCY = 8;
const TIMEOUT = 10_000;

const { external } = JSON.parse(await readFile('.audit/internal.json', 'utf8'));
const allow = new Set(
  (await readFile('references/allowlist.txt', 'utf8').catch(() => ''))
    .split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
);

async function probe(url, method = 'HEAD', attempt = 1) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
  try {
    const res = await fetch(url, {
      method, redirect: 'manual',
      headers: { 'User-Agent': UA }, signal: ctrl.signal,
    });
    // 部分服务器不支持 HEAD,降级用 GET 再试一次
    if ((res.status === 405 || res.status === 403) && method === 'HEAD') {
      return probe(url, 'GET', attempt);
    }
    // 限流和 5xx 退避重试,最多 3 次
    if ((res.status === 429 || res.status >= 500) && attempt < 3) {
      await new Promise((r) => setTimeout(r, attempt * 2000));
      return probe(url, method, attempt + 1);
    }
    return { url, status: res.status, location: res.headers.get('location') };
  } catch (err) {
    if (attempt < 3) {
      await new Promise((r) => setTimeout(r, attempt * 2000));
      return probe(url, method, attempt + 1);
    }
    return { url, status: 0, error: err.name === 'AbortError' ? 'timeout' : err.message };
  } finally {
    clearTimeout(timer);
  }
}

const targets = external.filter((u) => ![...allow].some((d) => u.includes(d)));
const results = [];
const queue = [...targets];

await Promise.all(
  Array.from({ length: CONCURRENCY }, async () => {
    while (queue.length) results.push(await probe(queue.shift()));
  })
);

await writeFile('.audit/external.json', JSON.stringify(results, null, 2));

const dead = results.filter((r) => r.status === 404 || r.status === 410);
const review = results.filter((r) => [401, 403, 429, 0].includes(r.status));
console.log(`外链 ${results.length} 个:确认失效 ${dead.length},待人工确认 ${review.length}`);

三个容易被忽略的细节:

  • redirect: 'manual':默认的 follow 会自动跟随重定向,你拿到的是最终状态码,重定向链就查不出来了
  • HEAD 降级 GET:不少服务器对 HEAD 返回 405 或 403,直接判死就是误报
  • 退避重试attempt * 2000 的递增间隔,避免把对方(和自己)打限流

八、报告长什么样

脚本产出 JSON,模型按说明书的输出格式转成报告。真实跑一遍的样子:

# 链接审计报告

扫描 47 页 / 链接 612 条 / 阻断级 3 / 建议修复 11 / 待确认 4

## 🔴 阻断级问题

### src/content/guides/astro-content-site-guide.md
| 目标 | 状态 | 类型 | 建议动作 |
|---|---|---|---|
| /guides/deploy-astro/ | 404 | 内链死链 | 改为 /guides/deploy-astro-on-vps/ |

### sitemap.xml
| 目标 | 状态 | 类型 | 建议动作 |
|---|---|---|---|
| /guides/old-post/ | 404 | sitemap 含已删页 | 从 sitemap 移除;如有外链则加 301 |

## 🟡 建议修复

### src/content/practice/building-blktech-site.md
| 目标 | 状态 | 类型 | 建议动作 |
|---|---|---|---|
| /resources/learning-paths | 301 | 尾部斜杠 | 补 `/`,省一跳重定向 |
| https://old-tool.dev/docs | 404 | 外链失效 | 换官方新地址或删除链接 |

## ⚪ 需人工确认

| 目标 | 状态 | 说明 |
|---|---|---|
| https://zhuanlan.zhihu.com/p/xxx | 403 | 疑似反爬,浏览器可正常打开 |

和一张裸 URL 列表的差别在于:每一条都直接给了动作,而且按文件分好组。这份报告可以直接扔给 Agent 说「按报告修阻断级问题」,也可以自己照着改。


九、按类型给修复动作

类型 动作 注意
内链 404 直接改链接指向正确 URL 不要用 301 兜底,见下
外链 404 换新地址;找不到替代就删链接或去掉超链 别留着「等以后修」
重定向链 内链改成最终 URL 超过 2 跳一定要拉平
软 404 修目标页或让它返回真 404 两者取一,别留 200 空壳
sitemap 含已删页 从 sitemap 移除 有外部流量的另加 301
孤儿页 从相关页面加内链 或确认它本就该 noindex
canonical 指向 404 改成实际存在的 URL 这条对索引影响最大
尾部斜杠 统一成构建产物的形式 顺手加进 lint 规则
已知反爬域名 加白名单 注明日期和原因

单独强调一条反直觉的:

内链不要靠 301 兜底,要直接改成最终 URL。

301 是给外部流量和老书签用的兜底机制。内链走 301 意味着每次访问都白走一跳,而且你完全有能力改对——链接就在自己仓库里。用重定向解决内链问题,是把自己能修的事推给了服务器每次请求去承担。


十、接进 CI

Skill 手动跑有价值,但真正省事的是让它自动跑。关键是内链和外链要用完全不同的策略

内链 外链
触发时机 每次构建 每周定时
失败处理 阻断构建 只报告,不阻断
理由 是你自己的错,且必然可修 别人的站挂了,且抖动多
# .github/workflows/link-audit.yml
name: link-audit
on:
  push:
  schedule:
    - cron: '0 3 * * 1'   # 每周一 03:00 查外链

jobs:
  internal:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install && bun run build
      - run: bun scripts/crawl-local.mjs dist   # 有内链 404 则退出码 1,构建失败

  external:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install && bun run build
      - run: bun scripts/crawl-local.mjs dist || true
      - run: bun scripts/check-external.mjs
      - uses: actions/upload-artifact@v4
        with: { name: link-audit, path: .audit/ }

外链检查千万不要阻断构建。 外链失效不是你的错,而且第三方站点抖动频繁——只要有一次因为别人的服务器打嗝导致你发不了版,这个检查就会在一周内被注释掉。


十一、误报与坑

跑过几轮后一定会遇到的:

403 反爬。 知乎、微信公众号、部分政府和媒体站会拦截非浏览器 UA。换 UA 能解决一部分,剩下的加白名单。判断方法:浏览器能打开就是误报。

429 限流。 同一域名下链接太多时容易触发。除了退避重试,更好的做法是按域名分组、每个域名串行

爬自己站把自己打限流。 检查线上站点时,并发开太大会被自己的 CDN 或防火墙拦。所以本文优先推荐检查本地构建产物——不联网、更快、也不会误伤。

需登录的链接。 后台路径、会员页在检查范围里就该排除,别等报告出来才发现半页都是 401。

JS 渲染后才出现的链接。 静态抓取拿不到。要么接无头浏览器,要么在说明书里写明「本 Skill 不覆盖客户端渲染的链接」——明确说明不覆盖,比假装覆盖了要好

相对路径解析。 ../foo 这类相对链接必须以当前页面 URL 为基准解析,直接拼字符串一定出错。上面脚本用 new URL(raw, base) 就是为此。


十二、系列导航

这是「SEO Skills 工具箱」系列的第一篇。整个系列把技术 SEO 审计拆成几个各自独立、可单独使用的 Skill:

  1. 当前:死链与断链审计
  2. Meta 审计:title、description、H1 的缺失、重复与超长
  3. 结构化数据与 JSON-LD 校验
  4. Sitemap 与实际路由的差异审计、孤儿页发现
  5. 内链结构审计:权重分布、内容孤岛与锚文本
  6. 内置 seo:audit 与 Skill 封装
  7. 配套资源:说明书、规则注册表与检查清单

它们共用同一套设计原则——确定性交给脚本,判断交给模型,中间用一份写死了判定规则和输出格式的说明书连接。把这条原则吃透,后面几个 Skill 你自己就能写出来。

相关阅读:

NEXT ACTION / 下一步

对照 Astro 内容站的 SEO 清单

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

继续

RELATED / 相关推荐

接着读这些

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