内链腐烂:文章链向已改名/已删除的 slug——主机级 301 + CI 断链检查

重命名 slug 没回头更新 47 篇 linker——内链一半 404。加主机级 301、CI 跑 lychee、prebuild 在内链指向不存在的 slug 时直接 fail。

读者点你文章底部的”相关:GPT Tips”——落到 404。你 6 个月前把那个 slug 改成了 chatgpt-tips,却没回头更新 47 篇链向旧 gpt-tips 的文章。Search Console 默默把这些 404 记在 Pages -> Not indexed -> Not found (404) 下。Google 开始降低对这些 linker 页面的信任——因为它们不停把读者(和爬虫)送进死胡同。你的内部链接图从里头烂掉了。

最快修法: 在主机层把旧 slug 301 到新 slug(写进 firebase.jsonpublic/_redirects 或 CDN),再加一个 prebuild 脚本——从 frontmatter 建 slug 索引,正文里任何内链指向不存在的 slug 就让 build fail。重定向先把今天的血止住;prebuild 检查防止它再次发生。CI 里再跑一层 lychee,做支持 fragment 的二次校验。

内链腐烂是无声的:build 不挂、页面正常渲染、坏链躺在文章底部多数读者根本不滚到的地方。但每一条悬空链都是泄漏的权威信号 + 更差的用户体验。

你属于哪一类?

症状可能原因修法
链原本能用、现在 404,目标文件被改名了改了 slug 没加重定向主机级 301(Step 2)
链 404,目标文件没了文章被删恢复,或 301 到最接近的文章(Step 4)
链 404,目标从来不存在路径拼错修链接 + prebuild 检查(Step 3)
页面加载但只跳顶、不到那一节anchor 被改名lychee --include-fragments(Step 1)
外链 302 到首页或 404外链腐烂去掉 --offline、每周跑(预防)

常见原因

1. 改了 slug 没更新引用方

你把 gpt-tips.mdx 改成 chatgpt-tips.mdx,因为它排名更好。所有链向 /en/articles/gpt-tips/ 的文章现在全 404。无 build error、无重定向、无 warning。

如何判断: grep 旧 slug。

grep -rc "/articles/gpt-tips/" src/content/articles/ | grep -v ':0'

2. 删了一篇文章没查入链

你把一篇薄文废了,但还有 43 篇链着它——这些链现在全 404。

如何判断: 删之前扫入链。

grep -rl "/articles/SLUG-TO-DELETE/" src/content/articles/

只要有输出,merge 前就必须更新或重定向。

3. 链接目标拼错

你写了 /en/articles/chatgpt-tipss/(多了一个 s)。页面照常渲染、build 不校验、读者点完 404。

如何判断: 只有真正的链接检查器或 prebuild 校验器抓得到。把每篇 frontmatter 的 urlSlug 收成合法 slug 集合,就有了一份明确的白名单来比对所有内链(Step 3)。

4. 外链腐烂(第三方网站搬了页)

对 SEO 的伤害比内部 404 小,但 UX 仍然糟。你链到 https://example.com/great-article/,对方重构了——现在 302 到首页或 404。

如何判断:lychee不要--offline,让它真正发网络请求。lychee 没有 --check-external 这个 flag——发网络请求本来就是默认行为,是 --offline 把它关掉的。把扫描范围限定到外部主机,并且每周跑、而不是每个 PR 跑(外链检查太抖,不适合卡 merge)。

5. 指向被改名章节的 anchor-only 链接

你链到 /en/articles/foo/#step-3。文章被重写、## Step 3 改成了 ## Step 3: Verify,于是生成的 anchor 变成了 step-3-verify。这个 fragment 悄无声息地 404:页面照常加载,但只跳到顶部、不到那一节。

如何判断: lychee --include-fragments anchor-only 会把 #section fragment 跟目标页里渲染出的标题比对。(--include-fragments 接受 noneanchor-onlytext-onlyfull;裸写这个 flag 默认是 anchor-only,截至 2026 年 6 月。)

最短修复路径

Step 1:在构建产物上跑 lychee

# https://github.com/lycheeverse/lychee
brew install lychee
NODE_OPTIONS=--max-old-space-size=8192 npm run build
lychee --offline --include-fragments anchor-only \
  --root-dir "$(pwd)/dist" \
  --output link-report.txt \
  "dist/**/*.html"

--offline 把它限定在本地文件(快、可复现、不踩限流)。当 HTML 里有 /en/articles/... 这样的绝对链接时,--root-dir 是必需的,否则 lychee 会把每条绝对路径都报成错误。--include-fragments anchor-only 抓的就是原因 #5 里那种 anchor 被改名的情况。

想要 JS 原生方案,linkinator 会爬构建目录:

npx linkinator dist --recurse --format json \
  --skip "^https://(facebook|twitter|x)\.com" > link-report.json

把输出按”有多少页面引用了这条死链”排序,先修扇入最高的目标。

Step 2:为改名 slug 加主机级 301

这一步最容易做错:在静态构建里,astro.config.mjs 里的重定向并不会产出真正的 301。 截至 2026 年 6 月,当你以静态(SSG)模式跑 astro build 时,Astro 会为每条重定向写一个带 <meta http-equiv="refresh"> 标签的 HTML 文件。那是客户端 meta refresh,不是 HTTP 301——所以 Google 不会像对待真正的永久重定向那样转发 link equity。

要拿到能保留权威的真 301,就把重定向配在主机层。本站部署在 Firebase Hosting,所以写进 firebase.json

{
  "hosting": {
    "redirects": [
      { "source": "/en/articles/gpt-tips", "destination": "/en/articles/chatgpt-tips", "type": 301 },
      { "source": "/zh/articles/gpt-tips", "destination": "/zh/articles/chatgpt-tips", "type": 301 }
    ]
  }
}

Firebase 套用第一条匹配的规则,所以要把具体规则排在通配规则前面。在 Netlify 或 Cloudflare Pages 上,对应的东西放在 public/_redirects

/en/articles/gpt-tips/  /en/articles/chatgpt-tips/  301
/zh/articles/gpt-tips/  /zh/articles/chatgpt-tips/  301

301 上线后,旧链还能用、Google 也会转发 link equity——你可以慢慢修链接文本(或者干脆不修)。用 firebase deploy --only hosting 部署,再用 curl 确认(Step 6)。

Step 3:prebuild 在内链断链时直接 fail

重定向救改名;prebuild 检查在拼错和删除上线前就把它们拦下来。从 frontmatter 建 slug 索引,再校验正文里每条内链。

// scripts/check-internal-links.mjs
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const root = "src/content/articles";
const langs = ["en", "zh"];
const validSlugs = new Set();

// 1. 遍历每篇文章,收集合法的 /lang/articles/<slug>/ URL
function walk(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
    const p = path.join(dir, e.name);
    return e.isDirectory() ? walk(p) : p.endsWith(".mdx") ? [p] : [];
  });
}

for (const lang of langs) {
  for (const file of walk(path.join(root, lang))) {
    const { data } = matter(fs.readFileSync(file, "utf8"));
    if (data.urlSlug && data.lang) {
      validSlugs.add(`/${data.lang}/articles/${data.urlSlug}/`);
    }
  }
}

// 2. 把每条正文内链跟索引比对
let broken = 0;
for (const lang of langs) {
  for (const file of walk(path.join(root, lang))) {
    const txt = fs.readFileSync(file, "utf8");
    const links = [...txt.matchAll(/\]\((\/(?:en|zh)\/articles\/[^)#\s]+?\/?)(#[^)]*)?\)/g)];
    for (const m of links) {
      let url = m[1];
      if (!url.endsWith("/")) url += "/";
      if (!validSlugs.has(url)) {
        console.error(`BROKEN in ${path.basename(file)}: ${url}`);
        broken++;
      }
    }
  }
}
process.exit(broken > 0 ? 1 : 0);

把它接进 prebuild(或从 npm run audit:content 里跑),这样 PR 就落不下悬空内链。如果你打算只靠主机重定向保活某个旧 URL,可以给”已重定向但还没文件”的 slug 留一份小白名单。

Step 4:按批修复存量

每个断链目标:

  • 改了名: 加主机级 301(Step 2),正文不动。
  • 删了但内容仍有价值: 从 git 恢复(git log --diff-filter=D -- <path> 找到删除那次提交,再 git checkout <commit>^ -- <path>)。
  • 是主动删的: 301 到最接近的现存文章,或用 grep-and-sed 批量把 linker 指向别处、或整条剥掉。

不要留 404 在那儿。要么重定向、要么重写链接。

Step 5:重提 sitemap + 请求重抓

修完后在 Search Console 重提 sitemap(Indexing -> Sitemaps),并对受影响最重的几个 linker 页面用 URL Inspection -> Request Indexing,让 Google 重抓、看到干净的邻居。从 404 报告里恢复是渐进的,别指望当天就清空。

Step 6:确认真的修好了

确认重定向返回的是真 301、而不是带 meta refresh 的 200:

curl -sI https://yoursite.com/en/articles/gpt-tips/ | grep -iE "^(HTTP|location)"
# 期望:HTTP/2 301  以及  location: /en/articles/chatgpt-tips/

然后重跑 prebuild 检查和 lychee。scripts/check-internal-links.mjs 干净退出(exit 0)+ lychee 零内部错误,就代表腐烂清掉了。再在浏览器里抽查一条之前坏掉的”相关”链接,确认它落到真正的内容页上。

预防

  • 每个 PR 在 CI 跑 lychee --offline;任何悬空内链或 anchor 都让 job fail。
  • prebuild 内链校验器(Step 3)把每条正文链接跟 frontmatter slug 索引比对。
  • 改 slug 必须在同一个 PR 里加主机重定向——用 lint 规则或 PR 模板里的清单项强制。
  • 删除清单:先扫入链(原因 #2),再 redirect 或重写,然后才 merge。
  • 外链检查每周跑(去掉 --offline),不要每 PR 跑,并对偶发超时留容忍。
  • 季度回顾重定向表:把链路折叠,让 A -> B -> C 变成 A -> C(链式重定向会漏 link equity、也拖慢爬虫)。

常见问题

为什么主机级 301 比 astro.config.mjs 重定向好?

在静态构建里,astro.config.mjsredirects 映射会被产出成一个带 <meta http-equiv="refresh"> 标签的 HTML 页面,而不是真正的 HTTP 301(截至 2026 年 6 月)。meta refresh 对人能用,但对搜索引擎来说是比真永久重定向弱的信号。firebase.json / _redirects 条目返回的是真正的 301 状态,能干净地转发 link equity。

断掉的内链到底是伤 SEO 还是只伤 UX?

两个都伤。单条 404 不会搞垮你的站,但一片内链全指向死页,会告诉 Google 你的 linker 页面疏于维护、浪费 crawl budget,也让权威无法在链接图里流动。内链正是 PageRank 在你自家页面间分配的方式,所以悬空内链就是被浪费掉的信号。

删掉的文章该 301 还是返回 404?

如果还有相近、仍有用的文章,就 301 过去——既保住入链权威,读者也能落到真实页面。如果没有相关内容留存,干净的 404(或 410 Gone)才对;但要先把指向它的内链剥掉或改向,别故意把读者往死胡同送。

为什么 lychee 把我所有绝对链接都报成错误?

因为它不知道站点根目录、解析不了 /en/articles/...。加上 --root-dir "$(pwd)/dist",lychee 才能把绝对路径映射到磁盘上的文件。不加它,每条绝对内链都会被报成断链。

怎么抓断掉的 anchor 链接、而不只是断掉的页面?

lychee --include-fragments anchor-only。它会把 #section fragment 跟目标页里渲染出的标题比对,正好抓住”标题被改名、生成的 anchor slug 跟着变”这种情况。

相关

标签: #内容运营 #站点质量 #站点审计 #排查 #broken-link