export-software-table.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report');
  5. const HEADER = SOFTWARE_TABLE_HEADER;
  6. const PLATFORM_NAMES = {
  7. xiaohongshu: '小红书',
  8. xhs: '小红书',
  9. douyin: '抖音',
  10. dy: '抖音',
  11. tiktok: 'TikTok'
  12. };
  13. const DEDUPE_RULE = '软件端交付表按全局博主唯一去重:同一平台 + 规范化主页链接不重复;没有主页链接时使用同一平台 + 规范化博主名称;同分保留证据更完整、综合分更高的记录。';
  14. function main() {
  15. const outputsRoot = path.resolve(arg('--outputs') || arg('--output-root') || path.join(__dirname, '..', 'outputs'));
  16. if (process.argv.includes('--scan')) {
  17. scanManualReviewSamples(outputsRoot);
  18. return;
  19. }
  20. const input = arg('--input') || latestManualReviewSample(outputsRoot);
  21. const output = arg('--output') || path.join(path.dirname(input), 'software-client-list.dedup.csv');
  22. const rows = parseCsv(fs.readFileSync(input, 'utf8').replace(/^\uFEFF/, ''));
  23. if (rows.length < 1) throw new Error(`CSV 为空:${input}`);
  24. const sourceHeader = rows[0].map(stripBom);
  25. const sourceRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim()));
  26. const normalized = sourceRows.map(row => normalizeRow(sourceHeader, row));
  27. const { rows: deduped, duplicateCount, duplicateSamples } = dedupeRows(normalized);
  28. const ranked = rankWithinBrief(deduped);
  29. const duplicateAudit = auditDuplicates(ranked);
  30. const finalDuplicateGroupCount = countDuplicateGroups(duplicateAudit);
  31. fs.mkdirSync(path.dirname(output), { recursive: true });
  32. fs.writeFileSync(output, '\uFEFF' + toCsv([HEADER, ...ranked.map(row => HEADER.map(key => row[key] ?? ''))]));
  33. const summary = {
  34. generatedAt: new Date().toISOString(),
  35. input,
  36. output,
  37. sourceRows: sourceRows.length,
  38. outputRows: ranked.length,
  39. sourceDuplicateRemovedCount: duplicateCount,
  40. finalDuplicateGroupCount,
  41. duplicateCountMeaning: 'source_duplicate_removed_count',
  42. duplicateCount,
  43. duplicateSamples: duplicateSamples.slice(0, 50),
  44. duplicateAudit,
  45. header: HEADER,
  46. rankContinuous: ranksContinuousWithinBrief(ranked),
  47. dedupeRule: DEDUPE_RULE
  48. };
  49. const summaryPath = output.replace(/\.csv$/i, '.summary.json');
  50. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
  51. console.log(JSON.stringify({ ...summary, summaryPath }, null, 2));
  52. }
  53. function scanManualReviewSamples(root) {
  54. const files = listFiles(root).filter(file => path.basename(file) === 'manual-review-sample.csv');
  55. const summaries = files.map(file => {
  56. const rows = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  57. const sourceHeader = (rows[0] || []).map(stripBom);
  58. const sourceRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim()));
  59. const normalized = sourceRows.map(row => normalizeRow(sourceHeader, row));
  60. const { rows: deduped, duplicateCount, duplicateSamples } = dedupeRows(normalized);
  61. const ranked = rankWithinBrief(deduped);
  62. const duplicateAudit = auditDuplicates(ranked);
  63. const finalDuplicateGroupCount = countDuplicateGroups(duplicateAudit);
  64. return {
  65. file,
  66. sourceRows: sourceRows.length,
  67. outputRows: ranked.length,
  68. sourceDuplicateRemovedCount: duplicateCount,
  69. finalDuplicateGroupCount,
  70. duplicateCountMeaning: 'source_duplicate_removed_count',
  71. duplicateCount,
  72. duplicateSamples: duplicateSamples.slice(0, 10),
  73. duplicateAudit,
  74. rankContinuous: ranksContinuousWithinBrief(ranked),
  75. dedupeRule: DEDUPE_RULE,
  76. mtime: fs.statSync(file).mtime.toISOString()
  77. };
  78. });
  79. summaries.sort((a, b) => b.duplicateCount - a.duplicateCount || b.sourceRows - a.sourceRows);
  80. console.log(JSON.stringify(summaries.filter(item => item.duplicateCount > 0).slice(0, 50), null, 2));
  81. }
  82. function arg(name) {
  83. const index = process.argv.indexOf(name);
  84. return index >= 0 ? process.argv[index + 1] : '';
  85. }
  86. function latestManualReviewSample(root) {
  87. const files = listFiles(root).filter(file => path.basename(file) === 'manual-review-sample.csv');
  88. const candidates = files
  89. .filter(isRefreshCandidateSample)
  90. .map(file => ({
  91. file,
  92. sourceRows: manualReviewSampleRowCount(file),
  93. mtimeMs: fs.statSync(file).mtimeMs
  94. }))
  95. .filter(item => item.sourceRows > 0)
  96. .sort((a, b) => b.sourceRows - a.sourceRows || b.mtimeMs - a.mtimeMs);
  97. if (!candidates.length) throw new Error(`未在 ${root} 下找到非空的 overnight-quality manual-review-sample.csv`);
  98. return candidates[0].file;
  99. }
  100. function isRefreshCandidateSample(file) {
  101. const normalized = file.replace(/\\/g, '/').toLowerCase();
  102. const parent = path.basename(path.dirname(file));
  103. return /^overnight-quality-\d+$/.test(parent) && !normalized.includes('smoke');
  104. }
  105. function manualReviewSampleRowCount(file) {
  106. const rows = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  107. return rows.slice(1).filter(row => row.some(cell => String(cell || '').trim())).length;
  108. }
  109. function listFiles(dir) {
  110. if (!fs.existsSync(dir)) return [];
  111. return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
  112. const full = path.join(dir, entry.name);
  113. return entry.isDirectory() ? listFiles(full) : [full];
  114. });
  115. }
  116. function normalizeRow(header, row) {
  117. const source = Object.fromEntries(header.map((key, index) => [key, row[index] || '']));
  118. const rank = number(pick(source, ['序号', '排名', 'rank']));
  119. const name = pick(source, ['账号名称', '博主名称', 'displayName', 'name']);
  120. return {
  121. 'brief编号': pick(source, ['brief编号', 'briefId']),
  122. '策略': pick(source, ['策略', 'variant']),
  123. '序号': rank,
  124. '排名': rank,
  125. '目标城市': pick(source, ['目标城市', 'targetCity', 'city']),
  126. '预算规则': pick(source, ['预算规则', 'budgetRule']),
  127. '命中价格类型': pick(source, ['命中价格类型', 'matchedPriceType']),
  128. '命中价格': pick(source, ['命中价格', 'matchedPrice']),
  129. '平台': platformName(pick(source, ['平台', 'platform'])),
  130. '账号名称': name,
  131. '博主名称': name,
  132. '账号ID': pick(source, ['账号ID', 'platformUserId', 'userId']),
  133. '主页链接': pick(source, ['主页链接', 'profileUrl']),
  134. '粉丝数': number(pick(source, ['粉丝数', 'fansCount'])),
  135. '图文报价': number(pick(source, ['图文报价', 'imagePrice', 'picturePrice'])),
  136. '视频报价': number(pick(source, ['视频报价', 'videoPrice'])),
  137. '最低报价': number(pick(source, ['最低报价', 'minPrice'])),
  138. '报价状态': pick(source, ['报价状态', 'quoteStatus']),
  139. '地区/定位': pick(source, ['地区/定位', 'location', 'city']),
  140. '内容标签': pick(source, ['内容标签', 'contentTags']),
  141. '人设标签': pick(source, ['人设标签', 'personaTags']),
  142. '推荐等级': pick(source, ['推荐等级', 'recommendStatus']),
  143. '综合分': number(pick(source, ['综合分', 'score'])),
  144. 'brief匹配分': number(pick(source, ['brief匹配分', 'briefFitScore'])),
  145. '参考风格分': number(pick(source, ['参考风格分', 'referenceStyleFitScore'])),
  146. '主页证据分': number(pick(source, ['主页证据分', 'recentContentFitScore', 'homepageEvidenceScore'])),
  147. '视觉质感分': number(pick(source, ['视觉质感分', 'visualQualityScore'])),
  148. '调性一致分': number(pick(source, ['调性一致分', 'toneConsistencyScore'])),
  149. '证据加分': number(pick(source, ['证据加分', 'evidenceBoost', 'evidenceScoreBoost'])),
  150. '证据风险扣分': number(pick(source, ['证据风险扣分', 'evidenceRiskPenalty'])),
  151. '推荐理由': cleanBusinessText(pick(source, ['推荐理由', 'reason'])),
  152. '风险提示': cleanBusinessText(pick(source, ['风险提示', 'riskNote'])),
  153. '蒲公英判断': pick(source, ['蒲公英判断', 'pugongyingJudgement']),
  154. '图文CPM': number(pick(source, ['图文CPM', 'imageCpm', 'pictureCpm'])),
  155. '视频CPM': number(pick(source, ['视频CPM', 'videoCpm'])),
  156. '图文CPE': number(pick(source, ['图文CPE', 'imageCpe', 'pictureCpe'])),
  157. '视频CPE': number(pick(source, ['视频CPE', 'videoCpe'])),
  158. '商单数': number(pick(source, ['商单数', 'commercialOrderCount'])),
  159. '近30天商单数': number(pick(source, ['近30天商单数', 'commercialOrderCount30d'])),
  160. '商单阅读中位数': number(pick(source, ['商单阅读中位数', 'commercialReadMedian'])),
  161. '商单互动中位数': number(pick(source, ['商单互动中位数', 'commercialInteractionMedian'])),
  162. '近30天商单曝光中位数': number(pick(source, ['近30天商单曝光中位数', 'commercialExposureMedian30d'])),
  163. '粉丝30日增长率': pick(source, ['粉丝30日增长率', 'fanGrowthRate30d']),
  164. '48h邀约回复率': pick(source, ['48h邀约回复率', 'inviteReplyRate48h']),
  165. '是否低活跃': pick(source, ['是否低活跃', 'lowActivity']),
  166. '数据来源': pick(source, ['数据来源', 'sourceProvider']),
  167. '人工复核标签': pick(source, ['人工复核标签', 'manualLabel']),
  168. '人工复核项': pick(source, ['人工复核项', 'manualReviewFields'])
  169. };
  170. }
  171. function dedupeRows(rows) {
  172. const best = new Map();
  173. const duplicateSamples = [];
  174. for (const row of rows) {
  175. const key = dedupeKey(row);
  176. const previous = best.get(key);
  177. if (previous) {
  178. const kept = priority(row) > priority(previous) ? row : previous;
  179. const dropped = kept === row ? previous : row;
  180. duplicateSamples.push({
  181. key,
  182. keptStrategy: kept['策略'],
  183. droppedStrategy: dropped['策略'],
  184. keptName: kept['博主名称'],
  185. droppedName: dropped['博主名称'],
  186. profileUrl: kept['主页链接'] || dropped['主页链接']
  187. });
  188. }
  189. if (!previous || priority(row) > priority(previous)) best.set(key, row);
  190. }
  191. const outputRows = [...best.values()].sort((a, b) =>
  192. String(a['brief编号']).localeCompare(String(b['brief编号']), 'zh-Hans-CN') ||
  193. Number(b['综合分']) - Number(a['综合分']) ||
  194. Number(b['brief匹配分']) - Number(a['brief匹配分']) ||
  195. String(a['博主名称']).localeCompare(String(b['博主名称']), 'zh-Hans-CN')
  196. );
  197. return { rows: outputRows, duplicateCount: duplicateSamples.length, duplicateSamples };
  198. }
  199. function rankWithinBrief(rows) {
  200. const counters = new Map();
  201. return rows.map(row => {
  202. const briefId = String(row['brief编号'] || '未命名brief');
  203. const next = (counters.get(briefId) || 0) + 1;
  204. counters.set(briefId, next);
  205. return { ...row, '序号': next, '排名': next };
  206. });
  207. }
  208. function ranksContinuousWithinBrief(rows) {
  209. const counters = new Map();
  210. for (const row of rows) {
  211. const briefId = String(row['brief编号'] || '未命名brief');
  212. const expected = (counters.get(briefId) || 0) + 1;
  213. if (Number(row['排名']) !== expected) return false;
  214. counters.set(briefId, expected);
  215. }
  216. return true;
  217. }
  218. function auditDuplicates(rows) {
  219. return {
  220. sameBriefUrl: duplicateGroups(rows, row => {
  221. const url = normalizeUrl(row['主页链接']);
  222. return url ? `${normalizeKey(row['brief编号'])}|${normalizeKey(row['平台'])}|${url}` : '';
  223. }),
  224. sameBriefName: duplicateGroups(rows, row => `${normalizeKey(row['brief编号'])}|${normalizeKey(row['平台'])}|${normalizeName(row['博主名称'])}`),
  225. globalUrl: duplicateGroups(rows, row => normalizeUrl(row['主页链接'])),
  226. globalNamePlatform: duplicateGroups(rows, row => `${normalizeKey(row['平台'])}|${normalizeName(row['博主名称'])}`)
  227. };
  228. }
  229. function countDuplicateGroups(duplicateAudit) {
  230. if (!duplicateAudit) return null;
  231. return Object.values(duplicateAudit).reduce((sum, groups) => sum + (Array.isArray(groups) ? groups.length : 0), 0);
  232. }
  233. function duplicateGroups(rows, keyFn) {
  234. const groups = new Map();
  235. for (const row of rows) {
  236. const key = keyFn(row);
  237. if (!key) continue;
  238. if (!groups.has(key)) groups.set(key, []);
  239. groups.get(key).push({
  240. brief编号: row['brief编号'],
  241. 平台: row['平台'],
  242. 排名: row['排名'],
  243. 博主名称: row['博主名称'],
  244. 主页链接: row['主页链接']
  245. });
  246. }
  247. return [...groups.entries()]
  248. .filter(([, values]) => values.length > 1)
  249. .map(([key, values]) => ({ key, count: values.length, rows: values }));
  250. }
  251. function dedupeKey(row) {
  252. const platform = normalizeKey(row['平台']);
  253. const url = normalizeUrl(row['主页链接']);
  254. const name = normalizeName(row['博主名称']);
  255. return url ? `${platform}|url:${url}` : `${platform}|name:${name}`;
  256. }
  257. function priority(row) {
  258. const evidenceCompleteness = [
  259. row['主页链接'],
  260. row['推荐理由'],
  261. row['风险提示'],
  262. Number(row['主页证据分']) > 0 ? 'homepage' : '',
  263. Number(row['视觉质感分']) > 0 ? 'visual' : '',
  264. Number(row['调性一致分']) > 0 ? 'tone' : ''
  265. ].filter(Boolean).length;
  266. return evidenceCompleteness * 1000000 +
  267. Number(row['综合分'] || 0) * 10000 +
  268. Number(row['brief匹配分'] || 0) * 100 +
  269. Number(row['参考风格分'] || 0);
  270. }
  271. function pick(source, keys) {
  272. for (const key of keys) {
  273. if (source[key] !== undefined && source[key] !== '') return source[key];
  274. }
  275. return '';
  276. }
  277. function number(value) {
  278. const numeric = Number(value);
  279. return Number.isFinite(numeric) ? numeric : 0;
  280. }
  281. function platformName(value) {
  282. const text = String(value || '').trim();
  283. const key = text.toLowerCase();
  284. return PLATFORM_NAMES[key] || text;
  285. }
  286. function cleanBusinessText(value) {
  287. return dedupeAdjacentText(
  288. String(value || '')
  289. .replace(/标签覆盖\s*([^,。;;]+)/g, (_, terms) => `标签覆盖 ${dedupeTerms(terms)}`)
  290. .trim()
  291. );
  292. }
  293. function dedupeTerms(value) {
  294. const seen = new Set();
  295. return String(value || '')
  296. .split(/[、,,/]/)
  297. .map(term => term.trim())
  298. .filter(Boolean)
  299. .filter(term => {
  300. const key = normalizeName(term);
  301. if (seen.has(key)) return false;
  302. seen.add(key);
  303. return true;
  304. })
  305. .join('、');
  306. }
  307. function dedupeAdjacentText(text) {
  308. const parts = String(text || '').split(/([,。;;])/);
  309. const output = [];
  310. let previousPhrase = '';
  311. for (let index = 0; index < parts.length; index += 2) {
  312. const phrase = (parts[index] || '').trim();
  313. const delimiter = parts[index + 1] || '';
  314. if (!phrase) continue;
  315. const key = normalizeName(phrase);
  316. if (key !== previousPhrase) {
  317. output.push(phrase + delimiter);
  318. previousPhrase = key;
  319. }
  320. }
  321. return output.join('').trim();
  322. }
  323. function stripBom(value) {
  324. return String(value || '').replace(/^\uFEFF/, '');
  325. }
  326. function normalizeKey(value) {
  327. return String(value || '').trim().toLowerCase();
  328. }
  329. function normalizeName(value) {
  330. return String(value || '')
  331. .trim()
  332. .toLowerCase()
  333. .replace(/\s+/g, '')
  334. .replace(/[((].*?[))]/g, '')
  335. .replace(/[^\p{L}\p{N}\u4e00-\u9fa5]/gu, '');
  336. }
  337. function normalizeUrl(value) {
  338. return String(value || '')
  339. .trim()
  340. .toLowerCase()
  341. .replace(/^http:\/\//, 'https://')
  342. .replace(/^https:\/\/m\.xiaohongshu\.com\//, 'https://www.xiaohongshu.com/')
  343. .replace(/^https:\/\/www\.iesdouyin\.com\//, 'https://www.douyin.com/')
  344. .replace(/[?#].*$/, '')
  345. .replace(/\/$/, '');
  346. }
  347. function parseCsv(text) {
  348. const rows = [];
  349. let row = [];
  350. let cell = '';
  351. let quoted = false;
  352. for (let index = 0; index < text.length; index += 1) {
  353. const char = text[index];
  354. if (char === '\r') continue;
  355. if (char === '"' && quoted && text[index + 1] === '"') {
  356. cell += '"';
  357. index += 1;
  358. } else if (char === '"') {
  359. quoted = !quoted;
  360. } else if (char === ',' && !quoted) {
  361. row.push(cell);
  362. cell = '';
  363. } else if (char === '\n' && !quoted) {
  364. row.push(cell);
  365. rows.push(row);
  366. row = [];
  367. cell = '';
  368. } else {
  369. cell += char;
  370. }
  371. }
  372. if (cell || row.length) {
  373. row.push(cell);
  374. rows.push(row);
  375. }
  376. return rows;
  377. }
  378. function toCsv(rows) {
  379. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  380. }
  381. function csvCell(value) {
  382. const text = String(value ?? '');
  383. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  384. }
  385. main();