local-seed-material-index.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const zlib = require('zlib');
  5. const ROOT = path.resolve(__dirname, '..');
  6. const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'local-seed-material-index-latest');
  7. function main() {
  8. const args = parseArgs(process.argv.slice(2));
  9. const workspaceRoot = path.resolve(args.workspaceRoot || path.join(ROOT, '..'));
  10. const outputsDir = path.resolve(args.outputs || path.join(ROOT, 'outputs'));
  11. const outputDir = path.resolve(args.output || DEFAULT_OUTPUT);
  12. const summary = buildLocalSeedMaterialIndex({ root: ROOT, workspaceRoot, outputsDir });
  13. fs.mkdirSync(outputDir, { recursive: true });
  14. const summaryPath = path.join(outputDir, 'local-seed-material-index-summary.json');
  15. const reportPath = path.join(outputDir, 'local-seed-material-index.md');
  16. const csvPath = path.join(outputDir, 'local-seed-material-index.csv');
  17. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  18. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  19. fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8');
  20. console.log(JSON.stringify({
  21. outputDir,
  22. summary: summaryPath,
  23. report: reportPath,
  24. csv: csvPath,
  25. materialCount: summary.materialCount,
  26. existingMaterialCount: summary.existingMaterialCount,
  27. canCloseProofGap: summary.canCloseProofGap
  28. }, null, 2));
  29. if (args.strict && summary.directCustomerProof !== false) process.exitCode = 1;
  30. }
  31. function buildLocalSeedMaterialIndex({ root, workspaceRoot, outputsDir }) {
  32. const specs = [
  33. {
  34. id: 'dha-brief-workbook',
  35. title: 'DHA Brief 原始工作簿',
  36. file: path.join(workspaceRoot, 'dha_brief.xlsx'),
  37. role: 'brief_seed',
  38. usableFor: ['补真实 Brief 模板的需求字段', '识别 DHA/母婴/小红书账号类型线索', '作为候选池召回种子'],
  39. missingForProof: [
  40. '至少 5 个真实历史 Brief',
  41. '人工最终提报名单',
  42. '客户最终选中/拒绝记录',
  43. '拒绝原因或商务复核标签',
  44. '历史人工补号量基线'
  45. ]
  46. },
  47. {
  48. id: 'dna-brief-workbook-legacy-name',
  49. title: 'DNA Brief 旧命名工作簿',
  50. file: path.join(workspaceRoot, 'dna_brief.xlsx'),
  51. role: 'brief_seed_legacy_name',
  52. usableFor: ['兼容旧交接命名,确认是否另有 Brief 种子材料'],
  53. missingForProof: ['若文件不存在,不影响当前 DHA 种子材料索引']
  54. },
  55. {
  56. id: 'dha-poc-recommendation-workbook',
  57. title: 'AI 提号 DHA 小红书推荐名单 PoC',
  58. file: path.join(workspaceRoot, 'output', 'dha-tihao-poc', 'AI提号-DHA小红书推荐名单-PoC.xlsx'),
  59. role: 'candidate_seed',
  60. usableFor: ['候选池种子', '商务复核展示样例', '参考账号分析口径校准'],
  61. missingForProof: [
  62. '客户选择/拒绝结果',
  63. '负样本归因',
  64. '本轮人工补号量',
  65. '可审计的生成策略和 live 采集证明'
  66. ]
  67. },
  68. {
  69. id: 'dha-poc-social-reference-workbook',
  70. title: 'AI 提号 DHA 社媒参考分析版',
  71. file: path.join(workspaceRoot, 'output', 'dha-tihao-poc', 'AI提号-DHA小红书推荐名单-社媒参考分析版.xlsx'),
  72. role: 'reference_analysis_seed',
  73. usableFor: ['参考账号基准分析', '候选池补充', '视频/主页证据字段设计参考'],
  74. missingForProof: [
  75. '真实候选视频资源',
  76. '真实视频 A/B live proof',
  77. '商务复核结果',
  78. '客户最终选择'
  79. ]
  80. }
  81. ];
  82. const materials = specs.map(spec => inspectMaterial({ root, workspaceRoot, spec }));
  83. const pipelineArtifacts = inspectPipelineArtifacts({ root, outputsDir });
  84. const liveAutomationCandidates = inspectLiveAutomationCandidates({ root, outputsDir });
  85. const existingMaterialCount = materials.filter(item => item.exists).length;
  86. const workbookCount = materials.filter(item => item.exists && item.extension === '.xlsx').length;
  87. const promisingLiveAggregateCount = liveAutomationCandidates.filter(item => item.liveAutomationPass).length;
  88. return {
  89. generatedAt: new Date().toISOString(),
  90. root,
  91. workspaceRoot,
  92. outputsDir,
  93. proofLevel: 'not_business_proof',
  94. directCustomerProof: false,
  95. canCloseProofGap: false,
  96. complete: false,
  97. materialCount: materials.length,
  98. existingMaterialCount,
  99. workbookCount,
  100. promisingLiveAggregateCount,
  101. materials,
  102. pipelineArtifacts,
  103. liveAutomationCandidates,
  104. guardrails: [
  105. '本索引只把本地已有 Brief/PoC/历史输出做成可发现的种子材料台账,不关闭 proof-gap。',
  106. 'DHA Brief 和 PoC 推荐表可用于补真实模板、召回候选和校准口径,但不能证明客户效果。',
  107. '历史 pipeline 失败产物、模板数据、currentManualSupplementCount=0 或候选名单本身不能证明人工补号量下降。',
  108. 'live overnight aggregate 即使是真实自动化证据,也必须被 proof-gap:closure 在当前审计上下文中接受后,才可关闭 live/provider 缺口。',
  109. '不得在本索引或后续文档中写入 sessionToken、Authorization、模型 token 或 npm token。'
  110. ],
  111. nextActions: [
  112. {
  113. owner: '商务',
  114. title: '把 DHA Brief 种子转写进统一历史数据模板',
  115. action: '只把真实客户 Brief 字段、参考账号/视频线索和账号类型要求转写进 outputs/data-intake-pack-latest/history-data-template.csv;不足 5 个 Brief 时继续保持未完成。',
  116. acceptance: 'intake:readiness historyReady=true,且 history:audit readyForCustomerEffectProof=true。'
  117. },
  118. {
  119. owner: '商务/投放',
  120. title: '从 PoC 候选里补真实候选视频资源',
  121. action: '为候选账号补至少一个真实候选视频 URL、封面、ASR/字幕、帧图或正文证据,并写入 video-resource-template.csv。',
  122. acceptance: 'video:resource-readiness readyForVideoAbPreflight=true,realCandidateRows>=1。'
  123. },
  124. {
  125. owner: '技术/AI',
  126. title: '只把本索引用作补证入口,不作为完成证明',
  127. action: '补齐真实材料后重跑 intake:readiness、video:resource-readiness、proof-gap:closure、optimization:completion 和 round:refresh。',
  128. acceptance: 'proof-gap-closure.openCount=0 且 optimization-completion.readyForClaim=true 后,才进入长期目标完成判断。'
  129. }
  130. ]
  131. };
  132. }
  133. function inspectMaterial({ root, workspaceRoot, spec }) {
  134. const exists = fs.existsSync(spec.file);
  135. const extension = path.extname(spec.file).toLowerCase();
  136. const base = {
  137. id: spec.id,
  138. title: spec.title,
  139. role: spec.role,
  140. path: relPath(root, spec.file),
  141. workspacePath: relPath(workspaceRoot, spec.file),
  142. exists,
  143. extension,
  144. proofLevel: 'not_business_proof',
  145. directCustomerProof: false,
  146. canCloseProofGap: false,
  147. usableFor: spec.usableFor,
  148. missingForProof: spec.missingForProof
  149. };
  150. if (!exists) {
  151. return {
  152. ...base,
  153. status: 'missing',
  154. summary: '本地未发现该文件;只记录旧交接线索。'
  155. };
  156. }
  157. const stat = fs.statSync(spec.file);
  158. const workbook = extension === '.xlsx' ? readWorkbookSummary(spec.file) : null;
  159. const allText = workbook ? workbook.searchText : '';
  160. const signals = detectSignals(allText);
  161. return {
  162. ...base,
  163. status: 'available_seed',
  164. size: stat.size,
  165. updatedAt: stat.mtime.toISOString(),
  166. workbook: workbook ? {
  167. sheetCount: workbook.sheets.length,
  168. sheets: workbook.sheets.map(sheet => ({
  169. name: sheet.name,
  170. rowCount: sheet.rowCount,
  171. columnCount: sheet.columnCount,
  172. headers: sheet.headers,
  173. sampleRows: sheet.sampleRows
  174. })),
  175. secretLikeHitCount: workbook.secretLikeHitCount,
  176. parseWarnings: workbook.parseWarnings
  177. } : null,
  178. signals,
  179. summary: renderMaterialSummary({ spec, signals, workbook })
  180. };
  181. }
  182. function inspectPipelineArtifacts({ root, outputsDir }) {
  183. const pipelineDir = path.join(outputsDir, 'optimization-pipeline-latest');
  184. const pipeline = readJson(path.join(pipelineDir, 'optimization-pipeline-summary.json'));
  185. const history = readJson(path.join(pipelineDir, 'history-audit', 'historical-dataset-audit.json'));
  186. const customer = readJson(path.join(pipelineDir, 'customer-effect-audit', 'customer-effect-summary.json'));
  187. return {
  188. proofLevel: 'not_business_proof',
  189. directCustomerProof: false,
  190. canCloseProofGap: false,
  191. pipeline: pipeline ? {
  192. path: relPath(root, path.join(pipelineDir, 'optimization-pipeline-summary.json')),
  193. readyForClaim: Boolean(pipeline.readyForClaim),
  194. passCount: Number(pipeline.counts?.pass || 0),
  195. failCount: Number(pipeline.counts?.fail || 0),
  196. currentManualSupplementCount: pipeline.inputs?.currentManualSupplementCount ?? null,
  197. warning: '旧 pipeline 只能说明编排链路曾运行;包含模板/示例输入或 currentManualSupplementCount=0 时不能证明人工补号量下降。'
  198. } : null,
  199. historyAudit: history ? {
  200. path: relPath(root, path.join(pipelineDir, 'history-audit', 'historical-dataset-audit.json')),
  201. briefCount: Number(history.briefCount || 0),
  202. readyForCustomerEffectProof: Boolean(history.acceptance?.readyForCustomerEffectProof),
  203. readyForLongRun: Boolean(history.acceptance?.readyForLongRun),
  204. categoryCoverageMet: Boolean(history.acceptance?.categoryCoverageMet),
  205. warning: '历史审计未达到 readyForCustomerEffectProof=true 前,不能支撑客户效果证明。'
  206. } : null,
  207. customerEffect: customer ? {
  208. path: relPath(root, path.join(pipelineDir, 'customer-effect-audit', 'customer-effect-summary.json')),
  209. overallPass: Boolean(customer.acceptance?.overallPass),
  210. customerSelectedRateMeasured: Boolean(customer.acceptance?.customerSelectedRateMeasured),
  211. referenceCustomerSelectedRateMeasured: Boolean(customer.acceptance?.referenceCustomerSelectedRateMeasured),
  212. historyReadyForCustomerEffectProof: Boolean(customer.acceptance?.historyReadyForCustomerEffectProof),
  213. missingEvidence: customer.missingEvidence || [],
  214. warning: '客户效果审计 overallPass=false 或选择率未测量时,不能证明提号效果达标。'
  215. } : null
  216. };
  217. }
  218. function inspectLiveAutomationCandidates({ root, outputsDir }) {
  219. if (!fs.existsSync(outputsDir)) return [];
  220. return fs.readdirSync(outputsDir, { withFileTypes: true })
  221. .filter(entry => entry.isDirectory() && entry.name.startsWith('overnight-quality-'))
  222. .map(entry => {
  223. const dir = path.join(outputsDir, entry.name);
  224. const aggregatePath = path.join(dir, 'aggregate-summary.json');
  225. const aggregate = readJson(aggregatePath);
  226. if (!aggregate) return null;
  227. const failedGateCount = Number(aggregate.acceptance?.failedGateCount ?? aggregate.failedGateCount ?? 0);
  228. const liveAutomationPass = Boolean(aggregate.manifest?.liveEnabled) &&
  229. aggregate.acceptance?.overallPass === true &&
  230. Number(aggregate.failureCount || 0) === 0 &&
  231. failedGateCount === 0;
  232. return {
  233. id: entry.name,
  234. path: relPath(root, aggregatePath),
  235. updatedAt: fs.statSync(dir).mtime.toISOString(),
  236. liveEnabled: Boolean(aggregate.manifest?.liveEnabled),
  237. overallPass: Boolean(aggregate.acceptance?.overallPass),
  238. failureCount: Number(aggregate.failureCount || 0),
  239. failedGateCount,
  240. runCount: Number(aggregate.runCount || 0),
  241. fixtureCount: Number(aggregate.fixtureCount || 0),
  242. variantCount: Number(aggregate.variantCount || 0),
  243. liveAutomationPass,
  244. proofLevel: liveAutomationPass ? 'real_evidence_for_live_automation' : 'not_business_proof',
  245. directCustomerProof: false,
  246. canCloseCustomerEffectProof: false,
  247. canCloseProofGapNow: false,
  248. missingForClosure: liveAutomationPass
  249. ? ['需要 proof-gap:closure 在当前审计上下文中明确接受该 aggregate', '仍不能替代客户选择和人工补号量证明']
  250. : ['liveEnabled=true', 'overallPass=true', 'failureCount=0', 'failedGateCount=0']
  251. };
  252. })
  253. .filter(Boolean)
  254. .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
  255. .slice(0, 8);
  256. }
  257. function readWorkbookSummary(file) {
  258. const parseWarnings = [];
  259. try {
  260. const zip = readZipEntries(fs.readFileSync(file));
  261. const sharedStrings = parseSharedStrings(readZipText(zip, 'xl/sharedStrings.xml') || '');
  262. const workbookXml = readZipText(zip, 'xl/workbook.xml') || '';
  263. const relsXml = readZipText(zip, 'xl/_rels/workbook.xml.rels') || '';
  264. const sheets = parseWorkbookSheets(workbookXml, relsXml);
  265. if (sheets.length === 0) {
  266. Object.keys(zip)
  267. .filter(name => /^xl\/worksheets\/sheet\d+\.xml$/.test(name))
  268. .sort()
  269. .forEach((name, index) => sheets.push({ name: `Sheet${index + 1}`, target: name }));
  270. }
  271. const sheetSummaries = sheets.map(sheet => parseSheet({
  272. name: sheet.name,
  273. xml: readZipText(zip, sheet.target) || '',
  274. sharedStrings
  275. }));
  276. const searchText = sheetSummaries
  277. .flatMap(sheet => [sheet.name, ...sheet.allValues])
  278. .join('\n');
  279. return {
  280. sheets: sheetSummaries,
  281. searchText,
  282. secretLikeHitCount: countSecretLike(searchText),
  283. parseWarnings
  284. };
  285. } catch (error) {
  286. return {
  287. sheets: [],
  288. searchText: '',
  289. secretLikeHitCount: 0,
  290. parseWarnings: [`xlsx 解析失败:${error.message}`]
  291. };
  292. }
  293. }
  294. function readZipEntries(buffer) {
  295. const eocdOffset = findEndOfCentralDirectory(buffer);
  296. if (eocdOffset < 0) throw new Error('未找到 ZIP central directory');
  297. const totalEntries = buffer.readUInt16LE(eocdOffset + 10);
  298. const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
  299. const entries = {};
  300. let offset = centralDirectoryOffset;
  301. for (let index = 0; index < totalEntries; index += 1) {
  302. if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error('ZIP central directory 损坏');
  303. const method = buffer.readUInt16LE(offset + 10);
  304. const compressedSize = buffer.readUInt32LE(offset + 20);
  305. const fileNameLength = buffer.readUInt16LE(offset + 28);
  306. const extraLength = buffer.readUInt16LE(offset + 30);
  307. const commentLength = buffer.readUInt16LE(offset + 32);
  308. const localHeaderOffset = buffer.readUInt32LE(offset + 42);
  309. const name = buffer.slice(offset + 46, offset + 46 + fileNameLength).toString('utf8');
  310. const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
  311. const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28);
  312. const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
  313. const compressed = buffer.slice(dataStart, dataStart + compressedSize);
  314. let data;
  315. if (method === 0) data = compressed;
  316. else if (method === 8) data = zlib.inflateRawSync(compressed);
  317. else data = Buffer.alloc(0);
  318. entries[name] = data;
  319. offset += 46 + fileNameLength + extraLength + commentLength;
  320. }
  321. return entries;
  322. }
  323. function findEndOfCentralDirectory(buffer) {
  324. const min = Math.max(0, buffer.length - 66000);
  325. for (let offset = buffer.length - 22; offset >= min; offset -= 1) {
  326. if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
  327. }
  328. return -1;
  329. }
  330. function readZipText(entries, name) {
  331. const item = entries[name];
  332. return item ? item.toString('utf8') : '';
  333. }
  334. function parseSharedStrings(xml) {
  335. if (!xml) return [];
  336. return matchAll(xml, /<(?:\w+:)?si\b[\s\S]*?<\/(?:\w+:)?si>/g).map(item => {
  337. const textParts = [];
  338. const regex = /<(?:\w+:)?t\b[^>]*>([\s\S]*?)<\/(?:\w+:)?t>/g;
  339. let match;
  340. while ((match = regex.exec(item)) !== null) textParts.push(decodeXml(match[1]));
  341. return textParts.join('');
  342. });
  343. }
  344. function parseWorkbookSheets(workbookXml, relsXml) {
  345. const relMap = {};
  346. for (const rel of matchAll(relsXml, /<Relationship\b([^>]*?)\/>/g)) {
  347. const attrs = parseAttrs(rel);
  348. if (attrs.Id && attrs.Target) {
  349. relMap[attrs.Id] = normalizeWorkbookTarget(attrs.Target);
  350. }
  351. }
  352. return matchAll(workbookXml, /<(?:\w+:)?sheet\b([^>]*?)\/>/g).map((tag, index) => {
  353. const attrs = parseAttrs(tag);
  354. const rid = attrs['r:id'] || attrs.id || '';
  355. return {
  356. name: attrs.name || `Sheet${index + 1}`,
  357. target: relMap[rid] || `xl/worksheets/sheet${index + 1}.xml`
  358. };
  359. });
  360. }
  361. function normalizeWorkbookTarget(target) {
  362. const value = String(target || '').replace(/\\/g, '/');
  363. if (value.startsWith('/')) return path.posix.normalize(value.slice(1));
  364. return path.posix.normalize(path.posix.join('xl', value));
  365. }
  366. function parseSheet({ name, xml, sharedStrings }) {
  367. const rows = [];
  368. let maxColumn = 0;
  369. for (const rowXml of matchAll(xml, /<(?:\w+:)?row\b[^>]*>([\s\S]*?)<\/(?:\w+:)?row>/g)) {
  370. const values = [];
  371. for (const cellXml of matchAll(rowXml, /<(?:\w+:)?c\b([^>]*?)>([\s\S]*?)<\/(?:\w+:)?c>/g)) {
  372. const attrs = parseAttrs(cellXml);
  373. const body = cellXml;
  374. const colIndex = columnIndex((attrs.r || '').replace(/\d+/g, ''));
  375. const value = parseCellValue(body, attrs, sharedStrings);
  376. if (value !== '') {
  377. values[colIndex >= 0 ? colIndex : values.length] = sanitizeCell(value);
  378. maxColumn = Math.max(maxColumn, (colIndex >= 0 ? colIndex : values.length - 1) + 1);
  379. }
  380. }
  381. if (values.some(value => String(value || '').trim())) rows.push(values);
  382. }
  383. const headers = (rows[0] || []).map(value => sanitizeCell(value)).filter(Boolean).slice(0, 20);
  384. const sampleRows = rows.slice(1, 6).map(row => row.map(value => sanitizeCell(value)).slice(0, 12));
  385. const allValues = rows.flat().map(value => String(value || '')).filter(Boolean);
  386. return {
  387. name,
  388. rowCount: rows.length,
  389. columnCount: maxColumn,
  390. headers,
  391. sampleRows,
  392. allValues
  393. };
  394. }
  395. function parseCellValue(body, attrs, sharedStrings) {
  396. if (attrs.t === 'inlineStr') {
  397. const inline = /<(?:\w+:)?is\b[\s\S]*?<(?:\w+:)?t\b[^>]*>([\s\S]*?)<\/(?:\w+:)?t>[\s\S]*?<\/(?:\w+:)?is>/.exec(body);
  398. return inline ? decodeXml(inline[1]) : '';
  399. }
  400. const value = /<(?:\w+:)?v\b[^>]*>([\s\S]*?)<\/(?:\w+:)?v>/.exec(body);
  401. if (!value) return '';
  402. const raw = decodeXml(value[1]);
  403. if (attrs.t === 's') return sharedStrings[Number(raw)] || '';
  404. if (attrs.t === 'b') return raw === '1' ? 'TRUE' : 'FALSE';
  405. return raw;
  406. }
  407. function detectSignals(text) {
  408. const urls = text.match(/https?:\/\/[^\s'"<>]+/g) || [];
  409. return {
  410. mentionsDha: /DHA/i.test(text),
  411. mentionsXiaohongshu: /小红书|xiaohongshu/i.test(text),
  412. mentionsReference: /参考|reference/i.test(text),
  413. mentionsCustomerDecision: /客户.*(选中|拒绝|通过)|最终选择|customer/i.test(text),
  414. mentionsManualSupplement: /人工补号|补号量|manual supplement/i.test(text),
  415. urlCount: urls.length,
  416. sampleUrls: unique(urls.map(sanitizePublicUrl)).slice(0, 5)
  417. };
  418. }
  419. function renderMaterialSummary({ spec, signals, workbook }) {
  420. const parts = [];
  421. if (signals.mentionsDha) parts.push('包含 DHA 线索');
  422. if (signals.mentionsXiaohongshu) parts.push('包含小红书线索');
  423. if (signals.mentionsReference) parts.push('包含参考账号/参考内容线索');
  424. if (signals.urlCount > 0) parts.push(`发现 URL ${signals.urlCount} 个`);
  425. if (workbook?.sheets?.length) parts.push(`工作表 ${workbook.sheets.length} 个`);
  426. return parts.length ? parts.join(';') : `${spec.title} 可作为本地种子材料,但不能直接证明业务效果。`;
  427. }
  428. function renderReport(summary) {
  429. return [
  430. '# 本地种子材料索引',
  431. '',
  432. `- 生成时间:${summary.generatedAt}`,
  433. `- proofLevel:${summary.proofLevel}`,
  434. `- directCustomerProof:${summary.directCustomerProof}`,
  435. `- canCloseProofGap:${summary.canCloseProofGap}`,
  436. `- 本地材料:${summary.existingMaterialCount}/${summary.materialCount}`,
  437. `- 可疑 live 自动化候选:${summary.promisingLiveAggregateCount}`,
  438. '',
  439. '## 边界',
  440. '',
  441. ...summary.guardrails.map(item => `- ${item}`),
  442. '',
  443. '## 本地材料',
  444. '',
  445. '| ID | 状态 | 路径 | 可用于 | 缺失证明 | 摘要 |',
  446. '| --- | --- | --- | --- | --- | --- |',
  447. ...summary.materials.map(item => tableRow([
  448. item.id,
  449. item.status,
  450. item.path,
  451. item.usableFor.join(';'),
  452. item.missingForProof.join(';'),
  453. item.summary || ''
  454. ])),
  455. '',
  456. '## 工作簿概览',
  457. '',
  458. ...summary.materials.filter(item => item.workbook).flatMap(item => [
  459. `### ${item.title}`,
  460. '',
  461. `- 文件:${item.path}`,
  462. `- secretLikeHitCount:${item.workbook.secretLikeHitCount}`,
  463. '',
  464. '| Sheet | 行数 | 列数 | 表头 |',
  465. '| --- | ---: | ---: | --- |',
  466. ...item.workbook.sheets.map(sheet => tableRow([
  467. sheet.name,
  468. sheet.rowCount,
  469. sheet.columnCount,
  470. sheet.headers.join(' / ')
  471. ])),
  472. ''
  473. ]),
  474. '## 旧 Pipeline 产物边界',
  475. '',
  476. `- pipeline.readyForClaim:${summary.pipelineArtifacts.pipeline?.readyForClaim ?? 'missing'}`,
  477. `- pipeline.failCount:${summary.pipelineArtifacts.pipeline?.failCount ?? 'missing'}`,
  478. `- history.readyForCustomerEffectProof:${summary.pipelineArtifacts.historyAudit?.readyForCustomerEffectProof ?? 'missing'}`,
  479. `- customerEffect.overallPass:${summary.pipelineArtifacts.customerEffect?.overallPass ?? 'missing'}`,
  480. '',
  481. '## Live 自动化候选',
  482. '',
  483. '| 输出 | liveEnabled | overallPass | failureCount | failedGateCount | proofLevel | 当前可关闭缺口 |',
  484. '| --- | ---: | ---: | ---: | ---: | --- | --- |',
  485. ...summary.liveAutomationCandidates.map(item => tableRow([
  486. item.path,
  487. item.liveEnabled,
  488. item.overallPass,
  489. item.failureCount,
  490. item.failedGateCount,
  491. item.proofLevel,
  492. item.canCloseProofGapNow
  493. ])),
  494. '',
  495. '## 下一步',
  496. '',
  497. '| 负责人 | 动作 | 验收 |',
  498. '| --- | --- | --- |',
  499. ...summary.nextActions.map(item => tableRow([item.owner, `${item.title}:${item.action}`, item.acceptance])),
  500. ''
  501. ].join('\n');
  502. }
  503. function renderCsv(summary) {
  504. const rows = [
  505. ['类型', 'ID', '状态', '路径', 'proofLevel', 'directCustomerProof', 'canCloseProofGap', '可用于', '缺失证明', '摘要'],
  506. ...summary.materials.map(item => [
  507. 'local-material',
  508. item.id,
  509. item.status,
  510. item.path,
  511. item.proofLevel,
  512. item.directCustomerProof,
  513. item.canCloseProofGap,
  514. item.usableFor.join(';'),
  515. item.missingForProof.join(';'),
  516. item.summary || ''
  517. ]),
  518. ...summary.liveAutomationCandidates.map(item => [
  519. 'live-automation-candidate',
  520. item.id,
  521. item.liveAutomationPass ? 'pass' : 'not_passed',
  522. item.path,
  523. item.proofLevel,
  524. item.directCustomerProof,
  525. item.canCloseProofGapNow,
  526. 'live/provider 自动化证据候选',
  527. item.missingForClosure.join(';'),
  528. `runCount=${item.runCount}`
  529. ])
  530. ];
  531. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  532. }
  533. function parseArgs(argv) {
  534. const result = {};
  535. for (let index = 0; index < argv.length; index += 1) {
  536. const arg = argv[index];
  537. if (!arg.startsWith('--')) continue;
  538. const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  539. const next = argv[index + 1];
  540. if (!next || next.startsWith('--')) result[key] = true;
  541. else {
  542. result[key] = next;
  543. index += 1;
  544. }
  545. }
  546. return result;
  547. }
  548. function parseAttrs(tag) {
  549. const attrs = {};
  550. for (const match of matchAll(tag, /([:\w-]+)="([^"]*)"/g)) {
  551. const [, key, value] = /([:\w-]+)="([^"]*)"/.exec(match) || [];
  552. if (key) attrs[key] = decodeXml(value || '');
  553. }
  554. return attrs;
  555. }
  556. function matchAll(text, regex) {
  557. return Array.from(String(text || '').matchAll(regex)).map(match => match[0]);
  558. }
  559. function columnIndex(column) {
  560. if (!column) return -1;
  561. let value = 0;
  562. for (const char of column.toUpperCase()) value = value * 26 + (char.charCodeAt(0) - 64);
  563. return value - 1;
  564. }
  565. function decodeXml(text) {
  566. return String(text || '')
  567. .replace(/&lt;/g, '<')
  568. .replace(/&gt;/g, '>')
  569. .replace(/&amp;/g, '&')
  570. .replace(/&quot;/g, '"')
  571. .replace(/&apos;/g, "'");
  572. }
  573. function sanitizeCell(value) {
  574. const text = String(value || '').replace(/\s+/g, ' ').trim();
  575. const withoutTracking = sanitizeUrlsInText(text);
  576. if (isSecretLike(withoutTracking)) return '[REDACTED_SECRET_LIKE_VALUE]';
  577. return withoutTracking.slice(0, 240);
  578. }
  579. function sanitizeUrlsInText(text) {
  580. return String(text || '').replace(/https?:\/\/[^\s'"<>]+/g, match => sanitizePublicUrl(match));
  581. }
  582. function sanitizePublicUrl(value) {
  583. const text = String(value || '').trim();
  584. if (!text) return '';
  585. if (isSecretLike(text) && !/^https?:\/\//i.test(text)) return '[REDACTED_SECRET_LIKE_VALUE]';
  586. try {
  587. const parsed = new URL(text);
  588. parsed.search = '';
  589. parsed.hash = '';
  590. return parsed.toString();
  591. } catch (_) {
  592. return text
  593. .replace(/([?&])(?:xsec_token|access_token|refresh_token|session_token|sessionToken|api_key|apikey|token)=[^&#\s]+/gi, '$1')
  594. .replace(/[?&]+$/, '');
  595. }
  596. }
  597. function isSecretLike(text) {
  598. return /Authorization\s*[:=]\s*Bearer/i.test(text) ||
  599. /sessionToken\s*[:=]/i.test(text) ||
  600. /(?:xsec_token|access_token|refresh_token|session_token|api_key|apikey|token)=[^&#\s]+/i.test(text) ||
  601. /sk-[A-Za-z0-9_-]{10,}/.test(text) ||
  602. /npm_[A-Za-z0-9]{10,}/.test(text);
  603. }
  604. function countSecretLike(text) {
  605. return String(text || '').split(/\s+/).filter(isSecretLike).length;
  606. }
  607. function unique(values) {
  608. return Array.from(new Set(values.filter(Boolean)));
  609. }
  610. function readJson(file) {
  611. try {
  612. if (!fs.existsSync(file)) return null;
  613. return JSON.parse(fs.readFileSync(file, 'utf8'));
  614. } catch (_) {
  615. return null;
  616. }
  617. }
  618. function relPath(root, file) {
  619. const relative = path.relative(root, file).replace(/\\/g, '/');
  620. return relative || '.';
  621. }
  622. function withBom(text) {
  623. return `\uFEFF${text}`;
  624. }
  625. function tableRow(values) {
  626. return `| ${values.map(value => escapeCell(value)).join(' | ')} |`;
  627. }
  628. function escapeCell(value) {
  629. return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
  630. }
  631. function csvCell(value) {
  632. return `"${String(value ?? '').replace(/"/g, '""')}"`;
  633. }
  634. if (require.main === module) main();
  635. module.exports = {
  636. buildLocalSeedMaterialIndex,
  637. readWorkbookSummary,
  638. sanitizeCell,
  639. sanitizePublicUrl,
  640. sanitizeUrlsInText,
  641. isSecretLike
  642. };