| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340 |
- #!/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 <video-resource-template.csv> [--output <dir>] [--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
- };
|