test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+283
View File
@@ -0,0 +1,283 @@
// 全站 axe 节点计数门禁(验收 §8-⑤ 的永久化版本)。
//
// 来源:本文件由 `docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs` 平移而来(2026-09-23),
// 行为口径不变:同一份路由清单在 **chromium + firefox** × **light + dark** 四个组合下逐页跑两遍 axe-core
// 1) tag 通道 `runOnly:{type:'tag',values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}`
// 2) 规则级通道 `runOnly:{type:'rules',values:EXTRA_RULES}`
// 第 2 遍是 `presentation-role-conflict` 的**唯一真实覆盖**(该规则属 `best-practice` tag,不在 tag 通道内),
// 也是 Lighthouse 那三条死断言删除后 `autocomplete-valid` / `svg-img-alt` 的兜底。
// 证据链:docs/acceptance/2026-09-21-gates/final-tree-results.md §⑤ 与「Lighthouse 的 3 条死断言」。
//
// 与一次性版本的关键差异:**门禁判定从「打印 PASSED」升级为「exit code」**,并新增分母自证断言,
// 使「悄悄不再覆盖」无法以绿灯形式通过(CI 见 Jenkinsfile 的 ♿♿ 阶段)。
//
// 用法:
// npm run check:axe:routes # 生成路由清单(默认 /tmp/axe-routes.xml)
// npm run check:axe # 用该清单扫描并判定
// 环境变量:BASE(默认 http://localhost:3100,须是 `node dist/standalone/server.js` 起的生产服务,
// 非 dev server)、SITEMAP(路由清单 XML)、OUT(证据 JSON 输出目录,默认 docs/acceptance/2026-09-21-axe,
// 历史证据仍留在这里)。
//
// 退出码:0 通过 / 1 门禁判红(violation 节点 > 0、主题或底色不匹配、规则级通道分母不闭合)/
// 2 输入不可用(路由清单缺失或解析出 0 条)。
import { chromium, firefox } from 'playwright';
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, isAbsolute, join, resolve } from 'path';
// 本脚本从证据目录 docs/acceptance/2026-09-21-axe/ 搬到 scripts/accessibility/,cwd 相对路径一律改为
// 仓库根相对,这样无论从哪个目录调用都指向同一份 axe-core 与同一处证据目录(行为等价,只是不再依赖 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 OUT = repoPath(process.env.OUT || 'docs/acceptance/2026-09-21-axe');
const SITEMAP = repoPath(process.env.SITEMAP || '/tmp/axe-routes.xml');
const AXE_SCRIPT = repoPath('node_modules/axe-core/axe.min.js');
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
// Lighthouse 12.6.1 不包装这三条 axe 规则,且 presentation-role-conflict 属 best-practice tag(不在 TAGS 内)
// ⇒ 只有这里的规则级 runOnly 才真正测到它们;证据链见 docs/acceptance/2026-09-21-gates/final-tree-results.md
//
// 两条数组刻意分开:
// CANONICAL_EXTRA_RULES = 门禁**要求被判定**的三条规则(绝对下限,写死在此)
// EXTRA_RULES = 实际传给 axe 的 runOnly 列表(默认等于下限)
// 只删 EXTRA_RULES 会立即判红(分母不再等于 路由 × 组合 × 3 条下限),删 CANONICAL 则需要改动带注释的门禁契约本身。
const CANONICAL_EXTRA_RULES = ['autocomplete-valid', 'presentation-role-conflict', 'svg-img-alt'];
const EXTRA_RULES = [...CANONICAL_EXTRA_RULES];
const ENGINES = [['chromium', chromium], ['firefox', firefox]];
const THEMES = ['light', 'dark'];
const COMBOS = ENGINES.length * THEMES.length; // 4 个「引擎 × 主题」组合
// 底色必须等于本站令牌值:未加载样式时所有元素都落在默认黑白底上,对比度会「意外通过」。
const EXPECTED_BG = { light: 'rgb(255, 255, 255)', dark: 'rgb(10, 14, 20)' };
// 逐页状态码期望:清单里绝大多数路由必须 2xx/3xx,`/_not-found` 是本站 404 页、**期望** 404。
// 为什么必须逐页判:404/500 页几乎没有可比对节点,`violationNodes` 仍为 0,而 `s.pages` 计数照加;
// 聚合保险丝 `okResponses > 0` 只在**全部**响应都非 2xx 时才炸 ⇒ 单个路由退化成 404 会被当「干净」计过。
// 证据:docs/acceptance/2026-09-21-axe/axe-evidence.json 实测 136 行 = 132×200 + 4×404(全部来自 /_not-found)。
const EXPECTED_404 = ['/_not-found'];
const statusOffenders = [];
function fatal(message, code = 2) {
console.error(`FATAL: ${message}`);
process.exit(code);
}
// 扫描中途抛错(浏览器启动失败、服务不可达、axe 注入失败)会以非 0 退出,这里只是把原因说清楚。
for (const ev of ['unhandledRejection', 'uncaughtException']) {
process.on(ev, (err) => fatal(`扫描中断(${ev}):${err instanceof Error ? err.message : String(err)}`, 1));
}
if (!existsSync(AXE_SCRIPT)) {
fatal(`缺少 ${AXE_SCRIPT}(axe-core 未安装?先 npm ci)`, 2);
}
if (!existsSync(SITEMAP)) {
fatal(`路由清单 ${SITEMAP} 不存在。先跑 npm run check:axe:routes 生成清单`, 2);
}
const urls = [...new Set(
readFileSync(SITEMAP, 'utf8')
.split('<url>')
.slice(1)
.map((b) => (b.match(/<loc>([^<]+)<\/loc>/) || [])[1])
.map((loc) => {
try {
return new URL(loc).pathname;
} catch {
return null;
}
})
.filter(Boolean)
)];
// 空清单时下面的循环零次执行、计数器全为 0,「PASSED = true」是假绿,必须拒绝出证据。
if (urls.length === 0) {
fatal(`从 ${SITEMAP} 解析出 0 条路由,扫描等于没测,退出 2`, 2);
}
async function settle(page) {
await page.waitForTimeout(1200);
await page.evaluate(async () => {
let last = -1;
for (let i = 0; i < 12; i += 1) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise((r) => setTimeout(r, 180));
if (Math.round(window.scrollY) === last) break;
last = Math.round(window.scrollY);
}
window.scrollTo(0, 0);
});
await page.waitForTimeout(400);
}
const rows = [];
for (const [engineName, launcher] of ENGINES) {
const browser = await launcher.launch();
for (const theme of THEMES) {
const ctx = await browser.newContext({ colorScheme: theme });
await ctx.addInitScript((t) => {
localStorage.setItem('novalon-theme', t);
localStorage.setItem('novalon-cookie-preferences',
JSON.stringify({ necessary: true, analytics: false, functional: false, timestamp: '2026-09-21T00:00:00.000Z' }));
}, theme);
const page = await ctx.newPage();
for (const url of urls) {
const res = await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded', timeout: 45000 });
const statusCode = res ? res.status() : 0;
await settle(page);
const observedTheme = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme'));
await page.addScriptTag({ path: AXE_SCRIPT });
const tagRun = await page.evaluate((tags) => window.axe.run(document, {
runOnly: { type: 'tag', values: tags },
resultTypes: ['violations'],
}), TAGS);
const violations = tagRun.violations.map((v) => ({
id: v.id, impact: v.impact, nodes: v.nodes.length,
sample: v.nodes.slice(0, 3).map((n) => (n.target || []).join(' ')),
}));
// 规则级复跑:axe 遇到未知 rule id 会直接抛错;extraRulesChecked 断言三条规则确实参与了本页运行。
const ruleRes = await page.evaluate((rules) => window.axe.run(document, {
runOnly: { type: 'rules', values: rules },
}), EXTRA_RULES);
const ruleViolations = ruleRes.violations.map((v) => ({
id: v.id, impact: v.impact, nodes: v.nodes.length,
sample: v.nodes.slice(0, 3).map((n) => (n.target || []).join(' ')),
}));
const ruleIds = new Set([
...ruleRes.violations, ...ruleRes.passes, ...ruleRes.incomplete, ...ruleRes.inapplicable,
].map((r) => r.id));
const covered = CANONICAL_EXTRA_RULES.filter((id) => ruleIds.has(id));
const pageBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
rows.push({
engine: engineName, theme, url, observedTheme, statusCode, pageBg,
bgMatchesToken: pageBg === EXPECTED_BG[theme],
violationRules: violations.length,
violationNodes: violations.reduce((a, v) => a + v.nodes, 0),
contrastNodes: violations.filter((v) => v.id === 'color-contrast').reduce((a, v) => a + v.nodes, 0),
extraRuleNodes: ruleViolations.reduce((a, v) => a + v.nodes, 0),
extraRulesChecked: ruleIds.size,
extraRulesCovered: covered,
extraRulesMissing: CANONICAL_EXTRA_RULES.filter((id) => !ruleIds.has(id)),
violations,
extraRuleViolations: ruleViolations,
});
console.log(`${engineName}/${theme} ${url} status=${statusCode} theme=${observedTheme} bg=${pageBg} rules=${violations.length} nodes=${rows.at(-1).violationNodes} contrast=${rows.at(-1).contrastNodes} extra=${rows.at(-1).extraRuleNodes}/${covered.length}/${rows.at(-1).extraRulesChecked}`);
}
await ctx.close();
}
await browser.close();
}
const summary = {};
for (const r of rows) {
const k = `${r.engine}/${r.theme}`;
summary[k] = summary[k] || {
pages: 0, themeMismatch: 0, bgMismatch: 0, contrastNodes: 0, violationNodes: 0,
extraRuleNodes: 0, extraRulePagesUnderCovered: 0, extraRuleCoveragePairs: 0,
non2xxResponses: 0, okResponses: 0, unexpectedStatus: 0,
};
summary[k].pages += 1;
summary[k].contrastNodes += r.contrastNodes;
summary[k].violationNodes += r.violationNodes;
summary[k].extraRuleNodes += r.extraRuleNodes;
summary[k].extraRuleCoveragePairs += r.extraRulesCovered.length;
// 两条都要满足:三条下限规则逐条判定过(缺一条即覆盖腐化),且通道本身没跑出预期之外的形状。
if (r.extraRulesMissing.length > 0 || r.extraRulesChecked !== EXTRA_RULES.length) {
summary[k].extraRulePagesUnderCovered += 1;
}
if (r.observedTheme !== r.theme) summary[k].themeMismatch += 1;
if (!r.bgMatchesToken) summary[k].bgMismatch += 1;
if (r.statusCode >= 200 && r.statusCode < 400) summary[k].okResponses += 1;
else summary[k].non2xxResponses += 1;
// 逐页状态码判据(独立于上面两条聚合计数)
const expects404 = EXPECTED_404.includes(r.url);
const statusMatches = expects404
? r.statusCode === 404
: r.statusCode >= 200 && r.statusCode < 400;
if (!statusMatches) {
summary[k].unexpectedStatus += 1;
statusOffenders.push(
`${k} ${r.url} status=${r.statusCode} expected=${expects404 ? '404' : '2xx/3xx'}`,
);
}
}
const expectedCombos = ENGINES.flatMap(([e]) => THEMES.map((t) => `${e}/${t}`));
const totalRuleChecks = rows.reduce((a, r) => a + r.extraRulesChecked, 0);
const totalCanonicalCoverage = rows.reduce((a, r) => a + r.extraRulesCovered.length, 0);
const expectedCanonicalCoverage = urls.length * COMBOS * CANONICAL_EXTRA_RULES.length;
const missingRuleUnion = [...new Set(rows.flatMap((r) => r.extraRulesMissing))];
// ---- 门禁断言(exit code 由此决定,不再只是打印) ----
const failures = [];
const push = (cond, msg) => { if (!cond) failures.push(msg); };
// 分母自证:缺了它,「少扫了几个组合」与「扫了且全绿」在证据上无法区分。
push(rows.length === urls.length * COMBOS,
`分母不闭合:rows=${rows.length},应为 ${urls.length} 路由 × ${COMBOS} 组合 = ${urls.length * COMBOS}`);
for (const combo of expectedCombos) {
const s = summary[combo];
if (!s) { push(false, `${combo} 组合完全缺失(浏览器未启动或中途中断)`); continue; }
push(s.pages === urls.length, `${combo} 只扫了 ${s.pages} 页,清单有 ${urls.length} 页`);
push(s.violationNodes === 0, `${combo} violationNodes=${s.violationNodes}(阈值 0)`);
push(s.contrastNodes === 0, `${combo} contrastNodes=${s.contrastNodes}(阈值 0)`);
push(s.extraRuleNodes === 0, `${combo} extraRuleNodes=${s.extraRuleNodes}(三条规则级判据,阈值 0)`);
push(s.extraRulePagesUnderCovered === 0,
`${combo} 有 ${s.extraRulePagesUnderCovered} 页的规则级通道未覆盖满 ${CANONICAL_EXTRA_RULES.length} 条下限规则(缺:${missingRuleUnion.join(', ') || '形状异常'})`);
push(s.extraRuleCoveragePairs === urls.length * CANONICAL_EXTRA_RULES.length,
`${combo} 规则级覆盖 ${s.extraRuleCoveragePairs} ≠ ${urls.length} 路由 × ${CANONICAL_EXTRA_RULES.length} 规则`);
push(s.themeMismatch === 0, `${combo} themeMismatch=${s.themeMismatch}(以为在量深色其实还是浅色)`);
push(s.bgMismatch === 0, `${combo} bgMismatch=${s.bgMismatch}(底色不等于令牌值 ⇒ 样式未加载 ⇒ 对比度「意外达标」)`);
// 保险丝:整组全非 2xx ⇒ 服务或清单不对,扫的是 404 页而不是本站页面。
push(s.okResponses > 0, `${combo} 所有响应都非 2xx(BASE=${BASE} 不是可用的生产服务,或清单已过期)`);
push(s.unexpectedStatus === 0,
`${combo} 有 ${s.unexpectedStatus} 页状态码不符期望(404/500 页会被 axe 判为「无违规」且仍计入 pages 分母;期望见 EXPECTED_404)`);
}
// offenders 明细直接进判据文本(而非只打 stdout):本仓曾因 line reporter 不带 stdout
// 而在并发失败时「查不到规则名」,同理这里若不落进 failures 就查不到是哪一条路由 404。
if (statusOffenders.length > 0) {
push(false, `逐页状态码断言失败 ${statusOffenders.length} 处:\n ${statusOffenders.join('\n ')}`);
}
push(totalRuleChecks === urls.length * COMBOS * EXTRA_RULES.length,
`规则级通道总量 ${totalRuleChecks} ≠ ${urls.length}×${COMBOS}×${EXTRA_RULES.length} = ${urls.length * COMBOS * EXTRA_RULES.length}`);
// 绝对下限的分母闭合:三条规则 × 每一页 × 每一个组合,一个都不能少(0 违规 ≠ 没测到)。
push(totalCanonicalCoverage === expectedCanonicalCoverage,
`规则级覆盖 ${totalCanonicalCoverage} ≠ ${expectedCanonicalCoverage}` +
`(= ${urls.length} 路由 × ${COMBOS} 组合 × ${CANONICAL_EXTRA_RULES.length} 条下限规则 ${CANONICAL_EXTRA_RULES.join(', ')})`);
const artifact = {
generatedAt: new Date().toISOString(),
base: BASE,
axeCore: JSON.parse(readFileSync(repoPath('node_modules/axe-core/package.json'), 'utf8')).version,
routeSource: `${SITEMAP} → ${urls.length} 条路由(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)`,
routeCount: urls.length,
routes: urls,
combinations: expectedCombos,
tags: TAGS,
extraRules: EXTRA_RULES,
requiredRuleCoverage: CANONICAL_EXTRA_RULES,
thresholds: {
colorContrastNodes: 0, violationNodes: 0, themeMismatch: 0, bgMismatch: 0,
extraRuleNodes: 0, extraRulePagesUnderCovered: 0,
rows: urls.length * COMBOS,
totalExtraRuleChecks: urls.length * COMBOS * EXTRA_RULES.length,
requiredRuleCoverageTotal: expectedCanonicalCoverage,
},
totalExtraRuleChecks: totalRuleChecks,
expectedExtraRuleChecks: urls.length * COMBOS * EXTRA_RULES.length,
totalRequiredRuleCoverage: totalCanonicalCoverage,
expectedRequiredRuleCoverage: expectedCanonicalCoverage,
passed: failures.length === 0,
failures,
summary,
rows,
};
mkdirSync(OUT, { recursive: true });
writeFileSync(`${OUT}/axe-evidence.json`, JSON.stringify(artifact, null, 2));
console.log('\nSUMMARY', JSON.stringify(summary, null, 2));
console.log('PASSED =', artifact.passed);
if (!artifact.passed) {
console.error(`\nFAIL: axe 节点计数门禁 ${failures.length} 条判据未通过(证据已写入 ${OUT}/axe-evidence.json)`);
for (const f of failures) console.error(` ✗ ${f}`);
process.exit(1);
}
+165
View File
@@ -0,0 +1,165 @@
// 全站页面清单生成器(`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>([^<]+)<\/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 文档**(`<html id="__next_error__">`,零应用标记、
// 无 lang、深色落到浏览器默认画布),它不是本站编写的路由,扫它等于测框架模板。
// 本站确实提供了 src/app/global-error.tsx(佐证:dist/.../chunks/ssr/src_app_global-error_tsx_*.js 内含本站文案),
// 所以按**内容**判定而不是按名字硬编码:一旦框架把本站组件预渲染到此路径,条件不再命中,路由自动回到清单。
if (/<html[^>]*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(/<a\b[^>]*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 =
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
paths.map((p) => ` <url><loc>${BASE}${p}</loc></url>`).join('\n') +
'\n</urlset>\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'));