history-dataset-from-csv.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. function main() {
  5. const args = parseArgs(process.argv.slice(2));
  6. const input = args.input || args.csv || process.env.TIHAO_HISTORY_CSV || '';
  7. if (!input) throw new Error('Usage: node scripts/history-dataset-from-csv.js --input <history.csv> --output <history-dataset-dir>');
  8. const inputPath = path.resolve(input);
  9. const outputDir = path.resolve(args.output || process.env.TIHAO_HISTORY_DATASET_OUTPUT || path.join(path.dirname(inputPath), 'history-dataset'));
  10. const rows = readCsv(inputPath);
  11. const records = buildRecords(rows);
  12. fs.mkdirSync(outputDir, { recursive: true });
  13. const files = [];
  14. for (const record of records) {
  15. const file = path.join(outputDir, `${safeFileName(record.id)}.json`);
  16. fs.writeFileSync(file, JSON.stringify(record, null, 2), 'utf8');
  17. files.push(file);
  18. }
  19. const summary = {
  20. input: inputPath,
  21. outputDir,
  22. generatedAt: new Date().toISOString(),
  23. briefCount: records.length,
  24. creatorRows: rows.body.length,
  25. files
  26. };
  27. const summaryPath = path.join(outputDir, 'history-dataset-from-csv-summary.json');
  28. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  29. console.log(JSON.stringify({ ...summary, summaryPath }, null, 2));
  30. }
  31. function buildRecords(rows) {
  32. const groups = new Map();
  33. for (const row of rows.body) {
  34. const id = firstNonEmpty(row, ['brief编号', 'briefId', 'id', '项目编号']);
  35. if (!id) continue;
  36. const group = groups.get(id) || {
  37. id,
  38. name: firstNonEmpty(row, ['项目名称', 'brief名称', 'name']) || id,
  39. category: firstNonEmpty(row, ['类目', 'category', '品类']),
  40. briefText: firstNonEmpty(row, ['客户原始Brief', 'briefText', 'brief', 'originalBrief']),
  41. manualSupplementBaseline: numberOrNull(firstNonEmpty(row, ['历史人工补号量基线', 'manualSupplementBaseline', 'manualSupplementCount', 'manualAddedCount'])),
  42. referenceLinks: [],
  43. manualFinalList: [],
  44. customerFeedback: []
  45. };
  46. mergeReferenceLinks(group, firstNonEmpty(row, ['参考账号或视频', '参考链接', 'referenceLinks', 'referenceVideos', 'referenceAccounts']));
  47. const creatorName = firstNonEmpty(row, ['博主名称', 'creatorName', '达人名称', '账号名称']);
  48. const profileUrl = firstNonEmpty(row, ['主页链接', 'profileUrl', '账号链接']);
  49. const customerDecision = firstNonEmpty(row, ['客户选择', 'customerDecision', 'finalDecision', '客户最终结果']);
  50. const rejectReason = firstNonEmpty(row, ['拒绝原因', 'rejectReason', 'feedbackReason', '反馈原因']);
  51. const manualReviewLabel = firstNonEmpty(row, ['人工复核标签', 'manualReviewLabel', '复核标签']);
  52. if (creatorName || profileUrl) {
  53. const item = {
  54. platform: firstNonEmpty(row, ['平台', 'platform']),
  55. creatorName,
  56. profileUrl,
  57. manualReviewLabel,
  58. customerDecision
  59. };
  60. if (rejectReason) item.rejectReason = rejectReason;
  61. group.manualFinalList.push(item);
  62. if (customerDecision || rejectReason) {
  63. group.customerFeedback.push({
  64. creatorName,
  65. finalDecision: customerDecision,
  66. ...(rejectReason ? { rejectReason } : {})
  67. });
  68. }
  69. }
  70. groups.set(id, group);
  71. }
  72. return [...groups.values()].map(record => {
  73. if (record.manualSupplementBaseline === null) delete record.manualSupplementBaseline;
  74. record.referenceLinks = dedupeBy(record.referenceLinks, item => `${item.platform}|${item.url}`);
  75. record.manualFinalList = dedupeBy(record.manualFinalList, item => `${item.platform}|${item.profileUrl || item.creatorName}`);
  76. record.customerFeedback = dedupeBy(record.customerFeedback, item => `${item.creatorName}|${item.finalDecision}|${item.rejectReason || ''}`);
  77. return record;
  78. });
  79. }
  80. function mergeReferenceLinks(group, value) {
  81. const links = String(value || '')
  82. .split(/[;;\n]/)
  83. .map(item => item.trim())
  84. .filter(Boolean);
  85. for (const url of links) {
  86. group.referenceLinks.push({
  87. url,
  88. platform: inferPlatform(url),
  89. contentType: /video|note|aweme|douyin|tiktok/i.test(url) ? 'video' : 'account'
  90. });
  91. }
  92. }
  93. function inferPlatform(value) {
  94. const text = String(value || '').toLowerCase();
  95. if (text.includes('xiaohongshu') || text.includes('xhs')) return 'xiaohongshu';
  96. if (text.includes('douyin')) return 'douyin';
  97. if (text.includes('tiktok')) return 'tiktok';
  98. return '';
  99. }
  100. function readCsv(file) {
  101. const parsed = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  102. const header = (parsed[0] || []).map(item => String(item || '').trim());
  103. const body = parsed.slice(1)
  104. .filter(row => row.some(cell => String(cell || '').trim()))
  105. .map(row => Object.fromEntries(header.map((key, index) => [key, row[index] || ''])));
  106. return { header, body };
  107. }
  108. function parseCsv(text) {
  109. const rows = [];
  110. let row = [];
  111. let cell = '';
  112. let quoted = false;
  113. for (let index = 0; index < text.length; index += 1) {
  114. const char = text[index];
  115. if (char === '\r') continue;
  116. if (char === '"' && quoted && text[index + 1] === '"') {
  117. cell += '"';
  118. index += 1;
  119. } else if (char === '"') quoted = !quoted;
  120. else if (char === ',' && !quoted) {
  121. row.push(cell);
  122. cell = '';
  123. } else if (char === '\n' && !quoted) {
  124. row.push(cell);
  125. rows.push(row);
  126. row = [];
  127. cell = '';
  128. } else cell += char;
  129. }
  130. if (cell || row.length) {
  131. row.push(cell);
  132. rows.push(row);
  133. }
  134. return rows;
  135. }
  136. function firstNonEmpty(row, fields) {
  137. for (const field of fields) {
  138. const value = String(row[field] || '').trim();
  139. if (value) return value;
  140. }
  141. return '';
  142. }
  143. function numberOrNull(value) {
  144. if (value === '') return null;
  145. const numeric = Number(value);
  146. return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
  147. }
  148. function dedupeBy(items, keyFn) {
  149. const seen = new Set();
  150. return items.filter(item => {
  151. const key = keyFn(item);
  152. if (seen.has(key)) return false;
  153. seen.add(key);
  154. return true;
  155. });
  156. }
  157. function safeFileName(value) {
  158. return String(value || 'history-brief')
  159. .trim()
  160. .replace(/[<>:"/\\|?*\x00-\x1F]/g, '-')
  161. .replace(/\s+/g, '-')
  162. .slice(0, 80);
  163. }
  164. function parseArgs(argv) {
  165. const args = {};
  166. for (let index = 0; index < argv.length; index += 1) {
  167. const raw = argv[index];
  168. if (!raw.startsWith('--')) continue;
  169. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  170. const next = argv[index + 1];
  171. if (!next || next.startsWith('--')) args[key] = true;
  172. else {
  173. args[key] = next;
  174. index += 1;
  175. }
  176. }
  177. return args;
  178. }
  179. if (require.main === module) {
  180. try {
  181. main();
  182. } catch (error) {
  183. console.error(error && error.stack ? error.stack : String(error));
  184. process.exit(1);
  185. }
  186. }
  187. module.exports = {
  188. buildRecords,
  189. readCsv
  190. };