video-resource-readiness-audit.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const REQUIRED_HEADER = [
  5. 'brief编号',
  6. '项目名称',
  7. '平台',
  8. '资源角色',
  9. '博主名称',
  10. '主页链接',
  11. '视频链接',
  12. '封面链接',
  13. '字幕或ASR文本',
  14. '帧图链接',
  15. '标题',
  16. '发布时间',
  17. '内容摘要',
  18. '风格调性标签',
  19. '画面人设场景信号',
  20. '风险提示',
  21. '来源接口或备注',
  22. '是否真实资源'
  23. ];
  24. function main() {
  25. const args = parseArgs(process.argv.slice(2));
  26. const input = args.input || args.csv || process.env.TIHAO_VIDEO_RESOURCE_CSV || '';
  27. if (!input) throw new Error('Usage: node scripts/video-resource-readiness-audit.js --input <video-resource-template.csv> [--output <dir>] [--strict]');
  28. const inputPath = path.resolve(input);
  29. const outputDir = path.resolve(args.output || process.env.TIHAO_VIDEO_RESOURCE_AUDIT_OUTPUT || path.join(path.dirname(inputPath), 'video-resource-readiness'));
  30. const rows = readCsv(inputPath);
  31. const summary = buildVideoResourceReadiness({ inputPath, rows });
  32. fs.mkdirSync(outputDir, { recursive: true });
  33. const jsonPath = path.join(outputDir, 'video-resource-readiness-summary.json');
  34. const reportPath = path.join(outputDir, 'video-resource-readiness-report.md');
  35. const repairCsvPath = path.join(outputDir, 'video-resource-repair-actions.csv');
  36. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  37. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  38. fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8');
  39. console.log(JSON.stringify({
  40. input: inputPath,
  41. outputDir,
  42. json: jsonPath,
  43. report: reportPath,
  44. readyForVideoAbPreflight: summary.acceptance.readyForVideoAbPreflight,
  45. failureCount: summary.failureCount,
  46. repairActionCount: summary.repairActions.length,
  47. counts: summary.counts
  48. }, null, 2));
  49. if (args.strict && !summary.acceptance.readyForVideoAbPreflight) process.exitCode = 2;
  50. }
  51. function buildVideoResourceReadiness({ inputPath, rows }) {
  52. const body = rows.body;
  53. const headerOk = REQUIRED_HEADER.every((key, index) => rows.header[index] === key);
  54. const normalizedRows = body.map((row, index) => normalizeRow(row, index + 2));
  55. const realRows = normalizedRows.filter(row => row.isRealResource);
  56. const referenceRows = normalizedRows.filter(row => row.role === '参考视频');
  57. const candidateRows = normalizedRows.filter(row => row.role === '候选视频');
  58. const realReferenceRows = referenceRows.filter(row => row.isRealResource);
  59. const realCandidateRows = candidateRows.filter(row => row.isRealResource);
  60. const rowsWithVideoUrl = realRows.filter(row => row.hasVideoUrl);
  61. const rowsWithCover = realRows.filter(row => row.hasCover);
  62. const rowsWithAsr = realRows.filter(row => row.hasAsr);
  63. const rowsWithFrames = realRows.filter(row => row.hasFrames);
  64. const rowsWithAnyVisualOrTextEvidence = realRows.filter(row => row.hasCover || row.hasAsr || row.hasFrames || row.hasContentText);
  65. const issues = buildIssues({ headerOk, normalizedRows, realRows, realReferenceRows, realCandidateRows, rowsWithVideoUrl, rowsWithAnyVisualOrTextEvidence });
  66. const repairActions = buildRepairActions({ issues, inputPath });
  67. const acceptance = {
  68. headerOk,
  69. hasRows: body.length > 0,
  70. hasRealReferenceResource: realReferenceRows.length > 0,
  71. hasRealCandidateResource: realCandidateRows.length > 0,
  72. hasRealVideoUrl: rowsWithVideoUrl.length > 0,
  73. hasCoverOrAsrOrFrameOrText: rowsWithAnyVisualOrTextEvidence.length > 0,
  74. noFakeMarkedReal: normalizedRows.every(row => !row.isRealResource || row.hasVideoUrl || row.hasCover || row.hasAsr || row.hasFrames || row.hasContentText),
  75. noSecrets: !containsSecret(JSON.stringify(body))
  76. };
  77. acceptance.readyForVideoAbPreflight = Object.values(acceptance).every(Boolean);
  78. return {
  79. inputPath,
  80. generatedAt: new Date().toISOString(),
  81. counts: {
  82. rows: body.length,
  83. realRows: realRows.length,
  84. referenceRows: referenceRows.length,
  85. candidateRows: candidateRows.length,
  86. realReferenceRows: realReferenceRows.length,
  87. realCandidateRows: realCandidateRows.length,
  88. videoUrlRows: rowsWithVideoUrl.length,
  89. coverRows: rowsWithCover.length,
  90. asrRows: rowsWithAsr.length,
  91. frameRows: rowsWithFrames.length,
  92. visualOrTextEvidenceRows: rowsWithAnyVisualOrTextEvidence.length
  93. },
  94. acceptance,
  95. failureCount: issues.length,
  96. issues,
  97. repairActions,
  98. sample: normalizedRows.slice(0, 10).map(row => ({
  99. rowNumber: row.rowNumber,
  100. briefId: row.briefId,
  101. role: row.role,
  102. creatorName: row.creatorName,
  103. isRealResource: row.isRealResource,
  104. hasVideoUrl: row.hasVideoUrl,
  105. hasCover: row.hasCover,
  106. hasAsr: row.hasAsr,
  107. hasFrames: row.hasFrames,
  108. hasContentText: row.hasContentText
  109. }))
  110. };
  111. }
  112. function normalizeRow(row, rowNumber) {
  113. const role = value(row['资源角色']);
  114. const isRealResource = ['是', 'true', 'yes', '1'].includes(value(row['是否真实资源']).toLowerCase());
  115. const videoUrl = value(row['视频链接']);
  116. const coverUrl = value(row['封面链接']);
  117. const asrText = value(row['字幕或ASR文本']);
  118. const frameUrls = value(row['帧图链接']);
  119. const contentText = [row['标题'], row['内容摘要'], row['风格调性标签'], row['画面人设场景信号']].map(value).filter(Boolean).join(' ');
  120. return {
  121. rowNumber,
  122. briefId: value(row['brief编号']),
  123. role,
  124. creatorName: value(row['博主名称']),
  125. isRealResource,
  126. hasVideoUrl: isLikelyUrl(videoUrl),
  127. hasCover: isLikelyUrl(coverUrl),
  128. hasAsr: asrText.length >= 12,
  129. hasFrames: frameUrls.split('|').some(isLikelyUrl),
  130. hasContentText: contentText.length >= 12
  131. };
  132. }
  133. function buildIssues({ headerOk, normalizedRows, realRows, realReferenceRows, realCandidateRows, rowsWithVideoUrl, rowsWithAnyVisualOrTextEvidence }) {
  134. const issues = [];
  135. if (!headerOk) issues.push(issue('header', 'CSV 表头不符合视频资源模板。'));
  136. if (!normalizedRows.length) issues.push(issue('empty', '视频资源表没有可审计行。'));
  137. if (!realReferenceRows.length) issues.push(issue('missing-real-reference', '缺少标记为真实资源的参考视频。'));
  138. if (!realCandidateRows.length) issues.push(issue('missing-real-candidate', '缺少标记为真实资源的候选视频。'));
  139. if (!rowsWithVideoUrl.length) issues.push(issue('missing-video-url', '缺少真实视频 URL;不能只靠封面、标题或接口 200 声明视频分析完成。'));
  140. if (!rowsWithAnyVisualOrTextEvidence.length) issues.push(issue('missing-cover-asr-frame-text', '缺少封面、ASR、帧图或正文证据。'));
  141. for (const row of realRows) {
  142. if (!(row.hasVideoUrl || row.hasCover || row.hasAsr || row.hasFrames || row.hasContentText)) {
  143. issues.push(issue('fake-real-row', `第 ${row.rowNumber} 行标记为真实资源,但没有视频/封面/ASR/帧图/正文证据。`, row.rowNumber));
  144. }
  145. }
  146. if (containsSecret(JSON.stringify(normalizedRows))) issues.push(issue('secret-like-value', '视频资源表包含疑似 token 或鉴权头。'));
  147. return issues;
  148. }
  149. function issue(type, message, rowNumber = null) {
  150. return { type, message, rowNumber };
  151. }
  152. function renderReport(summary) {
  153. const lines = [
  154. '# 视频资源就绪审计',
  155. '',
  156. `- 生成时间:${summary.generatedAt}`,
  157. `- 是否具备视频 A/B 前置资源:${summary.acceptance.readyForVideoAbPreflight ? '是' : '否'}`,
  158. `- failureCount:${summary.failureCount}`,
  159. '',
  160. '## 资源统计',
  161. '',
  162. `- 总行数:${summary.counts.rows}`,
  163. `- 真实资源行:${summary.counts.realRows}`,
  164. `- 真实参考视频行:${summary.counts.realReferenceRows}`,
  165. `- 真实候选视频行:${summary.counts.realCandidateRows}`,
  166. `- 有视频 URL 的真实资源行:${summary.counts.videoUrlRows}`,
  167. `- 有封面/ASR/帧图/正文证据的真实资源行:${summary.counts.visualOrTextEvidenceRows}`,
  168. '',
  169. '## 前置门禁',
  170. '',
  171. '| 门禁 | 状态 |',
  172. '| --- | --- |',
  173. ...Object.entries(summary.acceptance).map(([key, pass]) => `| ${key} | ${pass ? 'pass' : 'fail'} |`),
  174. '',
  175. '## 问题',
  176. '',
  177. summary.issues.length
  178. ? '| 类型 | 行号 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.type} | ${item.rowNumber || ''} | ${escapeCell(item.message)} |`).join('\n')
  179. : '- 暂无问题。',
  180. '',
  181. '## 修复清单',
  182. '',
  183. summary.repairActions.length
  184. ? '| 优先级 | 负责人 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n')
  185. : '- 暂无修复动作。',
  186. '',
  187. '## 边界',
  188. '',
  189. '- 本审计只证明视频 A/B 前置资源是否齐备,不证明视频分析 provider 正常。',
  190. '- provider ok、证据卡、Top 10 非退化和强推荐数量不下降仍必须由 `npm run acceptance:video-ab` 证明。',
  191. '- 没有真实客户复核或客户选择数据时,不能声明视频分析提升了客户命中率。'
  192. ];
  193. return lines.join('\n');
  194. }
  195. function buildRepairActions({ issues, inputPath }) {
  196. return issues.map((item, index) => {
  197. const spec = videoRepairSpec(item);
  198. return {
  199. priority: index + 1,
  200. owner: spec.owner,
  201. type: item.type,
  202. rowNumber: item.rowNumber,
  203. file: inputPath,
  204. field: spec.field,
  205. action: spec.action,
  206. acceptance: spec.acceptance
  207. };
  208. });
  209. }
  210. function videoRepairSpec(issueItem) {
  211. const specs = {
  212. header: ['技术/AI', '表头', '使用 video:intake-template 重新生成模板,保持 18 列固定表头。', 'headerOk=true。'],
  213. empty: ['商务/投放', '全表', '补至少一条参考视频和一条候选视频。', 'hasRows=true。'],
  214. 'missing-real-reference': ['商务/投放', '资源角色/是否真实资源', '补真实参考视频,资源角色填参考视频,是否真实资源填是。', 'hasRealReferenceResource=true。'],
  215. 'missing-real-candidate': ['商务/投放', '资源角色/是否真实资源', '补真实候选视频,资源角色填候选视频,是否真实资源填是。', 'hasRealCandidateResource=true。'],
  216. 'missing-video-url': ['商务/投放', '视频链接', '补真实视频 URL;不能只填主页链接或封面。', 'hasRealVideoUrl=true。'],
  217. 'missing-cover-asr-frame-text': ['商务/投放', '封面链接/字幕或ASR文本/帧图链接/内容摘要', '补封面、ASR、帧图或正文证据至少一类。', 'hasCoverOrAsrOrFrameOrText=true。'],
  218. 'fake-real-row': ['商务/投放', '是否真实资源/视频证据', '把无证据占位行改为否,或补视频、封面、ASR、帧图、正文证据。', 'noFakeMarkedReal=true。'],
  219. 'secret-like-value': ['技术/AI', '全表', '删除疑似 token、鉴权头或密钥。', 'noSecrets=true。']
  220. };
  221. const fallback = specs[issueItem.type] || ['技术/AI', issueItem.type, issueItem.message, '对应 issue 消失。'];
  222. return {
  223. owner: fallback[0],
  224. field: fallback[1],
  225. action: fallback[2],
  226. acceptance: fallback[3]
  227. };
  228. }
  229. function renderRepairCsv(actions) {
  230. const header = ['优先级', '负责人', '问题类型', '行号', '文件', '字段', '修复动作', '通过标准'];
  231. const rows = actions.map(item => [
  232. item.priority,
  233. item.owner,
  234. item.type,
  235. item.rowNumber || '',
  236. item.file,
  237. item.field,
  238. item.action,
  239. item.acceptance
  240. ]);
  241. return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n');
  242. }
  243. function csvCell(value) {
  244. const text = String(value ?? '');
  245. return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  246. }
  247. function readCsv(file) {
  248. const parsed = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  249. const header = (parsed[0] || []).map(item => String(item || '').trim());
  250. const body = parsed.slice(1)
  251. .filter(row => row.some(cell => String(cell || '').trim()))
  252. .map(row => Object.fromEntries(header.map((key, index) => [key, row[index] || ''])));
  253. return { header, body };
  254. }
  255. function parseCsv(text) {
  256. const rows = [];
  257. let row = [];
  258. let cell = '';
  259. let quoted = false;
  260. for (let index = 0; index < text.length; index += 1) {
  261. const char = text[index];
  262. if (char === '\r') continue;
  263. if (char === '"' && quoted && text[index + 1] === '"') {
  264. cell += '"';
  265. index += 1;
  266. } else if (char === '"') quoted = !quoted;
  267. else if (char === ',' && !quoted) {
  268. row.push(cell);
  269. cell = '';
  270. } else if (char === '\n' && !quoted) {
  271. row.push(cell);
  272. rows.push(row);
  273. row = [];
  274. cell = '';
  275. } else cell += char;
  276. }
  277. if (cell || row.length) {
  278. row.push(cell);
  279. rows.push(row);
  280. }
  281. return rows;
  282. }
  283. function parseArgs(argv) {
  284. const args = {};
  285. for (let index = 0; index < argv.length; index += 1) {
  286. const raw = argv[index];
  287. if (!raw.startsWith('--')) continue;
  288. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  289. const next = argv[index + 1];
  290. if (!next || next.startsWith('--')) args[key] = true;
  291. else {
  292. args[key] = next;
  293. index += 1;
  294. }
  295. }
  296. return args;
  297. }
  298. function value(input) {
  299. return String(input || '').trim();
  300. }
  301. function isLikelyUrl(input) {
  302. return /^https?:\/\/\S+/i.test(value(input));
  303. }
  304. function containsSecret(text) {
  305. return /(sk-[A-Za-z0-9_-]{20,}|r:[A-Za-z0-9]{20,}|Authorization\s*[:=]\s*Bearer)/i.test(String(text || ''));
  306. }
  307. function escapeCell(input) {
  308. return String(input || '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  309. }
  310. function withBom(text) {
  311. return `\uFEFF${text}`;
  312. }
  313. if (require.main === module) main();
  314. module.exports = {
  315. REQUIRED_HEADER,
  316. buildVideoResourceReadiness,
  317. readCsv,
  318. renderReport
  319. };