build_centrum_douyin_live_workbook.mjs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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/live-full");
  5. const dataPath = path.join(outputDir, "live-analysis-data.json");
  6. const data = JSON.parse(await fs.readFile(dataPath, "utf8"));
  7. function rowsFromObjects(items, headers) {
  8. return [headers.map(h => h.label), ...items.map(item => headers.map(h => {
  9. const value = typeof h.value === "function" ? h.value(item) : item[h.value];
  10. if (Array.isArray(value)) return value.join("、");
  11. return value ?? "";
  12. }))];
  13. }
  14. function writeRows(sheet, row, col, rows) {
  15. if (!rows.length || !rows[0].length) return;
  16. sheet.getRangeByIndexes(row, col, rows.length, rows[0].length).values = rows;
  17. }
  18. function styleRange(range) {
  19. range.format = {
  20. font: { name: "Microsoft YaHei", size: 10 },
  21. wrapText: true,
  22. verticalAlignment: "top",
  23. borders: { preset: "all", style: "thin", color: "#D9E2EC" },
  24. };
  25. }
  26. function styleHeader(range) {
  27. range.format = {
  28. fill: "#1F4E79",
  29. font: { bold: true, color: "#FFFFFF" },
  30. wrapText: true,
  31. verticalAlignment: "middle",
  32. };
  33. }
  34. function setWidths(sheet, widths) {
  35. widths.forEach(([col, px]) => {
  36. sheet.getRange(`${col}:${col}`).format.columnWidthPx = px;
  37. });
  38. }
  39. function styleSheet(sheet, rangeAddress, widths) {
  40. sheet.showGridLines = false;
  41. if (rangeAddress) styleRange(sheet.getRange(rangeAddress));
  42. setWidths(sheet, widths);
  43. }
  44. function safeSheetRange(rowCount, colCount) {
  45. const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  46. const col = colCount <= 26 ? letters[colCount - 1] : "Z";
  47. return `A1:${col}${Math.max(1, rowCount)}`;
  48. }
  49. const workbook = Workbook.create();
  50. const summary = workbook.worksheets.add("执行摘要");
  51. summary.getRange("A1:H1").merge();
  52. summary.getRange("A1").values = [["善存抖音稿件审核润色与评论 VOC Live 版"]];
  53. summary.getRange("A1").format = {
  54. fill: "#0B1F33",
  55. font: { bold: true, color: "#FFFFFF", size: 16 },
  56. horizontalAlignment: "center",
  57. };
  58. const generatedAtText = `生成于 ${String(data.summary.generatedAt || "").replace("T", " ").slice(0, 19)}`;
  59. const summaryRows = [
  60. ["生成时间", generatedAtText, "去重视频样本", data.summary.videoCount],
  61. ["去重评论样本", data.summary.commentCount, "请求关键词", data.summary.requestedKeywordCount],
  62. ["有效返回关键词", data.summary.effectiveKeywordCount, "接口警告/错误", `${data.summary.warningCount} / ${data.summary.errorCount}`],
  63. ["核心结论", "生活场景/达人内容/选购答疑是主要过审路径", "主要风险", "精力、气血、代谢、甲流、大脑、一粒补全"],
  64. ["方法/限制", "抖音关键词采集并去重;脚本拆解基于标题、标签、描述、评论和可获取文本", "审核差异口径", "未取得审核前原稿/驳回流水;润色路径为基于已发布稿件和 Rule Pack 的反推"],
  65. ];
  66. writeRows(summary, 2, 0, summaryRows);
  67. const riskRows = [["风险等级", "视频数"], ...data.summary.riskCounts.map(row => [row.riskLevel, row.videoCount])];
  68. writeRows(summary, 8, 0, riskRows);
  69. styleHeader(summary.getRange("A9:B9"));
  70. const riskChart = summary.charts.add("bar", summary.getRange(`A9:B${8 + riskRows.length}`));
  71. riskChart.title = "视频风险等级分布";
  72. riskChart.hasLegend = false;
  73. riskChart.setPosition("D9", "H24");
  74. const skuRows = [["SKU/方向", "视频数"], ...data.summary.skuCounts.slice(0, 10).map(row => [row.skuGroup, row.videoCount])];
  75. writeRows(summary, 26, 0, skuRows);
  76. styleHeader(summary.getRange("A27:B27"));
  77. styleSheet(summary, "A3:H40", [["A", 150], ["B", 260], ["C", 130], ["D", 180], ["E", 130], ["F", 180], ["G", 130], ["H", 180]]);
  78. const videos = workbook.worksheets.add("视频样本_78");
  79. const videoHeaders = [
  80. { label: "videoId", value: "videoId" },
  81. { label: "关键词", value: "keyword" },
  82. { label: "SKU/方向", value: "skuGroup" },
  83. { label: "作者", value: "author" },
  84. { label: "标题/文案", value: "title" },
  85. { label: "赞", value: "likeCount" },
  86. { label: "评", value: "commentCount" },
  87. { label: "转", value: "shareCount" },
  88. { label: "藏", value: "collectCount" },
  89. { label: "综合分", value: "score" },
  90. { label: "风险等级", value: "riskLevel" },
  91. { label: "命中规则", value: "ruleHits" },
  92. { label: "规则名称", value: "ruleNames" },
  93. { label: "抓取评论数", value: "commentCaptured" },
  94. { label: "高赞评论", value: "topComment" },
  95. { label: "链接", value: "url" },
  96. ];
  97. const videoRows = rowsFromObjects(data.videoRows, videoHeaders);
  98. writeRows(videos, 0, 0, videoRows);
  99. styleHeader(videos.getRangeByIndexes(0, 0, 1, videoHeaders.length));
  100. styleSheet(videos, `A1:P${videoRows.length}`, [["A", 145], ["B", 150], ["C", 200], ["D", 130], ["E", 330], ["F", 75], ["G", 75], ["H", 75], ["I", 75], ["J", 85], ["K", 90], ["L", 160], ["M", 260], ["N", 90], ["O", 330], ["P", 360]]);
  101. videos.freezePanes.freezeRows(1);
  102. const themes = workbook.worksheets.add("评论主题");
  103. const themeHeaders = [
  104. { label: "主题", value: "theme" },
  105. { label: "评论数", value: "commentCount" },
  106. { label: "有赞评论数", value: "likedCommentCount" },
  107. { label: "总点赞", value: "totalLikes" },
  108. { label: "典型评论", value: row => (row.sampleComments || []).slice(0, 5).join("\n") },
  109. { label: "改进动作", value: "opportunity" },
  110. ];
  111. const themeRows = rowsFromObjects(data.themeSummary, themeHeaders);
  112. writeRows(themes, 0, 0, themeRows);
  113. styleHeader(themes.getRange("A1:F1"));
  114. styleSheet(themes, `A1:F${themeRows.length}`, [["A", 180], ["B", 85], ["C", 95], ["D", 85], ["E", 420], ["F", 460]]);
  115. themes.freezePanes.freezeRows(1);
  116. const comments = workbook.worksheets.add("评论明细_471");
  117. const commentHeaders = [
  118. { label: "commentId", value: "commentId" },
  119. { label: "videoId", value: "videoId" },
  120. { label: "视频标题", value: "videoTitle" },
  121. { label: "作者", value: "videoAuthor" },
  122. { label: "关键词", value: "keyword" },
  123. { label: "主题", value: "themeText" },
  124. { label: "评论", value: "text" },
  125. { label: "点赞", value: "likeCount" },
  126. { label: "回复数", value: "replyCount" },
  127. { label: "IP", value: "ipLocation" },
  128. ];
  129. const commentRows = rowsFromObjects(data.commentRows, commentHeaders);
  130. writeRows(comments, 0, 0, commentRows);
  131. styleHeader(comments.getRange("A1:J1"));
  132. styleSheet(comments, `A1:J${commentRows.length}`, [["A", 160], ["B", 145], ["C", 320], ["D", 120], ["E", 145], ["F", 190], ["G", 460], ["H", 70], ["I", 70], ["J", 90]]);
  133. comments.freezePanes.freezeRows(1);
  134. const goldens = workbook.worksheets.add("黄金改写集");
  135. const goldenHeaders = [
  136. { label: "ID", value: "id" },
  137. { label: "风险原型", value: "riskPrototype" },
  138. { label: "命中规则", value: "ruleHits" },
  139. { label: "风险表达", value: "beforeExample" },
  140. { label: "建议通过版", value: "afterExample" },
  141. { label: "为什么这样改", value: "why" },
  142. { label: "样本依据", value: "sourceEvidence" },
  143. ];
  144. const goldenRows = rowsFromObjects(data.goldenRows, goldenHeaders);
  145. writeRows(goldens, 0, 0, goldenRows);
  146. styleHeader(goldens.getRange("A1:G1"));
  147. styleSheet(goldens, `A1:G${goldenRows.length}`, [["A", 105], ["B", 250], ["C", 150], ["D", 300], ["E", 370], ["F", 310], ["G", 220]]);
  148. goldens.freezePanes.freezeRows(1);
  149. const scripts = workbook.worksheets.add("脚本拆解");
  150. const scriptHeaders = [
  151. { label: "videoId", value: "videoId" },
  152. { label: "标题", value: "title" },
  153. { label: "作者", value: "author" },
  154. { label: "SKU/方向", value: "skuGroup" },
  155. { label: "钩子类型", value: "hookType" },
  156. { label: "当前结构", value: "currentStructure" },
  157. { label: "风险规则", value: "riskRules" },
  158. { label: "可复用改法", value: "reusableFix" },
  159. ];
  160. const scriptRows = rowsFromObjects(data.scriptBreakdowns, scriptHeaders);
  161. writeRows(scripts, 0, 0, scriptRows);
  162. styleHeader(scripts.getRange("A1:H1"));
  163. styleSheet(scripts, `A1:H${scriptRows.length}`, [["A", 145], ["B", 320], ["C", 120], ["D", 210], ["E", 180], ["F", 330], ["G", 180], ["H", 380]]);
  164. scripts.freezePanes.freezeRows(1);
  165. const keywords = workbook.worksheets.add("关键词与采集日志");
  166. const keywordRows = [
  167. ["请求关键词", data.requestedKeywords.join("、")],
  168. ["有效返回关键词", data.effectiveKeywords.join("、")],
  169. ["警告数", data.summary.warningCount],
  170. ["错误数", data.summary.errorCount],
  171. ["说明", "部分关键词/单条评论页 fetch failed,已保留成功采集的视频与评论;未写入 VOC token。"],
  172. ["限制", "未取得品牌审核前原稿、驳回原因、修改版本流水和最终通过稿的一一对应关系;本轮输出不等同逐条原稿差异审计。"],
  173. ];
  174. writeRows(keywords, 0, 0, keywordRows);
  175. const errorsStart = keywordRows.length + 2;
  176. const errorRows = [["stage", "keyword", "videoId", "message", "httpStatus"], ...data.errors.map(err => [
  177. err.stage || "",
  178. err.keyword || "",
  179. err.videoId || "",
  180. err.message || "",
  181. err.httpStatus || "",
  182. ])];
  183. writeRows(keywords, errorsStart, 0, errorRows);
  184. styleHeader(keywords.getRangeByIndexes(errorsStart, 0, 1, 5));
  185. styleSheet(keywords, `A1:E${errorsStart + errorRows.length}`, [["A", 160], ["B", 260], ["C", 150], ["D", 480], ["E", 90]]);
  186. const inspect = await workbook.inspect({
  187. kind: "match",
  188. searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
  189. options: { useRegex: true, maxResults: 100 },
  190. summary: "formula error scan",
  191. });
  192. for (const sheetName of ["执行摘要", "视频样本_78", "评论主题", "黄金改写集", "脚本拆解", "关键词与采集日志"]) {
  193. const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 1, format: "png" });
  194. await fs.writeFile(path.join(outputDir, `${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
  195. }
  196. const xlsx = await SpreadsheetFile.exportXlsx(workbook);
  197. const xlsxPath = path.join(outputDir, "善存抖音稿件审核润色与评论VOC-live.xlsx");
  198. await xlsx.save(xlsxPath);
  199. console.log(JSON.stringify({
  200. status: "ok",
  201. xlsxPath,
  202. formulaScan: inspect.ndjson,
  203. }, null, 2));