build_dha_tihao_report_v2.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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_enriched.json"), "utf8"));
  6. const refs = JSON.parse(await fs.readFile(path.join(outDir, "reference_notes.json"), "utf8"));
  7. const refAccounts = JSON.parse(await fs.readFile(path.join(outDir, "reference_baseline.json"), "utf8"));
  8. const searches = JSON.parse(await fs.readFile(path.join(outDir, "justone_searches.json"), "utf8"));
  9. const mergedCandidates = JSON.parse(await fs.readFile(path.join(outDir, "candidates_merged_pre_detail.json"), "utf8"));
  10. function pct(value) {
  11. if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
  12. return `${Number(value).toFixed(1)}%`;
  13. }
  14. function money(value) {
  15. if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
  16. return Number(value);
  17. }
  18. function fixed(value, digits = 2) {
  19. if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
  20. return Number(value).toFixed(digits);
  21. }
  22. function join(value) {
  23. if (!value) return "";
  24. if (Array.isArray(value)) return value.filter(Boolean).join(";");
  25. return String(value);
  26. }
  27. function provinceSummary(value) {
  28. if (!Array.isArray(value)) return "";
  29. return value
  30. .slice(0, 5)
  31. .map((item) => `${item[0]} ${Number(item[1] || 0).toFixed(1)}%`)
  32. .join(";");
  33. }
  34. function verdict(row) {
  35. if (row.passCount >= 7 && row.referenceSimilarity >= 55) return "主推";
  36. if (row.passCount >= 6 || row.referenceSimilarity >= 70) return "备选/复核";
  37. return "需复核";
  38. }
  39. function fitReason(row) {
  40. const parts = [];
  41. if (row.passItems?.includes("预算")) parts.push("预算命中");
  42. if (row.passItems?.includes("女粉")) parts.push(`女粉${pct(row.femalePct)}`);
  43. if (row.passItems?.includes("25-44")) parts.push(`25-44岁${pct(row.age25_44Pct)}`);
  44. if (row.interestHits?.length) parts.push(`兴趣命中${join(row.interestHits)}`);
  45. if (row.passItems?.includes("阅读")) parts.push(`阅读中位${Math.round(row.readMedian)}`);
  46. if (row.passItems?.includes("CPE")) parts.push(`CPE ${fixed(row.cpe)}`);
  47. if (row.referenceFitLevel) parts.push(`参考相似度${row.referenceSimilarity}(${row.referenceFitLevel})`);
  48. return parts.join(";");
  49. }
  50. function riskReason(row) {
  51. const risks = [...(row.riskItems || [])];
  52. if ((row.referenceSimilarity || 0) < 55) risks.push("参考内容相似度偏低");
  53. if (!risks.length) return "硬指标与参考内容基准均通过,建议进入主推池";
  54. return `需复核:${join(risks)}`;
  55. }
  56. const mainRows = scored
  57. .filter((row) => row.passCount >= 7 && row.referenceSimilarity >= 55)
  58. .slice(0, 8);
  59. const backupRows = scored
  60. .filter((row) => !mainRows.some((m) => m.userId === row.userId))
  61. .slice(0, 14);
  62. const wb = Workbook.create();
  63. const summary = wb.worksheets.add("结论");
  64. const main = wb.worksheets.add("主推账号");
  65. const backup = wb.worksheets.add("备选复核");
  66. const refAccountSheet = wb.worksheets.add("参考账号基准");
  67. const refNoteSheet = wb.worksheets.add("参考链接解析");
  68. const method = wb.worksheets.add("Brief与方法");
  69. for (const sheet of [summary, main, backup, refAccountSheet, refNoteSheet, method]) {
  70. sheet.showGridLines = false;
  71. }
  72. summary.getRange("A1:I1").merge();
  73. summary.getRange("A1").values = [["AI提号 - DHA 小红书需求 PoC(补 TikHub 参考账号分析)"]];
  74. summary.getRange("A1").format = {
  75. fill: "#144D43",
  76. font: { bold: true, color: "#FFFFFF", size: 16 },
  77. };
  78. summary.getRange("A3:B9").values = [
  79. ["Brief", "小红书 / DHA / 干货科普 + 618合集 / 预算1000-5000"],
  80. ["硬性画像", "女性为主,25-44岁占比70%以上;兴趣前三需命中母婴/萌娃/生活记录中的2个"],
  81. ["数据门槛", "CPE≤3,阅读中位数≥4000,预估阅读单价≤1.0"],
  82. ["参考抓取", "TikHub 已抓 3 个有效参考账号主页画像与近期作品:17/20/22条;1个短链当前不可浏览"],
  83. ["候选召回", `Just One 15个搜索入口,去重候选${mergedCandidates.length}个,深查${scored.length}个`],
  84. ["参考基准", "妈妈真实使用 + 选品/避坑 + DHA/营养成分 + 节点合集/测评;不是只看账号标签"],
  85. ["结论", `主推${mainRows.length}个;备选/复核${backupRows.length}个。建议先复核主推前5个的最新档期与内容调性。`],
  86. ];
  87. summary.getRange("A3:A9").format = { fill: "#E7F3EF", font: { bold: true } };
  88. summary.getRange("A11:I11").values = [[
  89. "推荐结论",
  90. "账号",
  91. "等级",
  92. "预算",
  93. "粉丝画像",
  94. "数据",
  95. "参考相似度",
  96. "推荐理由",
  97. "风险提示",
  98. ]];
  99. summary.getRange("A11:I11").format = { fill: "#1F7A68", font: { bold: true, color: "#FFFFFF" } };
  100. const summaryRows = [...mainRows.slice(0, 5), ...backupRows.slice(0, 4)].map((row, idx) => [
  101. idx < 5 ? "优先沟通" : "备选观察",
  102. row.name,
  103. verdict(row),
  104. `${row.chosenFormat} ${row.chosenPrice}`,
  105. `女粉${pct(row.femalePct)} / 25-44 ${pct(row.age25_44Pct)} / ${join(row.interestHits)}`,
  106. `阅读${Math.round(row.readMedian)} / 单价${fixed(row.readUnit, 3)} / CPE ${fixed(row.cpe)}`,
  107. `${row.referenceSimilarity} / ${row.referenceHitPoints}`,
  108. fitReason(row),
  109. riskReason(row),
  110. ]);
  111. summary.getRangeByIndexes(11, 0, summaryRows.length, 9).values = summaryRows;
  112. summary.getRange("A11:I20").format = { wrapText: true };
  113. summary.freezePanes.freezeRows(11);
  114. const headers = [
  115. "推荐等级",
  116. "账号",
  117. "红书号",
  118. "地区",
  119. "粉丝数",
  120. "图文报价",
  121. "视频报价",
  122. "建议形式",
  123. "建议报价",
  124. "阅读中位",
  125. "互动中位",
  126. "阅读单价",
  127. "CPE",
  128. "女粉占比",
  129. "25-44占比",
  130. "兴趣命中",
  131. "Top兴趣",
  132. "标签",
  133. "省份分布",
  134. "参考相似度",
  135. "参考命中点",
  136. "内容采样证据",
  137. "通过项",
  138. "风险项",
  139. "推荐理由",
  140. ];
  141. function writeCandidateSheet(sheet, rows) {
  142. sheet.getRangeByIndexes(0, 0, 1, headers.length).values = [headers];
  143. sheet.getRangeByIndexes(0, 0, 1, headers.length).format = {
  144. fill: "#144D43",
  145. font: { bold: true, color: "#FFFFFF" },
  146. wrapText: true,
  147. };
  148. const values = rows.map((row) => [
  149. verdict(row),
  150. row.name,
  151. row.redId,
  152. row.location,
  153. row.fansNum,
  154. money(row.picturePrice),
  155. money(row.videoPrice),
  156. row.chosenFormat,
  157. money(row.chosenPrice),
  158. Math.round(row.readMedian || 0),
  159. Math.round(row.interactionMedian || 0),
  160. fixed(row.readUnit, 3),
  161. fixed(row.cpe, 2),
  162. pct(row.femalePct),
  163. pct(row.age25_44Pct),
  164. join(row.interestHits),
  165. join(row.interests),
  166. join(row.tags),
  167. provinceSummary(row.provinces),
  168. row.referenceSimilarity,
  169. row.referenceHitPoints,
  170. row.contentEvidence,
  171. join(row.passItems),
  172. join(row.riskItems),
  173. `${fitReason(row)}。${riskReason(row)}`,
  174. ]);
  175. sheet.getRangeByIndexes(1, 0, values.length, headers.length).values = values;
  176. sheet.getRangeByIndexes(0, 0, values.length + 1, headers.length).format = { wrapText: true };
  177. sheet.tables.add(`A1:Y${values.length + 1}`, true, `${sheet.name.replace(/\W/g, "")}Table`);
  178. sheet.freezePanes.freezeRows(1);
  179. }
  180. writeCandidateSheet(main, mainRows);
  181. writeCandidateSheet(backup, backupRows);
  182. refAccountSheet.getRange("A1:K1").values = [[
  183. "参考类型",
  184. "账号",
  185. "红书号",
  186. "userId",
  187. "粉丝数",
  188. "IP/地区",
  189. "主页简介",
  190. "采样作品数",
  191. "作品类型结构",
  192. "核心内容信号",
  193. "采样标题",
  194. ]];
  195. refAccountSheet.getRange("A1:K1").format = { fill: "#144D43", font: { bold: true, color: "#FFFFFF" }, wrapText: true };
  196. refAccountSheet.getRangeByIndexes(1, 0, refAccounts.length, 11).values = refAccounts.map((row) => [
  197. row.bucket,
  198. row.name,
  199. row.redId,
  200. row.userId,
  201. row.fans,
  202. row.location,
  203. row.profileDesc,
  204. row.sampleCount,
  205. row.typeMix,
  206. join(row.topSignals),
  207. join(row.sampleTitles),
  208. ]);
  209. refAccountSheet.getRange("A1:K5").format = { wrapText: true };
  210. refAccountSheet.freezePanes.freezeRows(1);
  211. refNoteSheet.getRange("A1:J1").values = [[
  212. "参考类型",
  213. "短链",
  214. "解析笔记ID",
  215. "作者昵称",
  216. "作者userId",
  217. "红书号",
  218. "标题",
  219. "类型",
  220. "状态",
  221. "最终链接",
  222. ]];
  223. refNoteSheet.getRange("A1:J1").format = { fill: "#144D43", font: { bold: true, color: "#FFFFFF" }, wrapText: true };
  224. refNoteSheet.getRangeByIndexes(1, 0, refs.length, 10).values = refs.map((row) => [
  225. row.bucket,
  226. row.url,
  227. row.note_id,
  228. row.author_nickname,
  229. row.author_userid,
  230. row.author_red_id,
  231. row.title,
  232. row.type,
  233. row.note_id ? "已解析" : "短链当前不可浏览",
  234. row.final_url,
  235. ]);
  236. refNoteSheet.getRange("A1:J5").format = { wrapText: true };
  237. method.getRange("A1:B15").values = [
  238. ["字段", "内容"],
  239. ["平台", "小红书"],
  240. ["产品", "DHA"],
  241. ["账号类型", "干货科普(营养师/育婴师/妈妈为主),618合集(6个月-7岁孩子妈妈博主)"],
  242. ["预算", "1000-5000"],
  243. ["粉丝画像", "女性为主,25-44岁占比70%以上;广东/山东/河南占比多属正常"],
  244. ["兴趣要求", "Top兴趣中母婴/萌娃/生活记录至少命中2个"],
  245. ["数据要求", "CPE≤3,阅读中位数≥4000,预估阅读单价≤1.0"],
  246. ["参考账号抓取", "TikHub get_user_info + app_v2/get_user_posted_notes,抓取参考账号主页画像和近期作品"],
  247. ["候选召回", "Just One 小红书蒲公英创作者搜索 + 详情/粉丝画像/近30天表现"],
  248. ["重排逻辑", "硬指标通过数优先;同等情况下用参考相似度重排;高相似但硬指标不足进入备选复核"],
  249. ["参考相似度", "基于DHA/营养成分、育婴师/专业背书、妈妈真实使用、选品避坑、合集节点、母婴萌娃、测评开箱等信号"],
  250. ["使用限制", "未接入本地历史合作与真实档期;Just One 部分账号 CPE/笔记样本为空需供应商二次确认"],
  251. ["费用提示", "TikHub 用户信息/用户作品接口调用会计费;本轮参考抓取 3个用户信息 + 3个用户作品接口"],
  252. ["生成时间", new Date().toLocaleString("zh-CN", { hour12: false })],
  253. ];
  254. method.getRange("A1:A15").format = { fill: "#E7F3EF", font: { bold: true } };
  255. method.getRange("A1:B15").format = { wrapText: true };
  256. for (const sheet of [summary, main, backup, refAccountSheet, refNoteSheet, method]) {
  257. const used = sheet.getUsedRange();
  258. used.format.autofitColumns();
  259. used.format.autofitRows();
  260. }
  261. const inspect = await wb.inspect({
  262. kind: "table",
  263. range: "结论!A1:I20",
  264. include: "values",
  265. tableMaxRows: 20,
  266. tableMaxCols: 9,
  267. });
  268. console.log(inspect.ndjson.split("\n").slice(0, 4).join("\n"));
  269. const errors = await wb.inspect({
  270. kind: "match",
  271. searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
  272. options: { useRegex: true, maxResults: 100 },
  273. summary: "formula error scan",
  274. });
  275. console.log(errors.ndjson);
  276. const preview = await wb.render({ sheetName: "结论", autoCrop: "all", scale: 1, format: "png" });
  277. await fs.writeFile(
  278. path.join(outDir, "dha-tihao-tikhub-summary-preview.png"),
  279. new Uint8Array(await preview.arrayBuffer()),
  280. );
  281. const outputPath = path.join(outDir, "AI提号-DHA小红书推荐名单-TikHub参考分析版.xlsx");
  282. const output = await SpreadsheetFile.exportXlsx(wb);
  283. await output.save(outputPath);
  284. console.log(outputPath);