// ============================================================================== // 江中乳酸菌素片儿童版 · VOC 数据分析模块(参考 liver-analyze.js 架构) // ============================================================================== // 数据源:docs/乳酸菌/raw/_merged.json(已在 lactic-collect.js mergeAll 阶段预展平 items[]) // 所以本模块不需要 flattenMerged(直接读取 items 即可) // ============================================================================== const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..', '..'); const RAW_DIR = path.join(ROOT, 'docs', '乳酸菌', 'raw'); const MERGED_PATH = path.join(RAW_DIR, '_merged.json'); // ------------------------------------------------------------ // 8 条核心假设定义(对应 docs/乳酸菌/2.VOC深度思路.md) // ------------------------------------------------------------ const HYPOTHESES = { H1: { title: '药品益生菌痛点', desc: '妈咪爱/亿活/金双岐 家长吐槽冷链、活菌失活、处方门槛、依赖焦虑' }, H2: { title: '食品益生菌信任缺口', desc: '合生元/万益蓝/拜奥/inne 家长"怕没效果"纠结' }, H3: { title: '儿童肠道刚需', desc: '便秘/腹泻/积食/挑食/消化不良 真实家长诉求' }, H4: { title: '场景触发', desc: '入园/换季/抗生素后/开学 集中发生的肠道问题' }, H5: { title: '喂药依从性', desc: '孩子抗拒吃药/口感差/独立包装/喂药像打仗' }, H6: { title: '包装 × 颜值决策', desc: '新生代妈妈专属感需求/货架辨识度/分龄/IP' }, H7: { title: '营养吸收焦虑', desc: '瓶瓶罐罐太多/补了不吸收/内源调理替代' }, H8: { title: '糖分 × 添加剂焦虑', desc: '新生代家长抵触糖/防腐剂/香精/色素' }, }; // 章节 → 假设映射 const CHAPTER_HYPOTHESIS_MAP = { challenge: ['H1', 'H2', 'H3'], // 诘问:现状三大痛点 drug_voc: ['H1'], // 药品益生菌 VOC food_voc: ['H2', 'H3'], // 食品益生菌 VOC kano: ['H3', 'H5', 'H8'], // KANO×JTBD scene: ['H4', 'H3'], // 场景四元素 competitor: ['H1', 'H2'], // 三轴 × 竞品 opportunity: ['H7', 'H5', 'H6'], // 新机会 blueprint: ['H3', 'H7'], // 4P 蓝图 }; // ------------------------------------------------------------ // 平台标签 // ------------------------------------------------------------ const PLATFORM_LABELS = { xhs: { name: '小红书', color: '#FF2442', short: '红' }, douyin: { name: '抖音', color: '#1A1A1A', short: '抖' }, taobao: { name: '淘宝', color: '#FF5000', short: '淘' }, jd: { name: '京东', color: '#E1251B', short: '京' }, tmall: { name: '天猫', color: '#FF0036', short: '猫' }, unknown: { name: '其他', color: '#888', short: '—' }, }; // ------------------------------------------------------------ // 关键词 → 产品 / 竞品 归一化 // ------------------------------------------------------------ const KEYWORD_TO_PRODUCT = { '乳酸菌素片': '江中乳酸菌素片', '乳酸菌素片儿童': '江中乳酸菌素片儿童', '江中乳酸菌素片': '江中乳酸菌素片', // 药品益生菌竞品 '妈咪爱': '妈咪爱', '妈咪爱 宝宝': '妈咪爱', '亿活 儿童': '亿活', '金双岐': '金双岐', '宝乐安': '宝乐安', // 食品益生菌竞品 '合生元益生菌': '合生元 儿童益生菌', '万益蓝益生菌': '万益蓝 儿童益生菌', '拜奥益生菌': '拜奥益生菌', 'inne 噗噗宝': 'inne 噗噗宝', '小胖瓶益生菌': '小胖瓶益生菌', '康萃乐益生菌': '康萃乐益生菌', 'lifespace 儿童': 'lifespace 儿童益生菌', '儿童益生菌推荐': '儿童益生菌(品类)', '儿童益生菌': '儿童益生菌(品类)', // 儿童肠道痛点话题 '宝宝便秘': '便秘话题', '宝宝积食': '积食话题', '宝宝腹泻': '腹泻话题', '儿童挑食': '挑食话题', '抗生素 益生菌': '抗生素后肠道话题', // 场景 '宝宝入园生病': '入园场景', '宝宝换季腹泻': '换季场景', // 喂药依从 × 包装 '喂药难': '喂药难话题', '儿童咀嚼片': '咀嚼片形态', '宝宝专用': '儿童专属话题', // 营养吸收赛道 '儿童营养软糖': '儿童营养软糖(竞品)', '儿童DHA': 'DHA 补充话题', '瓶瓶罐罐': '瓶瓶罐罐焦虑', '儿童补钙': '补钙话题', // 糖分 / 安全焦虑 '儿童无糖': '无糖需求', }; // ------------------------------------------------------------ // 启发式再判(兜底 · lactic-collect.js 已做主要打标) // ------------------------------------------------------------ const NEG_KEYS = ['智商税', '没用', '没效', '没有效果', '骗', '差评', '退货', '拉肚子', '副作用', '难喝', '难吃', '难喂', '抗拒', '吐出', '失望', '浪费钱', '坑']; const POS_KEYS = ['有效', '真香', '好用', '推荐', '回购', '复购', '神器', '爱吃', '管用', '对症', '好转', '有改善', '主动要']; const CONF_KEYS = ['纠结', '矛盾', '不知道.*好', '又.*又', '害怕', '担心', '想停', '是不是']; function inferSentiment(item) { if (item.sentiment && ['positive', 'negative', 'neutral', 'conflicted'].includes(item.sentiment)) return item.sentiment; const c = String(item.content || '').toLowerCase(); for (const k of NEG_KEYS) if (c.includes(k.toLowerCase())) return 'negative'; for (const k of POS_KEYS) if (c.includes(k.toLowerCase())) return 'positive'; for (const k of CONF_KEYS) if (new RegExp(k).test(c)) return 'conflicted'; return 'neutral'; } // ------------------------------------------------------------ // 数据加载 // ------------------------------------------------------------ const SKELETON = { meta: { collectedAt: '待采集', platforms: {}, hypotheses: {}, products: {}, stage: 'skeleton', sourceNote: '暂无采集数据', }, items: [], raw: null, }; function loadMerged() { if (fs.existsSync(MERGED_PATH)) { try { const raw = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8')); const items = (raw.items || []).map((it) => ({ ...it, product: KEYWORD_TO_PRODUCT[it.keyword] || it.product || it.keyword, sentiment: inferSentiment(it), })); return { meta: { sourceTier: raw.meta?.sourceTier || 'real-collected', stage: raw.meta?.stage || 'batch-real', collectedAt: raw.meta?.collectedAt || new Date().toISOString().slice(0, 10), product: '江中乳酸菌素片儿童版', stats: raw.meta || {}, sourceNote: 'docs/乳酸菌/raw/_merged.json · 真实多平台采集', }, items, raw, }; } catch (err) { console.warn(`⚠ _merged.json 解析失败:${err.message}`); } } return SKELETON; } // ------------------------------------------------------------ // 元数据汇总(给 cover / agenda 用) // ------------------------------------------------------------ function getMeta(data) { const items = (data && data.items) || []; const platforms = {}; const products = {}; const hypotheses = {}; const sources = {}; const sentiments = {}; 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; sentiments[it.sentiment || 'unknown'] = (sentiments[it.sentiment || 'unknown'] || 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, keywords: keywords.size, tagsTotal: tags.size, platforms, products, productsCount: Object.keys(products).length, hypotheses, sources, sentiments, stage: data?.meta?.stage || 'unknown', sourceTier: data?.meta?.sourceTier || 'unknown', collectedAt: data?.meta?.collectedAt || '待采集', sourceNote: data?.meta?.sourceNote || '', }; } // ------------------------------------------------------------ // 筛选接口(与 liver-analyze 一致) // ------------------------------------------------------------ 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) || (it.keyword || '').includes(product)); } function filterByKeyword(items, kw) { return items.filter((it) => it.keyword === kw); } 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 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; } 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); } // ------------------------------------------------------------ // 主查询接口:getEvidence // ------------------------------------------------------------ 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)); } 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); 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(/\s+|儿童|宝宝|益生菌/).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)); } 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; }); } 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'; } function getGlobalSourceLabel(meta) { if (!meta) return '未加载'; const tier = meta.sourceTier; if (tier === 'real-collected') return '真实采集'; if (tier === 'pattern-curated') return '公开模式归纳 · 种子样本'; return '骨架占位'; } module.exports = { HYPOTHESES, CHAPTER_HYPOTHESIS_MAP, PLATFORM_LABELS, loadMerged, getMeta, // filters filterByHypothesis, filterByProduct, filterByKeyword, filterByPlatform, filterBySentiment, filterByTag, filterByContent, filterByMinLikes, // sorting/sampling topByLikes, sample, groupByTag, // high-level getEvidence, isSeed, isReal, getGlobalSourceLabel, SKELETON, };