引用会烂:给每条一手源配一次存活检查,构建时不合格就别发布
做内容站、做知识库、给客户做周报,都会堆出一批外部链接。这些链接会自己烂掉。
厂商改域名、文档换路径、产品页下架、营销页过期——你什么都没做,三个月后打开是 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 只列出来、不阻断,人工复核一次即可。
五条验收标准
做完之后逐条打勾,缺一条说明没做对。
- 把某条内容的
source.url改成https://example.com/this-page-does-not-exist,跑npm run check:sources,进程 exit code 为 1,输出里有dead 404。 - 把一条指向政府站或大厂文档站的地址留着,跑一遍,它出现在
blocked里而不是dead,构建没有被它挡住。 - 找一条确定会跨域跳转的地址(比如旧文档域名),输出里能看到
moved和-> 新地址;把 frontmatter 改成新地址后重跑,这一条消失。 - 拔网线(或把
TIMEOUT_MS改成 1)跑一次,全部归到error,脚本不以 0 退出——静默通过比报错更危险。 - 跑满两周后打开
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——那是你已经欠下的技术债,改起来只要几分钟。