v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* # 脚本 全站链接检查
|
||||
* 从首页出发爬取站内链接(同源 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);
|
||||
Reference in New Issue
Block a user