#!/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('')}
七、用户决策旅程分布
| 阶段 | 出现次数 | 占比 | 分布 |
${journeyRows}
九、本品 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 `
| ${tag} |
${d.self} |
${d.competitor} |
${d.delta > 0 ? '+' : ''}${d.delta} |
${insight} |
`;
}).join('\n')}
`;
}
// =============================================================================
// 主函数
// =============================================================================
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 };