// ============================================================================== // 洪城到家 · VOC 数据分析模块 // ============================================================================== 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'); const HYPOTHESES = { H1: { title: '医院地推是最高效获客场景', desc: '直接触达即将生产的精准用户,转化率远高于泛流量' }, H2: { title: '价格透明是转化的关键因素', desc: '用户对月嫂价格不透明是核心痛点,价格公开透明化能显著提升转化率' }, H3: { title: '短剧内容能有效提升品牌认知', desc: '借南昌万亿短剧市场政策红利,通过短剧植入能低成本实现品牌曝光' }, H4: { title: '专业度信任需要"看得见"的证明', desc: '用户判断月嫂专业度的核心信号是资质证书、客户评价、实体规模' }, H5: { title: '竞品对比中社区店是主要威胁', desc: '社区月嫂门店对洪诚到家的主要威胁是"价格低",而非"质量好"' }, H6: { title: '老带新是低成本高转化渠道', desc: '老客户转介绍是月嫂业务最高效、最省钱的获客方式' }, H7: { title: '美团/小红书是搜索承接的主力平台', desc: '用户在网络上主动搜索月嫂的主要平台是美团和小红书' }, H8: { title: '"不满意能换"是重要的服务保障', desc: '用户担心"月嫂临时变更或服务不达标",服务保障承诺能显著降低决策门槛' }, }; const CHAPTER_HYPOTHESIS_MAP = { challenge: ['H1', 'H2', 'H4'], competitor: ['H1', 'H5', 'H7'], pain: ['H2', 'H4', 'H8'], kano: ['H2', 'H4', 'H6'], scene: ['H1', 'H3'], threeaxis: ['H5', 'H7'], opportunity: ['H1', 'H3', 'H7'], blueprint: ['H2', 'H4', 'H7', 'H8'], }; const PLATFORM_LABELS = { xhs: { name: '小红书', color: '#FF2442', short: '红' }, douyin: { name: '抖音', color: '#1A1A1A', short: '抖' }, meituan: { name: '美团', color: '#FFD100', short: '美' }, unknown: { name: '其他', color: '#888', short: '—' }, }; const KEYWORD_TO_PRODUCT = { '洪诚到家': '洪诚到家', '洪城到家': '洪诚到家', '南昌月嫂洪诚': '洪诚到家', '洪诚到家怎么样': '洪诚到家(口碑)', '天鹅到家月嫂': '天鹅到家', '好孕妈妈月嫂': '好孕妈妈', '多喜娃月嫂': '多喜娃', '妈咪无忧月嫂': '妈咪无忧', '新手爸妈找月嫂': '场景话题', '坐月子注意事项': '场景话题', '月嫂多少钱一个月': '价格话题', '婆婆不让请月嫂': '场景话题', '月嫂怎么找靠谱': '决策话题', '月嫂不满意可以换吗': '服务保障话题', '社区月嫂': '社区月嫂(竞品)', '南昌月嫂对比': '竞品对比', '朋友推荐月嫂': '口碑话题', '南昌月嫂价格': '价格话题', '月嫂服务': '品类话题', '职场妈妈月子': '场景话题', '新手爸妈': '场景话题', '南昌短剧': '短剧话题', '婆媳关系坐月子': '场景话题', '南昌月嫂': '品类词', '南昌月嫂哪家好': '品类词', '江西月嫂': '品类词', }; 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; } 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 || '', }; } 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); } 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 (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, filterByHypothesis, filterByProduct, filterByKeyword, filterByPlatform, filterBySentiment, filterByTag, filterByContent, filterByMinLikes, topByLikes, sample, groupByTag, getEvidence, isSeed, isReal, getGlobalSourceLabel, };