| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- #!/usr/bin/env node
- const fs = require('fs');
- const path = require('path');
- const { SOFTWARE_TABLE_HEADER } = require('../mcp/src/features/tihao-sourcing/report');
- const HEADER = SOFTWARE_TABLE_HEADER;
- const PLATFORM_NAMES = {
- xiaohongshu: '小红书',
- xhs: '小红书',
- douyin: '抖音',
- dy: '抖音',
- tiktok: 'TikTok'
- };
- const DEDUPE_RULE = '软件端交付表按全局博主唯一去重:同一平台 + 规范化主页链接不重复;没有主页链接时使用同一平台 + 规范化博主名称;同分保留证据更完整、综合分更高的记录。';
- function main() {
- const outputsRoot = path.resolve(arg('--outputs') || arg('--output-root') || path.join(__dirname, '..', 'outputs'));
- if (process.argv.includes('--scan')) {
- scanManualReviewSamples(outputsRoot);
- return;
- }
- const input = arg('--input') || latestManualReviewSample(outputsRoot);
- const output = arg('--output') || path.join(path.dirname(input), 'software-client-list.dedup.csv');
- const rows = parseCsv(fs.readFileSync(input, 'utf8').replace(/^\uFEFF/, ''));
- if (rows.length < 1) throw new Error(`CSV 为空:${input}`);
- const sourceHeader = rows[0].map(stripBom);
- const sourceRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim()));
- const normalized = sourceRows.map(row => normalizeRow(sourceHeader, row));
- const { rows: deduped, duplicateCount, duplicateSamples } = dedupeRows(normalized);
- const ranked = rankWithinBrief(deduped);
- const duplicateAudit = auditDuplicates(ranked);
- const finalDuplicateGroupCount = countDuplicateGroups(duplicateAudit);
- fs.mkdirSync(path.dirname(output), { recursive: true });
- fs.writeFileSync(output, '\uFEFF' + toCsv([HEADER, ...ranked.map(row => HEADER.map(key => row[key] ?? ''))]));
- const summary = {
- generatedAt: new Date().toISOString(),
- input,
- output,
- sourceRows: sourceRows.length,
- outputRows: ranked.length,
- sourceDuplicateRemovedCount: duplicateCount,
- finalDuplicateGroupCount,
- duplicateCountMeaning: 'source_duplicate_removed_count',
- duplicateCount,
- duplicateSamples: duplicateSamples.slice(0, 50),
- duplicateAudit,
- header: HEADER,
- rankContinuous: ranksContinuousWithinBrief(ranked),
- dedupeRule: DEDUPE_RULE
- };
- const summaryPath = output.replace(/\.csv$/i, '.summary.json');
- fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
- console.log(JSON.stringify({ ...summary, summaryPath }, null, 2));
- }
- function scanManualReviewSamples(root) {
- const files = listFiles(root).filter(file => path.basename(file) === 'manual-review-sample.csv');
- const summaries = files.map(file => {
- const rows = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
- const sourceHeader = (rows[0] || []).map(stripBom);
- const sourceRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim()));
- const normalized = sourceRows.map(row => normalizeRow(sourceHeader, row));
- const { rows: deduped, duplicateCount, duplicateSamples } = dedupeRows(normalized);
- const ranked = rankWithinBrief(deduped);
- const duplicateAudit = auditDuplicates(ranked);
- const finalDuplicateGroupCount = countDuplicateGroups(duplicateAudit);
- return {
- file,
- sourceRows: sourceRows.length,
- outputRows: ranked.length,
- sourceDuplicateRemovedCount: duplicateCount,
- finalDuplicateGroupCount,
- duplicateCountMeaning: 'source_duplicate_removed_count',
- duplicateCount,
- duplicateSamples: duplicateSamples.slice(0, 10),
- duplicateAudit,
- rankContinuous: ranksContinuousWithinBrief(ranked),
- dedupeRule: DEDUPE_RULE,
- mtime: fs.statSync(file).mtime.toISOString()
- };
- });
- summaries.sort((a, b) => b.duplicateCount - a.duplicateCount || b.sourceRows - a.sourceRows);
- console.log(JSON.stringify(summaries.filter(item => item.duplicateCount > 0).slice(0, 50), null, 2));
- }
- function arg(name) {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] : '';
- }
- function latestManualReviewSample(root) {
- const files = listFiles(root).filter(file => path.basename(file) === 'manual-review-sample.csv');
- const candidates = files
- .filter(isRefreshCandidateSample)
- .map(file => ({
- file,
- sourceRows: manualReviewSampleRowCount(file),
- mtimeMs: fs.statSync(file).mtimeMs
- }))
- .filter(item => item.sourceRows > 0)
- .sort((a, b) => b.sourceRows - a.sourceRows || b.mtimeMs - a.mtimeMs);
- if (!candidates.length) throw new Error(`未在 ${root} 下找到非空的 overnight-quality manual-review-sample.csv`);
- return candidates[0].file;
- }
- function isRefreshCandidateSample(file) {
- const normalized = file.replace(/\\/g, '/').toLowerCase();
- const parent = path.basename(path.dirname(file));
- return /^overnight-quality-\d+$/.test(parent) && !normalized.includes('smoke');
- }
- function manualReviewSampleRowCount(file) {
- const rows = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
- return rows.slice(1).filter(row => row.some(cell => String(cell || '').trim())).length;
- }
- function listFiles(dir) {
- if (!fs.existsSync(dir)) return [];
- return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
- const full = path.join(dir, entry.name);
- return entry.isDirectory() ? listFiles(full) : [full];
- });
- }
- function normalizeRow(header, row) {
- const source = Object.fromEntries(header.map((key, index) => [key, row[index] || '']));
- const rank = number(pick(source, ['序号', '排名', 'rank']));
- const name = pick(source, ['账号名称', '博主名称', 'displayName', 'name']);
- return {
- 'brief编号': pick(source, ['brief编号', 'briefId']),
- '策略': pick(source, ['策略', 'variant']),
- '序号': rank,
- '排名': rank,
- '目标城市': pick(source, ['目标城市', 'targetCity', 'city']),
- '预算规则': pick(source, ['预算规则', 'budgetRule']),
- '命中价格类型': pick(source, ['命中价格类型', 'matchedPriceType']),
- '命中价格': pick(source, ['命中价格', 'matchedPrice']),
- '平台': platformName(pick(source, ['平台', 'platform'])),
- '账号名称': name,
- '博主名称': name,
- '账号ID': pick(source, ['账号ID', 'platformUserId', 'userId']),
- '主页链接': pick(source, ['主页链接', 'profileUrl']),
- '粉丝数': number(pick(source, ['粉丝数', 'fansCount'])),
- '图文报价': number(pick(source, ['图文报价', 'imagePrice', 'picturePrice'])),
- '视频报价': number(pick(source, ['视频报价', 'videoPrice'])),
- '最低报价': number(pick(source, ['最低报价', 'minPrice'])),
- '报价状态': pick(source, ['报价状态', 'quoteStatus']),
- '地区/定位': pick(source, ['地区/定位', 'location', 'city']),
- '内容标签': pick(source, ['内容标签', 'contentTags']),
- '人设标签': pick(source, ['人设标签', 'personaTags']),
- '推荐等级': pick(source, ['推荐等级', 'recommendStatus']),
- '综合分': number(pick(source, ['综合分', 'score'])),
- 'brief匹配分': number(pick(source, ['brief匹配分', 'briefFitScore'])),
- '参考风格分': number(pick(source, ['参考风格分', 'referenceStyleFitScore'])),
- '主页证据分': number(pick(source, ['主页证据分', 'recentContentFitScore', 'homepageEvidenceScore'])),
- '视觉质感分': number(pick(source, ['视觉质感分', 'visualQualityScore'])),
- '调性一致分': number(pick(source, ['调性一致分', 'toneConsistencyScore'])),
- '证据加分': number(pick(source, ['证据加分', 'evidenceBoost', 'evidenceScoreBoost'])),
- '证据风险扣分': number(pick(source, ['证据风险扣分', 'evidenceRiskPenalty'])),
- '推荐理由': cleanBusinessText(pick(source, ['推荐理由', 'reason'])),
- '风险提示': cleanBusinessText(pick(source, ['风险提示', 'riskNote'])),
- '蒲公英判断': pick(source, ['蒲公英判断', 'pugongyingJudgement']),
- '图文CPM': number(pick(source, ['图文CPM', 'imageCpm', 'pictureCpm'])),
- '视频CPM': number(pick(source, ['视频CPM', 'videoCpm'])),
- '图文CPE': number(pick(source, ['图文CPE', 'imageCpe', 'pictureCpe'])),
- '视频CPE': number(pick(source, ['视频CPE', 'videoCpe'])),
- '商单数': number(pick(source, ['商单数', 'commercialOrderCount'])),
- '近30天商单数': number(pick(source, ['近30天商单数', 'commercialOrderCount30d'])),
- '商单阅读中位数': number(pick(source, ['商单阅读中位数', 'commercialReadMedian'])),
- '商单互动中位数': number(pick(source, ['商单互动中位数', 'commercialInteractionMedian'])),
- '近30天商单曝光中位数': number(pick(source, ['近30天商单曝光中位数', 'commercialExposureMedian30d'])),
- '粉丝30日增长率': pick(source, ['粉丝30日增长率', 'fanGrowthRate30d']),
- '48h邀约回复率': pick(source, ['48h邀约回复率', 'inviteReplyRate48h']),
- '是否低活跃': pick(source, ['是否低活跃', 'lowActivity']),
- '数据来源': pick(source, ['数据来源', 'sourceProvider']),
- '人工复核标签': pick(source, ['人工复核标签', 'manualLabel']),
- '人工复核项': pick(source, ['人工复核项', 'manualReviewFields'])
- };
- }
- function dedupeRows(rows) {
- const best = new Map();
- const duplicateSamples = [];
- for (const row of rows) {
- const key = dedupeKey(row);
- const previous = best.get(key);
- if (previous) {
- const kept = priority(row) > priority(previous) ? row : previous;
- const dropped = kept === row ? previous : row;
- duplicateSamples.push({
- key,
- keptStrategy: kept['策略'],
- droppedStrategy: dropped['策略'],
- keptName: kept['博主名称'],
- droppedName: dropped['博主名称'],
- profileUrl: kept['主页链接'] || dropped['主页链接']
- });
- }
- if (!previous || priority(row) > priority(previous)) best.set(key, row);
- }
- const outputRows = [...best.values()].sort((a, b) =>
- String(a['brief编号']).localeCompare(String(b['brief编号']), 'zh-Hans-CN') ||
- Number(b['综合分']) - Number(a['综合分']) ||
- Number(b['brief匹配分']) - Number(a['brief匹配分']) ||
- String(a['博主名称']).localeCompare(String(b['博主名称']), 'zh-Hans-CN')
- );
- return { rows: outputRows, duplicateCount: duplicateSamples.length, duplicateSamples };
- }
- function rankWithinBrief(rows) {
- const counters = new Map();
- return rows.map(row => {
- const briefId = String(row['brief编号'] || '未命名brief');
- const next = (counters.get(briefId) || 0) + 1;
- counters.set(briefId, next);
- return { ...row, '序号': next, '排名': next };
- });
- }
- function ranksContinuousWithinBrief(rows) {
- const counters = new Map();
- for (const row of rows) {
- const briefId = String(row['brief编号'] || '未命名brief');
- const expected = (counters.get(briefId) || 0) + 1;
- if (Number(row['排名']) !== expected) return false;
- counters.set(briefId, expected);
- }
- return true;
- }
- function auditDuplicates(rows) {
- return {
- sameBriefUrl: duplicateGroups(rows, row => {
- const url = normalizeUrl(row['主页链接']);
- return url ? `${normalizeKey(row['brief编号'])}|${normalizeKey(row['平台'])}|${url}` : '';
- }),
- sameBriefName: duplicateGroups(rows, row => `${normalizeKey(row['brief编号'])}|${normalizeKey(row['平台'])}|${normalizeName(row['博主名称'])}`),
- globalUrl: duplicateGroups(rows, row => normalizeUrl(row['主页链接'])),
- globalNamePlatform: duplicateGroups(rows, row => `${normalizeKey(row['平台'])}|${normalizeName(row['博主名称'])}`)
- };
- }
- function countDuplicateGroups(duplicateAudit) {
- if (!duplicateAudit) return null;
- return Object.values(duplicateAudit).reduce((sum, groups) => sum + (Array.isArray(groups) ? groups.length : 0), 0);
- }
- function duplicateGroups(rows, keyFn) {
- const groups = new Map();
- for (const row of rows) {
- const key = keyFn(row);
- if (!key) continue;
- if (!groups.has(key)) groups.set(key, []);
- groups.get(key).push({
- brief编号: row['brief编号'],
- 平台: row['平台'],
- 排名: row['排名'],
- 博主名称: row['博主名称'],
- 主页链接: row['主页链接']
- });
- }
- return [...groups.entries()]
- .filter(([, values]) => values.length > 1)
- .map(([key, values]) => ({ key, count: values.length, rows: values }));
- }
- function dedupeKey(row) {
- const platform = normalizeKey(row['平台']);
- const url = normalizeUrl(row['主页链接']);
- const name = normalizeName(row['博主名称']);
- return url ? `${platform}|url:${url}` : `${platform}|name:${name}`;
- }
- function priority(row) {
- const evidenceCompleteness = [
- row['主页链接'],
- row['推荐理由'],
- row['风险提示'],
- Number(row['主页证据分']) > 0 ? 'homepage' : '',
- Number(row['视觉质感分']) > 0 ? 'visual' : '',
- Number(row['调性一致分']) > 0 ? 'tone' : ''
- ].filter(Boolean).length;
- return evidenceCompleteness * 1000000 +
- Number(row['综合分'] || 0) * 10000 +
- Number(row['brief匹配分'] || 0) * 100 +
- Number(row['参考风格分'] || 0);
- }
- function pick(source, keys) {
- for (const key of keys) {
- if (source[key] !== undefined && source[key] !== '') return source[key];
- }
- return '';
- }
- function number(value) {
- const numeric = Number(value);
- return Number.isFinite(numeric) ? numeric : 0;
- }
- function platformName(value) {
- const text = String(value || '').trim();
- const key = text.toLowerCase();
- return PLATFORM_NAMES[key] || text;
- }
- function cleanBusinessText(value) {
- return dedupeAdjacentText(
- String(value || '')
- .replace(/标签覆盖\s*([^,。;;]+)/g, (_, terms) => `标签覆盖 ${dedupeTerms(terms)}`)
- .trim()
- );
- }
- function dedupeTerms(value) {
- const seen = new Set();
- return String(value || '')
- .split(/[、,,/]/)
- .map(term => term.trim())
- .filter(Boolean)
- .filter(term => {
- const key = normalizeName(term);
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- })
- .join('、');
- }
- function dedupeAdjacentText(text) {
- const parts = String(text || '').split(/([,。;;])/);
- const output = [];
- let previousPhrase = '';
- for (let index = 0; index < parts.length; index += 2) {
- const phrase = (parts[index] || '').trim();
- const delimiter = parts[index + 1] || '';
- if (!phrase) continue;
- const key = normalizeName(phrase);
- if (key !== previousPhrase) {
- output.push(phrase + delimiter);
- previousPhrase = key;
- }
- }
- return output.join('').trim();
- }
- function stripBom(value) {
- return String(value || '').replace(/^\uFEFF/, '');
- }
- function normalizeKey(value) {
- return String(value || '').trim().toLowerCase();
- }
- function normalizeName(value) {
- return String(value || '')
- .trim()
- .toLowerCase()
- .replace(/\s+/g, '')
- .replace(/[((].*?[))]/g, '')
- .replace(/[^\p{L}\p{N}\u4e00-\u9fa5]/gu, '');
- }
- function normalizeUrl(value) {
- return String(value || '')
- .trim()
- .toLowerCase()
- .replace(/^http:\/\//, 'https://')
- .replace(/^https:\/\/m\.xiaohongshu\.com\//, 'https://www.xiaohongshu.com/')
- .replace(/^https:\/\/www\.iesdouyin\.com\//, 'https://www.douyin.com/')
- .replace(/[?#].*$/, '')
- .replace(/\/$/, '');
- }
- 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;
- }
- function toCsv(rows) {
- return rows.map(row => row.map(csvCell).join(',')).join('\n');
- }
- function csvCell(value) {
- const text = String(value ?? '');
- return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
- }
- main();
|