#!/usr/bin/env node const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report'); const root = path.resolve(__dirname, '..'); const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tihao-software-table-')); const input = path.join(fixtureDir, 'manual-review-sample.csv'); const output = path.join(fixtureDir, 'software-client-list.dedup.csv'); const expectedHeader = SOFTWARE_TABLE_HEADER.join(','); const rows = [ [ 'brief编号', '策略', '排名', '平台', '博主名称', '综合分', 'brief匹配分', '参考风格分', '证据加分', '证据风险扣分', '推荐理由', '风险提示', '主页链接', '人工复核标签' ], ['brief-a', 'result-first', '1', 'xiaohongshu', '账号A', '88', '90', '80', '1', '0', '标签覆盖 测评、测评、开箱,理由A', '风险A', 'http://m.xiaohongshu.com/user/profile/a?x=1', ''], ['brief-a', 'video-enhanced', '2', 'xhs', '账号A(备孕中)', '90', '91', '81', '2', '0', '标签覆盖 测评、开箱,理由A2', '风险A2', 'https://www.xiaohongshu.com/user/profile/a#note', ''], ['brief-a', 'result-first', '3', 'douyin', '账号B', '80', '82', '70', '0', '0', '理由B', '风险B', '', ''], ['brief-a', 'result-first-risk', '4', 'dy', '账号B', '79', '81', '69', '0', '0', '理由B2', '风险B2', '', ''], ['brief-b', 'result-first', '5', 'xiaohongshu', '账号C', '81', '83', '71', '0', '0', '理由C', '风险C', 'https://example.com/c', ''], ['brief-b', 'result-first', '6', 'xiaohongshu', '账号D', '79', '81', '69', '0', '0', '理由D', '风险D', 'https://example.com/d', ''], ['brief-b', 'result-first', '7', 'xiaohongshu', '账号A', '85', '86', '78', '1', '0', '跨 brief 重复账号', '风险A3', 'https://www.xiaohongshu.com/user/profile/a?brief=b', ''] ]; fs.writeFileSync(input, '\uFEFF' + rows.map(row => row.map(csvCell).join(',')).join('\n')); const result = spawnSync(process.execPath, [ path.join(root, 'scripts', 'export-software-table.js'), '--input', input, '--output', output ], { cwd: root, encoding: 'utf8' }); if (result.status !== 0) { process.stderr.write(result.stderr || result.stdout || ''); throw new Error('export-software-table 执行失败'); } const text = fs.readFileSync(output, 'utf8').replace(/^\uFEFF/, ''); const lines = text.trim().split(/\r?\n/); assert(lines[0] === expectedHeader, '软件端表头必须固定为中文表头'); assert(lines.length === 5, '重复博主必须被移除'); assert(lines[1].includes('video-enhanced'), '重复项应保留证据更完整且分数更高的记录'); assert(lines[1].includes(',小红书,'), '平台必须导出为中文展示名'); assert(lines[1].includes('标签覆盖 测评、开箱'), '推荐理由里的重复标签必须清理'); assert(lines.some(line => line.includes(',抖音,')), '抖音平台必须导出为中文展示名'); const body = parseCsv(text).slice(1); assert(body.filter(row => row[0] === 'brief-a').map(row => row[2]).join(',') === '1,2', 'brief-a 去重后排名必须连续'); assert(body.filter(row => row[0] === 'brief-b').map(row => row[2]).join(',') === '1,2', 'brief-b 去重后排名必须连续'); const summary = JSON.parse(fs.readFileSync(output.replace(/\.csv$/i, '.summary.json'), 'utf8')); assert(summary.duplicateCount === 3, '摘要应报告 3 条重复记录'); assert(summary.sourceDuplicateRemovedCount === 3, 'summary should expose source duplicate rows removed'); assert(summary.duplicateCountMeaning === 'source_duplicate_removed_count', 'summary should define legacy duplicateCount meaning'); assert(summary.finalDuplicateGroupCount === 0, 'summary should expose zero final duplicate groups'); assert(summary.rankContinuous === true, '摘要应报告排名连续'); assert(summary.duplicateAudit.sameBriefUrl.length === 0, '同 brief URL 重复组必须为 0'); assert(summary.duplicateAudit.sameBriefName.length === 0, '同 brief 名称重复组必须为 0'); assert(summary.duplicateAudit.globalUrl.length === 0, '全局 URL 重复组必须为 0'); assert(summary.duplicateAudit.globalNamePlatform.length === 0, '全局平台+名称重复组必须为 0'); const markdownOutput = output.replace(/\.csv$/i, '.md'); const markdown = spawnSync(process.execPath, [ path.join(root, 'scripts', 'export-software-markdown.js'), '--input', output, '--output', markdownOutput ], { cwd: root, encoding: 'utf8' }); if (markdown.status !== 0) { process.stderr.write(markdown.stderr || markdown.stdout || ''); throw new Error('export-software-markdown 执行失败'); } const markdownText = fs.readFileSync(markdownOutput, 'utf8'); assert(markdownText.includes('# 软件端去重博主名单'), 'Markdown 标题必须为中文'); assert(markdownText.includes('源表去重丢弃行数:3'), 'Markdown should clarify removed rows are source duplicates'); assert(markdownText.includes('最终名单重复组数:0'), 'Markdown should expose final duplicate group count'); assert(markdownText.includes('同 brief URL 重复组:0'), 'Markdown 必须输出重复审计摘要'); console.log(JSON.stringify({ ok: true, input, output, markdownOutput, duplicateCount: summary.duplicateCount, sourceDuplicateRemovedCount: summary.sourceDuplicateRemovedCount, finalDuplicateGroupCount: summary.finalDuplicateGroupCount, outputRows: summary.outputRows }, null, 2)); function assert(condition, message) { if (!condition) throw new Error(message); } function csvCell(value) { const text = String(value ?? ''); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function parseCsv(text) { const rows = []; let row = []; let cell = ''; let quoted = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (char === '\r') continue; if (char === '"' && quoted && text[index + 1] === '"') { cell += '"'; index += 1; } else if (char === '"') { quoted = !quoted; } else if (char === ',' && !quoted) { row.push(cell); cell = ''; } else if (char === '\n' && !quoted) { row.push(cell); rows.push(row); row = []; cell = ''; } else { cell += char; } } if (cell || row.length) { row.push(cell); rows.push(row); } return rows; }