local-seed-to-intake-worklist.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { buildLocalSeedMaterialIndex, sanitizeCell, sanitizePublicUrl } = require('./local-seed-material-index');
  5. const ROOT = path.resolve(__dirname, '..');
  6. const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'local-seed-to-intake-worklist-latest');
  7. function main() {
  8. const args = parseArgs(process.argv.slice(2));
  9. const workspaceRoot = path.resolve(args.workspaceRoot || path.join(ROOT, '..'));
  10. const outputsDir = path.resolve(args.outputs || path.join(ROOT, 'outputs'));
  11. const outputDir = path.resolve(args.output || DEFAULT_OUTPUT);
  12. const seedIndex = buildLocalSeedMaterialIndex({ root: ROOT, workspaceRoot, outputsDir });
  13. const summary = buildLocalSeedToIntakeWorklist({ root: ROOT, seedIndex });
  14. fs.mkdirSync(outputDir, { recursive: true });
  15. const summaryPath = path.join(outputDir, 'local-seed-to-intake-worklist-summary.json');
  16. const reportPath = path.join(outputDir, 'local-seed-to-intake-worklist.md');
  17. const csvPath = path.join(outputDir, 'local-seed-to-intake-worklist.csv');
  18. const historyDraftPath = path.join(outputDir, 'history-intake-draft.csv');
  19. const reviewDraftPath = path.join(outputDir, 'manual-review-draft.csv');
  20. const videoWorklistPath = path.join(outputDir, 'video-resource-worklist.csv');
  21. const candidateWorklistPath = path.join(outputDir, 'candidate-seed-worklist.csv');
  22. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  23. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  24. fs.writeFileSync(csvPath, withBom(renderUnifiedCsv(summary)), 'utf8');
  25. fs.writeFileSync(historyDraftPath, withBom(renderHistoryDraftCsv(summary)), 'utf8');
  26. fs.writeFileSync(reviewDraftPath, withBom(renderReviewDraftCsv(summary)), 'utf8');
  27. fs.writeFileSync(videoWorklistPath, withBom(renderVideoWorklistCsv(summary)), 'utf8');
  28. fs.writeFileSync(candidateWorklistPath, withBom(renderCandidateWorklistCsv(summary)), 'utf8');
  29. console.log(JSON.stringify({
  30. outputDir,
  31. summary: summaryPath,
  32. report: reportPath,
  33. csv: csvPath,
  34. historyDraft: historyDraftPath,
  35. reviewDraft: reviewDraftPath,
  36. videoWorklist: videoWorklistPath,
  37. candidateWorklist: candidateWorklistPath,
  38. historyDraftRowCount: summary.historyDraftRows.length,
  39. reviewDraftRowCount: summary.reviewDraftRows.length,
  40. referenceSeedRowCount: summary.referenceSeedRows.length,
  41. candidateSeedRowCount: summary.candidateSeedRows.length,
  42. canCloseProofGap: summary.canCloseProofGap
  43. }, null, 2));
  44. if (args.strict && summary.directCustomerProof !== false) process.exitCode = 1;
  45. }
  46. function buildLocalSeedToIntakeWorklist({ root, seedIndex }) {
  47. const references = extractReferences(seedIndex.materials || []);
  48. const candidates = extractCandidates(seedIndex.materials || []);
  49. const videoCandidates = candidates.filter(item => /视频/i.test(item.suggestedFormat || ''));
  50. const briefSeed = buildDhaBriefSeed(seedIndex, references);
  51. const videoWorklistRows = buildVideoWorklistRows({ briefSeed, references, videoCandidates });
  52. const candidateSeedRows = candidates.slice(0, 12).map((item, index) => ({
  53. order: index + 1,
  54. briefId: briefSeed?.briefId || 'dha-xhs-mom-baby-seed-001',
  55. platform: '小红书',
  56. creatorName: item.creatorName,
  57. xhsId: item.xhsId,
  58. sourceSheet: item.sourceSheet,
  59. sourceWorkbook: item.sourceWorkbook,
  60. recommendationLevel: item.recommendationLevel,
  61. region: item.region,
  62. fans: item.fans,
  63. imageQuote: item.imageQuote,
  64. videoQuote: item.videoQuote,
  65. suggestedFormat: item.suggestedFormat,
  66. suggestedQuote: item.suggestedQuote,
  67. readMedian: item.readMedian,
  68. interactionMedian: item.interactionMedian,
  69. readUnitCost: item.readUnitCost,
  70. targetFile: 'outputs/data-intake-pack-latest/manual-review-template.csv',
  71. missingForProof: [
  72. '主页链接或真实可访问 profile URL',
  73. '商务人工复核标签',
  74. '客户最终选中/拒绝',
  75. '拒绝原因或负样本归因',
  76. '本轮人工补号量'
  77. ]
  78. }));
  79. const historyDraftRows = buildHistoryDraftRows({ briefSeed, candidateSeedRows });
  80. const reviewDraftRows = buildReviewDraftRows({ briefSeed, candidateSeedRows });
  81. const proofGapMaterialBridge = buildProofGapMaterialBridge({
  82. historyDraftRows,
  83. reviewDraftRows,
  84. referenceSeedRows: references,
  85. candidateSeedRows,
  86. videoWorklistRows,
  87. videoCandidates
  88. });
  89. return {
  90. generatedAt: new Date().toISOString(),
  91. root,
  92. proofLevel: 'not_business_proof',
  93. materialType: 'seed_to_intake_worklist',
  94. directCustomerProof: false,
  95. canCloseProofGap: false,
  96. complete: false,
  97. sourceSeedIndex: {
  98. proofLevel: seedIndex.proofLevel || 'not_business_proof',
  99. directCustomerProof: Boolean(seedIndex.directCustomerProof),
  100. canCloseProofGap: Boolean(seedIndex.canCloseProofGap),
  101. materialCount: Number(seedIndex.materialCount || 0),
  102. existingMaterialCount: Number(seedIndex.existingMaterialCount || 0)
  103. },
  104. counts: {
  105. historyDraftRows: historyDraftRows.length,
  106. reviewDraftRows: reviewDraftRows.length,
  107. referenceSeedRows: references.length,
  108. candidateSeedRows: candidateSeedRows.length,
  109. videoWorklistRows: videoWorklistRows.length,
  110. videoCandidateNeedsResourceRows: videoCandidates.length
  111. },
  112. historyDraftRows,
  113. reviewDraftRows,
  114. referenceSeedRows: references,
  115. candidateSeedRows,
  116. videoWorklistRows,
  117. proofGapMaterialBridge,
  118. missingProofRequirements: [
  119. '至少 5 个真实历史 Brief',
  120. '历史人工补号量基线',
  121. '人工最终名单中的真实博主名称和主页链接',
  122. '客户最终选中/拒绝记录',
  123. '拒绝原因或商务复核标签',
  124. '至少 1 个真实候选视频 URL、封面、ASR/字幕、帧图或正文证据',
  125. '本轮人工补号量',
  126. 'history:audit readyForCustomerEffectProof=true',
  127. 'video:resource-readiness readyForVideoAbPreflight=true',
  128. 'customer-effect:audit overallPass=true'
  129. ],
  130. targetFiles: [
  131. 'outputs/data-intake-pack-latest/history-data-template.csv',
  132. 'outputs/video-intake-pack-latest/video-resource-template.csv',
  133. 'outputs/data-intake-pack-latest/manual-review-template.csv'
  134. ],
  135. guardrails: [
  136. '本 worklist 只把本地 DHA Brief、PoC 候选和参考账号解析转成补表草稿,不写入正式 intake 模板。',
  137. '空字段表示仍需商务/投放补真实材料;不得用“未知”“0”或示例值补齐客户选择、拒绝原因或人工补号量。',
  138. '候选账号来自 PoC/参考分析种子,不等同于人工最终名单或客户选中名单。',
  139. '参考笔记链接和候选账号只能作为线索;缺真实候选视频 URL、封面、ASR/字幕或帧图时,不得启动视频 A/B 证明。',
  140. '本 worklist 的 proofLevel 固定为 not_business_proof,不能关闭 proof-gap。'
  141. ],
  142. nextActions: [
  143. {
  144. owner: '商务',
  145. title: '把 history-intake-draft.csv 扩成 5-10 个真实 Brief',
  146. action: '保留 DHA 行作为本地种子草稿,同时补齐其他真实历史 Brief、历史人工补号量基线、人工最终名单和客户选择/拒绝。',
  147. acceptance: 'intake:readiness historyReady=true,history:audit readyForCustomerEffectProof=true。'
  148. },
  149. {
  150. owner: '商务/投放',
  151. title: '按 video-resource-worklist.csv 补真实候选视频',
  152. action: '优先为建议形式为视频的候选补视频链接、封面、ASR/字幕、帧图或正文证据。',
  153. acceptance: 'video:resource-readiness readyForVideoAbPreflight=true,realCandidateRows>=1。'
  154. },
  155. {
  156. owner: '商务',
  157. title: '按 candidate-seed-worklist.csv 做人工复核',
  158. action: '只把候选种子作为复核池,补真实主页链接、人工复核标签、客户选择、拒绝原因和本轮人工补号量。',
  159. acceptance: 'review:metrics 与 customer-effect:audit 在真实标注后通过。'
  160. }
  161. ]
  162. };
  163. }
  164. function buildDhaBriefSeed(seedIndex, references) {
  165. const briefMaterial = (seedIndex.materials || []).find(item => item.id === 'dha-brief-workbook' && item.exists);
  166. if (!briefMaterial) return null;
  167. const referenceLinks = references
  168. .filter(item => item.finalLink && !/\/404\b/.test(item.finalLink))
  169. .slice(0, 4)
  170. .map(item => item.finalLink);
  171. return {
  172. briefId: 'dha-xhs-mom-baby-seed-001',
  173. projectName: 'DHA 小红书母婴提号',
  174. category: '母婴',
  175. customerBrief: [
  176. '平台:小红书',
  177. '产品:DHA',
  178. '账号类型:干货科普(营养师/育婴师/妈妈为主)、618合集(6个月-7岁孩子妈妈博主)',
  179. '预算:1000-5000',
  180. '粉丝画像:女性为主,25-44岁占比70%以上;兴趣需命中母婴/萌娃/记录生活中至少 2 个'
  181. ].join(';'),
  182. historicalManualSupplementBaseline: '',
  183. referenceAccountOrVideo: referenceLinks.join('|'),
  184. platform: '小红书',
  185. manualFinalCreatorName: '',
  186. manualFinalProfileUrl: '',
  187. manualReviewLabel: '',
  188. customerDecision: '',
  189. rejectReason: '',
  190. sourcePath: briefMaterial.path,
  191. canWritePartialFields: true,
  192. missingForProof: [
  193. '历史人工补号量基线',
  194. '人工最终名单博主名称',
  195. '人工最终名单主页链接',
  196. '客户最终选中/拒绝',
  197. '拒绝原因或商务复核标签',
  198. '仍需至少 4 个额外真实 Brief 才达到 minBriefs=5'
  199. ]
  200. };
  201. }
  202. function buildHistoryDraftRows({ briefSeed, candidateSeedRows }) {
  203. if (!briefSeed) return [];
  204. const rows = (candidateSeedRows || []).slice(0, 12).map(candidate => ({
  205. ...briefSeed,
  206. manualFinalCreatorName: candidate.creatorName || '',
  207. manualFinalProfileUrl: profileHint(candidate.xhsId),
  208. manualReviewLabel: '',
  209. customerDecision: '',
  210. rejectReason: '',
  211. sourcePath: `${candidate.sourceWorkbook} / ${candidate.sourceSheet}`,
  212. candidateSeedLevel: candidate.recommendationLevel || '',
  213. missingForProof: [
  214. '客户最终选中/拒绝记录',
  215. '拒绝原因或商务复核标签',
  216. '历史人工补号量基线',
  217. '真实主页链接复核',
  218. '仍需至少 5 个真实历史 Brief 才能满足 minBriefs=5'
  219. ]
  220. }));
  221. return rows.length ? rows : [briefSeed];
  222. }
  223. function buildReviewDraftRows({ briefSeed, candidateSeedRows }) {
  224. const briefId = briefSeed?.briefId || 'dha-xhs-mom-baby-seed-001';
  225. return (candidateSeedRows || []).map((candidate, index) => ({
  226. briefId,
  227. strategy: strategyFromCandidate(candidate),
  228. rank: index + 1,
  229. platform: candidate.platform || '小红书',
  230. creatorName: candidate.creatorName || '',
  231. totalScore: '',
  232. briefMatchScore: '',
  233. referenceStyleScore: '',
  234. homepageEvidenceScore: '',
  235. visualQualityScore: '',
  236. toneFitScore: '',
  237. evidenceBonus: '',
  238. evidenceRiskPenalty: '',
  239. reason: `PoC候选:${candidate.recommendationLevel || '待复核'};建议形式:${candidate.suggestedFormat || '待补'};建议报价:${candidate.suggestedQuote || '待补'}`,
  240. risk: '需商务复核真实主页链接、报价有效期、内容调性、客户最终选择和本轮人工补号量。',
  241. profileUrl: profileHint(candidate.xhsId),
  242. manualReviewLabel: '',
  243. customerDecision: '',
  244. attributionType: '',
  245. feedbackReason: '',
  246. currentManualSupplementCount: '',
  247. source: `${candidate.sourceWorkbook} / ${candidate.sourceSheet}`,
  248. proofBoundary: 'seed_only_not_customer_effect_proof'
  249. }));
  250. }
  251. function buildProofGapMaterialBridge({ historyDraftRows, reviewDraftRows, referenceSeedRows, candidateSeedRows, videoWorklistRows, videoCandidates }) {
  252. return [
  253. {
  254. id: 'historical-dataset',
  255. materialFound: historyDraftRows.length > 0,
  256. connectedDraft: 'history-intake-draft.csv',
  257. draftRowCount: historyDraftRows.length,
  258. canCloseProofGap: false,
  259. whyNotClosed: [
  260. '当前只接入 DHA/PoC 种子,未达到 5 个真实历史 Brief。',
  261. '客户选择/拒绝、拒绝原因和历史人工补号量基线仍为空,不能通过 readyForCustomerEffectProof。'
  262. ]
  263. },
  264. {
  265. id: 'video-ab-live-proof',
  266. materialFound: referenceSeedRows.length > 0 || videoWorklistRows.length > 0,
  267. connectedDraft: 'video-resource-worklist.csv',
  268. referenceSeedRowCount: referenceSeedRows.length,
  269. videoCandidateSeedRowCount: videoCandidates.length,
  270. canCloseProofGap: false,
  271. whyNotClosed: [
  272. '资源线索不等于 acceptance:video-ab 的 live proofContext。',
  273. '仍需要 live runtime credential、company、VOC social provider token、video analysis token 后运行 npm run acceptance:video-ab。'
  274. ]
  275. },
  276. {
  277. id: 'manual-review-and-customer-effect',
  278. materialFound: candidateSeedRows.length > 0 || reviewDraftRows.length > 0,
  279. connectedDraft: 'manual-review-draft.csv',
  280. draftRowCount: reviewDraftRows.length,
  281. canCloseProofGap: false,
  282. whyNotClosed: [
  283. '候选种子已转成商务复核草表,但人工复核标签、客户选择、归因、反馈原因和本轮人工补号量仍需真实填写。',
  284. 'customer-effect:audit 还必须绑定通过的历史审计 JSON。'
  285. ]
  286. }
  287. ];
  288. }
  289. function profileHint(xhsId) {
  290. return xhsId ? `小红书号:${xhsId}` : '';
  291. }
  292. function strategyFromCandidate(candidate) {
  293. const source = `${candidate.sourceWorkbook || ''} ${candidate.sourceSheet || ''}`.toLowerCase();
  294. if (source.includes('参考分析')) return 'reference-account';
  295. if (/主推|main|strong/i.test(candidate.recommendationLevel || candidate.sourceSheet || '')) return 'result-first-broad';
  296. return 'candidate-seed';
  297. }
  298. function extractReferences(materials) {
  299. const rows = [];
  300. for (const material of materials || []) {
  301. for (const sheet of material.workbook?.sheets || []) {
  302. if (!/参考.*(解析|链接)/.test(sheet.name || '')) continue;
  303. for (const row of sheet.sampleRows || []) {
  304. const referenceType = clean(row[0]);
  305. const shortLink = sanitizePublicUrl(clean(row[1]));
  306. const noteId = clean(row[2]);
  307. const authorName = clean(row[3]);
  308. const xhsId = clean(row[5]);
  309. const status = clean(row[8]);
  310. const finalLink = sanitizePublicUrl(clean(row[9]));
  311. if (!referenceType && !shortLink && !finalLink) continue;
  312. rows.push({
  313. referenceType,
  314. shortLink,
  315. noteId,
  316. authorName,
  317. xhsId,
  318. status,
  319. finalLink,
  320. sourceWorkbook: material.path,
  321. sourceSheet: sheet.name,
  322. usableFor: /已解析/.test(status) && finalLink && !/\/404\b/.test(finalLink)
  323. ? '可作为参考账号/参考内容线索转写'
  324. : '需人工重新打开短链或替换为可访问参考资源',
  325. missingForProof: [
  326. '是否真实参考视频需投放复核',
  327. '不能替代真实候选视频',
  328. '不能替代客户选择或人工补号量'
  329. ]
  330. });
  331. }
  332. }
  333. }
  334. return uniqueBy(rows, item => item.finalLink || item.shortLink || `${item.referenceType}:${item.authorName}`);
  335. }
  336. function extractCandidates(materials) {
  337. const rows = [];
  338. for (const material of materials || []) {
  339. for (const sheet of material.workbook?.sheets || []) {
  340. if (!/主推账号|备选/.test(sheet.name || '')) continue;
  341. for (const row of sheet.sampleRows || []) {
  342. const creatorName = clean(row[1]);
  343. if (!creatorName) continue;
  344. rows.push({
  345. recommendationLevel: clean(row[0]),
  346. creatorName,
  347. xhsId: clean(row[2]),
  348. region: clean(row[3]),
  349. fans: clean(row[4]),
  350. imageQuote: clean(row[5]),
  351. videoQuote: clean(row[6]),
  352. suggestedFormat: clean(row[7]),
  353. suggestedQuote: clean(row[8]),
  354. readMedian: clean(row[9]),
  355. interactionMedian: clean(row[10]),
  356. readUnitCost: clean(row[11]),
  357. sourceWorkbook: material.path,
  358. sourceSheet: sheet.name
  359. });
  360. }
  361. }
  362. }
  363. return uniqueBy(rows, item => item.xhsId || item.creatorName);
  364. }
  365. function buildVideoWorklistRows({ briefSeed, references, videoCandidates }) {
  366. const briefId = briefSeed?.briefId || 'dha-xhs-mom-baby-seed-001';
  367. const referenceRows = references
  368. .filter(item => /video|视频/i.test(item.referenceType || '') || /视频/.test(item.usableFor || ''))
  369. .map((item, index) => ({
  370. order: index + 1,
  371. briefId,
  372. projectName: briefSeed?.projectName || 'DHA 小红书母婴提号',
  373. platform: '小红书',
  374. role: '参考视频',
  375. creatorName: item.authorName,
  376. profileOrHomepage: item.xhsId ? `小红书号:${item.xhsId}` : '',
  377. videoLink: item.finalLink || item.shortLink,
  378. source: `${item.sourceWorkbook} / ${item.sourceSheet}`,
  379. resourceStatus: item.finalLink && !/\/404\b/.test(item.finalLink) ? '参考线索可访问,需投放复核是否为真实视频资源' : '短链或最终链接不可用,需替换',
  380. requiredFill: '补封面、ASR/字幕、帧图、标题、发布时间、内容摘要和真实资源确认',
  381. canMarkRealResourceNow: false
  382. }));
  383. const candidateRows = videoCandidates.map((item, index) => ({
  384. order: referenceRows.length + index + 1,
  385. briefId,
  386. projectName: briefSeed?.projectName || 'DHA 小红书母婴提号',
  387. platform: '小红书',
  388. role: '候选视频',
  389. creatorName: item.creatorName,
  390. profileOrHomepage: item.xhsId ? `小红书号:${item.xhsId}` : '',
  391. videoLink: '',
  392. source: `${item.sourceWorkbook} / ${item.sourceSheet}`,
  393. resourceStatus: '候选账号种子,缺真实候选视频 URL',
  394. requiredFill: '补真实候选视频 URL、封面、ASR/字幕、帧图或正文证据后,才能在 video-resource-template.csv 标为真实资源',
  395. canMarkRealResourceNow: false
  396. }));
  397. return [...referenceRows, ...candidateRows];
  398. }
  399. function renderReport(summary) {
  400. return [
  401. '# 本地种子转 intake 补表 worklist',
  402. '',
  403. `- 生成时间:${summary.generatedAt}`,
  404. `- proofLevel:${summary.proofLevel}`,
  405. `- materialType:${summary.materialType}`,
  406. `- directCustomerProof:${summary.directCustomerProof}`,
  407. `- canCloseProofGap:${summary.canCloseProofGap}`,
  408. `- 历史草稿行:${summary.counts.historyDraftRows}`,
  409. `- 商务复核草表行:${summary.counts.reviewDraftRows}`,
  410. `- 参考线索:${summary.counts.referenceSeedRows}`,
  411. `- 候选种子:${summary.counts.candidateSeedRows}`,
  412. `- 视频补表行:${summary.counts.videoWorklistRows}`,
  413. '',
  414. '## 边界',
  415. '',
  416. ...summary.guardrails.map(item => `- ${item}`),
  417. '',
  418. '## 目标文件',
  419. '',
  420. ...summary.targetFiles.map(item => `- ${item}`),
  421. '',
  422. '## 历史数据草稿',
  423. '',
  424. '| brief编号 | 项目 | 类目 | 参考链接 | 可直接转写 | 仍缺证明 |',
  425. '| --- | --- | --- | --- | --- | --- |',
  426. ...summary.historyDraftRows.map(item => tableRow([
  427. item.briefId,
  428. item.projectName,
  429. item.category,
  430. item.referenceAccountOrVideo || '缺失',
  431. item.canWritePartialFields,
  432. item.missingForProof.join(';')
  433. ])),
  434. '',
  435. '## 参考账号/内容线索',
  436. '',
  437. '| 类型 | 作者 | 状态 | 最终链接 | 来源 | 用途 | 仍缺证明 |',
  438. '| --- | --- | --- | --- | --- | --- | --- |',
  439. ...summary.referenceSeedRows.map(item => tableRow([
  440. item.referenceType,
  441. item.authorName || '缺失',
  442. item.status || '缺失',
  443. item.finalLink || item.shortLink || '缺失',
  444. `${item.sourceWorkbook} / ${item.sourceSheet}`,
  445. item.usableFor,
  446. item.missingForProof.join(';')
  447. ])),
  448. '',
  449. '## 候选种子',
  450. '',
  451. '| 序号 | 账号 | 红书号 | 推荐等级 | 建议形式 | 报价 | 来源 | 仍缺证明 |',
  452. '| ---: | --- | --- | --- | --- | --- | --- | --- |',
  453. ...summary.candidateSeedRows.map(item => tableRow([
  454. item.order,
  455. item.creatorName,
  456. item.xhsId,
  457. item.recommendationLevel,
  458. item.suggestedFormat,
  459. item.suggestedQuote,
  460. `${item.sourceWorkbook} / ${item.sourceSheet}`,
  461. item.missingForProof.join(';')
  462. ])),
  463. '',
  464. '## 视频资源补表清单',
  465. '',
  466. '| 序号 | 角色 | 博主 | 视频链接 | 当前状态 | 需补字段 | 可标真实资源 |',
  467. '| ---: | --- | --- | --- | --- | --- | --- |',
  468. ...summary.videoWorklistRows.map(item => tableRow([
  469. item.order,
  470. item.role,
  471. item.creatorName || '缺失',
  472. item.videoLink || '缺失',
  473. item.resourceStatus,
  474. item.requiredFill,
  475. item.canMarkRealResourceNow
  476. ])),
  477. '',
  478. '## Proof Gap Material Bridge',
  479. '',
  480. '| Gap | Material found | Connected draft | Rows | Can close | Why not closed |',
  481. '| --- | --- | --- | ---: | --- | --- |',
  482. ...summary.proofGapMaterialBridge.map(item => tableRow([
  483. item.id,
  484. item.materialFound,
  485. item.connectedDraft,
  486. item.draftRowCount ?? item.referenceSeedRowCount ?? 0,
  487. item.canCloseProofGap,
  488. item.whyNotClosed.join('; ')
  489. ])),
  490. '',
  491. '## 仍缺证明',
  492. '',
  493. ...summary.missingProofRequirements.map(item => `- ${item}`),
  494. '',
  495. '## 下一步',
  496. '',
  497. '| 负责人 | 动作 | 验收 |',
  498. '| --- | --- | --- |',
  499. ...summary.nextActions.map(item => tableRow([item.owner, `${item.title}:${item.action}`, item.acceptance])),
  500. ''
  501. ].join('\n');
  502. }
  503. function renderUnifiedCsv(summary) {
  504. const rows = [
  505. ['类型', '负责人', '目标文件', '字段/对象', '建议值/动作', '来源', '可直接转写', '仍缺证明', '边界'],
  506. ...summary.historyDraftRows.flatMap(item => [
  507. ['history-draft', '商务', 'outputs/data-intake-pack-latest/history-data-template.csv', '客户原始Brief', item.customerBrief, item.sourcePath, '是', item.missingForProof.join(';'), '不是业务证明'],
  508. ['history-draft', '商务', 'outputs/data-intake-pack-latest/history-data-template.csv', '历史人工补号量基线', '补真实历史人工补号量基线', item.sourcePath, '否', '历史人工补号量基线', '不得用 0 或示例值代替'],
  509. ['history-draft', '商务', 'outputs/data-intake-pack-latest/history-data-template.csv', '客户选择/拒绝原因', '补客户最终选中/拒绝和拒绝原因', item.sourcePath, '否', '客户最终选择和拒绝原因', '不得用主观判断代替客户反馈']
  510. ]),
  511. ...summary.reviewDraftRows.map(item => [
  512. 'manual-review-draft',
  513. '商务',
  514. 'outputs/data-intake-pack-latest/manual-review-template.csv',
  515. item.creatorName,
  516. '补人工复核标签、客户选择、归因类型、反馈原因和本轮人工补号量',
  517. item.source,
  518. '部分字段可转写',
  519. '客户选择、归因、反馈原因、本轮人工补号量',
  520. '候选草表不是客户效果证明'
  521. ]),
  522. ...summary.videoWorklistRows.map(item => [
  523. 'video-resource-worklist',
  524. item.role === '候选视频' ? '商务/投放' : '商务/投放',
  525. 'outputs/video-intake-pack-latest/video-resource-template.csv',
  526. `${item.role}:${item.creatorName}`,
  527. item.requiredFill,
  528. item.source,
  529. item.videoLink ? '部分字段可转写' : '否',
  530. '真实视频 URL、封面、ASR/字幕、帧图或正文证据',
  531. '补齐前不得标为真实候选视频'
  532. ]),
  533. ...summary.candidateSeedRows.map(item => [
  534. 'candidate-seed',
  535. '商务',
  536. 'outputs/data-intake-pack-latest/manual-review-template.csv',
  537. item.creatorName,
  538. `复核候选:${item.recommendationLevel},建议形式 ${item.suggestedFormat || '缺失'}`,
  539. `${item.sourceWorkbook} / ${item.sourceSheet}`,
  540. '部分字段可转写',
  541. item.missingForProof.join(';'),
  542. '候选种子不是人工最终名单或客户选择'
  543. ])
  544. ];
  545. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  546. }
  547. function renderHistoryDraftCsv(summary) {
  548. const rows = [
  549. ['brief编号', '项目名称', '类目', '客户原始Brief', '历史人工补号量基线', '参考账号或视频', '平台', '博主名称', '主页链接', '人工复核标签', '客户选择', '拒绝原因'],
  550. ...summary.historyDraftRows.map(item => [
  551. item.briefId,
  552. item.projectName,
  553. item.category,
  554. item.customerBrief,
  555. item.historicalManualSupplementBaseline,
  556. item.referenceAccountOrVideo,
  557. item.platform,
  558. item.manualFinalCreatorName,
  559. item.manualFinalProfileUrl,
  560. item.manualReviewLabel,
  561. item.customerDecision,
  562. item.rejectReason
  563. ])
  564. ];
  565. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  566. }
  567. function renderReviewDraftCsv(summary) {
  568. const rows = [
  569. ['brief编号', '策略', '排名', '平台', '博主名称', '综合分', 'brief匹配分', '参考风格分', '主页证据分', '视觉质感分', '调性一致分', '证据加分', '证据风险扣分', '推荐理由', '风险提示', '主页链接', '人工复核标签', '客户选择', '归因类型', '反馈原因', '本轮人工补号量', '来源', '证明边界'],
  570. ...summary.reviewDraftRows.map(item => [
  571. item.briefId,
  572. item.strategy,
  573. item.rank,
  574. item.platform,
  575. item.creatorName,
  576. item.totalScore,
  577. item.briefMatchScore,
  578. item.referenceStyleScore,
  579. item.homepageEvidenceScore,
  580. item.visualQualityScore,
  581. item.toneFitScore,
  582. item.evidenceBonus,
  583. item.evidenceRiskPenalty,
  584. item.reason,
  585. item.risk,
  586. item.profileUrl,
  587. item.manualReviewLabel,
  588. item.customerDecision,
  589. item.attributionType,
  590. item.feedbackReason,
  591. item.currentManualSupplementCount,
  592. item.source,
  593. item.proofBoundary
  594. ])
  595. ];
  596. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  597. }
  598. function renderVideoWorklistCsv(summary) {
  599. const rows = [
  600. ['序号', 'brief编号', '项目名称', '平台', '资源角色', '博主名称', '主页或红书号', '视频链接', '当前状态', '需补字段', '来源', '是否可直接标真实资源'],
  601. ...summary.videoWorklistRows.map(item => [
  602. item.order,
  603. item.briefId,
  604. item.projectName,
  605. item.platform,
  606. item.role,
  607. item.creatorName,
  608. item.profileOrHomepage,
  609. item.videoLink,
  610. item.resourceStatus,
  611. item.requiredFill,
  612. item.source,
  613. item.canMarkRealResourceNow ? '是' : '否'
  614. ])
  615. ];
  616. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  617. }
  618. function renderCandidateWorklistCsv(summary) {
  619. const rows = [
  620. ['序号', 'brief编号', '平台', '账号', '红书号', '推荐等级', '地区', '粉丝数', '图文报价', '视频报价', '建议形式', '建议报价', '阅读中位', '互动中位', '阅读单价', '来源', '仍缺证明'],
  621. ...summary.candidateSeedRows.map(item => [
  622. item.order,
  623. item.briefId,
  624. item.platform,
  625. item.creatorName,
  626. item.xhsId,
  627. item.recommendationLevel,
  628. item.region,
  629. item.fans,
  630. item.imageQuote,
  631. item.videoQuote,
  632. item.suggestedFormat,
  633. item.suggestedQuote,
  634. item.readMedian,
  635. item.interactionMedian,
  636. item.readUnitCost,
  637. `${item.sourceWorkbook} / ${item.sourceSheet}`,
  638. item.missingForProof.join(';')
  639. ])
  640. ];
  641. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  642. }
  643. function clean(value) {
  644. return sanitizeCell(value == null ? '' : String(value));
  645. }
  646. function uniqueBy(rows, keyFn) {
  647. const seen = new Set();
  648. const result = [];
  649. for (const row of rows) {
  650. const key = keyFn(row);
  651. if (!key || seen.has(key)) continue;
  652. seen.add(key);
  653. result.push(row);
  654. }
  655. return result;
  656. }
  657. function parseArgs(argv) {
  658. const result = {};
  659. for (let index = 0; index < argv.length; index += 1) {
  660. const arg = argv[index];
  661. if (!arg.startsWith('--')) continue;
  662. const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  663. const next = argv[index + 1];
  664. if (!next || next.startsWith('--')) result[key] = true;
  665. else {
  666. result[key] = next;
  667. index += 1;
  668. }
  669. }
  670. return result;
  671. }
  672. function withBom(text) {
  673. return `\uFEFF${text}`;
  674. }
  675. function tableRow(values) {
  676. return `| ${values.map(value => escapeCell(value)).join(' | ')} |`;
  677. }
  678. function escapeCell(value) {
  679. return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
  680. }
  681. function csvCell(value) {
  682. return `"${String(value ?? '').replace(/"/g, '""')}"`;
  683. }
  684. if (require.main === module) main();
  685. module.exports = {
  686. buildLocalSeedToIntakeWorklist,
  687. extractReferences,
  688. extractCandidates,
  689. buildVideoWorklistRows
  690. };