voc-validation-analyze.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. // ==============================================================================
  2. // VOC 验证报告 · 评论分析助手(合并双数据源:旧 extended.json + 新 _merged.json)
  3. // 旧源:data/jiangzhong-liver-voc-extended.json(2456 条 XHS, 16 kw)
  4. // 新源:docs/jiangzhong/raw/_merged.json(4719 条 XHS+抖音+Amazon, 32 kw, H1-H8 tags)
  5. // 合计去重后 ≈ 6000+ 条
  6. // ==============================================================================
  7. const fs = require('fs');
  8. const path = require('path');
  9. const EXT_PATH = path.join('data', 'jiangzhong-liver-voc-extended.json');
  10. const MERGED_PATH = path.join('docs', 'jiangzhong', 'raw', '_merged.json');
  11. const FLAT_PATH = path.join('docs', 'jiangzhong', 'raw', 'comments-flat.jsonl');
  12. // 关键词 → 模块映射(用于 _merged.json 集成到 by_module 结构)
  13. const KW_TO_MODULE = {
  14. // positioning(藤茶 DMY 差异化)
  15. '肝纯片': 'positioning',
  16. '江中肝纯片': 'positioning',
  17. '藤茶': 'positioning',
  18. // scene(场景)
  19. '熬夜护肝': 'scene',
  20. '熬夜伤肝': 'scene',
  21. '脂肪肝': 'scene',
  22. '应酬解酒': 'scene',
  23. '送长辈保健品': 'scene',
  24. '酒局必备': 'scene',
  25. // competitor(竞品)
  26. '护肝片': 'competitor',
  27. '易善复': 'competitor',
  28. 'swisse护肝片': 'competitor',
  29. '葵花护肝片': 'competitor',
  30. '汤臣倍健护肝': 'competitor',
  31. '海王金樽': 'competitor',
  32. '片仔癀护肝': 'competitor',
  33. '解酒神器': 'competitor',
  34. '水飞蓟': 'competitor',
  35. '奶蓟草': 'competitor',
  36. // 海外对标(amazon)单独一个 module
  37. 'milk thistle': 'overseas',
  38. 'silymarin': 'overseas',
  39. 'liver support supplement': 'overseas',
  40. 'dihydromyricetin': 'overseas',
  41. 'liver detox': 'overseas',
  42. 'hangover pills': 'overseas',
  43. };
  44. // 归一化单条评论结构,兼容 XHS / Douyin / Amazon / 旧 extended
  45. function normalizeComment(raw, platform, keyword, moduleKey, noteId = '', hypotheses = []) {
  46. if (!raw) return null;
  47. let content = '', like_count = 0, user_nickname = '匿名', user_ip = '', time = null;
  48. if (platform === 'xhs') {
  49. content = String(raw.content || '').trim();
  50. like_count = raw.like_count || 0;
  51. user_nickname = raw.user?.nickname || raw.user?.user_id || '匿名';
  52. user_ip = raw.ip_location || '';
  53. time = raw.time || null;
  54. } else if (platform === 'douyin') {
  55. content = String(raw.text || raw.content || '').trim();
  56. like_count = raw.digg_count || raw.like_count || 0;
  57. user_nickname = raw.user?.nickname || raw.user_name || '匿名';
  58. user_ip = raw.ip_label || raw.ip_location || '';
  59. time = raw.create_time ? raw.create_time * 1000 : null;
  60. } else if (platform === 'amazon') {
  61. // Amazon review: Title + Content 合并
  62. const title = String(raw.Title || '').trim();
  63. const body = String(raw.Content || '').trim();
  64. content = title ? (title + (body ? (' — ' + body) : '')) : body;
  65. like_count = parseInt(String(raw.Helpful || '0').replace(/\D/g, '')) || 0;
  66. user_nickname = raw.ConsumerName || 'Amazon User';
  67. user_ip = raw.ReviewedCountry || '';
  68. time = raw.UpdateTime || raw.ReviewsDate || null;
  69. }
  70. if (!content) return null;
  71. return {
  72. content,
  73. like_count,
  74. time,
  75. user_nickname,
  76. user_ip,
  77. note_id: noteId,
  78. note_title: '',
  79. note_likes: 0,
  80. keyword,
  81. module: moduleKey,
  82. platform,
  83. hypotheses, // H1-H8 tags(新源独有)
  84. star: raw.Star || null, // Amazon 星级
  85. };
  86. }
  87. // 加载新源 _merged.json,转成 by_module 结构
  88. function loadMerged() {
  89. if (!fs.existsSync(MERGED_PATH)) return null;
  90. let merged;
  91. try { merged = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf-8')); } catch { return null; }
  92. const by_module = {}; // { moduleKey: { keyword: { notes, comments: {noteId: [...]}, _hypotheses, _platform } } }
  93. // XHS
  94. for (const kw of Object.keys(merged.xhs || {})) {
  95. const block = merged.xhs[kw];
  96. const moduleKey = KW_TO_MODULE[kw] || 'other';
  97. if (!by_module[moduleKey]) by_module[moduleKey] = {};
  98. const notes = block.top_notes || [];
  99. const commentsMap = {};
  100. for (const nid of Object.keys(block.comments_by_note_id || {})) {
  101. commentsMap[nid] = block.comments_by_note_id[nid] || [];
  102. }
  103. by_module[moduleKey][kw] = {
  104. notes,
  105. comments: commentsMap,
  106. _hypotheses: block.hypotheses || [],
  107. _platform: 'xhs',
  108. };
  109. }
  110. // Douyin
  111. for (const kw of Object.keys(merged.douyin || {})) {
  112. const block = merged.douyin[kw];
  113. // 抖音和 XHS 可能 keyword 重复(如"肝纯片"),合并进同一 module 但不同 key 避免冲突
  114. const moduleKey = KW_TO_MODULE[kw] || 'other';
  115. const dyKey = kw + ' (抖音)';
  116. if (!by_module[moduleKey]) by_module[moduleKey] = {};
  117. const commentsMap = {};
  118. for (const vid of Object.keys(block.comments_by_aweme_id || {})) {
  119. commentsMap[vid] = block.comments_by_aweme_id[vid] || [];
  120. }
  121. by_module[moduleKey][dyKey] = {
  122. notes: block.top_videos || [],
  123. comments: commentsMap,
  124. _hypotheses: block.hypotheses || [],
  125. _platform: 'douyin',
  126. };
  127. }
  128. // Amazon
  129. for (const kw of Object.keys(merged.amazon || {})) {
  130. const block = merged.amazon[kw];
  131. const moduleKey = KW_TO_MODULE[kw] || 'overseas';
  132. const amzKey = kw + ' (Amazon)';
  133. if (!by_module[moduleKey]) by_module[moduleKey] = {};
  134. const commentsMap = {};
  135. for (const asin of Object.keys(block.reviews_by_asin || {})) {
  136. const rev = block.reviews_by_asin[asin];
  137. const arr = Array.isArray(rev) ? rev : (rev?.Reviews || rev?.reviews || []);
  138. commentsMap[asin] = arr;
  139. }
  140. by_module[moduleKey][amzKey] = {
  141. notes: block.top_products || [],
  142. comments: commentsMap,
  143. _hypotheses: block.hypotheses || [],
  144. _platform: 'amazon',
  145. };
  146. }
  147. return { by_module, _source: 'merged', stats: merged.stats };
  148. }
  149. // 加载旧源 extended.json
  150. function loadExtendedOnly() {
  151. if (!fs.existsSync(EXT_PATH)) return null;
  152. try { return JSON.parse(fs.readFileSync(EXT_PATH, 'utf-8')); } catch { return null; }
  153. }
  154. // 主入口:合并两源
  155. function loadExtended() {
  156. const merged = loadMerged();
  157. const old = loadExtendedOnly();
  158. if (!merged && !old) return null;
  159. if (!merged) return old;
  160. if (!old) return merged;
  161. // 合并两源的 by_module(同模块同 keyword 时,新源优先但保留老源唯一 keyword)
  162. const combined = { by_module: {} };
  163. for (const src of [old, merged]) {
  164. if (!src.by_module) continue;
  165. for (const m of Object.keys(src.by_module)) {
  166. if (!combined.by_module[m]) combined.by_module[m] = {};
  167. Object.assign(combined.by_module[m], src.by_module[m]);
  168. }
  169. }
  170. combined._source = 'combined';
  171. combined.stats = merged.stats;
  172. return combined;
  173. }
  174. /** 返回某模块/关键词下的所有评论(扁平化),附 note 元信息。已按 commentId/content 去重 */
  175. function getComments(data, moduleKey, keyword = null) {
  176. const out = [];
  177. const seen = new Set();
  178. if (!data?.by_module?.[moduleKey]) return out;
  179. const mod = data.by_module[moduleKey];
  180. const keywords = keyword ? [keyword] : Object.keys(mod);
  181. for (const kw of keywords) {
  182. const kwData = mod[kw];
  183. if (!kwData?.comments) continue;
  184. // 检测 platform:新源 _merged 块携带 _platform,老源无标记默认 xhs
  185. const platform = kwData._platform || 'xhs';
  186. const hypotheses = kwData._hypotheses || [];
  187. const noteMap = new Map();
  188. (kwData.notes || []).forEach((n) => {
  189. const nid = n?.id || n?.aweme_id || n?.asin || n?.ItemAsin;
  190. if (nid) noteMap.set(nid, n);
  191. });
  192. for (const noteId of Object.keys(kwData.comments)) {
  193. const note = noteMap.get(noteId);
  194. for (const raw of kwData.comments[noteId]) {
  195. const n = normalizeComment(raw, platform, kw, moduleKey, noteId, hypotheses);
  196. if (!n) continue;
  197. // 去重键
  198. const cid = raw.id || raw.cid || raw.Asin || '';
  199. const dedupKey = cid ? `${platform}|${cid}` : `${platform}|${n.content}|${n.user_nickname}|${noteId}`;
  200. if (seen.has(dedupKey)) continue;
  201. seen.add(dedupKey);
  202. // 补充 note 元信息
  203. if (note) {
  204. n.note_title = note.title || note.desc?.slice(0, 30) || note.Title || '';
  205. n.note_likes = note.liked_count || note.digg_count || 0;
  206. }
  207. out.push(n);
  208. }
  209. }
  210. }
  211. return out;
  212. }
  213. /** 所有模块所有评论(全量) */
  214. function getAllComments(data) {
  215. const out = [];
  216. if (!data?.by_module) return out;
  217. for (const m of Object.keys(data.by_module)) {
  218. out.push(...getComments(data, m));
  219. }
  220. return out;
  221. }
  222. /** Top N 按 like_count 降序;可 filter */
  223. function getTopComments(data, moduleKey, keyword = null, n = 10, filterFn = null) {
  224. let comments = getComments(data, moduleKey, keyword);
  225. if (filterFn) comments = comments.filter(filterFn);
  226. return comments
  227. .filter((c) => c.content.length >= 5 && c.content.length <= 300) // 过滤过短/过长
  228. .sort((a, b) => b.like_count - a.like_count)
  229. .slice(0, n);
  230. }
  231. /** 基于关键词命中,抽取主题相关评论 */
  232. function findByKeywords(data, moduleKey, keyword, matchWords, n = 10, includeSelf = true) {
  233. const comments = getComments(data, moduleKey, keyword);
  234. const matches = comments.filter((c) => {
  235. const txt = c.content;
  236. return matchWords.some((w) => txt.includes(w));
  237. });
  238. return matches.sort((a, b) => b.like_count - a.like_count).slice(0, n);
  239. }
  240. /** 跨模块搜索:在全部评论中搜索含特定关键词的评论 */
  241. function searchAllComments(data, matchWords, n = 20) {
  242. const all = getAllComments(data);
  243. const matches = all.filter((c) => matchWords.some((w) => c.content.includes(w)));
  244. return matches
  245. .filter((c) => c.content.length >= 5 && c.content.length <= 400)
  246. .sort((a, b) => b.like_count - a.like_count)
  247. .slice(0, n);
  248. }
  249. /** 统计主题词频(在某模块内) */
  250. function themeFreq(data, moduleKey, themes) {
  251. const comments = getComments(data, moduleKey);
  252. const stats = {};
  253. for (const theme of Object.keys(themes)) {
  254. const words = themes[theme];
  255. let count = 0;
  256. let totalLikes = 0;
  257. const hits = [];
  258. for (const c of comments) {
  259. if (words.some((w) => c.content.includes(w))) {
  260. count++;
  261. totalLikes += c.like_count;
  262. hits.push(c);
  263. }
  264. }
  265. stats[theme] = {
  266. count,
  267. totalLikes,
  268. avgLikes: count ? Math.round(totalLikes / count) : 0,
  269. samples: hits.sort((a, b) => b.like_count - a.like_count).slice(0, 3),
  270. pct: comments.length ? Math.round((count / comments.length) * 100) : 0,
  271. };
  272. }
  273. return { totalComments: comments.length, themes: stats };
  274. }
  275. /** 情感分布(简单规则分类) */
  276. function sentimentSplit(data, moduleKey, keyword = null) {
  277. const comments = getComments(data, moduleKey, keyword);
  278. const positive = ['好', '有用', '有效', '推荐', '棒', '爱', '喜欢', '舒服', '舒坦', '真香', '绝', '神', '真的', '赞', '👍', '可以的', '不错'];
  279. const negative = ['难', '差', '无效', '没用', '骗', '智商税', '副作用', '别买', '踩雷', '拉', '垃圾', '假', '不好', '失望', '退', '后悔'];
  280. const doubt = ['吗', '吗?', '?', '怎么', '有没有', '求', '问一下', '会', '能', '真的假的', '到底'];
  281. const out = { positive: 0, negative: 0, neutral: 0, doubt: 0, samples: { positive: [], negative: [], doubt: [] } };
  282. for (const c of comments) {
  283. const t = c.content;
  284. const hasPos = positive.some((w) => t.includes(w));
  285. const hasNeg = negative.some((w) => t.includes(w));
  286. const hasDoubt = doubt.some((w) => t.includes(w));
  287. if (hasNeg) { out.negative++; out.samples.negative.push(c); }
  288. else if (hasDoubt) { out.doubt++; out.samples.doubt.push(c); }
  289. else if (hasPos) { out.positive++; out.samples.positive.push(c); }
  290. else out.neutral++;
  291. }
  292. out.total = comments.length;
  293. // 取 top 示例
  294. out.samples.positive = out.samples.positive.sort((a, b) => b.like_count - a.like_count).slice(0, 3);
  295. out.samples.negative = out.samples.negative.sort((a, b) => b.like_count - a.like_count).slice(0, 3);
  296. out.samples.doubt = out.samples.doubt.sort((a, b) => b.like_count - a.like_count).slice(0, 3);
  297. return out;
  298. }
  299. /** 元数据 · 全局统计 */
  300. function getMeta(data) {
  301. if (!data?.by_module) return { modules: 0, keywords: 0, notes: 0, comments: 0, platforms: {} };
  302. let kws = 0, notes = 0, comments = 0;
  303. const platforms = { xhs: 0, douyin: 0, amazon: 0 };
  304. for (const m of Object.keys(data.by_module)) {
  305. for (const kw of Object.keys(data.by_module[m])) {
  306. kws++;
  307. const r = data.by_module[m][kw];
  308. const plat = r._platform || 'xhs';
  309. notes += r.notes?.length || 0;
  310. for (const nid of Object.keys(r.comments || {})) {
  311. const count = r.comments[nid]?.length || 0;
  312. comments += count;
  313. platforms[plat] = (platforms[plat] || 0) + count;
  314. }
  315. }
  316. }
  317. return { modules: Object.keys(data.by_module).length, keywords: kws, notes, comments, platforms };
  318. }
  319. /** 基于 H1-H8 假设 tag 搜索评论(新源独有) */
  320. function getCommentsByHypothesis(data, hypothesis, n = 50) {
  321. const all = getAllComments(data);
  322. const matches = all.filter((c) => Array.isArray(c.hypotheses) && c.hypotheses.includes(hypothesis));
  323. return matches
  324. .filter((c) => c.content.length >= 5 && c.content.length <= 400)
  325. .sort((a, b) => b.like_count - a.like_count)
  326. .slice(0, n);
  327. }
  328. /** 按平台筛评论(xhs / douyin / amazon) */
  329. function getCommentsByPlatform(data, platform, n = 50) {
  330. const all = getAllComments(data);
  331. return all
  332. .filter((c) => c.platform === platform && c.content.length >= 5 && c.content.length <= 400)
  333. .sort((a, b) => b.like_count - a.like_count)
  334. .slice(0, n);
  335. }
  336. /** Amazon 特有:按星级筛评论(1-5 星) */
  337. function getAmazonByStar(data, targetStars = [1, 2], n = 20) {
  338. const all = getAllComments(data);
  339. return all
  340. .filter((c) => c.platform === 'amazon' && c.star)
  341. .filter((c) => {
  342. const s = parseInt(String(c.star).match(/\d/)?.[0] || '0');
  343. return targetStars.includes(s);
  344. })
  345. .sort((a, b) => b.like_count - a.like_count)
  346. .slice(0, n);
  347. }
  348. module.exports = {
  349. loadExtended,
  350. loadMerged,
  351. getComments,
  352. getAllComments,
  353. getTopComments,
  354. findByKeywords,
  355. searchAllComments,
  356. themeFreq,
  357. sentimentSplit,
  358. getMeta,
  359. getCommentsByHypothesis,
  360. getCommentsByPlatform,
  361. getAmazonByStar,
  362. };