Files

126 lines
5.0 KiB
TypeScript

/**
* # 脚本 全站链接检查
* 从首页出发爬取站内链接(同源 http(s) 链接,跳过静态资源/锚点/mailto/tel 等),
* 逐页抓取其中全部 <a href>,再逐一检查状态码;非 2xx/3xx 的链接按「来源页 → 目标」报告。
*
* 用法:
* bun scripts/check-links.ts [baseUrl] [--max N]
* 缺省 baseUrl = http://localhost:3312(生产服);--max 限制爬取页数(缺省 500)。
* 退出码:发现死链 → 1;全部正常 → 0。供 CI / 交付前回归使用。
*/
export {};
/** # 成员变量 目标站点根地址(命令行第 1 参可覆盖) **/
const BASE = (process.argv[2] && !process.argv[2].startsWith("--")) || process.argv[2] === undefined
? process.argv[2] ?? "http://localhost:3312"
: "http://localhost:3312";
/** # 成员变量 最大爬取页数(--max N 可覆盖) **/
const maxArg = process.argv.indexOf("--max");
const MAX_PAGES = maxArg > -1 ? Number(process.argv[maxArg + 1]) : 500;
/** # 成员变量 待抓取队列与已抓取集合(以路径+查询为键) **/
const queue: string[] = ["/"];
const seen = new Set<string>(["/"]);
/** # 成员变量 同源 href 去重后的检查清单:href → 出现它的页面集合 **/
const links = new Map<string, Set<string>>();
/** # 成员变量 死链清单:{ href, status, 来源页 } **/
const broken: { href: string; status: number; from: string[] }[] = [];
/** # 函数 规范化站内链接:去尾锚点/查询,保证尾斜杠,剔除非页面资源
* 返回值 规范化路径;null 表示不检查
*/
function normalize(href: string): string | null {
if (!href.startsWith("http") && !href.startsWith("/")) return null;
let u: URL;
try {
u = new URL(href, BASE);
} catch {
return null;
}
// 只查本站
if (u.origin !== new URL(BASE).origin) return null;
let p = u.pathname;
if (/\.[a-z0-9]{2,5}$/i.test(p) && !p.endsWith("/")) return null; // 静态资源(图片/pdf 等)
if (p === "") p = "/";
if (!p.endsWith("/")) p += "/"; // 与 trailingSlash: "always" 一致
return p;
}
/** # 函数 从一页 HTML 中抽取全部 <a href>,登记到检查清单并把新页面入队 */
function harvest(path: string, html: string): void {
const re = /<a\s[^>]*href="([^"]*)"/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(html))) {
const p = normalize(m[1]!);
if (!p) continue;
if (!links.has(p)) links.set(p, new Set());
links.get(p)!.add(path);
if (!seen.has(p) && seen.size < MAX_PAGES) {
seen.add(p);
queue.push(p);
}
}
}
/** # 函数 检查单个路径:手动跟随重定向,只跟站内跳转
* 跳到站外(库里存在 target 为原站绝对地址的历史 301 规则)时视为通过,
* 不抓取外站页面——外站死链不在本站修复范围。
* 返回值 { status: 最终状态码, external: 是否跳出了本站 }
*/
async function probe(path: string): Promise<{ status: number; external: boolean }> {
let cur = path;
for (let i = 0; i < 6; i++) {
const res = await fetch(BASE + cur, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location");
await res.arrayBuffer().catch(() => {});
if (!loc) return { status: res.status, external: false };
const next = new URL(loc, BASE + cur);
if (next.origin !== new URL(BASE).origin) return { status: 200, external: true };
cur = next.pathname + next.search;
continue;
}
await res.arrayBuffer().catch(() => {});
return { status: res.status, external: false };
}
return { status: 200, external: false }; // 重定向环按已到达处理,避免误报
}
// ── 阶段 1:BFS 爬站,收集所有页面的出链 ──
while (queue.length) {
const path = queue.shift()!;
const { status, external } = await probe(path);
const html = external ? "" : await fetch(BASE + path).then((r) => r.text()).catch(() => "");
if (status !== 200) {
broken.push({ href: path, status, from: ["(直接爬取)"] });
continue;
}
if (external) continue; // 站外跳转:不抓取外站内容
harvest(path, html);
}
// 首页自身也要出现在检查清单里
if (!links.has("/")) links.set("/", new Set());
// ── 阶段 2:逐一检查全部收集到的链接 ──
const entries = [...links.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (let i = 0; i < entries.length; i += 20) {
await Promise.all(
entries.slice(i, i + 20).map(async ([href, from]) => {
const { status, external } = await probe(href);
if (!external && status >= 400) broken.push({ href, status, from: [...from].slice(0, 5) });
}),
);
}
// ── 报告 ──
console.log(`爬取页面 ${seen.size},检查链接 ${links.size} 条`);
if (broken.length === 0) {
console.log("✅ 全部链接正常");
process.exit(0);
}
console.log(`❌ 死链 ${broken.length} 条:`);
for (const b of broken.sort((a, b) => a.href.localeCompare(b.href))) {
console.log(` [${b.status}] ${b.href}`);
for (const f of b.from) console.log(` ← ${f}`);
}
process.exit(1);