software-table-smoke.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { spawnSync } = require('child_process');
  6. const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report');
  7. const root = path.resolve(__dirname, '..');
  8. const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tihao-software-table-'));
  9. const input = path.join(fixtureDir, 'manual-review-sample.csv');
  10. const output = path.join(fixtureDir, 'software-client-list.dedup.csv');
  11. const expectedHeader = SOFTWARE_TABLE_HEADER.join(',');
  12. const rows = [
  13. [
  14. 'brief编号',
  15. '策略',
  16. '排名',
  17. '平台',
  18. '博主名称',
  19. '综合分',
  20. 'brief匹配分',
  21. '参考风格分',
  22. '证据加分',
  23. '证据风险扣分',
  24. '推荐理由',
  25. '风险提示',
  26. '主页链接',
  27. '人工复核标签'
  28. ],
  29. ['brief-a', 'result-first', '1', 'xiaohongshu', '账号A', '88', '90', '80', '1', '0', '标签覆盖 测评、测评、开箱,理由A', '风险A', 'http://m.xiaohongshu.com/user/profile/a?x=1', ''],
  30. ['brief-a', 'video-enhanced', '2', 'xhs', '账号A(备孕中)', '90', '91', '81', '2', '0', '标签覆盖 测评、开箱,理由A2', '风险A2', 'https://www.xiaohongshu.com/user/profile/a#note', ''],
  31. ['brief-a', 'result-first', '3', 'douyin', '账号B', '80', '82', '70', '0', '0', '理由B', '风险B', '', ''],
  32. ['brief-a', 'result-first-risk', '4', 'dy', '账号B', '79', '81', '69', '0', '0', '理由B2', '风险B2', '', ''],
  33. ['brief-b', 'result-first', '5', 'xiaohongshu', '账号C', '81', '83', '71', '0', '0', '理由C', '风险C', 'https://example.com/c', ''],
  34. ['brief-b', 'result-first', '6', 'xiaohongshu', '账号D', '79', '81', '69', '0', '0', '理由D', '风险D', 'https://example.com/d', ''],
  35. ['brief-b', 'result-first', '7', 'xiaohongshu', '账号A', '85', '86', '78', '1', '0', '跨 brief 重复账号', '风险A3', 'https://www.xiaohongshu.com/user/profile/a?brief=b', '']
  36. ];
  37. fs.writeFileSync(input, '\uFEFF' + rows.map(row => row.map(csvCell).join(',')).join('\n'));
  38. const result = spawnSync(process.execPath, [
  39. path.join(root, 'scripts', 'export-software-table.js'),
  40. '--input',
  41. input,
  42. '--output',
  43. output
  44. ], { cwd: root, encoding: 'utf8' });
  45. if (result.status !== 0) {
  46. process.stderr.write(result.stderr || result.stdout || '');
  47. throw new Error('export-software-table 执行失败');
  48. }
  49. const text = fs.readFileSync(output, 'utf8').replace(/^\uFEFF/, '');
  50. const lines = text.trim().split(/\r?\n/);
  51. assert(lines[0] === expectedHeader, '软件端表头必须固定为中文表头');
  52. assert(lines.length === 5, '重复博主必须被移除');
  53. assert(lines[1].includes('video-enhanced'), '重复项应保留证据更完整且分数更高的记录');
  54. assert(lines[1].includes(',小红书,'), '平台必须导出为中文展示名');
  55. assert(lines[1].includes('标签覆盖 测评、开箱'), '推荐理由里的重复标签必须清理');
  56. assert(lines.some(line => line.includes(',抖音,')), '抖音平台必须导出为中文展示名');
  57. const body = parseCsv(text).slice(1);
  58. assert(body.filter(row => row[0] === 'brief-a').map(row => row[2]).join(',') === '1,2', 'brief-a 去重后排名必须连续');
  59. assert(body.filter(row => row[0] === 'brief-b').map(row => row[2]).join(',') === '1,2', 'brief-b 去重后排名必须连续');
  60. const summary = JSON.parse(fs.readFileSync(output.replace(/\.csv$/i, '.summary.json'), 'utf8'));
  61. assert(summary.duplicateCount === 3, '摘要应报告 3 条重复记录');
  62. assert(summary.sourceDuplicateRemovedCount === 3, 'summary should expose source duplicate rows removed');
  63. assert(summary.duplicateCountMeaning === 'source_duplicate_removed_count', 'summary should define legacy duplicateCount meaning');
  64. assert(summary.finalDuplicateGroupCount === 0, 'summary should expose zero final duplicate groups');
  65. assert(summary.rankContinuous === true, '摘要应报告排名连续');
  66. assert(summary.duplicateAudit.sameBriefUrl.length === 0, '同 brief URL 重复组必须为 0');
  67. assert(summary.duplicateAudit.sameBriefName.length === 0, '同 brief 名称重复组必须为 0');
  68. assert(summary.duplicateAudit.globalUrl.length === 0, '全局 URL 重复组必须为 0');
  69. assert(summary.duplicateAudit.globalNamePlatform.length === 0, '全局平台+名称重复组必须为 0');
  70. const markdownOutput = output.replace(/\.csv$/i, '.md');
  71. const markdown = spawnSync(process.execPath, [
  72. path.join(root, 'scripts', 'export-software-markdown.js'),
  73. '--input',
  74. output,
  75. '--output',
  76. markdownOutput
  77. ], { cwd: root, encoding: 'utf8' });
  78. if (markdown.status !== 0) {
  79. process.stderr.write(markdown.stderr || markdown.stdout || '');
  80. throw new Error('export-software-markdown 执行失败');
  81. }
  82. const markdownText = fs.readFileSync(markdownOutput, 'utf8');
  83. assert(markdownText.includes('# 软件端去重博主名单'), 'Markdown 标题必须为中文');
  84. assert(markdownText.includes('源表去重丢弃行数:3'), 'Markdown should clarify removed rows are source duplicates');
  85. assert(markdownText.includes('最终名单重复组数:0'), 'Markdown should expose final duplicate group count');
  86. assert(markdownText.includes('同 brief URL 重复组:0'), 'Markdown 必须输出重复审计摘要');
  87. console.log(JSON.stringify({
  88. ok: true,
  89. input,
  90. output,
  91. markdownOutput,
  92. duplicateCount: summary.duplicateCount,
  93. sourceDuplicateRemovedCount: summary.sourceDuplicateRemovedCount,
  94. finalDuplicateGroupCount: summary.finalDuplicateGroupCount,
  95. outputRows: summary.outputRows
  96. }, null, 2));
  97. function assert(condition, message) {
  98. if (!condition) throw new Error(message);
  99. }
  100. function csvCell(value) {
  101. const text = String(value ?? '');
  102. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  103. }
  104. function parseCsv(text) {
  105. const rows = [];
  106. let row = [];
  107. let cell = '';
  108. let quoted = false;
  109. for (let index = 0; index < text.length; index += 1) {
  110. const char = text[index];
  111. if (char === '\r') continue;
  112. if (char === '"' && quoted && text[index + 1] === '"') {
  113. cell += '"';
  114. index += 1;
  115. } else if (char === '"') {
  116. quoted = !quoted;
  117. } else if (char === ',' && !quoted) {
  118. row.push(cell);
  119. cell = '';
  120. } else if (char === '\n' && !quoted) {
  121. row.push(cell);
  122. rows.push(row);
  123. row = [];
  124. cell = '';
  125. } else {
  126. cell += char;
  127. }
  128. }
  129. if (cell || row.length) {
  130. row.push(cell);
  131. rows.push(row);
  132. }
  133. return rows;
  134. }