liver-analyze.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. // ==============================================================================
  2. // 江中肝纯片 · VOC 数据合并 + 分析模块(参考 houguyin-analyze.js 架构)
  3. // ==============================================================================
  4. // 数据源优先级:
  5. // 1. docs/jiangzhong/raw/_merged.json ← 真实多平台采集 · 4719 评论
  6. // (XHS 18kw × 80notes × 1515cmts + Douyin 8kw × 16videos × 627cmts + Amazon 6kw × 30prods × 2577reviews)
  7. // 2. SKELETON ← 兜底骨架
  8. //
  9. // 与 houguyin-analyze.js 的区别:
  10. // - 肝纯片已有真实数据 ⇒ 不需要 seed/pattern-curated 回退
  11. // - items[] 在 loadMerged 时动态构造(从 _merged.json 的 xhs/douyin/amazon 三级嵌套结构展平)
  12. // - 字段标准化:likes/nickname/hypothesis 统一(源数据有 like/user/hypotheses)
  13. // - 8 假设为肝纯片专属(H1 DMY认知 / H2 海王金樽 / H3 副作用 / H4 场景 / H5 竞品 / H6 送礼 / H7 礼盒 / H8 Amazon)
  14. const fs = require('fs');
  15. const path = require('path');
  16. const ROOT = path.resolve(__dirname, '..', '..');
  17. const RAW_DIR = path.join(ROOT, 'docs', 'jiangzhong', 'raw');
  18. const MERGED_PATH = path.join(RAW_DIR, '_merged.json');
  19. // ------------------------------------------------------------
  20. // 8 条核心假设定义(对应 docs/jiangzhong/2.数据采集矩阵.md)
  21. // ------------------------------------------------------------
  22. const HYPOTHESES = {
  23. H1: { title: '藤茶/DMY 认知空白', desc: 'DMY 总黄酮在用户心智中几乎零认知,"藤茶"被识别为茶饮非护肝' },
  24. H2: { title: '海王金樽为什么没成', desc: '硬广洗脑 + 体感弱 + 复购差,解酒饮料赛道负面教材' },
  25. H3: { title: '护肝片副作用恐惧', desc: '易善复/水飞蓟高频"胃不舒服/拉肚子"吐槽,用户想吃又怕吃' },
  26. H4: { title: '脂肪肝+熬夜声量', desc: '脂肪肝+熬夜伤肝是年轻化核心话题池,未被主流护肝品牌占据' },
  27. H5: { title: '五大竞品心智地图', desc: 'Swisse高端吸收差/易善复医嘱/葵花药味/片仔癀贵/海王金樽广告' },
  28. H6: { title: '送长辈护肝空白', desc: '父母孝心品类被钙片/阿胶/黄芪精占据,护肝几乎空白' },
  29. H7: { title: '礼盒心智 · "有面子"', desc: '用户明确期待"送得出手/有面子"的高端包装,反感药盒感' },
  30. H8: { title: 'Amazon 国际对标', desc: 'milk thistle 差评集中"no effect/hidden additives/allergy",国际品类痛点' },
  31. };
  32. // 章节 → 假设映射(每章主要消费哪些假设的证据)
  33. const CHAPTER_HYPOTHESIS_MAP = {
  34. challenge: ['H1', 'H2', 'H8'], // 诘问:认知空白 + 失败案例 + 国际差评
  35. dmy: ['H1'], // DMY 归因学
  36. market: ['H5', 'H4'], // 市场全景:竞品 + 场景人群
  37. kano: ['H3', 'H6', 'H7'], // KANO×JTBD:副作用反向 + 兴奋需求
  38. scene: ['H4', 'H2'], // 场景地图:四大场景 + 解酒子场景
  39. competitor: ['H5', 'H2'], // 竞品三轴:精细对标 + 警示
  40. opportunity: ['H1', 'H6', 'H7'], // 新机会:DMY教育 + 送礼 + 礼盒
  41. blueprint: ['H4', 'H6'], // 4P:场景人群 + 送礼
  42. };
  43. // ------------------------------------------------------------
  44. // 平台标签(复用 houguyin-components 的结构)
  45. // ------------------------------------------------------------
  46. const PLATFORM_LABELS = {
  47. xhs: { name: '小红书', color: '#FF2442', short: '红' },
  48. douyin: { name: '抖音', color: '#1A1A1A', short: '抖' },
  49. amazon: { name: 'Amazon', color: '#FF9900', short: 'Az' },
  50. taobao: { name: '淘宝', color: '#FF5000', short: '淘' },
  51. jd: { name: '京东', color: '#E1251B', short: '京' },
  52. tmall: { name: '天猫', color: '#FF0036', short: '猫' },
  53. unknown: { name: '其他', color: '#888', short: '—' },
  54. };
  55. // ------------------------------------------------------------
  56. // 关键词 → 产品名归一化
  57. // ------------------------------------------------------------
  58. const KEYWORD_TO_PRODUCT = {
  59. '肝纯片': '江中肝纯片',
  60. '江中肝纯片': '江中肝纯片',
  61. '护肝片': '通用护肝片',
  62. 'swisse护肝片':'Swisse 护肝片',
  63. '易善复': '易善复',
  64. '葵花护肝片': '葵花护肝片',
  65. '汤臣倍健护肝':'汤臣倍健护肝',
  66. '片仔癀护肝': '片仔癀护肝',
  67. '海王金樽': '海王金樽',
  68. '解酒神器': '解酒品类',
  69. '应酬解酒': '应酬解酒场景',
  70. '酒局必备': '酒局必备场景',
  71. '熬夜护肝': '熬夜护肝场景',
  72. '熬夜伤肝': '熬夜伤肝场景',
  73. '脂肪肝': '脂肪肝人群',
  74. '水飞蓟': '水飞蓟成分',
  75. '奶蓟草': '奶蓟草成分',
  76. '藤茶': '藤茶 (本品核心)',
  77. '送长辈保健品':'送长辈品类',
  78. 'milk thistle': 'Milk Thistle (国际)',
  79. 'silymarin': 'Silymarin (国际)',
  80. 'liver support supplement': 'Liver Support (国际)',
  81. 'liver detox': 'Liver Detox (国际)',
  82. 'dihydromyricetin': 'DMY 二氢杨梅素 (国际)',
  83. 'hangover pills': '解酒片 (国际)',
  84. };
  85. // ------------------------------------------------------------
  86. // 情绪启发式判定(基于 rating/content 关键词)
  87. // ------------------------------------------------------------
  88. const NEG_KEYS = ['智商税', '没用', '没效', '没有效果', '骗人', '坑', '差评', '退货', '拉肚子', '胃不舒服', '副作用', '难吃', '难喝', '催吐', '进ICU', 'no effect', 'waste', 'terrible', 'horrible', 'garbage', 'scam', 'disappointed'];
  89. const POS_KEYS = ['有效', '真香', '好用', '推荐', '回购', '神器', '绝了', '有感觉', 'amazing', 'works', 'effective', 'great', 'love', 'recommend'];
  90. const CONFLICTED_KEYS = ['有点怕', '不敢', '纠结', '矛盾', '不知道', '真的有效吗', '是不是智商税', '感觉不太', '不确定'];
  91. function inferSentiment(item) {
  92. if (typeof item.rating === 'number') {
  93. if (item.rating <= 2) return 'negative';
  94. if (item.rating >= 4) return 'positive';
  95. return 'neutral';
  96. }
  97. const c = String(item.content || '').toLowerCase();
  98. for (const k of NEG_KEYS) if (c.includes(k.toLowerCase())) return 'negative';
  99. for (const k of POS_KEYS) if (c.includes(k.toLowerCase())) return 'positive';
  100. for (const k of CONFLICTED_KEYS) if (c.includes(k.toLowerCase())) return 'conflicted';
  101. return 'neutral';
  102. }
  103. // ------------------------------------------------------------
  104. // 标签启发式抽取(粗粒度,用于后续渲染 tag cloud)
  105. // ------------------------------------------------------------
  106. const TAG_PATTERNS = [
  107. { tag: '智商税', keys: ['智商税', '骗人', 'waste of money', 'scam'] },
  108. { tag: '没有效果', keys: ['没用', '没效', '没有效果', 'no effect', 'didn.?t work'] },
  109. { tag: '副作用', keys: ['副作用', '拉肚子', '胃不舒服', '过敏', 'allergy', 'side effect'] },
  110. { tag: '推荐/真香', keys: ['真香', '神器', '推荐', 'amazing', 'highly recommend'] },
  111. { tag: '送长辈', keys: ['送长辈', '送父母', '送爸爸', '孝心', '给爸妈', '给我爸'] },
  112. { tag: '礼盒包装', keys: ['礼盒', '包装好看', '有面子', '送得出手'] },
  113. { tag: '熬夜', keys: ['熬夜', '通宵', '晚睡'] },
  114. { tag: '应酬解酒', keys: ['应酬', '酒局', '喝酒', '解酒', '不上头'] },
  115. { tag: '脂肪肝', keys: ['脂肪肝', '转氨酶', '肝功'] },
  116. { tag: '价格敏感', keys: ['贵', '太贵', '便宜', '价格', 'expensive'] },
  117. { tag: '医生推荐', keys: ['医生', '大夫', '医嘱', '开的'] },
  118. { tag: '怀疑', keys: ['是不是', '真的有效', '靠谱吗', '智商税吗'] },
  119. { tag: '成分查询', keys: ['成分', '配方', 'ingredient', '水飞蓟', '藤茶', 'DMY'] },
  120. { tag: '品牌认知', keys: ['Swisse', '易善复', '葵花', '片仔癀', '海王金樽', '汤臣倍健', '江中'] },
  121. { tag: '国际好评', keys: ['lifesaver', 'best', 'game changer'] },
  122. { tag: '长期服用焦虑', keys: ['长期吃', '一直吃', 'long term'] },
  123. ];
  124. function inferTags(item) {
  125. const c = String(item.content || '') + ' ' + String(item.title || '');
  126. const lo = c.toLowerCase();
  127. const out = new Set();
  128. for (const p of TAG_PATTERNS) {
  129. for (const k of p.keys) {
  130. const rx = new RegExp(k.toLowerCase());
  131. if (rx.test(lo)) { out.add(p.tag); break; }
  132. }
  133. }
  134. return [...out];
  135. }
  136. // ------------------------------------------------------------
  137. // 数据归一化:从 _merged.json 的平台嵌套结构展平为标准 items[]
  138. // ------------------------------------------------------------
  139. function flattenMerged(merged) {
  140. const items = [];
  141. // ---- XHS: merged.xhs[keyword].comments_by_note_id[noteId][] + top_notes[] ----
  142. // 采集时分页重叠导致同一 cid 多次出现,按 cid 去重
  143. for (const [kw, kwData] of Object.entries(merged.xhs || {})) {
  144. const notes = kwData.top_notes || [];
  145. const noteMap = new Map(notes.map((n) => [n.id, n]));
  146. for (const [noteId, cmts] of Object.entries(kwData.comments_by_note_id || {})) {
  147. const note = noteMap.get(noteId) || {};
  148. const seenCids = new Set();
  149. for (const c of cmts) {
  150. const cid = c.id || `${(c.content || '').slice(0, 30)}|${c.like_count || 0}`;
  151. if (seenCids.has(cid)) continue;
  152. seenCids.add(cid);
  153. const nick = c.user?.nickname || note.user?.nickname || '小红书用户';
  154. items.push({
  155. platform: 'xhs',
  156. keyword: kw,
  157. product: KEYWORD_TO_PRODUCT[kw] || kw,
  158. type: 'comment',
  159. id: `xhs_${noteId}_${c.id || Math.random().toString(36).slice(2, 8)}`,
  160. nickname: nick,
  161. ip: '', // XHS 评论字段无 IP
  162. content: c.content || '',
  163. likes: c.like_count || 0,
  164. subComments: c.sub_comment_count || 0,
  165. noteId,
  166. noteTitle: note.title || '',
  167. noteLiked: note.liked_count || 0,
  168. hypothesis: kwData.hypotheses || [],
  169. source: 'real-collected',
  170. });
  171. }
  172. }
  173. }
  174. // ---- Douyin: merged.douyin[keyword].comments_by_aweme_id[awemeId][] ----
  175. // 同样按 cid 去重
  176. for (const [kw, kwData] of Object.entries(merged.douyin || {})) {
  177. const videos = kwData.top_videos || [];
  178. const videoMap = new Map(videos.map((v) => [v.aweme_id, v]));
  179. for (const [awemeId, cmts] of Object.entries(kwData.comments_by_aweme_id || {})) {
  180. const video = videoMap.get(awemeId) || {};
  181. const seenCids = new Set();
  182. for (const c of cmts) {
  183. const cid = c.cid || `${(c.text || '').slice(0, 30)}|${c.digg_count || 0}`;
  184. if (seenCids.has(cid)) continue;
  185. seenCids.add(cid);
  186. const nick = c.user?.nickname || '抖音用户';
  187. items.push({
  188. platform: 'douyin',
  189. keyword: kw,
  190. product: KEYWORD_TO_PRODUCT[kw] || kw,
  191. type: 'comment',
  192. id: `dy_${awemeId}_${c.cid || Math.random().toString(36).slice(2, 8)}`,
  193. nickname: nick,
  194. ip: c.ip_label || '',
  195. content: c.text || '',
  196. likes: c.digg_count || 0,
  197. replyTotal: c.reply_comment_total || 0,
  198. awemeId,
  199. videoDesc: (video.desc || '').slice(0, 80),
  200. videoAuthor: video.author?.nickname || '',
  201. videoDiggs: video.statistics?.digg_count || 0,
  202. hypothesis: kwData.hypotheses || [],
  203. source: 'real-collected',
  204. });
  205. }
  206. }
  207. }
  208. // ---- Amazon: merged.amazon[keyword].reviews_by_asin[asin][] ----
  209. // Amazon review 同样做 ReviewsLink + Content 组合去重
  210. for (const [kw, kwData] of Object.entries(merged.amazon || {})) {
  211. const prods = kwData.top_products || [];
  212. const prodMap = new Map(prods.map((p) => [p.Asin, p]));
  213. for (const [asin, reviews] of Object.entries(kwData.reviews_by_asin || {})) {
  214. const prod = prodMap.get(asin) || {};
  215. const arr = Array.isArray(reviews) ? reviews : (
  216. reviews?.Reviews || reviews?.reviews ||
  217. (typeof reviews === 'object' ? Object.values(reviews).filter((v) => v && typeof v === 'object' && (v.Content || v.Title)) : [])
  218. );
  219. const seenKeys = new Set();
  220. for (const r of arr) {
  221. const linkId = (r.ReviewsLink || '').split('/').pop();
  222. const dedupKey = linkId || `${r.ConsumerName || ''}|${(r.Title || '').slice(0, 40)}|${r.Star || 0}|${r.Helpful || 0}`;
  223. if (seenKeys.has(dedupKey)) continue;
  224. seenKeys.add(dedupKey);
  225. items.push({
  226. platform: 'amazon',
  227. keyword: kw,
  228. product: KEYWORD_TO_PRODUCT[kw] || kw,
  229. type: 'review',
  230. id: `amz_${asin}_${linkId || Math.random().toString(36).slice(2, 8)}`,
  231. nickname: r.ConsumerName || 'Amazon User',
  232. ip: '',
  233. content: r.Content || '',
  234. title: r.Title || '',
  235. rating: r.Star || null,
  236. verified: r.IsVP ? 1 : 0,
  237. likes: r.Helpful || 0,
  238. date: r.ReviewsDate || '',
  239. asin: r.Asin || asin,
  240. parentAsin: asin,
  241. productTitle: (prod.Title || '').slice(0, 80),
  242. productBrand: prod.Brand || '',
  243. productPrice: prod.Price || '',
  244. variant: r.AsinProperty || '',
  245. hypothesis: kwData.hypotheses || [],
  246. source: 'real-collected',
  247. });
  248. }
  249. }
  250. }
  251. // 情绪 + 标签后处理
  252. for (const it of items) {
  253. it.sentiment = inferSentiment(it);
  254. it.tags = inferTags(it);
  255. }
  256. return items;
  257. }
  258. // ------------------------------------------------------------
  259. // 数据加载(带回退骨架)
  260. // ------------------------------------------------------------
  261. const SKELETON = {
  262. meta: {
  263. collectedAt: '待采集',
  264. platforms: {},
  265. hypotheses: {},
  266. products: {},
  267. stage: 'skeleton',
  268. sourceNote: '暂无采集数据',
  269. },
  270. items: [],
  271. raw: null,
  272. };
  273. function loadMerged() {
  274. if (fs.existsSync(MERGED_PATH)) {
  275. try {
  276. const raw = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8'));
  277. const items = flattenMerged(raw);
  278. return {
  279. meta: {
  280. sourceTier: 'real-collected',
  281. stage: 'batch-real',
  282. collectedAt: raw.collected_at || new Date().toISOString().slice(0, 10),
  283. product: raw.product || '江中肝纯片',
  284. stats: raw.stats || {},
  285. sourceNote: 'docs/jiangzhong/raw/_merged.json · 真实多平台采集',
  286. },
  287. items,
  288. raw,
  289. };
  290. } catch (err) {
  291. console.warn(`⚠ _merged.json 解析失败:${err.message}`);
  292. }
  293. }
  294. return SKELETON;
  295. }
  296. // ------------------------------------------------------------
  297. // 元数据汇总(给 cover / agenda 用)
  298. // ------------------------------------------------------------
  299. function getMeta(data) {
  300. const items = (data && data.items) || [];
  301. const platforms = {};
  302. const products = {};
  303. const hypotheses = {};
  304. const sources = {};
  305. const sentiments = {};
  306. const keywords = new Set();
  307. const tags = new Set();
  308. for (const it of items) {
  309. const pf = it.platform || 'unknown';
  310. platforms[pf] = (platforms[pf] || 0) + 1;
  311. const prod = it.product || 'unknown';
  312. products[prod] = (products[prod] || 0) + 1;
  313. const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []);
  314. for (const h of hs) hypotheses[h] = (hypotheses[h] || 0) + 1;
  315. const src = it.source || 'unknown';
  316. sources[src] = (sources[src] || 0) + 1;
  317. sentiments[it.sentiment || 'unknown'] = (sentiments[it.sentiment || 'unknown'] || 0) + 1;
  318. if (it.keyword) keywords.add(it.keyword);
  319. if (Array.isArray(it.tags)) it.tags.forEach((t) => tags.add(t));
  320. }
  321. const stats = data?.raw?.stats || {};
  322. return {
  323. comments: items.length,
  324. notes: stats?.xhs?.notes || 0,
  325. videos: stats?.douyin?.videos || 0,
  326. products_count: stats?.amazon?.products || 0,
  327. keywords: keywords.size,
  328. tagsTotal: tags.size,
  329. platforms,
  330. products,
  331. productsCount: Object.keys(products).length,
  332. hypotheses,
  333. sources,
  334. sentiments,
  335. stage: data?.meta?.stage || 'unknown',
  336. sourceTier: data?.meta?.sourceTier || 'unknown',
  337. collectedAt: data?.meta?.collectedAt || '待采集',
  338. sourceNote: data?.meta?.sourceNote || '',
  339. };
  340. }
  341. // ------------------------------------------------------------
  342. // 筛选接口
  343. // ------------------------------------------------------------
  344. function filterByHypothesis(items, h) {
  345. return items.filter((it) => {
  346. const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []);
  347. return hs.includes(h);
  348. });
  349. }
  350. function filterByProduct(items, product) {
  351. return items.filter((it) => (it.product || '').includes(product) || (it.keyword || '').includes(product));
  352. }
  353. function filterByKeyword(items, kw) {
  354. return items.filter((it) => it.keyword === kw);
  355. }
  356. function filterByPlatform(items, platform) {
  357. return items.filter((it) => it.platform === platform);
  358. }
  359. function filterBySentiment(items, sentiment) {
  360. return items.filter((it) => it.sentiment === sentiment);
  361. }
  362. function filterByTag(items, tag) {
  363. return items.filter((it) => Array.isArray(it.tags) && it.tags.some((t) => t.includes(tag)));
  364. }
  365. function filterByContent(items, re) {
  366. const rx = re instanceof RegExp ? re : new RegExp(String(re), 'i');
  367. return items.filter((it) => rx.test(String(it.content || '')));
  368. }
  369. function filterByRating(items, { min, max } = {}) {
  370. return items.filter((it) => {
  371. if (typeof it.rating !== 'number') return false;
  372. if (min != null && it.rating < min) return false;
  373. if (max != null && it.rating > max) return false;
  374. return true;
  375. });
  376. }
  377. function filterByMinLikes(items, min = 1) {
  378. return items.filter((it) => (it.likes || 0) >= min);
  379. }
  380. // ------------------------------------------------------------
  381. // 排序 / 抽样
  382. // ------------------------------------------------------------
  383. function topByLikes(items, n = 10) {
  384. return items.slice().sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, n);
  385. }
  386. function sample(items, n = 6, seed = 1) {
  387. const arr = items.slice();
  388. const result = [];
  389. let s = seed;
  390. while (result.length < n && arr.length) {
  391. s = (s * 9301 + 49297) % 233280;
  392. const idx = Math.floor((s / 233280) * arr.length);
  393. result.push(arr.splice(idx, 1)[0]);
  394. }
  395. return result;
  396. }
  397. function groupByTag(items) {
  398. const map = new Map();
  399. for (const it of items) {
  400. if (!Array.isArray(it.tags)) continue;
  401. for (const t of it.tags) {
  402. if (!map.has(t)) map.set(t, { tag: t, count: 0, items: [] });
  403. const g = map.get(t);
  404. g.count++;
  405. g.items.push(it);
  406. }
  407. }
  408. return Array.from(map.values()).sort((a, b) => b.count - a.count);
  409. }
  410. // ------------------------------------------------------------
  411. // 主查询接口:getEvidence —— 章节渲染器直接用
  412. // 新增默认去重 + 最小字符数 + 纯表情 / 纯短回复过滤
  413. // 新增 requireContentHit 选项:强制评论里提到关键词或同义词片段
  414. // ------------------------------------------------------------
  415. const EMOJI_REPLY_REGEX = /^[\s\p{P}\p{Emoji_Presentation}\p{Extended_Pictographic}\[\]R]+$/u;
  416. function isSubstantive(content, minChars) {
  417. const s = String(content || '').trim();
  418. if (s.length < minChars) return false;
  419. // 过滤纯表情 / 纯符号回复
  420. const stripped = s.replace(/\[[^\]]+\]/g, '').replace(/[\s\p{P}\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '');
  421. return stripped.length >= Math.max(4, Math.floor(minChars / 2));
  422. }
  423. function getEvidence(items, opts = {}) {
  424. const {
  425. hypothesis, product, keyword, platform, sentiment, tag,
  426. minLikes = 0, minChars = 10, contentMatch, requireContentHit = false,
  427. dedupByContent = true, dedupByNickname = false,
  428. top = 6, seed = 7, sortBy = 'likes',
  429. } = opts;
  430. let filtered = items.slice();
  431. if (hypothesis) filtered = filterByHypothesis(filtered, hypothesis);
  432. if (product) filtered = filterByProduct(filtered, product);
  433. if (keyword) filtered = filterByKeyword(filtered, keyword);
  434. if (platform) filtered = filterByPlatform(filtered, platform);
  435. if (sentiment) filtered = filterBySentiment(filtered, sentiment);
  436. if (tag) filtered = filterByTag(filtered, tag);
  437. if (minLikes) filtered = filterByMinLikes(filtered, minLikes);
  438. // 内容有效性过滤(默认开)
  439. filtered = filtered.filter((it) => isSubstantive(it.content, minChars));
  440. if (contentMatch) filtered = filterByContent(filtered, contentMatch);
  441. // requireContentHit: 评论必须提到 keyword 或其可识别片段
  442. if (requireContentHit) {
  443. filtered = filtered.filter((it) => {
  444. const c = String(it.content || '').toLowerCase();
  445. const kw = String(it.keyword || '').toLowerCase();
  446. if (!kw) return true;
  447. // keyword 拆分:去掉"护肝/片/解酒"等泛词,剩余碎片必须出现
  448. const fragments = kw.split(/护肝|片|解酒|保健品/).filter((x) => x.length >= 2);
  449. if (fragments.length === 0) return c.includes(kw);
  450. return fragments.some((f) => c.includes(f));
  451. });
  452. }
  453. // 排序
  454. if (sortBy === 'likes') {
  455. filtered = filtered.sort((a, b) => (b.likes || 0) - (a.likes || 0));
  456. }
  457. // 去重:content 前 40 字 + (可选) 昵称
  458. if (dedupByContent) {
  459. const seen = new Set();
  460. filtered = filtered.filter((it) => {
  461. const key = dedupByNickname
  462. ? `${(it.content || '').slice(0, 40)}|${it.nickname || ''}`
  463. : (it.content || '').slice(0, 40);
  464. if (seen.has(key)) return false;
  465. seen.add(key);
  466. return true;
  467. });
  468. }
  469. // 取 top N*2 然后稳定抽 N
  470. const pool = filtered.slice(0, Math.max(top * 2, top + 3));
  471. return sample(pool, Math.min(top, pool.length), seed);
  472. }
  473. // ------------------------------------------------------------
  474. // 源标识辅助
  475. // ------------------------------------------------------------
  476. function isSeed(item) { return (item?.source || '').includes('pattern') || (item?.source || '').includes('seed'); }
  477. function isReal(item) { return (item?.source || '') === 'real-collected'; }
  478. function getGlobalSourceLabel(meta) {
  479. if (!meta) return '未加载';
  480. const tier = meta.sourceTier;
  481. if (tier === 'real-collected') return '真实采集';
  482. if (tier === 'pattern-curated') return '公开模式归纳 · 种子样本';
  483. return '骨架占位';
  484. }
  485. module.exports = {
  486. HYPOTHESES,
  487. CHAPTER_HYPOTHESIS_MAP,
  488. PLATFORM_LABELS,
  489. loadMerged,
  490. getMeta,
  491. flattenMerged,
  492. // filters
  493. filterByHypothesis, filterByProduct, filterByKeyword, filterByPlatform,
  494. filterBySentiment, filterByTag, filterByContent, filterByRating, filterByMinLikes,
  495. // sorting/sampling
  496. topByLikes, sample, groupByTag,
  497. // high-level
  498. getEvidence,
  499. isSeed, isReal, getGlobalSourceLabel,
  500. SKELETON,
  501. };