// 全站页面清单生成器(`check:axe` 的分母来源)。 // // 来源:本文件由 `docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs` 平移而来(2026-09-23),口径不变: // 从 BASE 起 BFS 跟随同源链接,并与 sitemap / 预渲染产物取并集 —— 因为 `src/app/sitemap.ts` 只列 // 「希望被收录」的页面,404 与根错误兜底页天然不在其中(见 docs/lessons-learned.md §5.24)。 // // 用法(零参数即可跑通,Jenkins 的 ♿♿ 阶段就是这么调的): // BASE=http://localhost:3100 npm run check:axe:routes // 环境变量: // BASE 生产服务地址(默认 http://localhost:3100;须是 `node dist/standalone/server.js`,不是 dev server) // SEED 清单种子:既可以是本地 sitemap.xml 路径,也可以是 http(s) URL(默认取 `${BASE}/sitemap.xml`) // MANIFEST 预渲染产物目录(默认 dist/standalone/dist/server/app),存在才参与并集 // OUT 输出的路由清单 XML(默认 /tmp/axe-routes.xml,即 axe-node-count.mjs 的默认 SITEMAP) // MAX_PAGES BFS 上限(默认 200) // 退出码:0 成功 / 2 清单为空或 BASE 完全不可达(宁可不产出,也不要让门禁扫到 0 页还判绿)。 import { readFile, writeFile, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // 搬到 scripts/accessibility/ 后不再依赖 cwd:相对路径按仓库根解析。 const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const repoPath = (p) => (isAbsolute(p) ? p : resolve(REPO_ROOT, p)); const BASE = process.env.BASE ?? 'http://localhost:3100'; const SEED = process.env.SEED ?? `${BASE}/sitemap.xml`; const OUT = repoPath(process.env.OUT ?? '/tmp/axe-routes.xml'); const MANIFEST = repoPath(process.env.MANIFEST ?? 'dist/standalone/dist/server/app'); const MAX_PAGES = Number(process.env.MAX_PAGES ?? 200); const CONCURRENCY = 5; const origin = new URL(BASE).origin; const found = new Set(); const visited = new Set(); function normalize(raw) { let u; try { u = new URL(raw, BASE); } catch { return null; } if (u.origin !== origin) return null; if (u.search || u.hash) return null; const p = decodeURIComponent(u.pathname); if (/\.(svg|png|jpe?g|webp|avif|ico|css|js|mjs|txt|xml|json|woff2?|mp4|pdf|zip)$/i.test(p)) return null; if (p.startsWith('/api/') || p.startsWith('/_next/') || p.startsWith('/admin')) return null; if (p === '/_global-error') return null; // 框架内置 500 文档,见下方 MANIFEST 段的按内容判定 if (p.includes('/uploads/')) return null; return p === '/' ? '/' : p.replace(/\/+$/, '') || '/'; } async function readSeed() { if (!SEED) return ''; if (/^https?:\/\//i.test(SEED)) { try { const res = await fetch(SEED); if (!res.ok) { console.warn(`WARN: 种子 sitemap ${SEED} 返回 ${res.status},仅用预渲染产物 + BFS 作为清单来源`); return ''; } return await res.text(); } catch (err) { console.warn(`WARN: 种子 sitemap ${SEED} 不可达(${err instanceof Error ? err.message : err}),仅用预渲染产物 + BFS`); return ''; } } return readFile(repoPath(SEED), 'utf8'); // 本地文件路径:读不到就抛错,不静默降级 } for (const m of (await readSeed()).matchAll(/([^<]+)<\/loc>/g)) { const n = normalize(m[1]); if (n) found.add(n); } // 预渲染产物清单:sitemap 只收静态页 + 已发布 slug,站内无入口的孤儿页不在其中。 // 构建产物里的 .html 才是「实际会渲染的公开页面」全集,故与 sitemap 取并集。 async function walk(dir) { const out = []; for (const ent of await readdir(dir, { withFileTypes: true })) { const p = `${dir}/${ent.name}`; if (ent.isDirectory()) out.push(...(await walk(p))); else if (ent.name.endsWith('.html')) out.push(p); } return out; } let manifestCount = 0; let skippedInternal = 0; if (existsSync(MANIFEST)) { for (const file of await walk(MANIFEST)) { // Next.js 为根错误路径预渲染的是**框架内置 500 文档**(``,零应用标记、 // 无 lang、深色落到浏览器默认画布),它不是本站编写的路由,扫它等于测框架模板。 // 本站确实提供了 src/app/global-error.tsx(佐证:dist/.../chunks/ssr/src_app_global-error_tsx_*.js 内含本站文案), // 所以按**内容**判定而不是按名字硬编码:一旦框架把本站组件预渲染到此路径,条件不再命中,路由自动回到清单。 if (/]*id="__next_error__"/.test(await readFile(file, 'utf8'))) { skippedInternal++; continue; } const rel = relative(MANIFEST, file).replace(/\.html$/, ''); const path = rel === 'index' ? '/' : `/${rel.replace(/\/index$/, '')}`; const n = normalize(path); if (n && !found.has(n)) manifestCount++; if (n) found.add(n); } } else { console.warn(`WARN: 预渲染产物目录 ${MANIFEST} 不存在,清单将缺少孤儿页(先 npm run build)`); } let reachable = 0; let frontier = [...found]; while (frontier.length && visited.size < MAX_PAGES) { const batch = frontier.splice(0, CONCURRENCY * 4); const results = await Promise.all( batch.map(async (path) => { if (visited.has(path)) return []; visited.add(path); try { const res = await fetch(BASE + path); if (!res.ok) return []; reachable += 1; const html = await res.text(); const links = new Set(); for (const m of html.matchAll(/]*href="([^"]+)"/g)) { const n = normalize(m[1]); if (n && !visited.has(n)) links.add(n); } return [...links]; } catch { return []; } }) ); for (const links of results) { for (const l of links) { if (!found.has(l)) { found.add(l); frontier.push(l); } } } } const paths = [...found].sort(); // 两条「宁红不假绿」的保险丝:清单为空 ⇒ 门禁等于没测;一条都不可达 ⇒ BASE 起错了/没起。 // 两种情况下都**不写 OUT**,避免用空清单覆盖上一份可用清单。 if (paths.length === 0) { console.error(`FATAL: 三源(${SEED} ∪ ${MANIFEST} ∪ BFS)并集为 0 条路由,不写出 ${OUT}`); process.exit(2); } if (visited.size > 0 && reachable === 0) { console.error(`FATAL: BASE=${BASE} 上 ${visited.size} 条候选路由全部不可达,清单不可信,不写出 ${OUT}`); process.exit(2); } const xml = '\n\n' + paths.map((p) => ` ${BASE}${p}`).join('\n') + '\n\n'; await writeFile(OUT, xml); console.log( `crawled visited=${visited.size} reachable=${reachable} urls=${paths.length} manifestExtra=${manifestCount} skippedInternalDocs=${skippedInternal} -> ${OUT}` ); console.log(paths.join('\n'));