#!/usr/bin/env node /** * 洪城到家 · 小红书竞品数据分析脚本 * * 功能: * 1. 从 _merged.json 读取数据,按品牌(product)分类 * 2. 识别本品(洪诚到家)vs 竞品(天鹅到家、好孕妈妈、多喜娃、妈咪无忧等) * 3. 计算各品牌的声量、情感、标签、假设覆盖 * 4. 输出结构化分析报告(控制台 + HTML) * * 用法: * node scripts/tools/xhs-analyze.js * node scripts/tools/xhs-analyze.js --html */ 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 REPORT_DIR = path.join(ROOT, 'reports'); if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); // ============================================================================= // 品牌映射配置 // ============================================================================= const BRAND_META = { '洪诚到家月嫂': { label: '洪诚到家', type: 'self', aliases: ['洪城到家', '洪诚家政', '南昌洪诚'], }, '天鹅到家月嫂': { label: '天鹅到家', type: 'competitor', tier: '全国龙头', aliases: ['天鹅到家', '天鹅家政'], }, '好孕妈妈月嫂': { label: '好孕妈妈', type: 'competitor', tier: '全国龙头', aliases: ['好孕妈妈', '好孕'], }, '多喜娃月嫂': { label: '多喜娃', type: 'competitor', tier: '区域强者', aliases: ['多喜娃'], }, '妈咪无忧月嫂': { label: '妈咪无忧', type: 'competitor', tier: '区域强者', aliases: ['妈咪无忧'], }, '宜尔宝月嫂': { label: '宜尔宝', type: 'competitor', tier: '区域', aliases: ['宜尔宝'], }, '爱侬家政月嫂': { label: '爱侬家政', type: 'competitor', tier: '区域', aliases: ['爱侬家政', '爱侬'], }, '优护佳月嫂': { label: '优护佳', type: 'competitor', tier: '区域', aliases: ['优护佳'], }, }; const SELF_KEYWORDS = ['洪诚到家', '洪城到家', '洪诚家政', '南昌洪诚']; const COMPETITOR_KEYWORDS = ['天鹅到家', '好孕妈妈', '多喜娃', '妈咪无忧', '宜尔宝', '爱侬家政', '优护佳']; // ============================================================================= // 假设(Hypothesis)配置 // ============================================================================= const HYPOTHESIS_META = { H1: { label: '医院地推', desc: '医院/产检/待产场景是高效获客渠道' }, H2: { label: '价格透明', desc: '明码标价是转化关键' }, H3: { label: '短剧营销', desc: '短剧/视频内容提升品牌认知' }, H4: { label: '专业度信任', desc: '专业资质证明是信任基础' }, H5: { label: '社区店威胁', desc: '社区小店是主要竞争对手' }, H6: { label: '老带新', desc: '口碑推荐是低成本获客渠道' }, H7: { label: '搜索主力', desc: '美团/小红书是搜索主力平台' }, H8: { label: '服务保障', desc: '不满意能换是核心保障诉求' }, }; const HYPOTHESIS_KEYWORDS = { H1: ['医院', '产检', '待产', '生孩子', '妇幼', '生产', '月嫂怎么找'], H2: ['价格', '多少钱', '收费', '报价', '性价比', '便宜', '贵', '月嫂多少钱'], H3: ['短剧', '抖音', '视频', '小红书', '看到', '刷到'], H4: ['专业', '资质', '证书', '星级', '靠谱', '放心', '正规', '经验'], H5: ['社区', '小店', '私人', '对比', '选择', '附近'], H6: ['朋友', '推荐', '介绍', '转介绍', '口碑', '好评', '亲戚', '邻居'], H7: ['美团', '大众点评', '搜索', '排名', '评价', '小红书', '抖音'], H8: ['换', '退', '不满意', '保障', '售后', '风险', '能换', '换人'], }; // ============================================================================= // 标签规则 // ============================================================================= const TAG_RULES = [ { tag: '价格敏感', re: /价格|多少钱|贵|便宜|性价比|收费|报价|花销|消费/ }, { tag: '专业度关注', re: /专业|资质|证书|星级|培训|经验|年限|持证/ }, { tag: '安全保障', re: /放心|靠谱|安全|保障|正规|放心|靠谱/ }, { tag: '医院渠道', re: /医院|产检|妇幼|待产|生孩子|建档|临产/ }, { tag: '熟人推荐', re: /朋友推荐|介绍|口碑|好评|亲戚|邻居|同事|推荐/ }, { tag: '线上搜索', re: /美团|小红书|抖音|搜索|大众点评|看到|刷到/ }, { tag: '服务担忧', re: /换|退|不满意|售后|保障|风险|换人|投诉/ }, { tag: '婆媳关系', re: /婆婆|奶奶|家里|老人|家婆|丈母娘/ }, { tag: '职场妈妈', re: /上班|工作|复工|职场|产假|回去上班/ }, { tag: '新手爸妈', re: /新手|第一胎|第一次|头胎|没经验|不懂/ }, { tag: '决策犹豫', re: /纠结|犹豫|担心|怕|考虑|不知道|怎么选/ }, { tag: '月嫂面试', re: /面试|挑|选择|对比|比较|筛选/ }, { tag: '月子餐', re: /月子餐|饮食|营养|煲汤|炖汤|忌口/ }, { tag: '新生儿护理', re: /新生儿|宝宝|婴儿|黄疸|脐带|喂养|母乳/ }, { tag: '产后恢复', re: /产后|恢复|身材|盆地肌|腹直肌|月子病/ }, { tag: '情绪价值', re: /心情|情绪|焦虑|抑郁|崩溃|开心|舒服/ }, ]; // ============================================================================= // 情感规则 // ============================================================================= const SENTIMENT_POS = /好|推荐|满意|专业|靠谱|放心|值得|不错|棒|优秀|喜欢|感谢|舒服|贴心|耐心|细心|开心|放心/; const SENTIMENT_NEG = /差|坑|骗|贵|不专业|不满意|后悔|吐槽|垃圾|失望|糟糕|骗人|黑|投诉|暴力|态度差|不负责任/; const SENTIMENT_CONFLICT = /但是|可是|纠结|担心|犹豫|想又怕|虽然|不过|可惜/; function inferSentiment(text) { const t = String(text || ''); const pos = SENTIMENT_POS.test(t); const neg = SENTIMENT_NEG.test(t); const conf = SENTIMENT_CONFLICT.test(t); if (conf && (pos || neg)) return 'conflicted'; if (pos && !neg) return 'positive'; if (neg && !pos) return 'negative'; return 'neutral'; } function inferTags(text) { const tags = []; for (const r of TAG_RULES) { if (r.re.test(text || '')) tags.push(r.tag); } return tags; } function inferHypotheses(text, extraHypos = []) { const results = new Set(extraHypos || []); const t = String(text || '').toLowerCase(); for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) { for (const kw of kws) { if (t.includes(kw.toLowerCase())) { results.add(h); break; } } } return Array.from(results); } // ============================================================================= // 数据加载与预处理 // ============================================================================= function loadData() { if (!fs.existsSync(MERGED_PATH)) { console.error(`❌ 找不到数据文件: ${MERGED_PATH}`); console.error(' 请先运行: node scripts/tools/hongcheng-collect.js --batch=all --merge'); process.exit(1); } const d = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8')); const items = (d.items || []).filter((i) => i.content && i.content.length > 3); console.log(`✅ 加载 ${items.length} 条 VOC 数据 (平台: ${Object.keys(d.meta.platforms || {}).join(', ')})`); return { items, meta: d.meta }; } function classifyBrand(product) { const p = String(product || ''); const selfFound = SELF_KEYWORDS.some((k) => p.includes(k)); if (selfFound) return 'self'; const compFound = COMPETITOR_KEYWORDS.some((k) => p.includes(k)); if (compFound) return 'competitor'; return 'other'; } function mapBrandLabel(product) { const p = String(product || ''); for (const [kw, meta] of Object.entries(BRAND_META)) { if (p.includes(kw)) return meta.label; for (const alias of (meta.aliases || [])) { if (p.includes(alias)) return meta.label; } } return p; } function preprocessItems(items) { return items.map((it) => { const brandClass = classifyBrand(it.product); const brandLabel = mapBrandLabel(it.product); const sentiment = inferSentiment(it.content); const tags = inferTags(it.content); const hypotheses = inferHypotheses(it.content, it.hypothesis || []); return { ...it, brandClass, brandLabel, sentiment, tags, hypotheses }; }); } // ============================================================================= // 分析函数 // ============================================================================= function analyzeByBrand(items) { const brands = {}; for (const it of items) { const b = it.brandLabel; if (!brands[b]) { brands[b] = { label: b, class: it.brandClass, items: [], sentiment: { positive: 0, negative: 0, neutral: 0, conflicted: 0 }, tags: {}, hypotheses: {}, topLiked: [], ipDistribution: {}, typeDistribution: { video: 0, comment: 0 }, }; } brands[b].items.push(it); brands[b].sentiment[it.sentiment] = (brands[b].sentiment[it.sentiment] || 0) + 1; brands[b].typeDistribution[it.type] = (brands[b].typeDistribution[it.type] || 0) + 1; for (const t of (it.tags || [])) { brands[b].tags[t] = (brands[b].tags[t] || 0) + 1; } for (const h of (it.hypotheses || [])) { brands[b].hypotheses[h] = (brands[b].hypotheses[h] || 0) + 1; } if (it.ip) { brands[b].ipDistribution[it.ip] = (brands[b].ipDistribution[it.ip] || 0) + 1; } } for (const b of Object.values(brands)) { b.topLiked = [...b.items].sort((a, c) => (c.likes || 0) - (a.likes || 0)).slice(0, 5); } return brands; } function analyzeHypothesisCoverage(items) { const coverage = {}; for (const h of Object.keys(HYPOTHESIS_META)) { coverage[h] = { meta: HYPOTHESIS_META[h], count: 0, examples: [], byBrand: {} }; } for (const it of items) { for (const h of (it.hypotheses || [])) { if (coverage[h]) { coverage[h].count++; if (coverage[h].examples.length < 3) { coverage[h].examples.push({ content: it.content.slice(0, 200), likes: it.likes, platform: it.platform, brand: it.brandLabel }); } coverage[h].byBrand[it.brandLabel] = (coverage[h].byBrand[it.brandLabel] || 0) + 1; } } } return coverage; } function analyzeTags(items) { const tagStats = {}; for (const it of items) { for (const t of (it.tags || [])) { if (!tagStats[t]) tagStats[t] = { count: 0, examples: [], byBrand: {} }; tagStats[t].count++; if (tagStats[t].examples.length < 2) { tagStats[t].examples.push({ content: it.content.slice(0, 150), likes: it.likes, platform: it.platform }); } tagStats[t].byBrand[it.brandLabel] = (tagStats[t].byBrand[it.brandLabel] || 0) + 1; } } return tagStats; } function analyzePainPoints(items) { const painPatterns = { '价格太贵': { re: /贵|价格高|太贵|付不起|花钱|费用/ }, '不专业': { re: /不专业|没经验|不靠谱|证书|资质/ }, '服务不满意': { re: /不满意|换|退|投诉|差|坑/ }, '找不到合适的': { re: /找不到|不知道怎么选|纠结|犹豫|怕|担心/ }, '家人反对': { re: /婆婆不让|家里不同意|老人不让|老公说/ }, '信息不透明': { re: /不了解|不知道|不清楚|怎么找/ }, }; const pains = {}; for (const [name, cfg] of Object.entries(painPatterns)) { pains[name] = { count: 0, examples: [], byBrand: {} }; } for (const it of items) { for (const [name, cfg] of Object.entries(painPatterns)) { if (cfg.re.test(it.content)) { pains[name].count++; if (pains[name].examples.length < 3) { pains[name].examples.push({ content: it.content.slice(0, 180), likes: it.likes, platform: it.platform, brand: it.brandLabel }); } pains[name].byBrand[it.brandLabel] = (pains[name].byBrand[it.brandLabel] || 0) + 1; } } } return pains; } function analyzeIpGeo(items) { const geo = {}; for (const it of items) { if (it.ip) { if (!geo[it.ip]) geo[it.ip] = { count: 0, brands: {} }; geo[it.ip].count++; geo[it.ip].brands[it.brandLabel] = (geo[it.ip].brands[it.brandLabel] || 0) + 1; } } return geo; } function analyzeSelfVsCompetitor(items) { const self = items.filter((i) => i.brandClass === 'self'); const competitors = items.filter((i) => i.brandClass === 'competitor'); const other = items.filter((i) => i.brandClass === 'other'); const brands = analyzeByBrand(items); const selfSentiment = { positive: 0, negative: 0, neutral: 0, conflicted: 0 }; for (const it of self) selfSentiment[it.sentiment] = (selfSentiment[it.sentiment] || 0) + 1; const compSentiment = { positive: 0, negative: 0, neutral: 0, conflicted: 0 }; for (const it of competitors) compSentiment[it.sentiment] = (compSentiment[it.sentiment] || 0) + 1; const tagDiff = {}; const selfTags = {}; const compTags = {}; for (const it of self) { for (const t of (it.tags || [])) selfTags[t] = (selfTags[t] || 0) + 1; } for (const it of competitors) { for (const t of (it.tags || [])) compTags[t] = (compTags[t] || 0) + 1; } const allTags = new Set([...Object.keys(selfTags), ...Object.keys(compTags)]); for (const t of allTags) { const s = selfTags[t] || 0; const c = compTags[t] || 0; tagDiff[t] = { self: s, competitor: c, delta: s - c }; } const selfTopTags = Object.entries(selfTags).sort((a, b) => b[1] - a[1]).slice(0, 5); const compTopTags = Object.entries(compTags).sort((a, b) => b[1] - a[1]).slice(0, 5); return { self, competitors, other, brands, selfSentiment, compSentiment, tagDiff, selfTopTags, compTopTags }; } function analyzeDecisionJourney(items) { const stages = { '需求触发': { re: /怀孕了|待产|产检|建档|新手爸妈|第一胎|头胎/, count: 0, examples: [] }, '信息搜索': { re: /怎么找|哪家好|多少钱|推荐|搜索|小红书|抖音|美团|大众点评/, count: 0, examples: [] }, '决策比较': { re: /纠结|犹豫|对比|比较|面试|选择|挑|天鹅到家|好孕妈妈|多喜娃/, count: 0, examples: [] }, '购买/签约': { re: /签了|定了|请了|下单|付款|签约/, count: 0, examples: [] }, '服务体验': { re: /用了|服务|月嫂|照顾|宝宝|做饭|护理|月子/, count: 0, examples: [] }, '口碑传播': { re: /推荐|介绍|朋友|转介绍|好评|吐槽|分享|发小红书/, count: 0, examples: [] }, }; for (const it of items) { for (const [stage, cfg] of Object.entries(stages)) { if (cfg.re.test(it.content)) { cfg.count++; if (cfg.examples.length < 3) { cfg.examples.push({ content: it.content.slice(0, 160), likes: it.likes, platform: it.platform, brand: it.brandLabel }); } } } } return stages; } // ============================================================================= // 控制台报告输出 // ============================================================================= function pad(s, len = 24) { return String(s).padEnd(len); } function bar(n, total, w = 20) { if (!total) return '░'.repeat(w); const f = Math.round((n / total) * w); return '█'.repeat(f) + '░'.repeat(w - f); } function printSection(title) { console.log('\n' + '═'.repeat(72)); console.log(' ' + title); console.log('═'.repeat(72)); } function printReport(analysis) { const { items, brands, hypoCov, tagStats, painPoints, geo, selfVsComp, journey, meta } = analysis; const total = items.length; console.log('\n'); console.log('╔══════════════════════════════════════════════════════════════════╗'); console.log('║ 洪城到家 · 小红书 VOC 竞品分析报告 ║'); console.log('╚══════════════════════════════════════════════════════════════════╝'); // 1. 总览 printSection('一、数据总览'); const selfTotal = selfVsComp.self.length; const compTotal = selfVsComp.competitors.length; const otherTotal = selfVsComp.other.length; console.log(` 总 VOC 条数:${total}`); console.log(` 本品(洪诚到家):${selfTotal} 条 (${total ? ((selfTotal/total)*100).toFixed(1) : 0}%)`); console.log(` 竞品讨论:${compTotal} 条 (${total ? ((compTotal/total)*100).toFixed(1) : 0}%)`); console.log(` 其他话题:${otherTotal} 条 (${total ? ((otherTotal/total)*100).toFixed(1) : 0}%)`); console.log(` 品牌覆盖:${Object.keys(brands).length} 个`); console.log(` 数据采集时间:${(meta.collectedAt || '').slice(0, 10)}`); // 2. 声量排行 printSection('二、品牌声量排行'); const brandList = Object.entries(brands).sort((a, b) => b[1].items.length - a[1].items.length); const brandTotal = brandList.reduce((s, [, b]) => s + b.items.length, 0); console.log(` ${pad('品牌')} ${pad('类型')} ${pad('声量')} 占比 情感分布`); console.log(' ' + '─'.repeat(68)); for (const [label, b] of brandList) { const cnt = b.items.length; const pct = brandTotal ? ((cnt / brandTotal) * 100).toFixed(1) : '0.0'; const p = b.sentiment.positive || 0; const n = b.sentiment.negative || 0; const cls = b.class === 'self' ? '本品' : (BRAND_META[label]?.tier || '竞品'); console.log(` ${pad(label, 10)} ${pad(cls, 8)} ${pad(cnt, 6)} ${pct.padStart(6)}% ${bar(p + n, cnt, 8)} +${p}/-${n}`); } // 3. 本品 vs 竞品情感对比 printSection('三、本品 vs 竞品 情感分布'); const ss = selfVsComp.selfSentiment; const cs = selfVsComp.compSentiment; const sTotal = selfTotal || 1; const cTotal = compTotal || 1; console.log(` ${pad('情感')}${pad('本品('+selfTotal+')',14)}${pad('竞品('+compTotal+')',14)}差异`); console.log(' ' + '─'.repeat(60)); for (const sent of ['positive', 'negative', 'neutral', 'conflicted']) { const sl = ss[sent] || 0; const cl = cs[sent] || 0; const sd = sTotal ? ((sl / sTotal) * 100).toFixed(0) : 0; const cd = cTotal ? ((cl / cTotal) * 100).toFixed(0) : 0; const diff = parseInt(sd) - parseInt(cd); const sign = diff > 0 ? '+' : ''; const labels = { positive: '正向', negative: '负向', neutral: '中立', conflicted: '矛盾' }; console.log(` ${pad(labels[sent])}${pad(sl + '(' + sd + '%)', 14)}${pad(cl + '(' + cd + '%)', 14)}${sign}${diff}%`); } // 4. 标签分布 printSection('四、用户关注标签分布 (TOP 15)'); const sortedTags = Object.entries(tagStats).sort((a, b) => b[1].count - a[1].count).slice(0, 15); console.log(` ${pad('标签')} ${pad('出现次数')} 品牌分布`); console.log(' ' + '─'.repeat(65)); for (const [tag, s] of sortedTags) { const topBrands = Object.entries(s.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3) .map(([b, c]) => `${b}(${c})`).join(' '); console.log(` ${pad(tag, 12)} ${pad(s.count, 8)} ${topBrands}`); } // 5. 假设验证 printSection('五、假设(Hypothesis)验证覆盖'); const sortedHypos = Object.entries(hypoCov).sort((a, b) => b[1].count - a[1].count); console.log(` ${pad('假设')} ${pad('描述')} ${pad('声量')} 验证状态`); console.log(' ' + '─'.repeat(68)); for (const [h, data] of sortedHypos) { const m = HYPOTHESIS_META[h]; const status = data.count >= 100 ? '✅ 充分' : data.count >= 30 ? '⚠️ 少量' : '❌ 稀缺'; console.log(` ${pad(h + ' ' + m.label, 16)} ${pad(data.count, 8)} ${status}`); } // 6. 痛点分析 printSection('六、核心痛点分析'); const sortedPains = Object.entries(painPoints).sort((a, b) => b[1].count - a[1].count); console.log(` ${pad('痛点类型')} ${pad('出现次数')} 品牌分布`); console.log(' ' + '─'.repeat(65)); for (const [name, p] of sortedPains) { if (p.count === 0) continue; const topBrands = Object.entries(p.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3) .map(([b, c]) => `${b}(${c})`).join(' '); console.log(` ${pad(name, 14)} ${pad(p.count, 8)} ${topBrands}`); } // 7. IP地理 printSection('七、IP 地理分布 (TOP 10)'); const sortedGeo = Object.entries(geo).sort((a, b) => b[1].count - a[1].count).slice(0, 10); console.log(` ${pad('IP属地')} ${pad('条数')} 品牌占比`); console.log(' ' + '─'.repeat(60)); for (const [ip, g] of sortedGeo) { const topBrand = Object.entries(g.brands).sort((a, b) => b[1] - a[1])[0]; const pct = g.total ? ((topBrand[1] / g.count) * 100).toFixed(0) : 0; console.log(` ${pad(ip, 12)} ${pad(g.count, 6)} ${topBrand[0]}(${pct}%)`); } // 8. 决策旅程 printSection('八、用户决策旅程分布'); const journeyTotal = Object.values(journey).reduce((s, c) => s + c.count, 0); for (const [stage, cfg] of Object.entries(journey)) { const pct = journeyTotal ? ((cfg.count / journeyTotal) * 100).toFixed(1) : '0.0'; console.log(` ${pad(stage, 14)} ${pad(cfg.count, 6)} ${pct}% ${bar(cfg.count, journeyTotal, 20)}`); } // 9. 高赞 VOC printSection('九、高赞 VOC 精选 (TOP 15)'); const topLiked = [...items].sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, 15); for (const it of topLiked) { const content = String(it.content).slice(0, 80); console.log(` [${pad(it.platform, 7)}] ♥${String(it.likes || 0).padStart(5)} [${pad(it.brandLabel, 8)}] ${content}`); } // 10. 本品声量TOP标签 vs 竞品 printSection('十、本品 vs 竞品 标签差异分析'); const tagDiffSorted = Object.entries(selfVsComp.tagDiff).sort((a, b) => Math.abs(b[1].delta) - Math.abs(a[1].delta)); console.log(` ${pad('标签')} 本品 竞品 差值 机会`); console.log(' ' + '─'.repeat(60)); for (const [tag, d] of tagDiffSorted.slice(0, 10)) { const opportunity = d.self < d.competitor ? '⬆️ 本品机会' : '⬇️ 竞品领先'; console.log(` ${pad(tag, 10)} ${pad(d.self, 5)} ${pad(d.competitor, 5)} ${pad(d.delta > 0 ? '+' + d.delta : d.delta, 6)} ${opportunity}`); } console.log('\n' + '═'.repeat(72)); } // ============================================================================= // HTML 报告生成 // ============================================================================= function generateHTML(analysis) { const { items, brands, hypoCov, tagStats, painPoints, geo, selfVsComp, journey, meta } = analysis; const total = items.length; const brandList = Object.entries(brands).sort((a, b) => b[1].items.length - a[1].items.length); const sortedTags = Object.entries(tagStats).sort((a, b) => b[1].count - a[1].count).slice(0, 15); const sortedHypos = Object.entries(hypoCov).sort((a, b) => b[1].count - a[1].count); const sortedPains = Object.entries(painPoints).sort((a, b) => b[1].count - a[1].count); const sortedGeo = Object.entries(geo).sort((a, b) => b[1].count - a[1].count).slice(0, 10); const topLiked = [...items].sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, 20); const journeyTotal = Object.values(journey).reduce((s, c) => s + c.count, 0); const brandRows = brandList.map(([label, b]) => { const cnt = b.items.length; const pct = total ? ((cnt / total) * 100).toFixed(1) : '0.0'; const cls = b.class === 'self' ? 'tag-self' : 'tag-comp'; const clsLabel = b.class === 'self' ? '本品' : (BRAND_META[label]?.tier || '竞品'); return ` ${label} ${clsLabel} ${cnt} ${pct}% ${b.sentiment.positive || 0} ${b.sentiment.negative || 0} ${b.sentiment.neutral || 0} ${b.sentiment.conflicted || 0} `; }).join('\n'); const tagRows = sortedTags.map(([tag, s]) => { const brands = Object.entries(s.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3) .map(([b, c]) => `${b} ${c}`).join(' '); return `${tag}${s.count}${brands}`; }).join('\n'); const hypoRows = sortedHypos.map(([h, data]) => { const m = HYPOTHESIS_META[h]; const status = data.count >= 100 ? '✅ 充分' : data.count >= 30 ? '⚠️ 少量' : '❌ 稀缺'; const barW = Math.min(100, (data.count / 200) * 100); return ` ${h}
${m.label} ${m.desc} ${data.count}
${status} `; }).join('\n'); const painRows = sortedPains.filter(([,p]) => p.count > 0).map(([name, p]) => { const brands = Object.entries(p.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3) .map(([b, c]) => `${b} ${c}`).join(' '); return `${name}${p.count}${brands}`; }).join('\n'); const geoRows = sortedGeo.map(([ip, g]) => { const topBrand = Object.entries(g.brands).sort((a, b) => b[1] - a[1])[0]; return `${ip}${g.count}${topBrand[0]} (${topBrand[1]})`; }).join('\n'); const journeyRows = Object.entries(journey).map(([stage, cfg]) => { const pct = journeyTotal ? ((cfg.count / journeyTotal) * 100).toFixed(1) : '0.0'; const barW = journeyTotal ? ((cfg.count / journeyTotal) * 100) : 0; return ` ${stage} ${cfg.count} ${pct}%
`; }).join('\n'); const vocRows = topLiked.map((it) => { const content = String(it.content).replace(//g, '>').slice(0, 120); const ex = it.content.length > 120 ? '...' : ''; return ` ♥ ${it.likes || 0} ${it.platform} ${it.brandLabel} ${content}${ex} `; }).join('\n'); const selfSent = selfVsComp.selfSentiment; const compSent = selfVsComp.compSentiment; const sentimentData = JSON.stringify({ self: selfSent, competitor: compSent }); return ` 洪城到家 · 小红书竞品VOC分析报告

洪城到家 · 小红书竞品VOC分析报告

基于真实用户评论数据的多维度竞品分析 | 声量 × 情感 × 痛点 × 假设验证

📊 总 VOC: ${total} 条 🏷️ 品牌覆盖: ${brandList.length} 个 📅 采集时间: ${(meta.collectedAt || '').slice(0, 10)}
${selfVsComp.self.length}
本品声量 (洪诚到家)
${selfVsComp.competitors.length}
竞品声量合计
${brandList.filter(([,b]) => b.class === 'competitor').length}
竞品种类
${(selfVsComp.self.length && total) ? ((selfVsComp.self.length/total)*100).toFixed(1)+'%' : '0%'}
本品占比
一、品牌声量排行
${brandRows}
品牌类型声量占比 正向负向 中立矛盾
二、本品 vs 竞品 情感分布对比

本品(洪诚到家)情感

${['positive','negative','neutral','conflicted'].map(s => { const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'}; const colors = {positive:'pos',negative:'neg',neutral:'neu',conflicted:'con'}; const cnt = selfSent[s] || 0; const total2 = selfVsComp.self.length || 1; const w = (cnt/total2)*100; return `
${labels[s]}
${cnt} (${w.toFixed(0)}%)
`; }).join('')}

竞品(行业平均)情感

${['positive','negative','neutral','conflicted'].map(s => { const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'}; const colors = {positive:'pos',negative:'neg',neutral:'neu',conflicted:'con'}; const cnt = compSent[s] || 0; const total2 = selfVsComp.competitors.length || 1; const w = (cnt/total2)*100; return `
${labels[s]}
${cnt} (${w.toFixed(0)}%)
`; }).join('')}
三、用户关注标签分布 (TOP 15)
${tagRows}
标签出现次数品牌分布
四、假设(Hypothesis)验证覆盖
${hypoRows}
假设描述声量覆盖进度状态
五、核心痛点分析
${painRows}
痛点类型出现次数品牌分布
六、IP 地理分布 (TOP 10)
${geoRows}
IP属地条数主要品牌
七、用户决策旅程分布
${journeyRows}
阶段出现次数占比分布
八、高赞 VOC 精选 (TOP 20)
${vocRows}
点赞平台品牌内容摘要
九、本品 vs 竞品 标签差异分析
${Object.entries(selfVsComp.tagDiff) .sort((a, b) => Math.abs(b[1].delta) - Math.abs(a[1].delta)) .slice(0, 10).map(([tag, d]) => { const insight = d.self > d.competitor ? '⬆️ 本品关注更多' : '⬇️ 竞品关注更多'; return ``; }).join('\n')}
标签本品竞品差值洞察
${tag} ${d.self} ${d.competitor} ${d.delta > 0 ? '+' : ''}${d.delta} ${insight}
`; } // ============================================================================= // 主函数 // ============================================================================= function main() { const argv = process.argv.slice(2); const doHTML = argv.includes('--html') || argv.includes('--report'); const doCompact = argv.includes('--compact'); console.log('🔍 加载数据...'); const { items, meta } = loadData(); console.log('🔧 预处理数据(情感分析、标签、假设)...'); const processed = preprocessItems(items); console.log('📊 执行多维度分析...'); const brands = analyzeByBrand(processed); const hypoCov = analyzeHypothesisCoverage(processed); const tagStats = analyzeTags(processed); const painPoints = analyzePainPoints(processed); const geo = analyzeIpGeo(processed); const selfVsComp = analyzeSelfVsCompetitor(processed); const journey = analyzeDecisionJourney(processed); const analysis = { items: processed, brands, hypoCov, tagStats, painPoints, geo, selfVsComp, journey, meta }; console.log('📝 生成报告...'); printReport(analysis); if (doHTML) { const html = generateHTML(analysis); const outPath = path.join(REPORT_DIR, 'xhs-competitor-analysis.html'); fs.writeFileSync(outPath, html, 'utf8'); console.log(`\n✅ HTML 报告已生成: ${outPath}`); } if (!doHTML && !doCompact) { console.log('\n💡 提示:使用 --html 参数可生成完整 HTML 报告'); console.log(' 使用 --compact 参数仅显示精简摘要'); } } if (require.main === module) { main(); } module.exports = { loadData, preprocessItems, analyzeByBrand, analyzeHypothesisCoverage, analyzeTags, analyzePainPoints, analyzeIpGeo, analyzeSelfVsCompetitor, analyzeDecisionJourney };