build_dha_tihao_report.mjs 9.2 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 outDir = "E:/workspace/tihao-ai/output/dha-tihao-poc";
  5. const scored = JSON.parse(await fs.readFile(path.join(outDir, "scored_candidates.json"), "utf8"));
  6. const refs = JSON.parse(await fs.readFile(path.join(outDir, "reference_notes.json"), "utf8"));
  7. const searches = JSON.parse(await fs.readFile(path.join(outDir, "justone_searches.json"), "utf8"));
  8. const mergedCandidates = JSON.parse(await fs.readFile(path.join(outDir, "candidates_merged_pre_detail.json"), "utf8"));
  9. function pct(value) {
  10. if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
  11. return `${Number(value).toFixed(1)}%`;
  12. }
  13. function money(value) {
  14. if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
  15. return Number(value);
  16. }
  17. function fixed(value, digits = 2) {
  18. if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
  19. return Number(value).toFixed(digits);
  20. }
  21. function join(value) {
  22. if (!value) return "";
  23. if (Array.isArray(value)) return value.filter(Boolean).join("、");
  24. return String(value);
  25. }
  26. function provinceSummary(value) {
  27. if (!Array.isArray(value)) return "";
  28. return value
  29. .slice(0, 5)
  30. .map((item) => `${item[0]} ${Number(item[1] || 0).toFixed(1)}%`)
  31. .join(";");
  32. }
  33. function verdict(row) {
  34. if (row.passCount >= 7) return "主推";
  35. if (row.passCount >= 6) return "备选/补充";
  36. return "需复核";
  37. }
  38. function fitReason(row) {
  39. const parts = [];
  40. if (row.passItems?.includes("预算")) parts.push("预算命中");
  41. if (row.passItems?.includes("女粉")) parts.push(`女粉${pct(row.femalePct)}`);
  42. if (row.passItems?.includes("25-44")) parts.push(`25-44岁${pct(row.age25_44Pct)}`);
  43. if (row.interestHits?.length) parts.push(`兴趣命中${join(row.interestHits)}`);
  44. if (row.passItems?.includes("阅读")) parts.push(`阅读中位${Math.round(row.readMedian)}`);
  45. if (row.passItems?.includes("CPE")) parts.push(`CPE ${fixed(row.cpe)}`);
  46. return parts.join(";");
  47. }
  48. function riskReason(row) {
  49. if (!row.riskItems?.length) return "硬指标均通过,建议进入主推池";
  50. return `需复核:${join(row.riskItems)};${row.supportTagHits?.length ? `账号标签补充命中${join(row.supportTagHits)}` : ""}`;
  51. }
  52. const mainRows = scored
  53. .filter((row) => row.passCount >= 7)
  54. .slice(0, 8);
  55. const backupRows = scored
  56. .filter((row) => row.passCount < 7)
  57. .slice(0, 12);
  58. const wb = Workbook.create();
  59. const summary = wb.worksheets.add("结论");
  60. const main = wb.worksheets.add("主推账号");
  61. const backup = wb.worksheets.add("备选复核");
  62. const refSheet = wb.worksheets.add("参考账号解析");
  63. const method = wb.worksheets.add("Brief与方法");
  64. for (const sheet of [summary, main, backup, refSheet, method]) {
  65. sheet.showGridLines = false;
  66. }
  67. summary.getRange("A1:H1").merge();
  68. summary.getRange("A1").values = [["AI提号 - DHA 小红书需求 PoC"]];
  69. summary.getRange("A1").format = {
  70. fill: "#144D43",
  71. font: { bold: true, color: "#FFFFFF", size: 16 },
  72. };
  73. summary.getRange("A3:B8").values = [
  74. ["Brief", "小红书 / DHA / 干货科普 + 618合集 / 预算1000-5000"],
  75. ["硬性画像", "女性为主,25-44岁占比70%以上;兴趣前三需命中母婴/萌娃/生活记录中的2个"],
  76. ["数据门槛", "CPE≤3,阅读中位数≥4000,预估阅读单价≤1.0"],
  77. ["外部接口", "Just One 小红书蒲公英创作者搜索 + 详情/粉丝画像/近30天表现;TikHub 解析参考笔记"],
  78. ["采集规模", `${searches.length} 个搜索入口,去重候选 ${mergedCandidates.length} 个,深查 ${scored.length} 个`],
  79. ["结论", `主推 ${mainRows.length} 个;备选/复核 ${backupRows.length} 个。建议先人工复核主推前5个的内容调性和档期。`],
  80. ];
  81. summary.getRange("A3:A8").format = { fill: "#E7F3EF", font: { bold: true } };
  82. summary.getRange("A10:H10").values = [["推荐结论", "账号", "推荐等级", "预算", "粉丝画像", "内容/数据", "推荐理由", "风险提示"]];
  83. summary.getRange("A10:H10").format = { fill: "#1F7A68", font: { bold: true, color: "#FFFFFF" } };
  84. const summaryRows = [...mainRows.slice(0, 5), ...backupRows.slice(0, 3)].map((row, idx) => [
  85. idx < 5 ? "优先沟通" : "备选观察",
  86. row.name,
  87. verdict(row),
  88. `${row.chosenFormat} ${row.chosenPrice}`,
  89. `女粉${pct(row.femalePct)} / 25-44 ${pct(row.age25_44Pct)} / ${join(row.interestHits)}`,
  90. `读${Math.round(row.readMedian)} / 单价${fixed(row.readUnit)} / CPE ${fixed(row.cpe)}`,
  91. fitReason(row),
  92. riskReason(row),
  93. ]);
  94. summary.getRangeByIndexes(10, 0, summaryRows.length, 8).values = summaryRows;
  95. summary.getRange("A10:H18").format = { wrapText: true };
  96. summary.freezePanes.freezeRows(10);
  97. const headers = [
  98. "推荐等级",
  99. "账号",
  100. "红书号",
  101. "地区",
  102. "粉丝数",
  103. "图文报价",
  104. "视频报价",
  105. "建议形式",
  106. "建议报价",
  107. "阅读中位",
  108. "互动中位",
  109. "阅读单价",
  110. "CPE",
  111. "女粉占比",
  112. "25-44占比",
  113. "兴趣命中",
  114. "Top兴趣",
  115. "标签",
  116. "省份分布",
  117. "通过项",
  118. "风险项",
  119. "推荐理由",
  120. ];
  121. function writeCandidateSheet(sheet, rows) {
  122. sheet.getRangeByIndexes(0, 0, 1, headers.length).values = [headers];
  123. sheet.getRangeByIndexes(0, 0, 1, headers.length).format = {
  124. fill: "#144D43",
  125. font: { bold: true, color: "#FFFFFF" },
  126. wrapText: true,
  127. };
  128. const values = rows.map((row) => [
  129. verdict(row),
  130. row.name,
  131. row.redId,
  132. row.location,
  133. row.fansNum,
  134. money(row.picturePrice),
  135. money(row.videoPrice),
  136. row.chosenFormat,
  137. money(row.chosenPrice),
  138. Math.round(row.readMedian || 0),
  139. Math.round(row.interactionMedian || 0),
  140. fixed(row.readUnit, 3),
  141. fixed(row.cpe, 2),
  142. pct(row.femalePct),
  143. pct(row.age25_44Pct),
  144. join(row.interestHits),
  145. join(row.interests),
  146. join(row.tags),
  147. provinceSummary(row.provinces),
  148. join(row.passItems),
  149. join(row.riskItems),
  150. `${fitReason(row)}。${riskReason(row)}`,
  151. ]);
  152. sheet.getRangeByIndexes(1, 0, values.length, headers.length).values = values;
  153. sheet.getRangeByIndexes(0, 0, values.length + 1, headers.length).format = { wrapText: true };
  154. sheet.tables.add(`A1:V${values.length + 1}`, true, `${sheet.name.replace(/\W/g, "")}Table`);
  155. sheet.freezePanes.freezeRows(1);
  156. }
  157. writeCandidateSheet(main, mainRows);
  158. writeCandidateSheet(backup, backupRows);
  159. refSheet.getRange("A1:J1").values = [[
  160. "参考类型",
  161. "短链",
  162. "解析笔记ID",
  163. "作者昵称",
  164. "作者userId",
  165. "红书号",
  166. "标题",
  167. "类型",
  168. "状态",
  169. "最终链接",
  170. ]];
  171. refSheet.getRange("A1:J1").format = { fill: "#144D43", font: { bold: true, color: "#FFFFFF" }, wrapText: true };
  172. refSheet.getRangeByIndexes(1, 0, refs.length, 10).values = refs.map((row) => [
  173. row.bucket,
  174. row.url,
  175. row.note_id,
  176. row.author_nickname,
  177. row.author_userid,
  178. row.author_red_id,
  179. row.title,
  180. row.type,
  181. row.note_id ? "已解析" : "短链当前不可浏览",
  182. row.final_url,
  183. ]);
  184. refSheet.getRange("A1:J5").format = { wrapText: true };
  185. method.getRange("A1:B14").values = [
  186. ["字段", "内容"],
  187. ["平台", "小红书"],
  188. ["产品", "DHA"],
  189. ["账号类型", "干货科普(营养师/育婴师/妈妈为主),618合集,6个月-7岁孩子妈妈博主"],
  190. ["预算", "1000-5000"],
  191. ["粉丝画像", "女性为主,25-44岁占比70%以上;广东/山东/河南占比较多属正常"],
  192. ["兴趣要求", "Top兴趣中母婴/萌娃/生活记录至少命中2个"],
  193. ["数据要求", "CPE≤3,阅读中位数≥4000,预估阅读单价≤1.0"],
  194. ["召回策略", "Just One 蒲公英创作者搜索:DHA母婴、营养师科普、育婴师妈妈、DHA 618合集、宝宝DHA测评等关键词"],
  195. ["参考解析", "TikHub 解析短链笔记作者;参考作者用于昵称反查和风格参照"],
  196. ["评分逻辑", "预算、女粉、25-44、兴趣、阅读中位、阅读单价、CPE为硬指标;育婴师/合集/目标省份加少量业务分"],
  197. ["使用限制", "本版未接入本地资源库历史合作与真实档期;CPE为空的账号需二次确认"],
  198. ["下一步", "对主推前5个做近10条内容人工复核、供应商档期确认、客户版名单润色"],
  199. ["生成时间", new Date().toLocaleString("zh-CN", { hour12: false })],
  200. ];
  201. method.getRange("A1:A14").format = { fill: "#E7F3EF", font: { bold: true } };
  202. method.getRange("A1:B14").format = { wrapText: true };
  203. const sheets = [summary, main, backup, refSheet, method];
  204. for (const sheet of sheets) {
  205. const used = sheet.getUsedRange();
  206. used.format.autofitColumns();
  207. used.format.autofitRows();
  208. }
  209. const inspect = await wb.inspect({
  210. kind: "table",
  211. range: "结论!A1:H18",
  212. include: "values",
  213. tableMaxRows: 18,
  214. tableMaxCols: 8,
  215. });
  216. console.log(inspect.ndjson.split("\n").slice(0, 3).join("\n"));
  217. const errors = await wb.inspect({
  218. kind: "match",
  219. searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
  220. options: { useRegex: true, maxResults: 50 },
  221. });
  222. console.log(errors.ndjson);
  223. const preview = await wb.render({ sheetName: "结论", autoCrop: "all", scale: 1, format: "png" });
  224. await fs.writeFile(path.join(outDir, "dha-tihao-summary-preview.png"), new Uint8Array(await preview.arrayBuffer()));
  225. const output = await SpreadsheetFile.exportXlsx(wb);
  226. await output.save(path.join(outDir, "AI提号-DHA小红书推荐名单-PoC.xlsx"));