build_centrum_transcript_workbook.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import fs from "node:fs/promises";
  2. import path from "node:path";
  3. import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
  4. const outputDir = path.resolve("outputs/centrum_douyin_audit_2026-06-08/transcript-full");
  5. const data = JSON.parse(await fs.readFile(path.join(outputDir, "transcript-analysis-data.json"), "utf8"));
  6. const runLogPath = path.join(outputDir, "transcription-run-log.json");
  7. const poolPath = path.join(outputDir, "transcript-sample-pool.json");
  8. const runLog = JSON.parse(await fs.readFile(runLogPath, "utf8"));
  9. const pool = JSON.parse(await fs.readFile(poolPath, "utf8"));
  10. function rowsFromObjects(items, headers) {
  11. return [headers.map(h => h.label), ...items.map(item => headers.map(h => {
  12. const value = typeof h.value === "function" ? h.value(item) : item[h.value];
  13. if (Array.isArray(value)) return value.join("\n");
  14. return value ?? "";
  15. }))];
  16. }
  17. function writeRows(sheet, row, col, rows) {
  18. if (!rows.length || !rows[0].length) return;
  19. sheet.getRangeByIndexes(row, col, rows.length, rows[0].length).values = rows;
  20. }
  21. function styleRange(range) {
  22. range.format = {
  23. font: { name: "Microsoft YaHei", size: 10 },
  24. wrapText: true,
  25. verticalAlignment: "top",
  26. borders: { preset: "all", style: "thin", color: "#D9E2EC" },
  27. };
  28. }
  29. function styleHeader(range) {
  30. range.format = {
  31. fill: "#1F4E79",
  32. font: { bold: true, color: "#FFFFFF" },
  33. wrapText: true,
  34. verticalAlignment: "middle",
  35. };
  36. }
  37. function setWidths(sheet, widths) {
  38. widths.forEach(([col, px]) => {
  39. sheet.getRange(`${col}:${col}`).format.columnWidthPx = px;
  40. });
  41. }
  42. function styleSheet(sheet, rangeAddress, widths) {
  43. sheet.showGridLines = false;
  44. if (rangeAddress) styleRange(sheet.getRange(rangeAddress));
  45. setWidths(sheet, widths);
  46. }
  47. function validTranscriptRows() {
  48. return data.transcriptRows
  49. .filter(row => row.validTranscript)
  50. .sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
  51. }
  52. const workbook = Workbook.create();
  53. const summary = workbook.worksheets.add("执行摘要_逐字稿");
  54. summary.getRange("A1:H1").merge();
  55. summary.getRange("A1").values = [["善存抖音逐字稿话术审核分析"]];
  56. summary.getRange("A1").format = {
  57. fill: "#0B1F33",
  58. font: { bold: true, color: "#FFFFFF", size: 16 },
  59. horizontalAlignment: "center",
  60. };
  61. const s = data.summary;
  62. const summaryRows = [
  63. ["生成时间", String(s.generatedAt || "").replace("T", " ").slice(0, 19), "live 视频样本", s.liveVideoCount],
  64. ["live 评论样本", s.liveCommentCount, "转写优先样本池", s.transcriptAttemptSelectedCount],
  65. ["转写文件数", s.transcriptFileCount, "有效口播逐字稿", s.validTranscriptCount],
  66. ["低信息/背景音乐", s.invalidTranscriptCount, "本轮转写失败", s.runLogFailedCount],
  67. ["核心结论", "逐字稿确认主要风险集中在一包/一粒解决感、状态功效、促销 CTA、明星背书和版本边界", "使用限制", "ASR 存在同音误识别,合规定稿需结合原视频/原稿复核"],
  68. ];
  69. writeRows(summary, 2, 0, summaryRows);
  70. const moduleRows = [["话术模块", "有效逐字稿数"], ...data.moduleSummary.map(row => [row.module, row.count])];
  71. writeRows(summary, 9, 0, moduleRows);
  72. styleHeader(summary.getRange("A10:B10"));
  73. const moduleChart = summary.charts.add("bar", summary.getRange(`A10:B${9 + moduleRows.length}`));
  74. moduleChart.title = "话术模块分布";
  75. moduleChart.hasLegend = false;
  76. moduleChart.setPosition("D9", "H25");
  77. styleSheet(summary, "A3:H35", [["A", 150], ["B", 280], ["C", 150], ["D", 180], ["E", 130], ["F", 170], ["G", 120], ["H", 180]]);
  78. const statusSheet = workbook.worksheets.add("转写状态_45");
  79. const poolMap = new Map((pool.selected || []).map(row => [String(row.videoId), row]));
  80. const statusRows = runLog.map(row => ({
  81. ...row,
  82. ...(poolMap.get(String(row.videoId)) || {}),
  83. candidateAttemptsText: (row.candidateAttempts || []).map(item => `${item.keyPath}: ${item.transcriptStatus || item.stderr || item.exitCode}`).join("\n"),
  84. }));
  85. const statusHeaders = [
  86. { label: "videoId", value: "videoId" },
  87. { label: "作者", value: "author" },
  88. { label: "标题", value: "title" },
  89. { label: "SKU/方向", value: "skuGroup" },
  90. { label: "风险等级", value: "riskLevel" },
  91. { label: "转写状态", value: "transcriptStatus" },
  92. { label: "文本长度", value: "textLength" },
  93. { label: "媒体关键词", value: "mediaKeyword" },
  94. { label: "候选尝试", value: "candidateAttemptsText" },
  95. ];
  96. const statusTable = rowsFromObjects(statusRows, statusHeaders);
  97. writeRows(statusSheet, 0, 0, statusTable);
  98. styleHeader(statusSheet.getRange("A1:I1"));
  99. styleSheet(statusSheet, `A1:I${statusTable.length}`, [["A", 145], ["B", 120], ["C", 360], ["D", 220], ["E", 90], ["F", 100], ["G", 80], ["H", 170], ["I", 420]]);
  100. statusSheet.freezePanes.freezeRows(1);
  101. const transcriptSheet = workbook.worksheets.add("逐字稿明细_35");
  102. const transcriptHeaders = [
  103. { label: "videoId", value: "videoId" },
  104. { label: "作者", value: "author" },
  105. { label: "标题", value: "title" },
  106. { label: "SKU/方向", value: "skuGroup" },
  107. { label: "赞", value: "likeCount" },
  108. { label: "评", value: "commentCount" },
  109. { label: "话术模块", value: "moduleText" },
  110. { label: "命中规则", value: "ruleHits" },
  111. { label: "规则名称", value: "ruleNames" },
  112. { label: "逐字稿清洗文本", value: "cleanText" },
  113. { label: "改写方向", value: "rewriteDirection" },
  114. { label: "链接", value: "url" },
  115. ];
  116. const transcriptTable = rowsFromObjects(validTranscriptRows(), transcriptHeaders);
  117. writeRows(transcriptSheet, 0, 0, transcriptTable);
  118. styleHeader(transcriptSheet.getRange("A1:L1"));
  119. styleSheet(transcriptSheet, `A1:L${transcriptTable.length}`, [["A", 145], ["B", 120], ["C", 330], ["D", 220], ["E", 70], ["F", 70], ["G", 210], ["H", 160], ["I", 260], ["J", 520], ["K", 420], ["L", 360]]);
  120. transcriptSheet.freezePanes.freezeRows(1);
  121. const moduleSheet = workbook.worksheets.add("话术模块");
  122. const moduleDetailRows = [["话术模块", "有效逐字稿数", "过审处理方向"], ...data.moduleSummary.map(row => [
  123. row.module,
  124. row.count,
  125. ({
  126. "明星/达人背书": "达人分享仅代表个人体验;产品信息以包装、标签和官方说明为准。",
  127. "年龄/人群场景": "按具体 SKU、年龄、人群和标签说明选择,避免跨人群泛化。",
  128. "功效/状态表达": "改为日常营养支持,不承诺精力、气血、代谢、焕活等结果。",
  129. "成分/剂型说明": "保留成分/剂型事实,不推导全部所需或明显改善。",
  130. "一包/一粒解决感": "避免一包解决/一粒补全,回到按标签建议补充。",
  131. "版本/渠道信任": "以购买页、实物标签和客服说明解释版本/渠道。",
  132. "促销/购买引导": "活动价格以当前页面为准,CTA 不与功效绑定。",
  133. "比较/榜单/避坑": "避免红黑榜和竞品贬损,改为中性选购维度。",
  134. })[row.module] || "保留低风险信息,减少强承诺。"
  135. ])];
  136. writeRows(moduleSheet, 0, 0, moduleDetailRows);
  137. styleHeader(moduleSheet.getRange("A1:C1"));
  138. styleSheet(moduleSheet, `A1:C${moduleDetailRows.length}`, [["A", 180], ["B", 100], ["C", 520]]);
  139. const ruleSheet = workbook.worksheets.add("Rule命中");
  140. const ruleRows = [["Rule Pack", "命中逐字稿数"], ...data.ruleSummary.map(row => [row.rule, row.count])];
  141. writeRows(ruleSheet, 0, 0, ruleRows);
  142. styleHeader(ruleSheet.getRange("A1:B1"));
  143. styleSheet(ruleSheet, `A1:B${ruleRows.length}`, [["A", 150], ["B", 120]]);
  144. const goldenSheet = workbook.worksheets.add("黄金改写集_逐字稿");
  145. const goldenHeaders = [
  146. { label: "ID", value: "id" },
  147. { label: "模块", value: "module" },
  148. { label: "来源视频", value: row => `${row.author}|${row.sourceTitle}` },
  149. { label: "命中规则", value: "riskRules" },
  150. { label: "原逐字稿片段", value: "originalTranscriptSnippet" },
  151. { label: "建议通过版", value: "suggestedApprovedWording" },
  152. { label: "依据", value: "why" },
  153. ];
  154. const goldenTable = rowsFromObjects(data.goldenTranscriptRows, goldenHeaders);
  155. writeRows(goldenSheet, 0, 0, goldenTable);
  156. styleHeader(goldenSheet.getRange("A1:G1"));
  157. styleSheet(goldenSheet, `A1:G${goldenTable.length}`, [["A", 105], ["B", 160], ["C", 320], ["D", 160], ["E", 430], ["F", 480], ["G", 300]]);
  158. goldenSheet.freezePanes.freezeRows(1);
  159. const commentSheet = workbook.worksheets.add("评论主题复核");
  160. const commentRows = [["主题", "评论数", "典型评论", "改进动作"], ...data.commentThemeSummary.map(row => [
  161. row.theme,
  162. row.commentCount,
  163. (row.sampleComments || []).slice(0, 5).join("\n"),
  164. row.opportunity,
  165. ])];
  166. writeRows(commentSheet, 0, 0, commentRows);
  167. styleHeader(commentSheet.getRange("A1:D1"));
  168. styleSheet(commentSheet, `A1:D${commentRows.length}`, [["A", 180], ["B", 90], ["C", 500], ["D", 520]]);
  169. commentSheet.freezePanes.freezeRows(1);
  170. const videoSheet = workbook.worksheets.add("视频样本_78");
  171. const videoHeaders = [
  172. { label: "videoId", value: "videoId" },
  173. { label: "关键词", value: "keyword" },
  174. { label: "SKU/方向", value: "skuGroup" },
  175. { label: "作者", value: "author" },
  176. { label: "标题/文案", value: "title" },
  177. { label: "赞", value: "likeCount" },
  178. { label: "评", value: "commentCount" },
  179. { label: "转", value: "shareCount" },
  180. { label: "藏", value: "collectCount" },
  181. { label: "风险等级", value: "riskLevel" },
  182. { label: "命中规则", value: "ruleHits" },
  183. { label: "规则名称", value: "ruleNames" },
  184. { label: "链接", value: "url" },
  185. ];
  186. const videoTable = rowsFromObjects(data.videoRows, videoHeaders);
  187. writeRows(videoSheet, 0, 0, videoTable);
  188. styleHeader(videoSheet.getRange("A1:M1"));
  189. styleSheet(videoSheet, `A1:M${videoTable.length}`, [["A", 145], ["B", 145], ["C", 210], ["D", 130], ["E", 360], ["F", 75], ["G", 75], ["H", 75], ["I", 75], ["J", 90], ["K", 160], ["L", 270], ["M", 360]]);
  190. videoSheet.freezePanes.freezeRows(1);
  191. const commentDetailSheet = workbook.worksheets.add("评论明细_471");
  192. const commentHeaders = [
  193. { label: "commentId", value: "commentId" },
  194. { label: "videoId", value: "videoId" },
  195. { label: "视频标题", value: "videoTitle" },
  196. { label: "作者", value: "videoAuthor" },
  197. { label: "关键词", value: "keyword" },
  198. { label: "主题", value: "themeText" },
  199. { label: "评论", value: "text" },
  200. { label: "点赞", value: "likeCount" },
  201. { label: "回复数", value: "replyCount" },
  202. { label: "IP", value: "ipLocation" },
  203. ];
  204. const commentDetailTable = rowsFromObjects(data.commentRows, commentHeaders);
  205. writeRows(commentDetailSheet, 0, 0, commentDetailTable);
  206. styleHeader(commentDetailSheet.getRange("A1:J1"));
  207. styleSheet(commentDetailSheet, `A1:J${commentDetailTable.length}`, [["A", 160], ["B", 145], ["C", 320], ["D", 120], ["E", 145], ["F", 190], ["G", 460], ["H", 70], ["I", 70], ["J", 90]]);
  208. commentDetailSheet.freezePanes.freezeRows(1);
  209. const inspect = await workbook.inspect({
  210. kind: "match",
  211. searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
  212. options: { useRegex: true, maxResults: 100 },
  213. summary: "formula error scan",
  214. });
  215. for (const sheetName of ["执行摘要_逐字稿", "转写状态_45", "逐字稿明细_35", "话术模块", "Rule命中", "黄金改写集_逐字稿", "评论主题复核", "视频样本_78", "评论明细_471"]) {
  216. const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 1, format: "png" });
  217. await fs.writeFile(path.join(outputDir, `${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
  218. }
  219. const xlsx = await SpreadsheetFile.exportXlsx(workbook);
  220. const xlsxPath = path.join(outputDir, "善存抖音逐字稿话术审核分析.xlsx");
  221. await xlsx.save(xlsxPath);
  222. console.log(JSON.stringify({
  223. status: "ok",
  224. xlsxPath,
  225. formulaScan: inspect.ndjson,
  226. }, null, 2));