voc-data-normalizer.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. const fs = require('fs');
  2. const path = require('path');
  3. function parseArgs(argv) {
  4. const args = {};
  5. for (let i = 0; i < argv.length; i++) {
  6. const token = argv[i];
  7. if (!token.startsWith('--')) continue;
  8. const eq = token.indexOf('=');
  9. if (eq >= 0) {
  10. args[token.slice(2, eq)] = token.slice(eq + 1);
  11. } else {
  12. const key = token.slice(2);
  13. const next = argv[i + 1];
  14. if (next && !next.startsWith('--')) {
  15. args[key] = next;
  16. i++;
  17. } else {
  18. args[key] = true;
  19. }
  20. }
  21. }
  22. return args;
  23. }
  24. function usage() {
  25. return [
  26. 'Usage:',
  27. ' node scripts/tools/voc-data-normalizer.js --input <raw-dir> [--output <out-dir>] [--project <name>] [--category <name>]',
  28. '',
  29. 'Outputs:',
  30. ' _merged.json',
  31. ' comments-flat.jsonl',
  32. ' normalizer-audit.json'
  33. ].join('\n');
  34. }
  35. function readJson(filePath) {
  36. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  37. }
  38. function readJsonl(filePath) {
  39. return fs.readFileSync(filePath, 'utf8')
  40. .split(/\r?\n/)
  41. .map(line => line.trim())
  42. .filter(Boolean)
  43. .map(line => JSON.parse(line));
  44. }
  45. function ensureDir(dirPath) {
  46. fs.mkdirSync(dirPath, { recursive: true });
  47. }
  48. function listDataFiles(inputDir) {
  49. return fs.readdirSync(inputDir)
  50. .filter(name => name.endsWith('.json') || name.endsWith('.jsonl'))
  51. .filter(name => !['_merged.json', 'normalizer-audit.json'].includes(name))
  52. .filter(name => !['comments-flat.jsonl', 'comments-flat.normalized.jsonl'].includes(name))
  53. .map(name => path.join(inputDir, name));
  54. }
  55. function asArray(value) {
  56. if (!value) return [];
  57. return Array.isArray(value) ? value : [value];
  58. }
  59. function firstDefined(...values) {
  60. for (const value of values) {
  61. if (value !== undefined && value !== null && value !== '') return value;
  62. }
  63. return undefined;
  64. }
  65. function toNumber(value) {
  66. if (value === undefined || value === null || value === '') return undefined;
  67. const number = Number(value);
  68. return Number.isFinite(number) ? number : undefined;
  69. }
  70. function normalizeTime(value) {
  71. if (value === undefined || value === null || value === '') return undefined;
  72. if (typeof value === 'string') return value;
  73. if (typeof value !== 'number') return String(value);
  74. const ms = value > 100000000000 ? value : value * 1000;
  75. return new Date(ms).toISOString();
  76. }
  77. function cleanText(value) {
  78. if (value === undefined || value === null) return '';
  79. return String(value).replace(/\s+/g, ' ').trim();
  80. }
  81. function uniq(values) {
  82. return Array.from(new Set(values.filter(value => value !== undefined && value !== null && value !== '')));
  83. }
  84. function makeId(prefix, value, fallbackIndex) {
  85. const base = firstDefined(value, `${prefix}_${fallbackIndex}`);
  86. return `${prefix}_${String(base).replace(/[^a-zA-Z0-9_-]+/g, '_')}`;
  87. }
  88. function sourceUrl(record) {
  89. return firstDefined(record.url, record.sourceUrl, record.link, record.share_url, record.ReviewsLink, record.detailUrl);
  90. }
  91. function authorName(record) {
  92. if (record && typeof record === 'object' && !Array.isArray(record)) {
  93. const nested = firstDefined(
  94. record.nickname,
  95. record.name,
  96. record.user?.nickname,
  97. record.user_info?.nickname,
  98. record.author?.nickname,
  99. record.author?.name,
  100. record.ConsumerName,
  101. record.shop_name
  102. );
  103. if (nested !== undefined) return nested;
  104. }
  105. return firstDefined(
  106. record.author,
  107. record.nickname,
  108. record.user?.nickname,
  109. record.user_info?.nickname,
  110. record.user?.unique_id,
  111. record.ConsumerName,
  112. record.shop_name
  113. );
  114. }
  115. function xhsNoteToItem(note, source, index) {
  116. const metrics = note.metrics || {};
  117. return {
  118. id: makeId('xhs_note', firstDefined(note.noteId, note.id, note.note_id), index),
  119. platform: 'xiaohongshu',
  120. sourceType: firstDefined(note.type, 'note'),
  121. keyword: firstDefined(note.keyword, source.keyword, source.metadata?.keyword),
  122. batch: firstDefined(note.batch, source.batch, 'P0'),
  123. hypothesisTags: asArray(firstDefined(note.hypothesisTags, source.hypothesisTags)),
  124. author: authorName(note),
  125. title: note.title,
  126. content: cleanText(firstDefined(note.desc, note.content, note.text, note.title)),
  127. likeCount: toNumber(firstDefined(metrics.likeCount, note.likeCount, note.liked_count)),
  128. commentCount: toNumber(firstDefined(metrics.commentCount, note.commentCount, note.comments_count)),
  129. publishTime: normalizeTime(firstDefined(note.publishedAt, note.publishTime, note.timestamp, note.create_time)),
  130. collectedAt: firstDefined(note.collectedAt, source.metadata?.generatedAt, new Date().toISOString()),
  131. url: sourceUrl(note),
  132. productId: firstDefined(note.noteId, note.id, note.note_id),
  133. brand: note.brand,
  134. competitor: note.competitor,
  135. sentiment: note.sentiment,
  136. raw: note
  137. };
  138. }
  139. function xhsCommentToVoc(comment, source, index) {
  140. const user = comment.user || comment.user_info || {};
  141. return {
  142. id: makeId('xhs_comment', firstDefined(comment.commentId, comment.id, comment.comment_id), index),
  143. platform: 'xiaohongshu',
  144. sourceType: 'comment',
  145. keyword: firstDefined(comment.keyword, source.keyword, source.metadata?.keyword),
  146. batch: firstDefined(comment.batch, source.batch, 'P0'),
  147. hypothesisTags: asArray(firstDefined(comment.hypothesisTags, source.hypothesisTags)),
  148. author: firstDefined(comment.author, user.nickname, user.name),
  149. text: cleanText(firstDefined(comment.content, comment.text, comment.desc)),
  150. likeCount: toNumber(firstDefined(comment.likeCount, comment.like_count, comment.digg_count)),
  151. replyCount: toNumber(firstDefined(comment.subCommentCount, comment.sub_comment_count, comment.reply_comment_total)),
  152. rating: toNumber(comment.rating),
  153. publishTime: normalizeTime(firstDefined(comment.createdAt, comment.create_time, comment.publishTime)),
  154. collectedAt: firstDefined(comment.collectedAt, source.metadata?.generatedAt, new Date().toISOString()),
  155. url: sourceUrl(comment),
  156. parentId: firstDefined(comment.noteId, comment.note_id, comment.parentId),
  157. commentId: firstDefined(comment.commentId, comment.id, comment.comment_id),
  158. ipLocation: firstDefined(comment.ipLocation, comment.ip_location, comment.ip_label),
  159. sentiment: comment.sentiment,
  160. theme: comment.theme,
  161. raw: comment
  162. };
  163. }
  164. function douyinVideoToItem(video, source, index) {
  165. const stats = video.statistics || video.stats || {};
  166. return {
  167. id: makeId('douyin_video', firstDefined(video.aweme_id, video.videoId, video.id), index),
  168. platform: 'douyin',
  169. sourceType: 'video',
  170. keyword: firstDefined(video.keyword, source.keyword, source.metadata?.keyword),
  171. batch: firstDefined(video.batch, source.batch, 'P0'),
  172. hypothesisTags: asArray(firstDefined(video.hypothesisTags, source.hypothesisTags)),
  173. author: authorName(video.author || video),
  174. title: firstDefined(video.title, video.desc),
  175. content: cleanText(firstDefined(video.desc, video.title, video.text)),
  176. likeCount: toNumber(firstDefined(stats.digg_count, video.digg_count, video.likeCount)),
  177. commentCount: toNumber(firstDefined(stats.comment_count, video.comment_count, video.commentCount)),
  178. publishTime: normalizeTime(firstDefined(video.create_time, video.publishedAt, video.publishTime)),
  179. collectedAt: firstDefined(video.collectedAt, source.metadata?.generatedAt, new Date().toISOString()),
  180. url: sourceUrl(video),
  181. productId: firstDefined(video.aweme_id, video.videoId, video.id),
  182. brand: video.brand,
  183. competitor: video.competitor,
  184. sentiment: video.sentiment,
  185. raw: video
  186. };
  187. }
  188. function douyinCommentToVoc(comment, source, index) {
  189. return {
  190. id: makeId('douyin_comment', firstDefined(comment.cid, comment.commentId, comment.id), index),
  191. platform: 'douyin',
  192. sourceType: 'comment',
  193. keyword: firstDefined(comment.keyword, source.keyword, source.metadata?.keyword),
  194. batch: firstDefined(comment.batch, source.batch, 'P0'),
  195. hypothesisTags: asArray(firstDefined(comment.hypothesisTags, source.hypothesisTags)),
  196. author: authorName(comment),
  197. text: cleanText(firstDefined(comment.text, comment.content, comment.desc)),
  198. likeCount: toNumber(firstDefined(comment.digg_count, comment.likeCount, comment.like_count)),
  199. replyCount: toNumber(firstDefined(comment.reply_comment_total, comment.replyCount, comment.sub_comment_count)),
  200. rating: undefined,
  201. publishTime: normalizeTime(firstDefined(comment.create_time, comment.createdAt, comment.publishTime)),
  202. collectedAt: firstDefined(comment.collectedAt, source.metadata?.generatedAt, new Date().toISOString()),
  203. url: sourceUrl(comment),
  204. parentId: firstDefined(comment.aweme_id, comment.videoId, comment.parentId),
  205. commentId: firstDefined(comment.cid, comment.commentId, comment.id),
  206. ipLocation: firstDefined(comment.ip_label, comment.ipLocation, comment.ip_location),
  207. sentiment: comment.sentiment,
  208. theme: comment.theme,
  209. raw: comment
  210. };
  211. }
  212. function amazonReviewToVoc(review, source, index) {
  213. return {
  214. id: makeId('amazon_review', firstDefined(review.ReviewId, review.reviewId, review.ReviewsLink, review.Id), index),
  215. platform: 'amazon',
  216. sourceType: 'review',
  217. keyword: firstDefined(review.keyword, source.keyword, source.ASIN, review.Asin, review.ASIN),
  218. batch: firstDefined(review.batch, source.batch, 'P0'),
  219. hypothesisTags: asArray(firstDefined(review.hypothesisTags, source.hypothesisTags)),
  220. author: firstDefined(review.ConsumerName, review.author, review.nickname),
  221. text: cleanText([review.Title, review.Content, review.content, review.text].filter(Boolean).join(' ')),
  222. likeCount: toNumber(firstDefined(review.Helpful, review.likeCount)),
  223. replyCount: undefined,
  224. rating: toNumber(firstDefined(review.Star, review.rating)),
  225. publishTime: normalizeTime(firstDefined(review.ReviewsDate, review.publishTime, review.date)),
  226. collectedAt: firstDefined(review.collectedAt, source.metadata?.generatedAt, review.UpdateTime, new Date().toISOString()),
  227. url: sourceUrl(review),
  228. parentId: firstDefined(review.Asin, review.ASIN, source.ASIN),
  229. commentId: firstDefined(review.ReviewId, review.reviewId, review.ReviewsLink),
  230. ipLocation: firstDefined(review.ReviewedCountry, review.ipLocation),
  231. sentiment: review.sentiment,
  232. theme: review.theme,
  233. raw: review
  234. };
  235. }
  236. function amazonProductToItem(product, source, index) {
  237. return {
  238. id: makeId('amazon_product', firstDefined(product.Asin, product.ASIN, product.asin, product.productId), index),
  239. platform: 'amazon',
  240. sourceType: 'product',
  241. keyword: firstDefined(product.keyword, source.keyword, source.ASIN, product.Asin, product.ASIN),
  242. batch: firstDefined(product.batch, source.batch, 'P0'),
  243. hypothesisTags: asArray(firstDefined(product.hypothesisTags, source.hypothesisTags)),
  244. author: firstDefined(product.Brand, product.brand, product.shop_name),
  245. title: firstDefined(product.Title, product.title, product.productTitle),
  246. content: cleanText(firstDefined(product.Description, product.description, product.Title, product.title)),
  247. likeCount: undefined,
  248. commentCount: toNumber(firstDefined(product.ReviewsCount, product.reviewCount, product.commentCount)),
  249. rating: toNumber(firstDefined(product.Rating, product.rating)),
  250. publishTime: normalizeTime(firstDefined(product.UpdateTime, product.publishTime)),
  251. collectedAt: firstDefined(product.collectedAt, source.metadata?.generatedAt, new Date().toISOString()),
  252. url: sourceUrl(product),
  253. productId: firstDefined(product.Asin, product.ASIN, product.asin, product.productId),
  254. brand: firstDefined(product.Brand, product.brand),
  255. competitor: product.competitor,
  256. sentiment: product.sentiment,
  257. raw: product
  258. };
  259. }
  260. function collectXiaohongshu(data, filePath) {
  261. const items = [];
  262. const comments = [];
  263. if (Array.isArray(data.notes)) {
  264. data.notes.forEach((note, index) => items.push(xhsNoteToItem(note, data, index + 1)));
  265. }
  266. if (Array.isArray(data.commentsByNote)) {
  267. data.commentsByNote.forEach(group => {
  268. asArray(group.comments).forEach((comment, index) => {
  269. comments.push(xhsCommentToVoc({ ...comment, noteId: firstDefined(comment.noteId, group.noteId), keyword: firstDefined(comment.keyword, group.keyword) }, data, comments.length + index + 1));
  270. });
  271. });
  272. }
  273. if (Array.isArray(data.comments)) {
  274. data.comments.forEach((comment, index) => comments.push(xhsCommentToVoc(comment, data, index + 1)));
  275. }
  276. return { items, comments, source: sourceRecord(filePath, 'xiaohongshu', items.length, comments.length) };
  277. }
  278. function collectDouyin(data, filePath) {
  279. const items = [];
  280. const comments = [];
  281. const videos = asArray(firstDefined(data.videos, data.aweme_list, data.data?.aweme_list, data.data?.videos));
  282. videos.forEach((video, index) => items.push(douyinVideoToItem(video, data, index + 1)));
  283. const rawComments = asArray(firstDefined(data.comments, data.data?.comments));
  284. rawComments.forEach((comment, index) => comments.push(douyinCommentToVoc(comment, data, index + 1)));
  285. return { items, comments, source: sourceRecord(filePath, 'douyin', items.length, comments.length) };
  286. }
  287. function collectAmazon(data, filePath) {
  288. const items = [];
  289. const comments = [];
  290. const products = asArray(firstDefined(data.Products, data.products, data.Items, data.items));
  291. products.forEach((product, index) => items.push(amazonProductToItem(product, data, index + 1)));
  292. const reviews = asArray(firstDefined(data.Reviews, data.reviews, data.data?.Reviews, data.data?.reviews));
  293. reviews.forEach((review, index) => comments.push(amazonReviewToVoc(review, data, index + 1)));
  294. return { items, comments, source: sourceRecord(filePath, 'amazon', items.length, comments.length) };
  295. }
  296. function collectJsonl(records, filePath) {
  297. const items = [];
  298. const comments = [];
  299. records.forEach((record, index) => {
  300. const platform = String(firstDefined(record.platform, '')).toLowerCase();
  301. if (platform.includes('xiaohongshu') || platform === 'xhs') comments.push(xhsCommentToVoc(record, {}, index + 1));
  302. else if (platform.includes('douyin')) comments.push(douyinCommentToVoc(record, {}, index + 1));
  303. else if (platform.includes('amazon')) comments.push(amazonReviewToVoc(record, {}, index + 1));
  304. else comments.push(genericVoc(record, index + 1));
  305. });
  306. return { items, comments, source: sourceRecord(filePath, 'jsonl', items.length, comments.length) };
  307. }
  308. function genericVoc(record, index) {
  309. return {
  310. id: makeId('voc', firstDefined(record.id, record.commentId, record.reviewId), index),
  311. platform: firstDefined(record.platform, 'unknown'),
  312. sourceType: firstDefined(record.sourceType, 'comment'),
  313. keyword: record.keyword,
  314. batch: firstDefined(record.batch, 'P0'),
  315. hypothesisTags: asArray(record.hypothesisTags),
  316. author: authorName(record),
  317. text: cleanText(firstDefined(record.text, record.content, record.comment)),
  318. likeCount: toNumber(firstDefined(record.likeCount, record.like_count, record.digg_count, record.Helpful)),
  319. replyCount: toNumber(firstDefined(record.replyCount, record.subCommentCount, record.reply_comment_total)),
  320. rating: toNumber(firstDefined(record.rating, record.Star)),
  321. publishTime: normalizeTime(firstDefined(record.publishTime, record.createdAt, record.create_time, record.ReviewsDate)),
  322. collectedAt: firstDefined(record.collectedAt, new Date().toISOString()),
  323. url: sourceUrl(record),
  324. parentId: firstDefined(record.parentId, record.noteId, record.videoId, record.Asin, record.ASIN),
  325. commentId: firstDefined(record.commentId, record.id, record.reviewId, record.ReviewId),
  326. ipLocation: firstDefined(record.ipLocation, record.ip_location, record.ip_label, record.ReviewedCountry),
  327. sentiment: record.sentiment,
  328. theme: record.theme,
  329. raw: record
  330. };
  331. }
  332. function sourceRecord(filePath, platform, itemCount, commentCount) {
  333. return {
  334. file: path.basename(filePath),
  335. platform,
  336. itemCount,
  337. commentCount
  338. };
  339. }
  340. function inferCollector(data, filePath) {
  341. const name = path.basename(filePath).toLowerCase();
  342. const platform = String(firstDefined(data.metadata?.platform, data.platform, '')).toLowerCase();
  343. if (name.endsWith('.jsonl')) return collectJsonl(data, filePath);
  344. if (platform.includes('xiaohongshu') || platform === 'xhs' || name.includes('xhs') || name.includes('xiaohongshu') || Array.isArray(data.notes) || Array.isArray(data.commentsByNote)) return collectXiaohongshu(data, filePath);
  345. if (platform.includes('douyin') || name.includes('douyin') || data.data?.comments || data.data?.aweme_list) return collectDouyin(data, filePath);
  346. if (platform.includes('amazon') || name.includes('amazon') || Array.isArray(data.Reviews) || Array.isArray(data.reviews) || data.ASIN || data.TotalCount !== undefined) return collectAmazon(data, filePath);
  347. return { items: [], comments: [], source: sourceRecord(filePath, 'unknown', 0, 0) };
  348. }
  349. function dedupe(records, keyFn) {
  350. const seen = new Map();
  351. const duplicates = [];
  352. const output = [];
  353. records.forEach(record => {
  354. const key = keyFn(record);
  355. if (seen.has(key)) {
  356. duplicates.push({ key, keptId: seen.get(key).id, duplicateId: record.id });
  357. return;
  358. }
  359. seen.set(key, record);
  360. output.push(record);
  361. });
  362. return { output, duplicates };
  363. }
  364. function missingRequired(record, fields) {
  365. return fields.filter(field => {
  366. const value = record[field];
  367. if (Array.isArray(value)) return value.length === 0;
  368. return value === undefined || value === null || value === '';
  369. });
  370. }
  371. function main() {
  372. const args = parseArgs(process.argv.slice(2));
  373. if (args.help || !args.input) {
  374. console.log(usage());
  375. process.exit(args.help ? 0 : 1);
  376. }
  377. const inputDir = path.resolve(args.input);
  378. const outputDir = path.resolve(args.output || inputDir);
  379. if (!fs.existsSync(inputDir) || !fs.statSync(inputDir).isDirectory()) {
  380. throw new Error(`input directory not found: ${inputDir}`);
  381. }
  382. ensureDir(outputDir);
  383. const files = listDataFiles(inputDir);
  384. const sources = [];
  385. const items = [];
  386. const comments = [];
  387. const errors = [];
  388. files.forEach(filePath => {
  389. try {
  390. const data = filePath.endsWith('.jsonl') ? readJsonl(filePath) : readJson(filePath);
  391. const collected = inferCollector(data, filePath);
  392. sources.push(collected.source);
  393. items.push(...collected.items);
  394. comments.push(...collected.comments);
  395. } catch (error) {
  396. errors.push({ file: path.basename(filePath), message: error.message });
  397. }
  398. });
  399. const itemDedupe = dedupe(items.filter(item => cleanText(item.content)), item => `${item.platform}|${item.id}|${cleanText(item.content).slice(0, 120)}`);
  400. const commentDedupe = dedupe(comments.filter(comment => cleanText(comment.text)), comment => `${comment.platform}|${firstDefined(comment.commentId, comment.id)}|${cleanText(comment.text).slice(0, 120)}`);
  401. const normalizedItems = itemDedupe.output;
  402. const normalizedComments = commentDedupe.output;
  403. const missing = {
  404. items: normalizedItems.map(item => ({ id: item.id, missing: missingRequired(item, ['id', 'platform', 'sourceType', 'keyword', 'batch', 'hypothesisTags', 'content', 'collectedAt']) })).filter(row => row.missing.length),
  405. comments: normalizedComments.map(comment => ({ id: comment.id, missing: missingRequired(comment, ['id', 'platform', 'sourceType', 'keyword', 'batch', 'hypothesisTags', 'text', 'collectedAt']) })).filter(row => row.missing.length)
  406. };
  407. const platforms = uniq([...normalizedItems.map(item => item.platform), ...normalizedComments.map(comment => comment.platform)]);
  408. const keywords = uniq([...normalizedItems.map(item => item.keyword), ...normalizedComments.map(comment => comment.keyword)]);
  409. const batches = uniq([...normalizedItems.map(item => item.batch), ...normalizedComments.map(comment => comment.batch)]);
  410. const hypothesisTags = uniq([...normalizedItems.flatMap(item => item.hypothesisTags), ...normalizedComments.flatMap(comment => comment.hypothesisTags)]);
  411. const audit = {
  412. generatedAt: new Date().toISOString(),
  413. inputDir,
  414. outputDir,
  415. files: files.map(file => path.basename(file)),
  416. sources,
  417. rawItemCount: items.length,
  418. rawVocCount: comments.length,
  419. validItemCount: normalizedItems.length,
  420. validVocCount: normalizedComments.length,
  421. duplicateItemCount: itemDedupe.duplicates.length,
  422. duplicateVocCount: commentDedupe.duplicates.length,
  423. emptyItemDroppedCount: items.length - items.filter(item => cleanText(item.content)).length,
  424. emptyVocDroppedCount: comments.length - comments.filter(comment => cleanText(comment.text)).length,
  425. missingRequired: missing,
  426. errors,
  427. dedupeRule: 'platform + id/commentId + first 120 normalized text characters'
  428. };
  429. const merged = {
  430. metadata: {
  431. project: firstDefined(args.project, path.basename(inputDir)),
  432. category: firstDefined(args.category, ''),
  433. generatedAt: audit.generatedAt,
  434. platforms,
  435. keywords,
  436. batches,
  437. hypothesisTags,
  438. rawSampleCount: items.length + comments.length,
  439. rawItemCount: items.length,
  440. rawVocCount: comments.length,
  441. itemCount: normalizedItems.length,
  442. validVocCount: normalizedComments.length,
  443. dedupeRule: audit.dedupeRule
  444. },
  445. sources,
  446. items: normalizedItems,
  447. comments: normalizedComments,
  448. audit: {
  449. duplicateItemCount: audit.duplicateItemCount,
  450. duplicateVocCount: audit.duplicateVocCount,
  451. emptyItemDroppedCount: audit.emptyItemDroppedCount,
  452. emptyVocDroppedCount: audit.emptyVocDroppedCount,
  453. missingRequiredCount: missing.items.length + missing.comments.length,
  454. errorCount: errors.length
  455. }
  456. };
  457. fs.writeFileSync(path.join(outputDir, '_merged.json'), JSON.stringify(merged, null, 2) + '\n', 'utf8');
  458. fs.writeFileSync(path.join(outputDir, 'comments-flat.jsonl'), normalizedComments.map(comment => JSON.stringify(comment)).join('\n') + (normalizedComments.length ? '\n' : ''), 'utf8');
  459. fs.writeFileSync(path.join(outputDir, 'normalizer-audit.json'), JSON.stringify(audit, null, 2) + '\n', 'utf8');
  460. console.log(JSON.stringify({
  461. outputDir,
  462. files: ['_merged.json', 'comments-flat.jsonl', 'normalizer-audit.json'],
  463. itemCount: normalizedItems.length,
  464. validVocCount: normalizedComments.length,
  465. duplicateItemCount: audit.duplicateItemCount,
  466. duplicateVocCount: audit.duplicateVocCount,
  467. errors: errors.length
  468. }, null, 2));
  469. }
  470. if (require.main === module) {
  471. try {
  472. main();
  473. } catch (error) {
  474. console.error(error.message);
  475. process.exit(1);
  476. }
  477. }
  478. module.exports = {
  479. parseArgs,
  480. inferCollector,
  481. collectXiaohongshu,
  482. collectDouyin,
  483. collectAmazon,
  484. dedupe
  485. };