死链报告必须带来源页和解析后的目标。只报“有两个错误”,无法修复;把所有非 200 当删除,也会误判登录、限流和网络异常。
建三份小文件和一条明确路由规则
新建 public/index.html:
<!doctype html><meta charset="utf-8"><title>入口</title>
<a href="docs/start.html">开始</a>
public/docs/start.html:
<!doctype html><meta charset="utf-8"><title>开始</title>
<a href="../guide.html#setup">正确锚点</a>
<a href="../guide.html#gone">不存在的锚点</a>
<a href="/guide">重写路由</a>
<a href="../missing.html">缺失页面</a>
<a href="https://outside.example/article">外部待查</a>
public/guide.html:
<!doctype html><meta charset="utf-8"><title>指南</title>
<h1 id="setup">开始配置</h1>
教学部署规则:/guide 内部重写到 guide.html,状态保持 200。本地没有名为 guide 的文件,因此不能直接把它判 404。真实项目必须从实际部署配置读取路由,不能根据文件名猜测。
最小只读扫描器
准备 Node.js 与 Playwright:独立练习目录运行 npm init -y、npm install playwright、npx playwright install chromium。保存为 scan-links.mjs。
import fs from 'node:fs';
import path from 'node:path';
import {chromium} from 'playwright';
const root=path.resolve('public'),origin='https://example.test';
const routes={'/guide':'guide.html'};
const sources=['index.html','docs/start.html','guide.html'];
const browser=await chromium.launch({
headless:true,...(process.env.PW_CHANNEL?{channel:process.env.PW_CHANNEL}:{})
});
const page=await browser.newPage(),report=[];
try{
for(const source of sources){
const html=fs.readFileSync(path.join(root,source),'utf8');
const hrefs=await page.evaluate(text=>{
const doc=new DOMParser().parseFromString(text,'text/html');
return [...doc.querySelectorAll('a[href]')].map(a=>({raw:a.getAttribute('href'),label:a.textContent}));
},html);
for(const link of hrefs){
const row={source,...link};
let url;try{url=new URL(link.raw,origin+'/'+source);}catch{report.push({...row,status:'invalid-url'});continue;}
if(!['http:','https:'].includes(url.protocol)){report.push({...row,status:'non-http'});continue;}
row.resolved=url.href;
if(url.origin!==origin){report.push({...row,status:'external-not-probed'});continue;}
let decoded;try{decoded=decodeURIComponent(url.pathname);}catch{report.push({...row,status:'invalid-encoding'});continue;}
const relative=routes[url.pathname]??decoded.replace(/^\/+/,'');
const target=path.resolve(root,relative),rel=path.relative(root,target);
if(rel==='..'||rel.startsWith('..'+path.sep)||path.isAbsolute(rel)){
report.push({...row,status:'out-of-scope'});continue;
}
if(!fs.existsSync(target)||!fs.statSync(target).isFile()){
report.push({...row,target:rel,status:'missing-file'});continue;
}
if(url.hash){
let id;try{id=decodeURIComponent(url.hash.slice(1));}catch{report.push({...row,status:'invalid-fragment'});continue;}
const found=await page.evaluate(({text,id})=>{
const doc=new DOMParser().parseFromString(text,'text/html');
return !!doc.getElementById(id);
},{text:fs.readFileSync(target,'utf8'),id});
if(!found){report.push({...row,target:rel,status:'missing-fragment'});continue;}
}
report.push({...row,target:rel,status:routes[url.pathname]?'route-mapped':'local-ok'});
}
}
}finally{await browser.close();}
console.log(JSON.stringify(report,null,2));
运行 node scan-links.mjs > link-report.json。本程序范围是这三份普通 HTML 的 a[href] 与 id 锚点,默认不访问任何外链,不处理 CSS url()、srcset、JS 动态链接、base 标签、传统 name 锚点或完整服务器路由。正式扫描应让 Codex 按真实站点补这些解析与覆盖项,不能拿此小样宣称全站无死链。不要在包含指向项目外部的符号链接目录运行这个教学扫描器。
逐条预期结果
| 来源 | 原 href | 应报状态 |
|---|---|---|
| index.html | docs/start.html | local-ok |
| docs/start.html | ../guide.html#setup | local-ok |
| docs/start.html | ../guide.html#gone | missing-fragment |
| docs/start.html | /guide | route-mapped |
| docs/start.html | ../missing.html | missing-file |
| docs/start.html | https://outside.example/article | external-not-probed |
missing-fragment 不是页面不存在;点击可能打开页面却没跳到应有位置。route-mapped 只说明本地映射存在,不证明线上配置已经生效。
给 Codex 的修复任务
运行小样扫描,保留每条来源与目标。
把 #gone 改到实际 setup;缺失页面链接根据上下文改为 guide.html。
只改教学源 public/docs/start.html,其他 href 保留;重扫后比较新旧报告。
预期 missing-file 与 missing-fragment 各少一条,其他类别不被吞掉。
真实生成站要先追到链接的内容源再改,不只改输出 HTML。
外链本轮不访问;需要外部探测时另列允许域名、超时与低并发范围。
如果后续获得外链探测授权,记录每次方法、时间、状态、最终 URL 与重定向链。GET 403 表示访问被拒,不能直接断言文章被删除;HEAD 不支持时需谨慎用 GET 复核。200 也可能是登录页或统一错误页。超时与 5xx 可在低频重试后保留待查,不自动替换成看似相近的网页。
本例两个缺陷有本地文件和 id 证据,可直接修。外链与真实路由则需要对应环境证据;这两种结论应留在不同字段里。
下一步
继续阅读与你当前问题相关的教程:


