| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- // ==============================================================================
- // 洪城到家 · 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,
- };
|