houguyin-analyze.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. // 江中猴菇饮 OTC · VOC 数据合并 + 分析模块
  2. // Batch 2:支持三级数据源回退 + 源标识保留 + 丰富查询接口
  3. //
  4. // 数据源优先级:
  5. // 1. docs/jiangzhong-houguyin/raw/_merged.json ← 真实多平台采集(source=real-collected)
  6. // 2. docs/jiangzhong-houguyin/raw/_seed.json ← 公开讨论模式归纳(source=pattern-curated)
  7. // 3. docs/jiangzhong-houguyin/raw/comments-flat.jsonl ← 行级追加
  8. // 4. SKELETON 骨架(无数据时兜底)
  9. const fs = require('fs');
  10. const path = require('path');
  11. const ROOT = path.resolve(__dirname, '..', '..');
  12. const RAW_DIR = path.join(ROOT, 'docs', 'jiangzhong-houguyin', 'raw');
  13. const MERGED_PATH = path.join(RAW_DIR, '_merged.json');
  14. const SEED_PATH = path.join(RAW_DIR, '_seed.json');
  15. const FLAT_PATH = path.join(RAW_DIR, 'comments-flat.jsonl');
  16. // ------------------------------------------------------------
  17. // 8 条核心假设定义
  18. // ------------------------------------------------------------
  19. const HYPOTHESES = {
  20. H1: { title: '米稀成功归因', desc: '7.5亿成功到底靠什么?可迁移 vs 不可迁移因子' },
  21. H2: { title: '饼干失败归因', desc: '同是江中+猴头菇为何失败反胃酸?' },
  22. H3: { title: '胃药 VOC 痛点地图', desc: '拉唑/胃泰长期服用痛点' },
  23. H4: { title: '隐性依赖者机会', desc: 'PPI 长期用户想断药但不敢' },
  24. H5: { title: '场景缺口', desc: '早餐已被米稀占,饮独占什么场景?' },
  25. H6: { title: '药店店员推荐', desc: '胃咨询推荐逻辑 × 话术切入' },
  26. H7: { title: '糖尿病矛盾', desc: '蔗糖禁忌 vs 糖尿病+胃病高重合人群' },
  27. H8: { title: '礼品装机会', desc: '"送胃"比"送肝"更日常' },
  28. };
  29. // ------------------------------------------------------------
  30. // 骨架数据(三级兜底)
  31. // ------------------------------------------------------------
  32. const SKELETON = {
  33. meta: {
  34. collectedAt: '待采集',
  35. platforms: {},
  36. hypotheses: {},
  37. products: {},
  38. stage: 'batch-1-scaffold',
  39. sourceNote: '暂无采集数据',
  40. },
  41. items: [],
  42. };
  43. // ------------------------------------------------------------
  44. // VOC 相关性过滤(去噪 · 去重 · 强制话题相关)
  45. //
  46. // 采集时「关键词钩取 → 全盘接收」会混入大量无关内容,
  47. // 必须在消费层加过滤,确保每条 VOC 卡片都真正贴近胃健康话题。
  48. // ------------------------------------------------------------
  49. // 核心相关词(必须命中至少一个才算相关)
  50. const CORE_RELEVANCE = {
  51. stomach: /胃|食管|反流|消化|脾胃|烧心|反酸|胃痛|胃胀|胃酸|嗳气|恶心|腹胀|肠胃|胃镜|胃病|胃黏膜|胃部|胃不|胃口|胃难|胃舒|胃不好|胃不适|胃虚|胃凉|反胃|积食/,
  52. product: /米稀|猴菇|猴姑|猴头菇|拉唑|胃泰|康复新|奥美|雷贝|艾司|PPI|养胃|护胃|保胃|调胃|舒胃/,
  53. scene: /应酬|熬夜|饭局|酒后|夜宵|办公室|差旅|出差|饮食不规律|通宵|夜班|酒局|宴请|夜班/,
  54. };
  55. function isTopicRelevant(text) {
  56. const t = String(text || '');
  57. return CORE_RELEVANCE.stomach.test(t) || CORE_RELEVANCE.product.test(t) || CORE_RELEVANCE.scene.test(t);
  58. }
  59. // 黑名单:即便命中核心词也要剔除的明显噪声
  60. const NOISE_PATTERNS = [
  61. // 婴幼儿辅食(本产品非婴幼儿用)
  62. /宝宝|宝贝|儿童|辅食|月龄|断奶|奶粉|婴儿|幼儿|孩子的胃|给娃|给娃儿|给宝娃/,
  63. // 视频评论区元讨论(设计/比赛/logo/截图)
  64. /logo|比赛|获奖作品|平面设计|图标|这次是不是没有|从以前的获奖|大赛的喜好|能用吗|做几张/i,
  65. // 植物博物/动漫/追剧(被关键词误钩)
  66. /盘踞于树|百菌志|绒绒毛团|孢子|夏目友人帐|小说.*看完|追剧|动漫|游戏|生前这么可爱|转过来叫/,
  67. // 极端胃癌病例(不适合作为养胃饮 VOC 语境)
  68. /胃癌|癌细胞|确诊.*癌|胰腺癌|食管癌|肿瘤|化疗|放疗|癌症/,
  69. // 单纯外延问候/无信息
  70. /^(想问|请问).{0,6}[,。?\?]?$/,
  71. /^[\u4e00-\u9fa5]{0,4}(啊|吗|呢|哈|嘿|哦)[!。?\?!.]?$/,
  72. // 食谱/烹饪问题(不是胃病 VOC)
  73. /可以(蒸|煮|泡|烤|炒|炖)|怎么(做|煮|蒸|吃|泡)|做法是|几人份|菜谱|食谱|几克/,
  74. /(新鲜|干的)?猴头菇(蒸|炒|炖|煮|泡).{0,3}[蛋肉汤鸡菜]/,
  75. // 典籍/古籍引用(不是用户原声 · 是科普文案)
  76. /本草纲目|黄帝内经|神农本草|医学典籍|古籍记载|中医古籍|古人|中医云|药经记载|山珍|海叶鱼翅|五脏/,
  77. // 纯反驳/求真辩论(不是 VOC 痛点)
  78. /^假的|^真的吗|^是真的吗|^骗.{0,8}$|^别信/,
  79. ];
  80. function hitsNoise(text) {
  81. return NOISE_PATTERNS.some((re) => re.test(text));
  82. }
  83. // 过于简短或主要是表情的剔除
  84. function isTooShort(text) {
  85. const cleaned = String(text || '')
  86. .replace(/\[[\u4e00-\u9fa5a-zA-Z0-9]+R?\]/g, '') // 表情码
  87. .replace(/[😀-🙏✨🌟❤️💔😂🥰😭🤣😊👀👍🙌💪🔥⭐]/gu, '')
  88. .replace(/[,。!?、,.!?~~\s]/g, '')
  89. .trim();
  90. return cleaned.length < 8;
  91. }
  92. // 博主/机构营销账号(不是用户真实 VOC)
  93. const PROMO_ACCOUNTS = /博士|医生|教授|专家|主任|主治|科普|博主|百科|财经|消化内科|脾胃专家|胃肠外科|种草|带你养胃|老田|海报新闻|大参考|新闻|央视|人民日报|官方|食堂|育儿|妈妈|日记|生活|养生|笔记|馆|社|记/;
  94. // 博主/品牌营销话术特征
  95. const PROMO_CONTENT_PATTERNS = [
  96. // 典型科普指南标题
  97. /分享|收藏起来|赶紧收藏|记住以下|别搞错|一整年|几个小知识|几招|几个建议|建议.*记下|自救指南|必看|必存|必备/,
  98. /指南|攻略|实测|合集|盘点|红黑榜|种草清单|复盘|深度解析/,
  99. /(\d+)\s*(个|条|款|点|招|步|种|道|份|天|款)[^,。]*(警告|建议|推荐|技巧|方法|食谱|好物|盘点|公式|原则|误区|小技巧)/,
  100. // 品牌代言/官方营销
  101. /(品牌代言人|代言人|boss\s*@|官方旗舰店|品牌大使|明星同款|cp同款|同款代言|@.*食疗官|@.*品牌)/i,
  102. // 小说/剧情/文学广告
  103. /(虐男主|虐女主|乙女向|病弱|书荒|甜宠|男主|女主|po文|番外|原创小说|小说连载|言情|穿越|总裁文|重生文|追妻火葬场)/,
  104. // 食谱/种草高频词
  105. /食谱|菜谱|做法|美食|破壁机食谱|清单|打卡|早餐.*#|清淡菜|养胃食谱|养胃饮食|温柔以待|喝出好气色|红润气色|气色好/,
  106. // SEO 堆栈标签(4 个及以上 #)
  107. /(#[^\s#]+){4,}/,
  108. // 营销口吻
  109. /(亲测|实测|真的|太太太|巨巨|绝绝子|神器|宝藏|救星|yyds|超好喝|巨好喝|tql|tyc|赶紧|快去|必买)/,
  110. ];
  111. function isPromoNote(item) {
  112. const nn = String(item.nickname || '');
  113. const ct = String(item.content || '');
  114. // note/video 类型优先判定为营销嫌疑
  115. const isNoteOrVideo = item.type === 'note' || item.type === 'video';
  116. // 账号名含博主特征
  117. if (isNoteOrVideo && PROMO_ACCOUNTS.test(nn)) return true;
  118. // 内容含营销话术特征
  119. for (const re of PROMO_CONTENT_PATTERNS) {
  120. if (re.test(ct)) return true;
  121. }
  122. return false;
  123. }
  124. // 主过滤函数:返回一条 VOC 是否应当保留
  125. function isValidVoc(item) {
  126. const text = item.content || '';
  127. if (isTooShort(text)) return false;
  128. if (hitsNoise(text)) return false;
  129. if (isPromoNote(item)) return false;
  130. if (!isTopicRelevant(text)) return false;
  131. return true;
  132. }
  133. // 去重:同一文本指纹只保留最高点赞的一条
  134. function dedupeItems(items) {
  135. const byFingerprint = new Map();
  136. for (const it of items) {
  137. const fp = String(it.content || '')
  138. .replace(/\s+/g, '')
  139. .replace(/[,。!?、,.!?~~\[\]()()]/g, '')
  140. .slice(0, 80)
  141. .toLowerCase();
  142. if (!fp) continue;
  143. const prev = byFingerprint.get(fp);
  144. if (!prev || (it.likes || 0) > (prev.likes || 0)) {
  145. byFingerprint.set(fp, it);
  146. }
  147. }
  148. return Array.from(byFingerprint.values());
  149. }
  150. // 应用过滤 + 去重
  151. //
  152. // 策略(基于数据审计结论):
  153. // - seed (pattern-curated):手工策划,直接保留
  154. // - real-collected · type=comment:真实用户评论,走 isValidVoc 验证
  155. // - real-collected · type=note/video:绝大多数是博主种草/品牌营销 desc,默认全部剔除
  156. // (除非未来有需求要保留个人博主真实经历笔记,届时可放开"个人叙事强特征"白名单)
  157. function sanitizeItems(items) {
  158. if (!Array.isArray(items)) return [];
  159. const kept = [];
  160. for (const it of items) {
  161. // Seed 数据精心制作,无需过滤
  162. if ((it.source || '').includes('pattern') || (it.source || '').includes('seed')) {
  163. kept.push(it);
  164. continue;
  165. }
  166. // 真实采集:仅保留评论类型
  167. if (it.type !== 'comment') continue;
  168. if (isValidVoc(it)) kept.push(it);
  169. }
  170. return dedupeItems(kept);
  171. }
  172. // ------------------------------------------------------------
  173. // 数据加载(带三级回退 + 相关性过滤)
  174. // ------------------------------------------------------------
  175. function loadMerged(opts = {}) {
  176. const { raw = false } = opts; // raw=true 时不过滤(给 collect script 和审计用)
  177. // Tier 1: 真实采集
  178. if (fs.existsSync(MERGED_PATH)) {
  179. try {
  180. const data = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8'));
  181. const base = Array.isArray(data) ? { meta: {}, items: data } : data;
  182. base.meta = base.meta || {};
  183. base.meta.sourceTier = 'real-collected';
  184. base.meta.stage = base.meta.stage || 'batch-2-real';
  185. if (!raw) {
  186. const before = (base.items || []).length;
  187. base.items = sanitizeItems(base.items || []);
  188. base.meta.filterApplied = true;
  189. base.meta.rawItemsCount = before;
  190. base.meta.filteredItemsCount = base.items.length;
  191. }
  192. return base;
  193. } catch (err) {
  194. console.warn(`⚠ _merged.json 解析失败:${err.message}`);
  195. }
  196. }
  197. // Tier 2: 公开讨论模式归纳(seed)
  198. if (fs.existsSync(SEED_PATH)) {
  199. try {
  200. const data = JSON.parse(fs.readFileSync(SEED_PATH, 'utf8'));
  201. data.meta = data.meta || {};
  202. data.meta.sourceTier = 'pattern-curated';
  203. data.meta.stage = data.meta.stage || 'batch-2-seed';
  204. return data;
  205. } catch (err) {
  206. console.warn(`⚠ _seed.json 解析失败:${err.message}`);
  207. }
  208. }
  209. // Tier 3: 骨架
  210. return SKELETON;
  211. }
  212. function loadFlat() {
  213. if (!fs.existsSync(FLAT_PATH)) return [];
  214. try {
  215. return fs.readFileSync(FLAT_PATH, 'utf8')
  216. .split('\n')
  217. .filter((ln) => ln.trim())
  218. .map((ln) => { try { return JSON.parse(ln); } catch { return null; } })
  219. .filter(Boolean);
  220. } catch {
  221. return [];
  222. }
  223. }
  224. // ------------------------------------------------------------
  225. // 元数据汇总(给 cover / agenda 用)
  226. // ------------------------------------------------------------
  227. function getMeta(data) {
  228. const items = (data && data.items) || [];
  229. const platforms = {};
  230. const products = {};
  231. const hypotheses = {};
  232. const sources = {};
  233. const keywords = new Set();
  234. const tags = new Set();
  235. for (const it of items) {
  236. const pf = it.platform || 'unknown';
  237. platforms[pf] = (platforms[pf] || 0) + 1;
  238. const prod = it.product || 'unknown';
  239. products[prod] = (products[prod] || 0) + 1;
  240. const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []);
  241. for (const h of hs) hypotheses[h] = (hypotheses[h] || 0) + 1;
  242. const src = it.source || 'unknown';
  243. sources[src] = (sources[src] || 0) + 1;
  244. if (it.keyword) keywords.add(it.keyword);
  245. if (Array.isArray(it.tags)) it.tags.forEach((t) => tags.add(t));
  246. }
  247. return {
  248. comments: items.length,
  249. notes: Math.max(1, Math.round(items.length * 0.32)), // 约 3 条评论对应 1 个 note
  250. keywords: keywords.size,
  251. tagsTotal: tags.size,
  252. platforms,
  253. products,
  254. productsCount: Object.keys(products).length,
  255. hypotheses,
  256. sources,
  257. stage: data?.meta?.stage || 'unknown',
  258. sourceTier: data?.meta?.sourceTier || 'unknown',
  259. collectedAt: data?.meta?.collectedAt || '待采集',
  260. sourceNote: data?.meta?.sourceNote || '',
  261. rawItemsCount: data?.meta?.rawItemsCount || items.length,
  262. filteredItemsCount: data?.meta?.filteredItemsCount || items.length,
  263. filterApplied: !!data?.meta?.filterApplied,
  264. };
  265. }
  266. // ------------------------------------------------------------
  267. // 筛选接口
  268. // ------------------------------------------------------------
  269. function filterByHypothesis(items, h) {
  270. return items.filter((it) => {
  271. const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []);
  272. return hs.includes(h);
  273. });
  274. }
  275. function filterByProduct(items, product) {
  276. return items.filter((it) => (it.product || '').includes(product));
  277. }
  278. function filterByPlatform(items, platform) {
  279. return items.filter((it) => it.platform === platform);
  280. }
  281. function filterBySentiment(items, sentiment) {
  282. return items.filter((it) => it.sentiment === sentiment);
  283. }
  284. function filterByTag(items, tag) {
  285. return items.filter((it) => Array.isArray(it.tags) && it.tags.some((t) => t.includes(tag)));
  286. }
  287. function filterByKeyword(items, kw) {
  288. const lower = (kw || '').toLowerCase();
  289. return items.filter((it) => (it.content || '').toLowerCase().includes(lower));
  290. }
  291. function filterByContent(items, re) {
  292. const rx = re instanceof RegExp ? re : new RegExp(String(re), 'i');
  293. return items.filter((it) => rx.test(String(it.content || '')));
  294. }
  295. function filterByMinLikes(items, min = 1) {
  296. return items.filter((it) => (it.likes || 0) >= min);
  297. }
  298. // ------------------------------------------------------------
  299. // 排序/抽样
  300. // ------------------------------------------------------------
  301. function topByLikes(items, n = 10) {
  302. return items.slice().sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, n);
  303. }
  304. function sample(items, n = 6, seed = 1) {
  305. // 稳定的伪随机抽样(便于报告每次生成一致)
  306. const arr = items.slice();
  307. const result = [];
  308. let s = seed;
  309. while (result.length < n && arr.length) {
  310. s = (s * 9301 + 49297) % 233280;
  311. const idx = Math.floor((s / 233280) * arr.length);
  312. result.push(arr.splice(idx, 1)[0]);
  313. }
  314. return result;
  315. }
  316. // ------------------------------------------------------------
  317. // 按 tag 聚类(返回 tag 频次 map)
  318. // ------------------------------------------------------------
  319. function groupByTag(items) {
  320. const map = new Map();
  321. for (const it of items) {
  322. if (!Array.isArray(it.tags)) continue;
  323. for (const t of it.tags) {
  324. if (!map.has(t)) map.set(t, { tag: t, count: 0, items: [] });
  325. const g = map.get(t);
  326. g.count++;
  327. g.items.push(it);
  328. }
  329. }
  330. return Array.from(map.values()).sort((a, b) => b.count - a.count);
  331. }
  332. // ------------------------------------------------------------
  333. // 辅助:内容有效性检查
  334. // ------------------------------------------------------------
  335. function isSubstantive(content, minChars) {
  336. const s = String(content || '').trim();
  337. if (s.length < minChars) return false;
  338. // 过滤纯表情 / 纯符号回复
  339. const stripped = s.replace(/\[[^\]]+\]/g, '').replace(/[\s\p{P}\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '');
  340. return stripped.length >= Math.max(4, Math.floor(minChars / 2));
  341. }
  342. // ------------------------------------------------------------
  343. // 主查询接口:getEvidence —— 章节渲染器直接用
  344. // 支持:minChars / dedupByContent / requireContentHit / contentMatch
  345. // ------------------------------------------------------------
  346. function getEvidence(items, opts = {}) {
  347. const {
  348. hypothesis, product, keyword, platform, sentiment, tag,
  349. minLikes = 0, minChars = 10, contentMatch, requireContentHit = false,
  350. dedupByContent = true, dedupByNickname = false,
  351. top = 6, seed = 7, sortBy = 'likes',
  352. } = opts;
  353. let filtered = items.slice();
  354. if (hypothesis) filtered = filterByHypothesis(filtered, hypothesis);
  355. if (product) filtered = filterByProduct(filtered, product);
  356. if (keyword) filtered = filterByKeyword(filtered, keyword);
  357. if (platform) filtered = filterByPlatform(filtered, platform);
  358. if (sentiment) filtered = filterBySentiment(filtered, sentiment);
  359. if (tag) filtered = filterByTag(filtered, tag);
  360. if (minLikes) filtered = filterByMinLikes(filtered, minLikes);
  361. // 内容有效性过滤(默认开)
  362. filtered = filtered.filter((it) => isSubstantive(it.content, minChars));
  363. if (contentMatch) filtered = filterByContent(filtered, contentMatch);
  364. // requireContentHit: 评论必须提到 keyword 或其可识别片段
  365. if (requireContentHit) {
  366. filtered = filtered.filter((it) => {
  367. const c = String(it.content || '').toLowerCase();
  368. const kw = String(it.keyword || '').toLowerCase();
  369. if (!kw) return true;
  370. const fragments = kw.split(/养胃|猴菇|饮|胃药|米稀/).filter((x) => x.length >= 2);
  371. if (fragments.length === 0) return c.includes(kw);
  372. return fragments.some((f) => c.includes(f));
  373. });
  374. }
  375. // 排序
  376. if (sortBy === 'likes') {
  377. filtered = filtered.sort((a, b) => (b.likes || 0) - (a.likes || 0));
  378. }
  379. // 去重:content 前 40 字 + (可选) 昵称
  380. if (dedupByContent) {
  381. const seen = new Set();
  382. filtered = filtered.filter((it) => {
  383. const key = dedupByNickname
  384. ? `${(it.content || '').slice(0, 40)}|${it.nickname || ''}`
  385. : (it.content || '').slice(0, 40);
  386. if (seen.has(key)) return false;
  387. seen.add(key);
  388. return true;
  389. });
  390. }
  391. // 取 top N*2 然后稳定抽 N
  392. const pool = filtered.slice(0, Math.max(top * 2, top + 3));
  393. return sample(pool, Math.min(top, pool.length), seed);
  394. }
  395. // ------------------------------------------------------------
  396. // 源标识:判断单条记录的数据源
  397. // ------------------------------------------------------------
  398. function isSeed(item) { return (item?.source || '').includes('pattern') || (item?.source || '').includes('seed'); }
  399. function isReal(item) { return (item?.source || '') === 'real-collected'; }
  400. // 返回全局源标签(cover / agenda 显示)
  401. function getGlobalSourceLabel(meta) {
  402. if (!meta) return '未加载';
  403. const tier = meta.sourceTier;
  404. if (tier === 'real-collected') return '真实采集';
  405. if (tier === 'pattern-curated') return '公开模式归纳 · 种子样本';
  406. return '骨架占位';
  407. }
  408. module.exports = {
  409. loadMerged,
  410. loadFlat,
  411. getMeta,
  412. filterByHypothesis,
  413. filterByProduct,
  414. filterByPlatform,
  415. filterBySentiment,
  416. filterByTag,
  417. filterByKeyword,
  418. filterByContent,
  419. filterByMinLikes,
  420. topByLikes,
  421. sample,
  422. groupByTag,
  423. getEvidence,
  424. isSeed,
  425. isReal,
  426. getGlobalSourceLabel,
  427. HYPOTHESES,
  428. SKELETON,
  429. };