引用会烂:给每条一手源配一次存活检查,构建时不合格就别发布

2026-10-27 Skills内容运营自动化可观测性node

做内容站、做知识库、给客户做周报,都会堆出一批外部链接。这些链接会自己烂掉。

厂商改域名、文档换路径、产品页下架、营销页过期——你什么都没做,三个月后打开是 404。更糟的是跳转:老链接 301 到官网首页,页面还在、状态码 200,读者点进去看到的是一张卖货首页,不是你以为的那份公告。

AI 写稿把这件事放大了一个量级:模型不会告诉你链接死了,它只会继续引用。

所以这件事得让机器做。做法不复杂:每条内容留一个 source.url,一个脚本定期把它全跑一遍,结果落成一份可读的账,构建时挡住死链。

脚本

存成 scripts/check-sources.mjs,Node 20 以上直接跑。

import fs from 'node:fs';
import path from 'node:path';

const ROOT = 'content';
const DIRS = ['news', 'skills', 'policies', 'stories'];
const OUT = 'content/.sources.json';
const CONCURRENCY = 5;
const TIMEOUT_MS = 15000;
const UA = 'yike-source-check/1.0 (+https://1k.com.cn)';

function walk(dir) {
  const out = [];
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    const p = path.join(dir, e.name);
    if (e.isDirectory()) out.push(...walk(p));
    else if (e.name.endsWith('.md')) out.push(p);
  }
  return out;
}

// frontmatter 里 source: 下面那一行 url
function sourceUrl(file) {
  const src = fs.readFileSync(file, 'utf8');
  const fm = src.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!fm) return null;
  const m = fm[1].match(/^ {2}url:\s*(\S+)\s*$/m);
  return m ? m[1] : null;
}

async function probe(url) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      method: 'GET',
      redirect: 'follow',
      headers: { 'user-agent': UA, accept: 'text/html,*/*' },
      signal: ctrl.signal,
    });
    const moved = new URL(res.url).host !== new URL(url).host;
    let verdict = 'ok';
    if ([404, 410].includes(res.status)) verdict = 'dead';
    else if ([401, 403, 412, 429].includes(res.status)) verdict = 'blocked';
    else if (res.status >= 500) verdict = 'error';
    else if (moved) verdict = 'moved';
    return { url, status: res.status, finalUrl: res.url, verdict, checkedAt: new Date().toISOString() };
  } catch (e) {
    return { url, status: 0, verdict: 'error', error: String(e).slice(0, 120), checkedAt: new Date().toISOString() };
  } finally {
    clearTimeout(timer);
  }
}

const files = DIRS.flatMap((d) => (fs.existsSync(path.join(ROOT, d)) ? walk(path.join(ROOT, d)) : []));
const urls = [...new Set(files.map(sourceUrl).filter(Boolean))];

const results = [];
let cursor = 0;
await Promise.all(
  Array.from({ length: Math.min(CONCURRENCY, urls.length) }, async () => {
    while (cursor < urls.length) results.push(await probe(urls[cursor++]));
  })
);

results.sort((a, b) => a.verdict.localeCompare(b.verdict));
fs.writeFileSync(OUT, JSON.stringify(results, null, 2));

const by = (v) => results.filter((r) => r.verdict === v);
for (const v of ['dead', 'moved', 'blocked', 'error']) {
  for (const r of by(v)) {
    const arrow = r.finalUrl && r.finalUrl !== r.url ? ` -> ${r.finalUrl}` : '';
    console.log(`${v.padEnd(7)} ${String(r.status).padEnd(3)} ${r.url}${arrow}`);
  }
}
console.log(
  `\n${results.length} 条:ok ${by('ok').length} / moved ${by('moved').length} / blocked ${by('blocked').length} / error ${by('error').length} / dead ${by('dead').length}`
);

if (by('dead').length > 0 && process.argv.includes('--fail-on-dead')) process.exit(1);

package.json 加两条:

{
  "scripts": {
    "sources": "node scripts/check-sources.mjs",
    "check:sources": "node scripts/check-sources.mjs --fail-on-dead"
  }
}

日常跑 npm run sources 看报告,发布前跑 npm run check:sources,有死链就 exit 1,整个构建链断在这里。

为什么要分五档,而不是”能打开 / 打不开”

这一条是整套东西的关键,分错了会白干。

  • ok:200 且还在同一个域名。放心。
  • moved:跳到了别的域名。这不是错误,但你的 frontmatter 该改了——很多站会 302 到 www.、从 docs. 迁到 platform.。留着旧地址,下次就是 404。
  • dead:404 或 410。这是唯一该让构建失败的。
  • blocked:401 / 403 / 412 / 429。政府站、企业站上 WAF 之后对脚本返回 403 是常态,页面在浏览器里是好的。把它当死链处理,你会误删一批最好的源。
  • error:5xx、超时、DNS 挂了。可能是对方在维护,也可能是你本机网络。重试一次再判。

真实经验:一批看起来”死了”的政府公告页,403 全部是 WAF 拦脚本,用浏览器打开一切正常。所以 blocked 只列出来、不阻断,人工复核一次即可。

五条验收标准

做完之后逐条打勾,缺一条说明没做对。

  1. 把某条内容的 source.url 改成 https://example.com/this-page-does-not-exist,跑 npm run check:sources,进程 exit code 为 1,输出里有 dead 404。
  2. 把一条指向政府站或大厂文档站的地址留着,跑一遍,它出现在 blocked 里而不是 dead,构建没有被它挡住。
  3. 找一条确定会跨域跳转的地址(比如旧文档域名),输出里能看到 moved 和 -> 新地址;把 frontmatter 改成新地址后重跑,这一条消失。
  4. 拔网线(或把 TIMEOUT_MS 改成 1)跑一次,全部归到 error,脚本不以 0 退出——静默通过比报错更危险。
  5. 跑满两周后打开 content/.sources.json,每一条都有 checkedAt,能按时间排序找出”三个月没检查过”的条目。

四个坑

  • 不要伪装浏览器 UA。 改 UA 能骗过一部分 WAF,但这是在跟对方的安全策略打游击,对方一升级规则你就全线报错。老老实实自报名称,被拦就归 blocked。
  • 并发别超过 5。 几百条链接同时打出去,轻则被限流,重则 IP 进黑名单。5 个并发跑 500 条,两分钟出结果。
  • 它只能证明”页面还在”,证明不了”内容没变”。 想防”页面被改成了别的东西”,得再存一次正文的 hash。做法是抓 HTML、去掉标签、只对正文前 2000 字算 sha256,和上一轮存的值比。这一步先别做——先把存活检查跑顺,再往上加。
  • 别把 HTML 快照塞进 git。 500 条页面就是几百 MB,仓库会越来越难 clone。真要存档,放对象存储,或者只存 hash。

泼冷水

  • 这套东西在链接少的时候是负担。 10 篇以内,你手动点一遍更快。它的价值从”超过 50 条外部引用”开始显现。
  • 会被自己骗。 脚本说 ok,不代表读者能打开——地域封锁、登录墙、付费墙它都看不出来。它只能替你排除最蠢的那类错误。
  • 定期跑才有意义。 一次性跑完再不看,等于没做。挂到每周一次的定时任务上,或者放进 CI,比记在自己脑子里靠谱。

最后一句

你现在能做的:把 check-sources.mjs 放进项目,先跑一次 npm run sources 看报告。大概率会有几条 moved——那是你已经欠下的技术债,改起来只要几分钟。