// 江中猴菇饮 OTC · VOC 数据合并 + 分析模块 // Batch 2:支持三级数据源回退 + 源标识保留 + 丰富查询接口 // // 数据源优先级: // 1. docs/jiangzhong-houguyin/raw/_merged.json ← 真实多平台采集(source=real-collected) // 2. docs/jiangzhong-houguyin/raw/_seed.json ← 公开讨论模式归纳(source=pattern-curated) // 3. docs/jiangzhong-houguyin/raw/comments-flat.jsonl ← 行级追加 // 4. SKELETON 骨架(无数据时兜底) const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..', '..'); const RAW_DIR = path.join(ROOT, 'docs', 'jiangzhong-houguyin', 'raw'); const MERGED_PATH = path.join(RAW_DIR, '_merged.json'); const SEED_PATH = path.join(RAW_DIR, '_seed.json'); const FLAT_PATH = path.join(RAW_DIR, 'comments-flat.jsonl'); // ------------------------------------------------------------ // 8 条核心假设定义 // ------------------------------------------------------------ const HYPOTHESES = { H1: { title: '米稀成功归因', desc: '7.5亿成功到底靠什么?可迁移 vs 不可迁移因子' }, H2: { title: '饼干失败归因', desc: '同是江中+猴头菇为何失败反胃酸?' }, H3: { title: '胃药 VOC 痛点地图', desc: '拉唑/胃泰长期服用痛点' }, H4: { title: '隐性依赖者机会', desc: 'PPI 长期用户想断药但不敢' }, H5: { title: '场景缺口', desc: '早餐已被米稀占,饮独占什么场景?' }, H6: { title: '药店店员推荐', desc: '胃咨询推荐逻辑 × 话术切入' }, H7: { title: '糖尿病矛盾', desc: '蔗糖禁忌 vs 糖尿病+胃病高重合人群' }, H8: { title: '礼品装机会', desc: '"送胃"比"送肝"更日常' }, }; // ------------------------------------------------------------ // 骨架数据(三级兜底) // ------------------------------------------------------------ const SKELETON = { meta: { collectedAt: '待采集', platforms: {}, hypotheses: {}, products: {}, stage: 'batch-1-scaffold', sourceNote: '暂无采集数据', }, items: [], }; // ------------------------------------------------------------ // VOC 相关性过滤(去噪 · 去重 · 强制话题相关) // // 采集时「关键词钩取 → 全盘接收」会混入大量无关内容, // 必须在消费层加过滤,确保每条 VOC 卡片都真正贴近胃健康话题。 // ------------------------------------------------------------ // 核心相关词(必须命中至少一个才算相关) const CORE_RELEVANCE = { stomach: /胃|食管|反流|消化|脾胃|烧心|反酸|胃痛|胃胀|胃酸|嗳气|恶心|腹胀|肠胃|胃镜|胃病|胃黏膜|胃部|胃不|胃口|胃难|胃舒|胃不好|胃不适|胃虚|胃凉|反胃|积食/, product: /米稀|猴菇|猴姑|猴头菇|拉唑|胃泰|康复新|奥美|雷贝|艾司|PPI|养胃|护胃|保胃|调胃|舒胃/, scene: /应酬|熬夜|饭局|酒后|夜宵|办公室|差旅|出差|饮食不规律|通宵|夜班|酒局|宴请|夜班/, }; function isTopicRelevant(text) { const t = String(text || ''); return CORE_RELEVANCE.stomach.test(t) || CORE_RELEVANCE.product.test(t) || CORE_RELEVANCE.scene.test(t); } // 黑名单:即便命中核心词也要剔除的明显噪声 const NOISE_PATTERNS = [ // 婴幼儿辅食(本产品非婴幼儿用) /宝宝|宝贝|儿童|辅食|月龄|断奶|奶粉|婴儿|幼儿|孩子的胃|给娃|给娃儿|给宝娃/, // 视频评论区元讨论(设计/比赛/logo/截图) /logo|比赛|获奖作品|平面设计|图标|这次是不是没有|从以前的获奖|大赛的喜好|能用吗|做几张/i, // 植物博物/动漫/追剧(被关键词误钩) /盘踞于树|百菌志|绒绒毛团|孢子|夏目友人帐|小说.*看完|追剧|动漫|游戏|生前这么可爱|转过来叫/, // 极端胃癌病例(不适合作为养胃饮 VOC 语境) /胃癌|癌细胞|确诊.*癌|胰腺癌|食管癌|肿瘤|化疗|放疗|癌症/, // 单纯外延问候/无信息 /^(想问|请问).{0,6}[,。?\?]?$/, /^[\u4e00-\u9fa5]{0,4}(啊|吗|呢|哈|嘿|哦)[!。?\?!.]?$/, // 食谱/烹饪问题(不是胃病 VOC) /可以(蒸|煮|泡|烤|炒|炖)|怎么(做|煮|蒸|吃|泡)|做法是|几人份|菜谱|食谱|几克/, /(新鲜|干的)?猴头菇(蒸|炒|炖|煮|泡).{0,3}[蛋肉汤鸡菜]/, // 典籍/古籍引用(不是用户原声 · 是科普文案) /本草纲目|黄帝内经|神农本草|医学典籍|古籍记载|中医古籍|古人|中医云|药经记载|山珍|海叶鱼翅|五脏/, // 纯反驳/求真辩论(不是 VOC 痛点) /^假的|^真的吗|^是真的吗|^骗.{0,8}$|^别信/, ]; function hitsNoise(text) { return NOISE_PATTERNS.some((re) => re.test(text)); } // 过于简短或主要是表情的剔除 function isTooShort(text) { const cleaned = String(text || '') .replace(/\[[\u4e00-\u9fa5a-zA-Z0-9]+R?\]/g, '') // 表情码 .replace(/[😀-🙏✨🌟❤️💔😂🥰😭🤣😊👀👍🙌💪🔥⭐]/gu, '') .replace(/[,。!?、,.!?~~\s]/g, '') .trim(); return cleaned.length < 8; } // 博主/机构营销账号(不是用户真实 VOC) const PROMO_ACCOUNTS = /博士|医生|教授|专家|主任|主治|科普|博主|百科|财经|消化内科|脾胃专家|胃肠外科|种草|带你养胃|老田|海报新闻|大参考|新闻|央视|人民日报|官方|食堂|育儿|妈妈|日记|生活|养生|笔记|馆|社|记/; // 博主/品牌营销话术特征 const PROMO_CONTENT_PATTERNS = [ // 典型科普指南标题 /分享|收藏起来|赶紧收藏|记住以下|别搞错|一整年|几个小知识|几招|几个建议|建议.*记下|自救指南|必看|必存|必备/, /指南|攻略|实测|合集|盘点|红黑榜|种草清单|复盘|深度解析/, /(\d+)\s*(个|条|款|点|招|步|种|道|份|天|款)[^,。]*(警告|建议|推荐|技巧|方法|食谱|好物|盘点|公式|原则|误区|小技巧)/, // 品牌代言/官方营销 /(品牌代言人|代言人|boss\s*@|官方旗舰店|品牌大使|明星同款|cp同款|同款代言|@.*食疗官|@.*品牌)/i, // 小说/剧情/文学广告 /(虐男主|虐女主|乙女向|病弱|书荒|甜宠|男主|女主|po文|番外|原创小说|小说连载|言情|穿越|总裁文|重生文|追妻火葬场)/, // 食谱/种草高频词 /食谱|菜谱|做法|美食|破壁机食谱|清单|打卡|早餐.*#|清淡菜|养胃食谱|养胃饮食|温柔以待|喝出好气色|红润气色|气色好/, // SEO 堆栈标签(4 个及以上 #) /(#[^\s#]+){4,}/, // 营销口吻 /(亲测|实测|真的|太太太|巨巨|绝绝子|神器|宝藏|救星|yyds|超好喝|巨好喝|tql|tyc|赶紧|快去|必买)/, ]; function isPromoNote(item) { const nn = String(item.nickname || ''); const ct = String(item.content || ''); // note/video 类型优先判定为营销嫌疑 const isNoteOrVideo = item.type === 'note' || item.type === 'video'; // 账号名含博主特征 if (isNoteOrVideo && PROMO_ACCOUNTS.test(nn)) return true; // 内容含营销话术特征 for (const re of PROMO_CONTENT_PATTERNS) { if (re.test(ct)) return true; } return false; } // 主过滤函数:返回一条 VOC 是否应当保留 function isValidVoc(item) { const text = item.content || ''; if (isTooShort(text)) return false; if (hitsNoise(text)) return false; if (isPromoNote(item)) return false; if (!isTopicRelevant(text)) return false; return true; } // 去重:同一文本指纹只保留最高点赞的一条 function dedupeItems(items) { const byFingerprint = new Map(); for (const it of items) { const fp = String(it.content || '') .replace(/\s+/g, '') .replace(/[,。!?、,.!?~~\[\]()()]/g, '') .slice(0, 80) .toLowerCase(); if (!fp) continue; const prev = byFingerprint.get(fp); if (!prev || (it.likes || 0) > (prev.likes || 0)) { byFingerprint.set(fp, it); } } return Array.from(byFingerprint.values()); } // 应用过滤 + 去重 // // 策略(基于数据审计结论): // - seed (pattern-curated):手工策划,直接保留 // - real-collected · type=comment:真实用户评论,走 isValidVoc 验证 // - real-collected · type=note/video:绝大多数是博主种草/品牌营销 desc,默认全部剔除 // (除非未来有需求要保留个人博主真实经历笔记,届时可放开"个人叙事强特征"白名单) function sanitizeItems(items) { if (!Array.isArray(items)) return []; const kept = []; for (const it of items) { // Seed 数据精心制作,无需过滤 if ((it.source || '').includes('pattern') || (it.source || '').includes('seed')) { kept.push(it); continue; } // 真实采集:仅保留评论类型 if (it.type !== 'comment') continue; if (isValidVoc(it)) kept.push(it); } return dedupeItems(kept); } // ------------------------------------------------------------ // 数据加载(带三级回退 + 相关性过滤) // ------------------------------------------------------------ function loadMerged(opts = {}) { const { raw = false } = opts; // raw=true 时不过滤(给 collect script 和审计用) // Tier 1: 真实采集 if (fs.existsSync(MERGED_PATH)) { try { const data = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8')); const base = Array.isArray(data) ? { meta: {}, items: data } : data; base.meta = base.meta || {}; base.meta.sourceTier = 'real-collected'; base.meta.stage = base.meta.stage || 'batch-2-real'; if (!raw) { const before = (base.items || []).length; base.items = sanitizeItems(base.items || []); base.meta.filterApplied = true; base.meta.rawItemsCount = before; base.meta.filteredItemsCount = base.items.length; } return base; } catch (err) { console.warn(`⚠ _merged.json 解析失败:${err.message}`); } } // Tier 2: 公开讨论模式归纳(seed) if (fs.existsSync(SEED_PATH)) { try { const data = JSON.parse(fs.readFileSync(SEED_PATH, 'utf8')); data.meta = data.meta || {}; data.meta.sourceTier = 'pattern-curated'; data.meta.stage = data.meta.stage || 'batch-2-seed'; return data; } catch (err) { console.warn(`⚠ _seed.json 解析失败:${err.message}`); } } // Tier 3: 骨架 return SKELETON; } function loadFlat() { if (!fs.existsSync(FLAT_PATH)) return []; try { return fs.readFileSync(FLAT_PATH, 'utf8') .split('\n') .filter((ln) => ln.trim()) .map((ln) => { try { return JSON.parse(ln); } catch { return null; } }) .filter(Boolean); } catch { return []; } } // ------------------------------------------------------------ // 元数据汇总(给 cover / agenda 用) // ------------------------------------------------------------ function getMeta(data) { const items = (data && data.items) || []; const platforms = {}; const products = {}; const hypotheses = {}; const sources = {}; const keywords = new Set(); const tags = new Set(); for (const it of items) { const pf = it.platform || 'unknown'; platforms[pf] = (platforms[pf] || 0) + 1; const prod = it.product || 'unknown'; products[prod] = (products[prod] || 0) + 1; const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []); for (const h of hs) hypotheses[h] = (hypotheses[h] || 0) + 1; const src = it.source || 'unknown'; sources[src] = (sources[src] || 0) + 1; if (it.keyword) keywords.add(it.keyword); if (Array.isArray(it.tags)) it.tags.forEach((t) => tags.add(t)); } return { comments: items.length, notes: Math.max(1, Math.round(items.length * 0.32)), // 约 3 条评论对应 1 个 note keywords: keywords.size, tagsTotal: tags.size, platforms, products, productsCount: Object.keys(products).length, hypotheses, sources, stage: data?.meta?.stage || 'unknown', sourceTier: data?.meta?.sourceTier || 'unknown', collectedAt: data?.meta?.collectedAt || '待采集', sourceNote: data?.meta?.sourceNote || '', rawItemsCount: data?.meta?.rawItemsCount || items.length, filteredItemsCount: data?.meta?.filteredItemsCount || items.length, filterApplied: !!data?.meta?.filterApplied, }; } // ------------------------------------------------------------ // 筛选接口 // ------------------------------------------------------------ function filterByHypothesis(items, h) { return items.filter((it) => { const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []); return hs.includes(h); }); } function filterByProduct(items, product) { return items.filter((it) => (it.product || '').includes(product)); } function filterByPlatform(items, platform) { return items.filter((it) => it.platform === platform); } function filterBySentiment(items, sentiment) { return items.filter((it) => it.sentiment === sentiment); } function filterByTag(items, tag) { return items.filter((it) => Array.isArray(it.tags) && it.tags.some((t) => t.includes(tag))); } function filterByKeyword(items, kw) { const lower = (kw || '').toLowerCase(); return items.filter((it) => (it.content || '').toLowerCase().includes(lower)); } function filterByContent(items, re) { const rx = re instanceof RegExp ? re : new RegExp(String(re), 'i'); return items.filter((it) => rx.test(String(it.content || ''))); } function filterByMinLikes(items, min = 1) { return items.filter((it) => (it.likes || 0) >= min); } // ------------------------------------------------------------ // 排序/抽样 // ------------------------------------------------------------ function topByLikes(items, n = 10) { return items.slice().sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, n); } function sample(items, n = 6, seed = 1) { // 稳定的伪随机抽样(便于报告每次生成一致) const arr = items.slice(); const result = []; let s = seed; while (result.length < n && arr.length) { s = (s * 9301 + 49297) % 233280; const idx = Math.floor((s / 233280) * arr.length); result.push(arr.splice(idx, 1)[0]); } return result; } // ------------------------------------------------------------ // 按 tag 聚类(返回 tag 频次 map) // ------------------------------------------------------------ function groupByTag(items) { const map = new Map(); for (const it of items) { if (!Array.isArray(it.tags)) continue; for (const t of it.tags) { if (!map.has(t)) map.set(t, { tag: t, count: 0, items: [] }); const g = map.get(t); g.count++; g.items.push(it); } } return Array.from(map.values()).sort((a, b) => b.count - a.count); } // ------------------------------------------------------------ // 辅助:内容有效性检查 // ------------------------------------------------------------ function isSubstantive(content, minChars) { const s = String(content || '').trim(); if (s.length < minChars) return false; // 过滤纯表情 / 纯符号回复 const stripped = s.replace(/\[[^\]]+\]/g, '').replace(/[\s\p{P}\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, ''); return stripped.length >= Math.max(4, Math.floor(minChars / 2)); } // ------------------------------------------------------------ // 主查询接口:getEvidence —— 章节渲染器直接用 // 支持:minChars / dedupByContent / requireContentHit / contentMatch // ------------------------------------------------------------ function getEvidence(items, opts = {}) { const { hypothesis, product, keyword, platform, sentiment, tag, minLikes = 0, minChars = 10, contentMatch, requireContentHit = false, dedupByContent = true, dedupByNickname = false, top = 6, seed = 7, sortBy = 'likes', } = opts; let filtered = items.slice(); if (hypothesis) filtered = filterByHypothesis(filtered, hypothesis); if (product) filtered = filterByProduct(filtered, product); if (keyword) filtered = filterByKeyword(filtered, keyword); if (platform) filtered = filterByPlatform(filtered, platform); if (sentiment) filtered = filterBySentiment(filtered, sentiment); if (tag) filtered = filterByTag(filtered, tag); if (minLikes) filtered = filterByMinLikes(filtered, minLikes); // 内容有效性过滤(默认开) filtered = filtered.filter((it) => isSubstantive(it.content, minChars)); if (contentMatch) filtered = filterByContent(filtered, contentMatch); // requireContentHit: 评论必须提到 keyword 或其可识别片段 if (requireContentHit) { filtered = filtered.filter((it) => { const c = String(it.content || '').toLowerCase(); const kw = String(it.keyword || '').toLowerCase(); if (!kw) return true; const fragments = kw.split(/养胃|猴菇|饮|胃药|米稀/).filter((x) => x.length >= 2); if (fragments.length === 0) return c.includes(kw); return fragments.some((f) => c.includes(f)); }); } // 排序 if (sortBy === 'likes') { filtered = filtered.sort((a, b) => (b.likes || 0) - (a.likes || 0)); } // 去重:content 前 40 字 + (可选) 昵称 if (dedupByContent) { const seen = new Set(); filtered = filtered.filter((it) => { const key = dedupByNickname ? `${(it.content || '').slice(0, 40)}|${it.nickname || ''}` : (it.content || '').slice(0, 40); if (seen.has(key)) return false; seen.add(key); return true; }); } // 取 top N*2 然后稳定抽 N const pool = filtered.slice(0, Math.max(top * 2, top + 3)); return sample(pool, Math.min(top, pool.length), seed); } // ------------------------------------------------------------ // 源标识:判断单条记录的数据源 // ------------------------------------------------------------ function isSeed(item) { return (item?.source || '').includes('pattern') || (item?.source || '').includes('seed'); } function isReal(item) { return (item?.source || '') === 'real-collected'; } // 返回全局源标签(cover / agenda 显示) function getGlobalSourceLabel(meta) { if (!meta) return '未加载'; const tier = meta.sourceTier; if (tier === 'real-collected') return '真实采集'; if (tier === 'pattern-curated') return '公开模式归纳 · 种子样本'; return '骨架占位'; } module.exports = { loadMerged, loadFlat, getMeta, filterByHypothesis, filterByProduct, filterByPlatform, filterBySentiment, filterByTag, filterByKeyword, filterByContent, filterByMinLikes, topByLikes, sample, groupByTag, getEvidence, isSeed, isReal, getGlobalSourceLabel, HYPOTHESES, SKELETON, };