| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916 |
- #!/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 `<tr>
- <td><span class="brand-tag ${cls}">${label}</span></td>
- <td>${clsLabel}</td>
- <td class="num">${cnt}</td>
- <td class="num">${pct}%</td>
- <td class="num positive">${b.sentiment.positive || 0}</td>
- <td class="num negative">${b.sentiment.negative || 0}</td>
- <td class="num">${b.sentiment.neutral || 0}</td>
- <td class="num">${b.sentiment.conflicted || 0}</td>
- </tr>`;
- }).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]) => `<span class="mini-tag">${b} ${c}</span>`).join(' ');
- return `<tr><td>${tag}</td><td class="num">${s.count}</td><td>${brands}</td></tr>`;
- }).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 `<tr>
- <td><strong>${h}</strong><br><small>${m.label}</small></td>
- <td><small>${m.desc}</small></td>
- <td class="num">${data.count}</td>
- <td><div class="mini-bar"><div class="mini-fill" style="width:${barW}%"></div></div></td>
- <td>${status}</td>
- </tr>`;
- }).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]) => `<span class="mini-tag">${b} ${c}</span>`).join(' ');
- return `<tr><td>${name}</td><td class="num">${p.count}</td><td>${brands}</td></tr>`;
- }).join('\n');
- const geoRows = sortedGeo.map(([ip, g]) => {
- const topBrand = Object.entries(g.brands).sort((a, b) => b[1] - a[1])[0];
- return `<tr><td>${ip}</td><td class="num">${g.count}</td><td>${topBrand[0]} (${topBrand[1]})</td></tr>`;
- }).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 `<tr>
- <td>${stage}</td>
- <td class="num">${cfg.count}</td>
- <td class="num">${pct}%</td>
- <td><div class="mini-bar"><div class="mini-fill" style="width:${barW}%"></div></div></td>
- </tr>`;
- }).join('\n');
- const vocRows = topLiked.map((it) => {
- const content = String(it.content).replace(/</g, '<').replace(/>/g, '>').slice(0, 120);
- const ex = it.content.length > 120 ? '...' : '';
- return `<tr>
- <td class="num">♥ ${it.likes || 0}</td>
- <td>${it.platform}</td>
- <td><span class="brand-tag ${it.brandClass === 'self' ? 'tag-self' : 'tag-comp'}">${it.brandLabel}</span></td>
- <td><small>${content}${ex}</small></td>
- </tr>`;
- }).join('\n');
- const selfSent = selfVsComp.selfSentiment;
- const compSent = selfVsComp.compSentiment;
- const sentimentData = JSON.stringify({ self: selfSent, competitor: compSent });
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>洪城到家 · 小红书竞品VOC分析报告</title>
- <style>
- :root {
- --bg-0: #ffffff; --bg-1: #f7f8fa; --bg-2: #eef0f4; --border: #e2e5ed;
- --text-1: #1a1d23; --text-2: #4a5568; --text-3: #8896a6;
- --accent: #2563eb; --accent-light: #dbeafe;
- --positive: #16a34a; --negative: #dc2626; --neutral: #6b7280;
- --tag-self-bg: #dbeafe; --tag-self-color: #1e40af;
- --tag-comp-bg: #fce7f3; --tag-comp-color: #9d174d;
- --radius: 8px;
- }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body { font-family: 'PingFang SC', 'Microsoft YaHei', 'Inter', sans-serif; background: var(--bg-1); color: var(--text-1); font-size: 14px; line-height: 1.6; }
- .container { max-width: 1200px; margin: 0 auto; padding: 32px 20px; }
- .hd { background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); color: white; padding: 40px 32px; border-radius: var(--radius); margin-bottom: 32px; }
- .hd h1 { font-size: 1.8rem; font-weight: 800; margin-bottom: 8px; }
- .hd p { opacity: 0.85; font-size: 0.9rem; }
- .hd .meta { margin-top: 16px; display: flex; gap: 24px; font-size: 0.82rem; opacity: 0.8; }
- .kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px; }
- .kpi { background: white; border-radius: var(--radius); padding: 20px; border: 1px solid var(--border); }
- .kpi .val { font-size: 2rem; font-weight: 800; color: var(--accent); }
- .kpi .lbl { color: var(--text-2); font-size: 0.8rem; margin-top: 4px; }
- .kpi.self-card .val { color: #1e40af; }
- .card { background: white; border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: 24px; overflow: hidden; }
- .card-head { padding: 16px 24px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 1rem; background: var(--bg-1); }
- .card-body { padding: 20px 24px; }
- table { width: 100%; border-collapse: collapse; }
- th { text-align: left; padding: 10px 12px; font-size: 0.78rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 2px solid var(--border); background: var(--bg-1); }
- td { padding: 10px 12px; border-bottom: 1px solid var(--bg-2); vertical-align: top; }
- tr:last-child td { border-bottom: none; }
- tr:hover td { background: var(--bg-1); }
- .num { text-align: right; font-variant-numeric: tabular-nums; }
- .positive { color: var(--positive); font-weight: 600; }
- .negative { color: var(--negative); font-weight: 600; }
- .brand-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; font-weight: 600; }
- .tag-self { background: var(--tag-self-bg); color: var(--tag-self-color); }
- .tag-comp { background: var(--tag-comp-bg); color: var(--tag-comp-color); }
- .mini-tag { display: inline-block; background: var(--bg-2); padding: 1px 6px; border-radius: 3px; font-size: 0.72rem; margin: 1px; }
- .mini-bar { background: var(--bg-2); border-radius: 3px; height: 8px; overflow: hidden; }
- .mini-fill { background: var(--accent); height: 100%; border-radius: 3px; transition: width 0.3s; }
- .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
- .sentiment-chart { display: flex; flex-direction: column; gap: 12px; }
- .sent-row { display: flex; align-items: center; gap: 12px; }
- .sent-label { width: 60px; font-size: 0.82rem; color: var(--text-2); }
- .sent-bar-bg { flex: 1; height: 20px; background: var(--bg-2); border-radius: 4px; overflow: hidden; }
- .sent-fill { height: 100%; border-radius: 4px; }
- .sent-fill.pos { background: var(--positive); }
- .sent-fill.neg { background: var(--negative); }
- .sent-fill.neu { background: var(--neutral); }
- .sent-fill.con { background: #f59e0b; }
- .sent-count { width: 50px; text-align: right; font-size: 0.82rem; font-variant-numeric: tabular-nums; }
- .example { background: var(--bg-1); border-radius: 6px; padding: 12px; margin-top: 8px; font-size: 0.82rem; color: var(--text-2); border-left: 3px solid var(--accent); }
- .example small { color: var(--text-3); }
- .footer { text-align: center; padding: 32px; color: var(--text-3); font-size: 0.78rem; }
- @media (max-width: 768px) {
- .kpi-row { grid-template-columns: repeat(2, 1fr); }
- .chart-grid { grid-template-columns: 1fr; }
- }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="hd">
- <h1>洪城到家 · 小红书竞品VOC分析报告</h1>
- <p>基于真实用户评论数据的多维度竞品分析 | 声量 × 情感 × 痛点 × 假设验证</p>
- <div class="meta">
- <span>📊 总 VOC: ${total} 条</span>
- <span>🏷️ 品牌覆盖: ${brandList.length} 个</span>
- <span>📅 采集时间: ${(meta.collectedAt || '').slice(0, 10)}</span>
- </div>
- </div>
- <div class="kpi-row">
- <div class="kpi self-card">
- <div class="val">${selfVsComp.self.length}</div>
- <div class="lbl">本品声量 (洪诚到家)</div>
- </div>
- <div class="kpi">
- <div class="val">${selfVsComp.competitors.length}</div>
- <div class="lbl">竞品声量合计</div>
- </div>
- <div class="kpi">
- <div class="val">${brandList.filter(([,b]) => b.class === 'competitor').length}</div>
- <div class="lbl">竞品种类</div>
- </div>
- <div class="kpi">
- <div class="val">${(selfVsComp.self.length && total) ? ((selfVsComp.self.length/total)*100).toFixed(1)+'%' : '0%'}</div>
- <div class="lbl">本品占比</div>
- </div>
- </div>
- <div class="card">
- <div class="card-head">一、品牌声量排行</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr>
- <th>品牌</th><th>类型</th><th class="num">声量</th><th class="num">占比</th>
- <th class="num positive">正向</th><th class="num negative">负向</th>
- <th class="num">中立</th><th class="num">矛盾</th>
- </tr></thead>
- <tbody>${brandRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">二、本品 vs 竞品 情感分布对比</div>
- <div class="card-body">
- <div class="chart-grid">
- <div>
- <h4 style="margin-bottom:12px;color:var(--text-2);font-size:0.85rem">本品(洪诚到家)情感</h4>
- <div class="sentiment-chart">
- ${['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 `<div class="sent-row">
- <div class="sent-label">${labels[s]}</div>
- <div class="sent-bar-bg"><div class="sent-fill ${colors[s]}" style="width:${w}%"></div></div>
- <div class="sent-count">${cnt} (${w.toFixed(0)}%)</div>
- </div>`;
- }).join('')}
- </div>
- </div>
- <div>
- <h4 style="margin-bottom:12px;color:var(--text-2);font-size:0.85rem">竞品(行业平均)情感</h4>
- <div class="sentiment-chart">
- ${['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 `<div class="sent-row">
- <div class="sent-label">${labels[s]}</div>
- <div class="sent-bar-bg"><div class="sent-fill ${colors[s]}" style="width:${w}%"></div></div>
- <div class="sent-count">${cnt} (${w.toFixed(0)}%)</div>
- </div>`;
- }).join('')}
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="card">
- <div class="card-head">三、用户关注标签分布 (TOP 15)</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>标签</th><th class="num">出现次数</th><th>品牌分布</th></tr></thead>
- <tbody>${tagRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">四、假设(Hypothesis)验证覆盖</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>假设</th><th>描述</th><th class="num">声量</th><th>覆盖进度</th><th>状态</th></tr></thead>
- <tbody>${hypoRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">五、核心痛点分析</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>痛点类型</th><th class="num">出现次数</th><th>品牌分布</th></tr></thead>
- <tbody>${painRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">六、IP 地理分布 (TOP 10)</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>IP属地</th><th class="num">条数</th><th>主要品牌</th></tr></thead>
- <tbody>${geoRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">七、用户决策旅程分布</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>阶段</th><th class="num">出现次数</th><th class="num">占比</th><th>分布</th></tr></thead>
- <tbody>${journeyRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">八、高赞 VOC 精选 (TOP 20)</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>点赞</th><th>平台</th><th>品牌</th><th>内容摘要</th></tr></thead>
- <tbody>${vocRows}</tbody>
- </table>
- </div>
- </div>
- <div class="card">
- <div class="card-head">九、本品 vs 竞品 标签差异分析</div>
- <div class="card-body" style="overflow-x:auto">
- <table>
- <thead><tr><th>标签</th><th class="num">本品</th><th class="num">竞品</th><th class="num">差值</th><th>洞察</th></tr></thead>
- <tbody>${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 `<tr>
- <td>${tag}</td>
- <td class="num">${d.self}</td>
- <td class="num">${d.competitor}</td>
- <td class="num ${d.delta > 0 ? 'positive' : 'negative'}">${d.delta > 0 ? '+' : ''}${d.delta}</td>
- <td><small>${insight}</small></td>
- </tr>`;
- }).join('\n')}</tbody>
- </table>
- </div>
- </div>
- <div class="footer">
- <p>洪城到家 · 小红书竞品VOC分析报告 | 数据来源: _merged.json | 生成时间: ${new Date().toLocaleString('zh-CN')}</p>
- </div>
- </div>
- </body>
- </html>`;
- }
- // =============================================================================
- // 主函数
- // =============================================================================
- 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 };
|