#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); function parseArgs(argv) { const args = { caseDir: 'case', manifest: '', requireRuntimeRefresh: false, json: false, markdown: false, help: false, }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || 'case'; else if (a === '--manifest') args.manifest = argv[++i] || ''; else if (a === '--require-runtime-refresh') args.requireRuntimeRefresh = true; else if (a === '--json') args.json = true; else if (a === '--markdown') args.markdown = true; else if (a === '--help' || a === '-h') args.help = true; else throw new Error(`未知参数:${a}`); } if (!args.json && !args.markdown) args.markdown = true; return args; } function usage() { return `用法: node scripts/check_dynamic_resources.js --case-dir case --markdown node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --json 说明:检查 case/notes/resource-manifest.json 中的动态 HTML / JS / challenge 资源是否只作为分析快照,并确认影响最终参数生成的动态资源已设计运行时刷新模块;同时检查 result/ 中是否混入动态快照文件。`; } function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } } function stat(p) { try { return fs.statSync(p); } catch { return null; } } function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); } function walk(p, out = []) { if (!exists(p)) return out; const st = stat(p); if (!st) return out; if (st.isDirectory()) { let names = []; try { names = fs.readdirSync(p); } catch { names = []; } for (const name of names) walk(path.join(p, name), out); } else if (st.isFile()) out.push(p); return out; } function sha256File(file) { try { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return ''; } } function normalizeBool(v) { return v === true || v === 'true' || v === 1 || v === '1'; } function isDynamicValue(v) { if (typeof v === 'boolean') return v; if (typeof v === 'string') return ['true', 'yes', 'dynamic', 'high-risk', '高风险', '动态'].includes(v.toLowerCase()); return false; } function getHash(resource) { return resource.sha256 || resource.bodySha256 || resource.bodyHash || resource.hash || ''; } function sanitizeRel(p) { return String(p || '').replace(/\\/g, '/').replace(/^\.\//, ''); } function resolveCasePath(caseDir, maybePath) { const raw = String(maybePath || '').trim(); if (!raw) return []; if (path.isAbsolute(raw)) return [raw]; const normalized = sanitizeRel(raw); const parent = path.dirname(caseDir); const withoutCase = normalized.replace(/^case\//i, ''); const candidates = [ path.join(caseDir, normalized), path.join(parent, normalized), path.join(caseDir, withoutCase), ]; if (!/^result\//i.test(normalized)) candidates.push(path.join(caseDir, 'result', normalized)); return [...new Set(candidates.map(p => path.normalize(p)))]; } function firstExisting(paths) { return paths.find(p => exists(p)) || ''; } function readManifest(manifestPath) { const text = fs.readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, ''); const parsed = JSON.parse(text); if (Array.isArray(parsed)) return { raw: parsed, resources: parsed }; const resources = Array.isArray(parsed.resources) ? parsed.resources : []; return { raw: parsed, resources }; } function finalEntryFiles(resultDir) { const files = []; for (const name of ['final.js', 'final.mjs', 'final.cjs', 'final.py']) { const p = path.join(resultDir, name); if (exists(p)) files.push(p); } return files; } function readTextSafe(file) { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } } function inspect(caseDir, args) { const problems = []; const warnings = []; const manifestPath = args.manifest ? path.resolve(args.manifest) : path.join(caseDir, 'notes', 'resource-manifest.json'); const resultDir = path.join(caseDir, 'result'); const snapshotsDir = path.join(caseDir, 'js', 'snapshots'); const report = { ok: false, caseDir, manifestPath, manifestExists: exists(manifestPath), resources: [], dynamicCount: 0, requiredDynamicCount: 0, runtimeRefreshCount: 0, problems, warnings, }; if (!report.manifestExists) { const hasSnapshots = exists(snapshotsDir) && walk(snapshotsDir).length > 0; const msg = `未找到资源清单:${rel(caseDir, manifestPath)}`; if (args.requireRuntimeRefresh || hasSnapshots) problems.push(hasSnapshots ? `${msg};但已存在 js/snapshots 动态快照目录,必须补写 resource-manifest.json。` : `${msg};当前要求强制检查运行时刷新。`); else warnings.push(`${msg};如果本 case 没有下载 / 保存 HTML、JS 或 challenge 资源,可以忽略。`); report.ok = problems.length === 0; return report; } let manifest; try { manifest = readManifest(manifestPath); } catch (err) { problems.push(`resource-manifest.json 解析失败:${err.message}`); report.ok = false; return report; } if (!Array.isArray(manifest.resources)) { problems.push('resource-manifest.json 缺少 resources 数组。'); report.ok = false; return report; } const resultFiles = walk(resultDir).filter(p => stat(p)?.isFile()); const resultHashes = new Map(); for (const file of resultFiles) resultHashes.set(file, sha256File(file)); const resultRelList = resultFiles.map(file => rel(caseDir, file)); for (let i = 0; i < manifest.resources.length; i++) { const r = manifest.resources[i] || {}; const item = { index: i, url: r.url || '', type: r.type || '', file: r.file || '', sha256: getHash(r), dynamic: isDynamicValue(r.dynamic), requiredForFinal: normalizeBool(r.requiredForFinal), runtimeRefresh: normalizeBool(r.runtimeRefresh) || r.use === 'runtime-refresh', refreshEntry: r.refreshEntry || '', use: r.use || '', problems: [], warnings: [], }; report.resources.push(item); const prefix = `resources[${i}]`; if (!item.url) item.problems.push(`${prefix} 缺少 url。`); if (!item.sha256) item.problems.push(`${prefix} 缺少 sha256 / bodySha256 / hash。`); if (typeof r.dynamic === 'undefined') item.problems.push(`${prefix} 缺少 dynamic 字段,必须明确 true / false。`); if (!item.use) item.problems.push(`${prefix} 缺少 use 字段,应标明 analysis-snapshot / runtime-refresh / static 等。`); if (item.dynamic) report.dynamicCount += 1; if (item.dynamic && item.requiredForFinal) report.requiredDynamicCount += 1; if (item.runtimeRefresh) report.runtimeRefreshCount += 1; if (item.dynamic && item.file && !/analysis-snapshot|runtime-refresh/i.test(item.use)) { item.warnings.push(`${prefix} 是动态资源,但 use 未标明 analysis-snapshot 或 runtime-refresh。`); } if (item.dynamic && item.requiredForFinal) { if (!item.runtimeRefresh) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但 runtimeRefresh 未设置为 true。`); if (!item.refreshEntry) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但缺少 refreshEntry。`); if (args.requireRuntimeRefresh && item.refreshEntry) { const refreshPath = firstExisting(resolveCasePath(caseDir, item.refreshEntry)); if (!refreshPath) item.problems.push(`${prefix} 的 refreshEntry 不存在:${item.refreshEntry}。`); } } if (item.dynamic && item.file) { const resourcePath = firstExisting(resolveCasePath(caseDir, item.file)); const resourceHash = resourcePath ? sha256File(resourcePath) : item.sha256; const resourceBase = path.basename(item.file).toLowerCase(); for (const file of resultFiles) { const rr = rel(caseDir, file); const rrLower = rr.toLowerCase(); const sameHash = resourceHash && resultHashes.get(file) === resourceHash; const snapshotPath = /(^|\/)result\/(?:.*\/)?(?:js\/)?snapshots\//i.test(rrLower) || /(^|\/)snapshots\//i.test(rrLower); if (sameHash) item.problems.push(`${prefix} 的动态快照内容疑似被复制到最终产物:${rr}。`); else if (snapshotPath) item.problems.push(`最终产物包含 snapshots 路径,不应交付动态快照:${rr}。`); else if (resourceBase && path.basename(file).toLowerCase() === resourceBase && /snapshot|challenge|dynamic/i.test(resourceBase)) { item.warnings.push(`${prefix} 的文件名与 result/ 中 ${rr} 相同,请确认不是复制动态快照。`); } } } for (const p of item.problems) problems.push(p); for (const w of item.warnings) warnings.push(w); } if (args.requireRuntimeRefresh && report.requiredDynamicCount > 0) { const entries = finalEntryFiles(resultDir); if (entries.length === 0) { warnings.push('result/ 中未发现 final.js / final.py;无法检查最终入口是否调用运行时刷新模块。'); } else { const finalText = entries.map(readTextSafe).join('\n'); const refreshNames = report.resources .filter(r => r.dynamic && r.requiredForFinal && r.refreshEntry) .map(r => path.basename(r.refreshEntry).replace(/\.[^.]+$/, '')) .filter(Boolean); const hasRefreshSignal = /fetchRuntimeResources|refreshRuntimeResources|loadRuntimeResources|resourceManifest|运行时刷新|动态资源/.test(finalText) || refreshNames.some(name => new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(finalText)); if (!hasRefreshSignal) { problems.push('最终入口未发现运行时刷新调用痕迹;动态 HTML / JS 影响最终参数生成时,final.js / final.py 必须先刷新当前资源再生成参数。'); } } } if (manifest.resources.length === 0) warnings.push('resource-manifest.json 中 resources 为空;如果已下载 HTML / JS,请补充清单。'); if (resultRelList.some(x => /(^|\/)js\/snapshots\//i.test(x) || /(^|\/)snapshots\//i.test(x))) { problems.push('最终 result/ 中存在 snapshots 目录或文件;动态快照不得进入最终产物。'); } report.ok = problems.length === 0; return report; } function toMarkdown(report) { const lines = []; lines.push('# 动态资源保鲜检查'); lines.push(''); lines.push(`- 检查结果:${report.ok ? '通过' : '未通过'}`); lines.push(`- case 目录:${report.caseDir}`); lines.push(`- manifest:${report.manifestExists ? report.manifestPath : '未找到'}`); lines.push(`- 资源数量:${report.resources.length}`); lines.push(`- 动态资源数量:${report.dynamicCount}`); lines.push(`- 影响最终参数生成的动态资源:${report.requiredDynamicCount}`); lines.push(`- 已声明运行时刷新的资源:${report.runtimeRefreshCount}`); lines.push(''); if (report.resources.length) { lines.push('## 资源概览'); lines.push(''); lines.push('| 序号 | 类型 | 动态 | 最终依赖 | 运行时刷新 | use | refreshEntry | URL |'); lines.push('|---|---|---|---|---|---|---|---|'); for (const r of report.resources) { lines.push(`| ${r.index} | ${r.type || '-'} | ${r.dynamic ? '是' : '否'} | ${r.requiredForFinal ? '是' : '否'} | ${r.runtimeRefresh ? '是' : '否'} | ${r.use || '-'} | ${r.refreshEntry || '-'} | ${String(r.url || '').replace(/\|/g, '%7C')} |`); } lines.push(''); } if (report.problems.length) { lines.push('## 必须修复'); lines.push(''); for (const p of report.problems) lines.push(`- ${p}`); lines.push(''); } if (report.warnings.length) { lines.push('## 提醒'); lines.push(''); for (const w of report.warnings) lines.push(`- ${w}`); lines.push(''); } if (report.ok) { lines.push('## 结论'); lines.push(''); lines.push('- 未发现动态资源固定化交付问题。'); if (report.requiredDynamicCount > 0) lines.push('- 影响最终参数生成的动态资源已声明运行时刷新方案。'); else lines.push('- 当前 manifest 未声明影响最终参数生成的动态资源。'); } return lines.join('\n'); } function main() { const args = parseArgs(process.argv); if (args.help) { console.log(usage()); return; } const caseDir = path.resolve(args.caseDir || 'case'); const report = inspect(caseDir, args); if (args.json) console.log(JSON.stringify(report, null, 2)); else console.log(toMarkdown(report)); if (!report.ok) process.exitCode = 1; } try { main(); } catch (err) { console.error(`动态资源检查失败:${err.message}`); process.exit(1); }