#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const REQUIRED_HEADER = [ 'brief编号', '项目名称', '平台', '资源角色', '博主名称', '主页链接', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '标题', '发布时间', '内容摘要', '风格调性标签', '画面人设场景信号', '风险提示', '来源接口或备注', '是否真实资源' ]; function main() { const args = parseArgs(process.argv.slice(2)); const input = args.input || args.csv || process.env.TIHAO_VIDEO_RESOURCE_CSV || ''; if (!input) throw new Error('Usage: node scripts/video-resource-readiness-audit.js --input [--output ] [--strict]'); const inputPath = path.resolve(input); const outputDir = path.resolve(args.output || process.env.TIHAO_VIDEO_RESOURCE_AUDIT_OUTPUT || path.join(path.dirname(inputPath), 'video-resource-readiness')); const rows = readCsv(inputPath); const summary = buildVideoResourceReadiness({ inputPath, rows }); fs.mkdirSync(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, 'video-resource-readiness-summary.json'); const reportPath = path.join(outputDir, 'video-resource-readiness-report.md'); const repairCsvPath = path.join(outputDir, 'video-resource-repair-actions.csv'); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8'); console.log(JSON.stringify({ input: inputPath, outputDir, json: jsonPath, report: reportPath, readyForVideoAbPreflight: summary.acceptance.readyForVideoAbPreflight, failureCount: summary.failureCount, repairActionCount: summary.repairActions.length, counts: summary.counts }, null, 2)); if (args.strict && !summary.acceptance.readyForVideoAbPreflight) process.exitCode = 2; } function buildVideoResourceReadiness({ inputPath, rows }) { const body = rows.body; const headerOk = REQUIRED_HEADER.every((key, index) => rows.header[index] === key); const normalizedRows = body.map((row, index) => normalizeRow(row, index + 2)); const realRows = normalizedRows.filter(row => row.isRealResource); const referenceRows = normalizedRows.filter(row => row.role === '参考视频'); const candidateRows = normalizedRows.filter(row => row.role === '候选视频'); const realReferenceRows = referenceRows.filter(row => row.isRealResource); const realCandidateRows = candidateRows.filter(row => row.isRealResource); const rowsWithVideoUrl = realRows.filter(row => row.hasVideoUrl); const rowsWithCover = realRows.filter(row => row.hasCover); const rowsWithAsr = realRows.filter(row => row.hasAsr); const rowsWithFrames = realRows.filter(row => row.hasFrames); const rowsWithAnyVisualOrTextEvidence = realRows.filter(row => row.hasCover || row.hasAsr || row.hasFrames || row.hasContentText); const issues = buildIssues({ headerOk, normalizedRows, realRows, realReferenceRows, realCandidateRows, rowsWithVideoUrl, rowsWithAnyVisualOrTextEvidence }); const repairActions = buildRepairActions({ issues, inputPath }); const acceptance = { headerOk, hasRows: body.length > 0, hasRealReferenceResource: realReferenceRows.length > 0, hasRealCandidateResource: realCandidateRows.length > 0, hasRealVideoUrl: rowsWithVideoUrl.length > 0, hasCoverOrAsrOrFrameOrText: rowsWithAnyVisualOrTextEvidence.length > 0, noFakeMarkedReal: normalizedRows.every(row => !row.isRealResource || row.hasVideoUrl || row.hasCover || row.hasAsr || row.hasFrames || row.hasContentText), noSecrets: !containsSecret(JSON.stringify(body)) }; acceptance.readyForVideoAbPreflight = Object.values(acceptance).every(Boolean); return { inputPath, generatedAt: new Date().toISOString(), counts: { rows: body.length, realRows: realRows.length, referenceRows: referenceRows.length, candidateRows: candidateRows.length, realReferenceRows: realReferenceRows.length, realCandidateRows: realCandidateRows.length, videoUrlRows: rowsWithVideoUrl.length, coverRows: rowsWithCover.length, asrRows: rowsWithAsr.length, frameRows: rowsWithFrames.length, visualOrTextEvidenceRows: rowsWithAnyVisualOrTextEvidence.length }, acceptance, failureCount: issues.length, issues, repairActions, sample: normalizedRows.slice(0, 10).map(row => ({ rowNumber: row.rowNumber, briefId: row.briefId, role: row.role, creatorName: row.creatorName, isRealResource: row.isRealResource, hasVideoUrl: row.hasVideoUrl, hasCover: row.hasCover, hasAsr: row.hasAsr, hasFrames: row.hasFrames, hasContentText: row.hasContentText })) }; } function normalizeRow(row, rowNumber) { const role = value(row['资源角色']); const isRealResource = ['是', 'true', 'yes', '1'].includes(value(row['是否真实资源']).toLowerCase()); const videoUrl = value(row['视频链接']); const coverUrl = value(row['封面链接']); const asrText = value(row['字幕或ASR文本']); const frameUrls = value(row['帧图链接']); const contentText = [row['标题'], row['内容摘要'], row['风格调性标签'], row['画面人设场景信号']].map(value).filter(Boolean).join(' '); return { rowNumber, briefId: value(row['brief编号']), role, creatorName: value(row['博主名称']), isRealResource, hasVideoUrl: isLikelyUrl(videoUrl), hasCover: isLikelyUrl(coverUrl), hasAsr: asrText.length >= 12, hasFrames: frameUrls.split('|').some(isLikelyUrl), hasContentText: contentText.length >= 12 }; } function buildIssues({ headerOk, normalizedRows, realRows, realReferenceRows, realCandidateRows, rowsWithVideoUrl, rowsWithAnyVisualOrTextEvidence }) { const issues = []; if (!headerOk) issues.push(issue('header', 'CSV 表头不符合视频资源模板。')); if (!normalizedRows.length) issues.push(issue('empty', '视频资源表没有可审计行。')); if (!realReferenceRows.length) issues.push(issue('missing-real-reference', '缺少标记为真实资源的参考视频。')); if (!realCandidateRows.length) issues.push(issue('missing-real-candidate', '缺少标记为真实资源的候选视频。')); if (!rowsWithVideoUrl.length) issues.push(issue('missing-video-url', '缺少真实视频 URL;不能只靠封面、标题或接口 200 声明视频分析完成。')); if (!rowsWithAnyVisualOrTextEvidence.length) issues.push(issue('missing-cover-asr-frame-text', '缺少封面、ASR、帧图或正文证据。')); for (const row of realRows) { if (!(row.hasVideoUrl || row.hasCover || row.hasAsr || row.hasFrames || row.hasContentText)) { issues.push(issue('fake-real-row', `第 ${row.rowNumber} 行标记为真实资源,但没有视频/封面/ASR/帧图/正文证据。`, row.rowNumber)); } } if (containsSecret(JSON.stringify(normalizedRows))) issues.push(issue('secret-like-value', '视频资源表包含疑似 token 或鉴权头。')); return issues; } function issue(type, message, rowNumber = null) { return { type, message, rowNumber }; } function renderReport(summary) { const lines = [ '# 视频资源就绪审计', '', `- 生成时间:${summary.generatedAt}`, `- 是否具备视频 A/B 前置资源:${summary.acceptance.readyForVideoAbPreflight ? '是' : '否'}`, `- failureCount:${summary.failureCount}`, '', '## 资源统计', '', `- 总行数:${summary.counts.rows}`, `- 真实资源行:${summary.counts.realRows}`, `- 真实参考视频行:${summary.counts.realReferenceRows}`, `- 真实候选视频行:${summary.counts.realCandidateRows}`, `- 有视频 URL 的真实资源行:${summary.counts.videoUrlRows}`, `- 有封面/ASR/帧图/正文证据的真实资源行:${summary.counts.visualOrTextEvidenceRows}`, '', '## 前置门禁', '', '| 门禁 | 状态 |', '| --- | --- |', ...Object.entries(summary.acceptance).map(([key, pass]) => `| ${key} | ${pass ? 'pass' : 'fail'} |`), '', '## 问题', '', summary.issues.length ? '| 类型 | 行号 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.type} | ${item.rowNumber || ''} | ${escapeCell(item.message)} |`).join('\n') : '- 暂无问题。', '', '## 修复清单', '', summary.repairActions.length ? '| 优先级 | 负责人 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n') : '- 暂无修复动作。', '', '## 边界', '', '- 本审计只证明视频 A/B 前置资源是否齐备,不证明视频分析 provider 正常。', '- provider ok、证据卡、Top 10 非退化和强推荐数量不下降仍必须由 `npm run acceptance:video-ab` 证明。', '- 没有真实客户复核或客户选择数据时,不能声明视频分析提升了客户命中率。' ]; return lines.join('\n'); } function buildRepairActions({ issues, inputPath }) { return issues.map((item, index) => { const spec = videoRepairSpec(item); return { priority: index + 1, owner: spec.owner, type: item.type, rowNumber: item.rowNumber, file: inputPath, field: spec.field, action: spec.action, acceptance: spec.acceptance }; }); } function videoRepairSpec(issueItem) { const specs = { header: ['技术/AI', '表头', '使用 video:intake-template 重新生成模板,保持 18 列固定表头。', 'headerOk=true。'], empty: ['商务/投放', '全表', '补至少一条参考视频和一条候选视频。', 'hasRows=true。'], 'missing-real-reference': ['商务/投放', '资源角色/是否真实资源', '补真实参考视频,资源角色填参考视频,是否真实资源填是。', 'hasRealReferenceResource=true。'], 'missing-real-candidate': ['商务/投放', '资源角色/是否真实资源', '补真实候选视频,资源角色填候选视频,是否真实资源填是。', 'hasRealCandidateResource=true。'], 'missing-video-url': ['商务/投放', '视频链接', '补真实视频 URL;不能只填主页链接或封面。', 'hasRealVideoUrl=true。'], 'missing-cover-asr-frame-text': ['商务/投放', '封面链接/字幕或ASR文本/帧图链接/内容摘要', '补封面、ASR、帧图或正文证据至少一类。', 'hasCoverOrAsrOrFrameOrText=true。'], 'fake-real-row': ['商务/投放', '是否真实资源/视频证据', '把无证据占位行改为否,或补视频、封面、ASR、帧图、正文证据。', 'noFakeMarkedReal=true。'], 'secret-like-value': ['技术/AI', '全表', '删除疑似 token、鉴权头或密钥。', 'noSecrets=true。'] }; const fallback = specs[issueItem.type] || ['技术/AI', issueItem.type, issueItem.message, '对应 issue 消失。']; return { owner: fallback[0], field: fallback[1], action: fallback[2], acceptance: fallback[3] }; } function renderRepairCsv(actions) { const header = ['优先级', '负责人', '问题类型', '行号', '文件', '字段', '修复动作', '通过标准']; const rows = actions.map(item => [ item.priority, item.owner, item.type, item.rowNumber || '', item.file, item.field, item.action, item.acceptance ]); return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n'); } function csvCell(value) { const text = String(value ?? ''); return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function readCsv(file) { const parsed = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); const header = (parsed[0] || []).map(item => String(item || '').trim()); const body = parsed.slice(1) .filter(row => row.some(cell => String(cell || '').trim())) .map(row => Object.fromEntries(header.map((key, index) => [key, row[index] || '']))); return { header, body }; } function parseCsv(text) { const rows = []; let row = []; let cell = ''; let quoted = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (char === '\r') continue; if (char === '"' && quoted && text[index + 1] === '"') { cell += '"'; index += 1; } else if (char === '"') quoted = !quoted; else if (char === ',' && !quoted) { row.push(cell); cell = ''; } else if (char === '\n' && !quoted) { row.push(cell); rows.push(row); row = []; cell = ''; } else cell += char; } if (cell || row.length) { row.push(cell); rows.push(row); } return rows; } function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { const raw = argv[index]; if (!raw.startsWith('--')) continue; const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[index + 1]; if (!next || next.startsWith('--')) args[key] = true; else { args[key] = next; index += 1; } } return args; } function value(input) { return String(input || '').trim(); } function isLikelyUrl(input) { return /^https?:\/\/\S+/i.test(value(input)); } function containsSecret(text) { return /(sk-[A-Za-z0-9_-]{20,}|r:[A-Za-z0-9]{20,}|Authorization\s*[:=]\s*Bearer)/i.test(String(text || '')); } function escapeCell(input) { return String(input || '').replace(/\|/g, '/').replace(/\r?\n/g, ' '); } function withBom(text) { return `\uFEFF${text}`; } if (require.main === module) main(); module.exports = { REQUIRED_HEADER, buildVideoResourceReadiness, readCsv, renderReport };