hongcheng-analyze-xhs.js 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
  1. #!/usr/bin/env node
  2. /**
  3. * 洪城到家 · 小红书竞品数据分析脚本
  4. *
  5. * 功能:
  6. * 1. 从 _merged.json 读取数据,按品牌(product)分类
  7. * 2. 识别本品(洪诚到家)vs 竞品(天鹅到家、好孕妈妈、多喜娃、妈咪无忧等)
  8. * 3. 计算各品牌的声量、情感、标签、假设覆盖
  9. * 4. 输出结构化分析报告(控制台 + HTML)
  10. *
  11. * 用法:
  12. * node scripts/tools/xhs-analyze.js
  13. * node scripts/tools/xhs-analyze.js --html
  14. */
  15. const fs = require('fs');
  16. const path = require('path');
  17. const ROOT = path.resolve(__dirname, '..', '..');
  18. const RAW_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw');
  19. const MERGED_PATH = path.join(RAW_DIR, '_merged.json');
  20. const REPORT_DIR = path.join(ROOT, 'reports');
  21. if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true });
  22. // =============================================================================
  23. // 品牌映射配置
  24. // =============================================================================
  25. const BRAND_META = {
  26. '洪诚到家月嫂': {
  27. label: '洪诚到家',
  28. type: 'self',
  29. aliases: ['洪城到家', '洪诚家政', '南昌洪诚'],
  30. },
  31. '天鹅到家月嫂': {
  32. label: '天鹅到家',
  33. type: 'competitor',
  34. tier: '全国龙头',
  35. aliases: ['天鹅到家', '天鹅家政'],
  36. },
  37. '好孕妈妈月嫂': {
  38. label: '好孕妈妈',
  39. type: 'competitor',
  40. tier: '全国龙头',
  41. aliases: ['好孕妈妈', '好孕'],
  42. },
  43. '多喜娃月嫂': {
  44. label: '多喜娃',
  45. type: 'competitor',
  46. tier: '区域强者',
  47. aliases: ['多喜娃'],
  48. },
  49. '妈咪无忧月嫂': {
  50. label: '妈咪无忧',
  51. type: 'competitor',
  52. tier: '区域强者',
  53. aliases: ['妈咪无忧'],
  54. },
  55. '宜尔宝月嫂': {
  56. label: '宜尔宝',
  57. type: 'competitor',
  58. tier: '区域',
  59. aliases: ['宜尔宝'],
  60. },
  61. '爱侬家政月嫂': {
  62. label: '爱侬家政',
  63. type: 'competitor',
  64. tier: '区域',
  65. aliases: ['爱侬家政', '爱侬'],
  66. },
  67. '优护佳月嫂': {
  68. label: '优护佳',
  69. type: 'competitor',
  70. tier: '区域',
  71. aliases: ['优护佳'],
  72. },
  73. };
  74. const SELF_KEYWORDS = ['洪诚到家', '洪城到家', '洪诚家政', '南昌洪诚'];
  75. const COMPETITOR_KEYWORDS = ['天鹅到家', '好孕妈妈', '多喜娃', '妈咪无忧', '宜尔宝', '爱侬家政', '优护佳'];
  76. // =============================================================================
  77. // 假设(Hypothesis)配置
  78. // =============================================================================
  79. const HYPOTHESIS_META = {
  80. H1: { label: '医院地推', desc: '医院/产检/待产场景是高效获客渠道' },
  81. H2: { label: '价格透明', desc: '明码标价是转化关键' },
  82. H3: { label: '短剧营销', desc: '短剧/视频内容提升品牌认知' },
  83. H4: { label: '专业度信任', desc: '专业资质证明是信任基础' },
  84. H5: { label: '社区店威胁', desc: '社区小店是主要竞争对手' },
  85. H6: { label: '老带新', desc: '口碑推荐是低成本获客渠道' },
  86. H7: { label: '搜索主力', desc: '美团/小红书是搜索主力平台' },
  87. H8: { label: '服务保障', desc: '不满意能换是核心保障诉求' },
  88. };
  89. const HYPOTHESIS_KEYWORDS = {
  90. H1: ['医院', '产检', '待产', '生孩子', '妇幼', '生产', '月嫂怎么找'],
  91. H2: ['价格', '多少钱', '收费', '报价', '性价比', '便宜', '贵', '月嫂多少钱'],
  92. H3: ['短剧', '抖音', '视频', '小红书', '看到', '刷到'],
  93. H4: ['专业', '资质', '证书', '星级', '靠谱', '放心', '正规', '经验'],
  94. H5: ['社区', '小店', '私人', '对比', '选择', '附近'],
  95. H6: ['朋友', '推荐', '介绍', '转介绍', '口碑', '好评', '亲戚', '邻居'],
  96. H7: ['美团', '大众点评', '搜索', '排名', '评价', '小红书', '抖音'],
  97. H8: ['换', '退', '不满意', '保障', '售后', '风险', '能换', '换人'],
  98. };
  99. // =============================================================================
  100. // 标签规则
  101. // =============================================================================
  102. const TAG_RULES = [
  103. { tag: '价格敏感', re: /价格|多少钱|贵|便宜|性价比|收费|报价|花销|消费/ },
  104. { tag: '专业度关注', re: /专业|资质|证书|星级|培训|经验|年限|持证/ },
  105. { tag: '安全保障', re: /放心|靠谱|安全|保障|正规|放心|靠谱/ },
  106. { tag: '医院渠道', re: /医院|产检|妇幼|待产|生孩子|建档|临产/ },
  107. { tag: '熟人推荐', re: /朋友推荐|介绍|口碑|好评|亲戚|邻居|同事|推荐/ },
  108. { tag: '线上搜索', re: /美团|小红书|抖音|搜索|大众点评|看到|刷到/ },
  109. { tag: '服务担忧', re: /换|退|不满意|售后|保障|风险|换人|投诉/ },
  110. { tag: '婆媳关系', re: /婆婆|奶奶|家里|老人|家婆|丈母娘/ },
  111. { tag: '职场妈妈', re: /上班|工作|复工|职场|产假|回去上班/ },
  112. { tag: '新手爸妈', re: /新手|第一胎|第一次|头胎|没经验|不懂/ },
  113. { tag: '决策犹豫', re: /纠结|犹豫|担心|怕|考虑|不知道|怎么选/ },
  114. { tag: '月嫂面试', re: /面试|挑|选择|对比|比较|筛选/ },
  115. { tag: '月子餐', re: /月子餐|饮食|营养|煲汤|炖汤|忌口/ },
  116. { tag: '新生儿护理', re: /新生儿|宝宝|婴儿|黄疸|脐带|喂养|母乳/ },
  117. { tag: '产后恢复', re: /产后|恢复|身材|盆地肌|腹直肌|月子病/ },
  118. { tag: '情绪价值', re: /心情|情绪|焦虑|抑郁|崩溃|开心|舒服/ },
  119. ];
  120. // =============================================================================
  121. // 情感规则
  122. // =============================================================================
  123. const SENTIMENT_POS = /好|推荐|满意|专业|靠谱|放心|值得|不错|棒|优秀|喜欢|感谢|舒服|贴心|耐心|细心|开心|放心/;
  124. const SENTIMENT_NEG = /差|坑|骗|贵|不专业|不满意|后悔|吐槽|垃圾|失望|糟糕|骗人|黑|投诉|暴力|态度差|不负责任/;
  125. const SENTIMENT_CONFLICT = /但是|可是|纠结|担心|犹豫|想又怕|虽然|不过|可惜/;
  126. function inferSentiment(text) {
  127. const t = String(text || '');
  128. const pos = SENTIMENT_POS.test(t);
  129. const neg = SENTIMENT_NEG.test(t);
  130. const conf = SENTIMENT_CONFLICT.test(t);
  131. if (conf && (pos || neg)) return 'conflicted';
  132. if (pos && !neg) return 'positive';
  133. if (neg && !pos) return 'negative';
  134. return 'neutral';
  135. }
  136. function inferTags(text) {
  137. const tags = [];
  138. for (const r of TAG_RULES) {
  139. if (r.re.test(text || '')) tags.push(r.tag);
  140. }
  141. return tags;
  142. }
  143. function inferHypotheses(text, extraHypos = []) {
  144. const results = new Set(extraHypos || []);
  145. const t = String(text || '').toLowerCase();
  146. for (const [h, kws] of Object.entries(HYPOTHESIS_KEYWORDS)) {
  147. for (const kw of kws) {
  148. if (t.includes(kw.toLowerCase())) { results.add(h); break; }
  149. }
  150. }
  151. return Array.from(results);
  152. }
  153. // =============================================================================
  154. // 数据加载与预处理
  155. // =============================================================================
  156. function loadData() {
  157. if (!fs.existsSync(MERGED_PATH)) {
  158. console.error(`❌ 找不到数据文件: ${MERGED_PATH}`);
  159. console.error(' 请先运行: node scripts/tools/hongcheng-collect.js --batch=all --merge');
  160. process.exit(1);
  161. }
  162. const d = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8'));
  163. const items = (d.items || []).filter((i) => i.content && i.content.length > 3);
  164. console.log(`✅ 加载 ${items.length} 条 VOC 数据 (平台: ${Object.keys(d.meta.platforms || {}).join(', ')})`);
  165. return { items, meta: d.meta };
  166. }
  167. function classifyBrand(product) {
  168. const p = String(product || '');
  169. const selfFound = SELF_KEYWORDS.some((k) => p.includes(k));
  170. if (selfFound) return 'self';
  171. const compFound = COMPETITOR_KEYWORDS.some((k) => p.includes(k));
  172. if (compFound) return 'competitor';
  173. return 'other';
  174. }
  175. function mapBrandLabel(product) {
  176. const p = String(product || '');
  177. for (const [kw, meta] of Object.entries(BRAND_META)) {
  178. if (p.includes(kw)) return meta.label;
  179. for (const alias of (meta.aliases || [])) {
  180. if (p.includes(alias)) return meta.label;
  181. }
  182. }
  183. return p;
  184. }
  185. function preprocessItems(items) {
  186. return items.map((it) => {
  187. const brandClass = classifyBrand(it.product);
  188. const brandLabel = mapBrandLabel(it.product);
  189. const sentiment = inferSentiment(it.content);
  190. const tags = inferTags(it.content);
  191. const hypotheses = inferHypotheses(it.content, it.hypothesis || []);
  192. return { ...it, brandClass, brandLabel, sentiment, tags, hypotheses };
  193. });
  194. }
  195. // =============================================================================
  196. // 分析函数
  197. // =============================================================================
  198. function analyzeByBrand(items) {
  199. const brands = {};
  200. for (const it of items) {
  201. const b = it.brandLabel;
  202. if (!brands[b]) {
  203. brands[b] = {
  204. label: b,
  205. class: it.brandClass,
  206. items: [],
  207. sentiment: { positive: 0, negative: 0, neutral: 0, conflicted: 0 },
  208. tags: {},
  209. hypotheses: {},
  210. topLiked: [],
  211. ipDistribution: {},
  212. typeDistribution: { video: 0, comment: 0 },
  213. };
  214. }
  215. brands[b].items.push(it);
  216. brands[b].sentiment[it.sentiment] = (brands[b].sentiment[it.sentiment] || 0) + 1;
  217. brands[b].typeDistribution[it.type] = (brands[b].typeDistribution[it.type] || 0) + 1;
  218. for (const t of (it.tags || [])) {
  219. brands[b].tags[t] = (brands[b].tags[t] || 0) + 1;
  220. }
  221. for (const h of (it.hypotheses || [])) {
  222. brands[b].hypotheses[h] = (brands[b].hypotheses[h] || 0) + 1;
  223. }
  224. if (it.ip) {
  225. brands[b].ipDistribution[it.ip] = (brands[b].ipDistribution[it.ip] || 0) + 1;
  226. }
  227. }
  228. for (const b of Object.values(brands)) {
  229. b.topLiked = [...b.items].sort((a, c) => (c.likes || 0) - (a.likes || 0)).slice(0, 5);
  230. }
  231. return brands;
  232. }
  233. function analyzeHypothesisCoverage(items) {
  234. const coverage = {};
  235. for (const h of Object.keys(HYPOTHESIS_META)) {
  236. coverage[h] = { meta: HYPOTHESIS_META[h], count: 0, examples: [], byBrand: {} };
  237. }
  238. for (const it of items) {
  239. for (const h of (it.hypotheses || [])) {
  240. if (coverage[h]) {
  241. coverage[h].count++;
  242. if (coverage[h].examples.length < 3) {
  243. coverage[h].examples.push({ content: it.content.slice(0, 200), likes: it.likes, platform: it.platform, brand: it.brandLabel });
  244. }
  245. coverage[h].byBrand[it.brandLabel] = (coverage[h].byBrand[it.brandLabel] || 0) + 1;
  246. }
  247. }
  248. }
  249. return coverage;
  250. }
  251. function analyzeTags(items) {
  252. const tagStats = {};
  253. for (const it of items) {
  254. for (const t of (it.tags || [])) {
  255. if (!tagStats[t]) tagStats[t] = { count: 0, examples: [], byBrand: {} };
  256. tagStats[t].count++;
  257. if (tagStats[t].examples.length < 2) {
  258. tagStats[t].examples.push({ content: it.content.slice(0, 150), likes: it.likes, platform: it.platform });
  259. }
  260. tagStats[t].byBrand[it.brandLabel] = (tagStats[t].byBrand[it.brandLabel] || 0) + 1;
  261. }
  262. }
  263. return tagStats;
  264. }
  265. function analyzePainPoints(items) {
  266. const painPatterns = {
  267. '价格太贵': { re: /贵|价格高|太贵|付不起|花钱|费用/ },
  268. '不专业': { re: /不专业|没经验|不靠谱|证书|资质/ },
  269. '服务不满意': { re: /不满意|换|退|投诉|差|坑/ },
  270. '找不到合适的': { re: /找不到|不知道怎么选|纠结|犹豫|怕|担心/ },
  271. '家人反对': { re: /婆婆不让|家里不同意|老人不让|老公说/ },
  272. '信息不透明': { re: /不了解|不知道|不清楚|怎么找/ },
  273. };
  274. const pains = {};
  275. for (const [name, cfg] of Object.entries(painPatterns)) {
  276. pains[name] = { count: 0, examples: [], byBrand: {} };
  277. }
  278. for (const it of items) {
  279. for (const [name, cfg] of Object.entries(painPatterns)) {
  280. if (cfg.re.test(it.content)) {
  281. pains[name].count++;
  282. if (pains[name].examples.length < 3) {
  283. pains[name].examples.push({ content: it.content.slice(0, 180), likes: it.likes, platform: it.platform, brand: it.brandLabel });
  284. }
  285. pains[name].byBrand[it.brandLabel] = (pains[name].byBrand[it.brandLabel] || 0) + 1;
  286. }
  287. }
  288. }
  289. return pains;
  290. }
  291. function analyzeIpGeo(items) {
  292. const geo = {};
  293. for (const it of items) {
  294. if (it.ip) {
  295. if (!geo[it.ip]) geo[it.ip] = { count: 0, brands: {} };
  296. geo[it.ip].count++;
  297. geo[it.ip].brands[it.brandLabel] = (geo[it.ip].brands[it.brandLabel] || 0) + 1;
  298. }
  299. }
  300. return geo;
  301. }
  302. function analyzeSelfVsCompetitor(items) {
  303. const self = items.filter((i) => i.brandClass === 'self');
  304. const competitors = items.filter((i) => i.brandClass === 'competitor');
  305. const other = items.filter((i) => i.brandClass === 'other');
  306. const brands = analyzeByBrand(items);
  307. const selfSentiment = { positive: 0, negative: 0, neutral: 0, conflicted: 0 };
  308. for (const it of self) selfSentiment[it.sentiment] = (selfSentiment[it.sentiment] || 0) + 1;
  309. const compSentiment = { positive: 0, negative: 0, neutral: 0, conflicted: 0 };
  310. for (const it of competitors) compSentiment[it.sentiment] = (compSentiment[it.sentiment] || 0) + 1;
  311. const tagDiff = {};
  312. const selfTags = {};
  313. const compTags = {};
  314. for (const it of self) { for (const t of (it.tags || [])) selfTags[t] = (selfTags[t] || 0) + 1; }
  315. for (const it of competitors) { for (const t of (it.tags || [])) compTags[t] = (compTags[t] || 0) + 1; }
  316. const allTags = new Set([...Object.keys(selfTags), ...Object.keys(compTags)]);
  317. for (const t of allTags) {
  318. const s = selfTags[t] || 0;
  319. const c = compTags[t] || 0;
  320. tagDiff[t] = { self: s, competitor: c, delta: s - c };
  321. }
  322. const selfTopTags = Object.entries(selfTags).sort((a, b) => b[1] - a[1]).slice(0, 5);
  323. const compTopTags = Object.entries(compTags).sort((a, b) => b[1] - a[1]).slice(0, 5);
  324. return { self, competitors, other, brands, selfSentiment, compSentiment, tagDiff, selfTopTags, compTopTags };
  325. }
  326. function analyzeDecisionJourney(items) {
  327. const stages = {
  328. '需求触发': { re: /怀孕了|待产|产检|建档|新手爸妈|第一胎|头胎/, count: 0, examples: [] },
  329. '信息搜索': { re: /怎么找|哪家好|多少钱|推荐|搜索|小红书|抖音|美团|大众点评/, count: 0, examples: [] },
  330. '决策比较': { re: /纠结|犹豫|对比|比较|面试|选择|挑|天鹅到家|好孕妈妈|多喜娃/, count: 0, examples: [] },
  331. '购买/签约': { re: /签了|定了|请了|下单|付款|签约/, count: 0, examples: [] },
  332. '服务体验': { re: /用了|服务|月嫂|照顾|宝宝|做饭|护理|月子/, count: 0, examples: [] },
  333. '口碑传播': { re: /推荐|介绍|朋友|转介绍|好评|吐槽|分享|发小红书/, count: 0, examples: [] },
  334. };
  335. for (const it of items) {
  336. for (const [stage, cfg] of Object.entries(stages)) {
  337. if (cfg.re.test(it.content)) {
  338. cfg.count++;
  339. if (cfg.examples.length < 3) {
  340. cfg.examples.push({ content: it.content.slice(0, 160), likes: it.likes, platform: it.platform, brand: it.brandLabel });
  341. }
  342. }
  343. }
  344. }
  345. return stages;
  346. }
  347. // =============================================================================
  348. // 控制台报告输出
  349. // =============================================================================
  350. function pad(s, len = 24) { return String(s).padEnd(len); }
  351. function bar(n, total, w = 20) {
  352. if (!total) return '░'.repeat(w);
  353. const f = Math.round((n / total) * w);
  354. return '█'.repeat(f) + '░'.repeat(w - f);
  355. }
  356. function printSection(title) {
  357. console.log('\n' + '═'.repeat(72));
  358. console.log(' ' + title);
  359. console.log('═'.repeat(72));
  360. }
  361. function printReport(analysis) {
  362. const { items, brands, hypoCov, tagStats, painPoints, geo, selfVsComp, journey, meta } = analysis;
  363. const total = items.length;
  364. console.log('\n');
  365. console.log('╔══════════════════════════════════════════════════════════════════╗');
  366. console.log('║ 洪城到家 · 小红书 VOC 竞品分析报告 ║');
  367. console.log('╚══════════════════════════════════════════════════════════════════╝');
  368. // 1. 总览
  369. printSection('一、数据总览');
  370. const selfTotal = selfVsComp.self.length;
  371. const compTotal = selfVsComp.competitors.length;
  372. const otherTotal = selfVsComp.other.length;
  373. console.log(` 总 VOC 条数:${total}`);
  374. console.log(` 本品(洪诚到家):${selfTotal} 条 (${total ? ((selfTotal/total)*100).toFixed(1) : 0}%)`);
  375. console.log(` 竞品讨论:${compTotal} 条 (${total ? ((compTotal/total)*100).toFixed(1) : 0}%)`);
  376. console.log(` 其他话题:${otherTotal} 条 (${total ? ((otherTotal/total)*100).toFixed(1) : 0}%)`);
  377. console.log(` 品牌覆盖:${Object.keys(brands).length} 个`);
  378. console.log(` 数据采集时间:${(meta.collectedAt || '').slice(0, 10)}`);
  379. // 2. 声量排行
  380. printSection('二、品牌声量排行');
  381. const brandList = Object.entries(brands).sort((a, b) => b[1].items.length - a[1].items.length);
  382. const brandTotal = brandList.reduce((s, [, b]) => s + b.items.length, 0);
  383. console.log(` ${pad('品牌')} ${pad('类型')} ${pad('声量')} 占比 情感分布`);
  384. console.log(' ' + '─'.repeat(68));
  385. for (const [label, b] of brandList) {
  386. const cnt = b.items.length;
  387. const pct = brandTotal ? ((cnt / brandTotal) * 100).toFixed(1) : '0.0';
  388. const p = b.sentiment.positive || 0;
  389. const n = b.sentiment.negative || 0;
  390. const cls = b.class === 'self' ? '本品' : (BRAND_META[label]?.tier || '竞品');
  391. console.log(` ${pad(label, 10)} ${pad(cls, 8)} ${pad(cnt, 6)} ${pct.padStart(6)}% ${bar(p + n, cnt, 8)} +${p}/-${n}`);
  392. }
  393. // 3. 本品 vs 竞品情感对比
  394. printSection('三、本品 vs 竞品 情感分布');
  395. const ss = selfVsComp.selfSentiment;
  396. const cs = selfVsComp.compSentiment;
  397. const sTotal = selfTotal || 1;
  398. const cTotal = compTotal || 1;
  399. console.log(` ${pad('情感')}${pad('本品('+selfTotal+')',14)}${pad('竞品('+compTotal+')',14)}差异`);
  400. console.log(' ' + '─'.repeat(60));
  401. for (const sent of ['positive', 'negative', 'neutral', 'conflicted']) {
  402. const sl = ss[sent] || 0;
  403. const cl = cs[sent] || 0;
  404. const sd = sTotal ? ((sl / sTotal) * 100).toFixed(0) : 0;
  405. const cd = cTotal ? ((cl / cTotal) * 100).toFixed(0) : 0;
  406. const diff = parseInt(sd) - parseInt(cd);
  407. const sign = diff > 0 ? '+' : '';
  408. const labels = { positive: '正向', negative: '负向', neutral: '中立', conflicted: '矛盾' };
  409. console.log(` ${pad(labels[sent])}${pad(sl + '(' + sd + '%)', 14)}${pad(cl + '(' + cd + '%)', 14)}${sign}${diff}%`);
  410. }
  411. // 4. 标签分布
  412. printSection('四、用户关注标签分布 (TOP 15)');
  413. const sortedTags = Object.entries(tagStats).sort((a, b) => b[1].count - a[1].count).slice(0, 15);
  414. console.log(` ${pad('标签')} ${pad('出现次数')} 品牌分布`);
  415. console.log(' ' + '─'.repeat(65));
  416. for (const [tag, s] of sortedTags) {
  417. const topBrands = Object.entries(s.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3)
  418. .map(([b, c]) => `${b}(${c})`).join(' ');
  419. console.log(` ${pad(tag, 12)} ${pad(s.count, 8)} ${topBrands}`);
  420. }
  421. // 5. 假设验证
  422. printSection('五、假设(Hypothesis)验证覆盖');
  423. const sortedHypos = Object.entries(hypoCov).sort((a, b) => b[1].count - a[1].count);
  424. console.log(` ${pad('假设')} ${pad('描述')} ${pad('声量')} 验证状态`);
  425. console.log(' ' + '─'.repeat(68));
  426. for (const [h, data] of sortedHypos) {
  427. const m = HYPOTHESIS_META[h];
  428. const status = data.count >= 100 ? '✅ 充分' : data.count >= 30 ? '⚠️ 少量' : '❌ 稀缺';
  429. console.log(` ${pad(h + ' ' + m.label, 16)} ${pad(data.count, 8)} ${status}`);
  430. }
  431. // 6. 痛点分析
  432. printSection('六、核心痛点分析');
  433. const sortedPains = Object.entries(painPoints).sort((a, b) => b[1].count - a[1].count);
  434. console.log(` ${pad('痛点类型')} ${pad('出现次数')} 品牌分布`);
  435. console.log(' ' + '─'.repeat(65));
  436. for (const [name, p] of sortedPains) {
  437. if (p.count === 0) continue;
  438. const topBrands = Object.entries(p.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3)
  439. .map(([b, c]) => `${b}(${c})`).join(' ');
  440. console.log(` ${pad(name, 14)} ${pad(p.count, 8)} ${topBrands}`);
  441. }
  442. // 7. IP地理
  443. printSection('七、IP 地理分布 (TOP 10)');
  444. const sortedGeo = Object.entries(geo).sort((a, b) => b[1].count - a[1].count).slice(0, 10);
  445. console.log(` ${pad('IP属地')} ${pad('条数')} 品牌占比`);
  446. console.log(' ' + '─'.repeat(60));
  447. for (const [ip, g] of sortedGeo) {
  448. const topBrand = Object.entries(g.brands).sort((a, b) => b[1] - a[1])[0];
  449. const pct = g.total ? ((topBrand[1] / g.count) * 100).toFixed(0) : 0;
  450. console.log(` ${pad(ip, 12)} ${pad(g.count, 6)} ${topBrand[0]}(${pct}%)`);
  451. }
  452. // 8. 决策旅程
  453. printSection('八、用户决策旅程分布');
  454. const journeyTotal = Object.values(journey).reduce((s, c) => s + c.count, 0);
  455. for (const [stage, cfg] of Object.entries(journey)) {
  456. const pct = journeyTotal ? ((cfg.count / journeyTotal) * 100).toFixed(1) : '0.0';
  457. console.log(` ${pad(stage, 14)} ${pad(cfg.count, 6)} ${pct}% ${bar(cfg.count, journeyTotal, 20)}`);
  458. }
  459. // 9. 高赞 VOC
  460. printSection('九、高赞 VOC 精选 (TOP 15)');
  461. const topLiked = [...items].sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, 15);
  462. for (const it of topLiked) {
  463. const content = String(it.content).slice(0, 80);
  464. console.log(` [${pad(it.platform, 7)}] ♥${String(it.likes || 0).padStart(5)} [${pad(it.brandLabel, 8)}] ${content}`);
  465. }
  466. // 10. 本品声量TOP标签 vs 竞品
  467. printSection('十、本品 vs 竞品 标签差异分析');
  468. const tagDiffSorted = Object.entries(selfVsComp.tagDiff).sort((a, b) => Math.abs(b[1].delta) - Math.abs(a[1].delta));
  469. console.log(` ${pad('标签')} 本品 竞品 差值 机会`);
  470. console.log(' ' + '─'.repeat(60));
  471. for (const [tag, d] of tagDiffSorted.slice(0, 10)) {
  472. const opportunity = d.self < d.competitor ? '⬆️ 本品机会' : '⬇️ 竞品领先';
  473. console.log(` ${pad(tag, 10)} ${pad(d.self, 5)} ${pad(d.competitor, 5)} ${pad(d.delta > 0 ? '+' + d.delta : d.delta, 6)} ${opportunity}`);
  474. }
  475. console.log('\n' + '═'.repeat(72));
  476. }
  477. // =============================================================================
  478. // HTML 报告生成
  479. // =============================================================================
  480. function generateHTML(analysis) {
  481. const { items, brands, hypoCov, tagStats, painPoints, geo, selfVsComp, journey, meta } = analysis;
  482. const total = items.length;
  483. const brandList = Object.entries(brands).sort((a, b) => b[1].items.length - a[1].items.length);
  484. const sortedTags = Object.entries(tagStats).sort((a, b) => b[1].count - a[1].count).slice(0, 15);
  485. const sortedHypos = Object.entries(hypoCov).sort((a, b) => b[1].count - a[1].count);
  486. const sortedPains = Object.entries(painPoints).sort((a, b) => b[1].count - a[1].count);
  487. const sortedGeo = Object.entries(geo).sort((a, b) => b[1].count - a[1].count).slice(0, 10);
  488. const topLiked = [...items].sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, 20);
  489. const journeyTotal = Object.values(journey).reduce((s, c) => s + c.count, 0);
  490. const brandRows = brandList.map(([label, b]) => {
  491. const cnt = b.items.length;
  492. const pct = total ? ((cnt / total) * 100).toFixed(1) : '0.0';
  493. const cls = b.class === 'self' ? 'tag-self' : 'tag-comp';
  494. const clsLabel = b.class === 'self' ? '本品' : (BRAND_META[label]?.tier || '竞品');
  495. return `<tr>
  496. <td><span class="brand-tag ${cls}">${label}</span></td>
  497. <td>${clsLabel}</td>
  498. <td class="num">${cnt}</td>
  499. <td class="num">${pct}%</td>
  500. <td class="num positive">${b.sentiment.positive || 0}</td>
  501. <td class="num negative">${b.sentiment.negative || 0}</td>
  502. <td class="num">${b.sentiment.neutral || 0}</td>
  503. <td class="num">${b.sentiment.conflicted || 0}</td>
  504. </tr>`;
  505. }).join('\n');
  506. const tagRows = sortedTags.map(([tag, s]) => {
  507. const brands = Object.entries(s.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3)
  508. .map(([b, c]) => `<span class="mini-tag">${b} ${c}</span>`).join(' ');
  509. return `<tr><td>${tag}</td><td class="num">${s.count}</td><td>${brands}</td></tr>`;
  510. }).join('\n');
  511. const hypoRows = sortedHypos.map(([h, data]) => {
  512. const m = HYPOTHESIS_META[h];
  513. const status = data.count >= 100 ? '✅ 充分' : data.count >= 30 ? '⚠️ 少量' : '❌ 稀缺';
  514. const barW = Math.min(100, (data.count / 200) * 100);
  515. return `<tr>
  516. <td><strong>${h}</strong><br><small>${m.label}</small></td>
  517. <td><small>${m.desc}</small></td>
  518. <td class="num">${data.count}</td>
  519. <td><div class="mini-bar"><div class="mini-fill" style="width:${barW}%"></div></div></td>
  520. <td>${status}</td>
  521. </tr>`;
  522. }).join('\n');
  523. const painRows = sortedPains.filter(([,p]) => p.count > 0).map(([name, p]) => {
  524. const brands = Object.entries(p.byBrand).sort((a, b) => b[1] - a[1]).slice(0, 3)
  525. .map(([b, c]) => `<span class="mini-tag">${b} ${c}</span>`).join(' ');
  526. return `<tr><td>${name}</td><td class="num">${p.count}</td><td>${brands}</td></tr>`;
  527. }).join('\n');
  528. const geoRows = sortedGeo.map(([ip, g]) => {
  529. const topBrand = Object.entries(g.brands).sort((a, b) => b[1] - a[1])[0];
  530. return `<tr><td>${ip}</td><td class="num">${g.count}</td><td>${topBrand[0]} (${topBrand[1]})</td></tr>`;
  531. }).join('\n');
  532. const journeyRows = Object.entries(journey).map(([stage, cfg]) => {
  533. const pct = journeyTotal ? ((cfg.count / journeyTotal) * 100).toFixed(1) : '0.0';
  534. const barW = journeyTotal ? ((cfg.count / journeyTotal) * 100) : 0;
  535. return `<tr>
  536. <td>${stage}</td>
  537. <td class="num">${cfg.count}</td>
  538. <td class="num">${pct}%</td>
  539. <td><div class="mini-bar"><div class="mini-fill" style="width:${barW}%"></div></div></td>
  540. </tr>`;
  541. }).join('\n');
  542. const vocRows = topLiked.map((it) => {
  543. const content = String(it.content).replace(/</g, '&lt;').replace(/>/g, '&gt;').slice(0, 120);
  544. const ex = it.content.length > 120 ? '...' : '';
  545. return `<tr>
  546. <td class="num">♥ ${it.likes || 0}</td>
  547. <td>${it.platform}</td>
  548. <td><span class="brand-tag ${it.brandClass === 'self' ? 'tag-self' : 'tag-comp'}">${it.brandLabel}</span></td>
  549. <td><small>${content}${ex}</small></td>
  550. </tr>`;
  551. }).join('\n');
  552. const selfSent = selfVsComp.selfSentiment;
  553. const compSent = selfVsComp.compSentiment;
  554. const sentimentData = JSON.stringify({ self: selfSent, competitor: compSent });
  555. return `<!DOCTYPE html>
  556. <html lang="zh-CN">
  557. <head>
  558. <meta charset="UTF-8">
  559. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  560. <title>洪城到家 · 小红书竞品VOC分析报告</title>
  561. <style>
  562. :root {
  563. --bg-0: #ffffff; --bg-1: #f7f8fa; --bg-2: #eef0f4; --border: #e2e5ed;
  564. --text-1: #1a1d23; --text-2: #4a5568; --text-3: #8896a6;
  565. --accent: #2563eb; --accent-light: #dbeafe;
  566. --positive: #16a34a; --negative: #dc2626; --neutral: #6b7280;
  567. --tag-self-bg: #dbeafe; --tag-self-color: #1e40af;
  568. --tag-comp-bg: #fce7f3; --tag-comp-color: #9d174d;
  569. --radius: 8px;
  570. }
  571. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  572. 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; }
  573. .container { max-width: 1200px; margin: 0 auto; padding: 32px 20px; }
  574. .hd { background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); color: white; padding: 40px 32px; border-radius: var(--radius); margin-bottom: 32px; }
  575. .hd h1 { font-size: 1.8rem; font-weight: 800; margin-bottom: 8px; }
  576. .hd p { opacity: 0.85; font-size: 0.9rem; }
  577. .hd .meta { margin-top: 16px; display: flex; gap: 24px; font-size: 0.82rem; opacity: 0.8; }
  578. .kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px; }
  579. .kpi { background: white; border-radius: var(--radius); padding: 20px; border: 1px solid var(--border); }
  580. .kpi .val { font-size: 2rem; font-weight: 800; color: var(--accent); }
  581. .kpi .lbl { color: var(--text-2); font-size: 0.8rem; margin-top: 4px; }
  582. .kpi.self-card .val { color: #1e40af; }
  583. .card { background: white; border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: 24px; overflow: hidden; }
  584. .card-head { padding: 16px 24px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 1rem; background: var(--bg-1); }
  585. .card-body { padding: 20px 24px; }
  586. table { width: 100%; border-collapse: collapse; }
  587. 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); }
  588. td { padding: 10px 12px; border-bottom: 1px solid var(--bg-2); vertical-align: top; }
  589. tr:last-child td { border-bottom: none; }
  590. tr:hover td { background: var(--bg-1); }
  591. .num { text-align: right; font-variant-numeric: tabular-nums; }
  592. .positive { color: var(--positive); font-weight: 600; }
  593. .negative { color: var(--negative); font-weight: 600; }
  594. .brand-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; font-weight: 600; }
  595. .tag-self { background: var(--tag-self-bg); color: var(--tag-self-color); }
  596. .tag-comp { background: var(--tag-comp-bg); color: var(--tag-comp-color); }
  597. .mini-tag { display: inline-block; background: var(--bg-2); padding: 1px 6px; border-radius: 3px; font-size: 0.72rem; margin: 1px; }
  598. .mini-bar { background: var(--bg-2); border-radius: 3px; height: 8px; overflow: hidden; }
  599. .mini-fill { background: var(--accent); height: 100%; border-radius: 3px; transition: width 0.3s; }
  600. .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
  601. .sentiment-chart { display: flex; flex-direction: column; gap: 12px; }
  602. .sent-row { display: flex; align-items: center; gap: 12px; }
  603. .sent-label { width: 60px; font-size: 0.82rem; color: var(--text-2); }
  604. .sent-bar-bg { flex: 1; height: 20px; background: var(--bg-2); border-radius: 4px; overflow: hidden; }
  605. .sent-fill { height: 100%; border-radius: 4px; }
  606. .sent-fill.pos { background: var(--positive); }
  607. .sent-fill.neg { background: var(--negative); }
  608. .sent-fill.neu { background: var(--neutral); }
  609. .sent-fill.con { background: #f59e0b; }
  610. .sent-count { width: 50px; text-align: right; font-size: 0.82rem; font-variant-numeric: tabular-nums; }
  611. .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); }
  612. .example small { color: var(--text-3); }
  613. .footer { text-align: center; padding: 32px; color: var(--text-3); font-size: 0.78rem; }
  614. @media (max-width: 768px) {
  615. .kpi-row { grid-template-columns: repeat(2, 1fr); }
  616. .chart-grid { grid-template-columns: 1fr; }
  617. }
  618. </style>
  619. </head>
  620. <body>
  621. <div class="container">
  622. <div class="hd">
  623. <h1>洪城到家 · 小红书竞品VOC分析报告</h1>
  624. <p>基于真实用户评论数据的多维度竞品分析 | 声量 × 情感 × 痛点 × 假设验证</p>
  625. <div class="meta">
  626. <span>📊 总 VOC: ${total} 条</span>
  627. <span>🏷️ 品牌覆盖: ${brandList.length} 个</span>
  628. <span>📅 采集时间: ${(meta.collectedAt || '').slice(0, 10)}</span>
  629. </div>
  630. </div>
  631. <div class="kpi-row">
  632. <div class="kpi self-card">
  633. <div class="val">${selfVsComp.self.length}</div>
  634. <div class="lbl">本品声量 (洪诚到家)</div>
  635. </div>
  636. <div class="kpi">
  637. <div class="val">${selfVsComp.competitors.length}</div>
  638. <div class="lbl">竞品声量合计</div>
  639. </div>
  640. <div class="kpi">
  641. <div class="val">${brandList.filter(([,b]) => b.class === 'competitor').length}</div>
  642. <div class="lbl">竞品种类</div>
  643. </div>
  644. <div class="kpi">
  645. <div class="val">${(selfVsComp.self.length && total) ? ((selfVsComp.self.length/total)*100).toFixed(1)+'%' : '0%'}</div>
  646. <div class="lbl">本品占比</div>
  647. </div>
  648. </div>
  649. <div class="card">
  650. <div class="card-head">一、品牌声量排行</div>
  651. <div class="card-body" style="overflow-x:auto">
  652. <table>
  653. <thead><tr>
  654. <th>品牌</th><th>类型</th><th class="num">声量</th><th class="num">占比</th>
  655. <th class="num positive">正向</th><th class="num negative">负向</th>
  656. <th class="num">中立</th><th class="num">矛盾</th>
  657. </tr></thead>
  658. <tbody>${brandRows}</tbody>
  659. </table>
  660. </div>
  661. </div>
  662. <div class="card">
  663. <div class="card-head">二、本品 vs 竞品 情感分布对比</div>
  664. <div class="card-body">
  665. <div class="chart-grid">
  666. <div>
  667. <h4 style="margin-bottom:12px;color:var(--text-2);font-size:0.85rem">本品(洪诚到家)情感</h4>
  668. <div class="sentiment-chart">
  669. ${['positive','negative','neutral','conflicted'].map(s => {
  670. const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'};
  671. const colors = {positive:'pos',negative:'neg',neutral:'neu',conflicted:'con'};
  672. const cnt = selfSent[s] || 0;
  673. const total2 = selfVsComp.self.length || 1;
  674. const w = (cnt/total2)*100;
  675. return `<div class="sent-row">
  676. <div class="sent-label">${labels[s]}</div>
  677. <div class="sent-bar-bg"><div class="sent-fill ${colors[s]}" style="width:${w}%"></div></div>
  678. <div class="sent-count">${cnt} (${w.toFixed(0)}%)</div>
  679. </div>`;
  680. }).join('')}
  681. </div>
  682. </div>
  683. <div>
  684. <h4 style="margin-bottom:12px;color:var(--text-2);font-size:0.85rem">竞品(行业平均)情感</h4>
  685. <div class="sentiment-chart">
  686. ${['positive','negative','neutral','conflicted'].map(s => {
  687. const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'};
  688. const colors = {positive:'pos',negative:'neg',neutral:'neu',conflicted:'con'};
  689. const cnt = compSent[s] || 0;
  690. const total2 = selfVsComp.competitors.length || 1;
  691. const w = (cnt/total2)*100;
  692. return `<div class="sent-row">
  693. <div class="sent-label">${labels[s]}</div>
  694. <div class="sent-bar-bg"><div class="sent-fill ${colors[s]}" style="width:${w}%"></div></div>
  695. <div class="sent-count">${cnt} (${w.toFixed(0)}%)</div>
  696. </div>`;
  697. }).join('')}
  698. </div>
  699. </div>
  700. </div>
  701. </div>
  702. </div>
  703. <div class="card">
  704. <div class="card-head">三、用户关注标签分布 (TOP 15)</div>
  705. <div class="card-body" style="overflow-x:auto">
  706. <table>
  707. <thead><tr><th>标签</th><th class="num">出现次数</th><th>品牌分布</th></tr></thead>
  708. <tbody>${tagRows}</tbody>
  709. </table>
  710. </div>
  711. </div>
  712. <div class="card">
  713. <div class="card-head">四、假设(Hypothesis)验证覆盖</div>
  714. <div class="card-body" style="overflow-x:auto">
  715. <table>
  716. <thead><tr><th>假设</th><th>描述</th><th class="num">声量</th><th>覆盖进度</th><th>状态</th></tr></thead>
  717. <tbody>${hypoRows}</tbody>
  718. </table>
  719. </div>
  720. </div>
  721. <div class="card">
  722. <div class="card-head">五、核心痛点分析</div>
  723. <div class="card-body" style="overflow-x:auto">
  724. <table>
  725. <thead><tr><th>痛点类型</th><th class="num">出现次数</th><th>品牌分布</th></tr></thead>
  726. <tbody>${painRows}</tbody>
  727. </table>
  728. </div>
  729. </div>
  730. <div class="card">
  731. <div class="card-head">六、IP 地理分布 (TOP 10)</div>
  732. <div class="card-body" style="overflow-x:auto">
  733. <table>
  734. <thead><tr><th>IP属地</th><th class="num">条数</th><th>主要品牌</th></tr></thead>
  735. <tbody>${geoRows}</tbody>
  736. </table>
  737. </div>
  738. </div>
  739. <div class="card">
  740. <div class="card-head">七、用户决策旅程分布</div>
  741. <div class="card-body" style="overflow-x:auto">
  742. <table>
  743. <thead><tr><th>阶段</th><th class="num">出现次数</th><th class="num">占比</th><th>分布</th></tr></thead>
  744. <tbody>${journeyRows}</tbody>
  745. </table>
  746. </div>
  747. </div>
  748. <div class="card">
  749. <div class="card-head">八、高赞 VOC 精选 (TOP 20)</div>
  750. <div class="card-body" style="overflow-x:auto">
  751. <table>
  752. <thead><tr><th>点赞</th><th>平台</th><th>品牌</th><th>内容摘要</th></tr></thead>
  753. <tbody>${vocRows}</tbody>
  754. </table>
  755. </div>
  756. </div>
  757. <div class="card">
  758. <div class="card-head">九、本品 vs 竞品 标签差异分析</div>
  759. <div class="card-body" style="overflow-x:auto">
  760. <table>
  761. <thead><tr><th>标签</th><th class="num">本品</th><th class="num">竞品</th><th class="num">差值</th><th>洞察</th></tr></thead>
  762. <tbody>${Object.entries(selfVsComp.tagDiff)
  763. .sort((a, b) => Math.abs(b[1].delta) - Math.abs(a[1].delta))
  764. .slice(0, 10).map(([tag, d]) => {
  765. const insight = d.self > d.competitor ? '⬆️ 本品关注更多' : '⬇️ 竞品关注更多';
  766. return `<tr>
  767. <td>${tag}</td>
  768. <td class="num">${d.self}</td>
  769. <td class="num">${d.competitor}</td>
  770. <td class="num ${d.delta > 0 ? 'positive' : 'negative'}">${d.delta > 0 ? '+' : ''}${d.delta}</td>
  771. <td><small>${insight}</small></td>
  772. </tr>`;
  773. }).join('\n')}</tbody>
  774. </table>
  775. </div>
  776. </div>
  777. <div class="footer">
  778. <p>洪城到家 · 小红书竞品VOC分析报告 | 数据来源: _merged.json | 生成时间: ${new Date().toLocaleString('zh-CN')}</p>
  779. </div>
  780. </div>
  781. </body>
  782. </html>`;
  783. }
  784. // =============================================================================
  785. // 主函数
  786. // =============================================================================
  787. function main() {
  788. const argv = process.argv.slice(2);
  789. const doHTML = argv.includes('--html') || argv.includes('--report');
  790. const doCompact = argv.includes('--compact');
  791. console.log('🔍 加载数据...');
  792. const { items, meta } = loadData();
  793. console.log('🔧 预处理数据(情感分析、标签、假设)...');
  794. const processed = preprocessItems(items);
  795. console.log('📊 执行多维度分析...');
  796. const brands = analyzeByBrand(processed);
  797. const hypoCov = analyzeHypothesisCoverage(processed);
  798. const tagStats = analyzeTags(processed);
  799. const painPoints = analyzePainPoints(processed);
  800. const geo = analyzeIpGeo(processed);
  801. const selfVsComp = analyzeSelfVsCompetitor(processed);
  802. const journey = analyzeDecisionJourney(processed);
  803. const analysis = { items: processed, brands, hypoCov, tagStats, painPoints, geo, selfVsComp, journey, meta };
  804. console.log('📝 生成报告...');
  805. printReport(analysis);
  806. if (doHTML) {
  807. const html = generateHTML(analysis);
  808. const outPath = path.join(REPORT_DIR, 'xhs-competitor-analysis.html');
  809. fs.writeFileSync(outPath, html, 'utf8');
  810. console.log(`\n✅ HTML 报告已生成: ${outPath}`);
  811. }
  812. if (!doHTML && !doCompact) {
  813. console.log('\n💡 提示:使用 --html 参数可生成完整 HTML 报告');
  814. console.log(' 使用 --compact 参数仅显示精简摘要');
  815. }
  816. }
  817. if (require.main === module) {
  818. main();
  819. }
  820. module.exports = { loadData, preprocessItems, analyzeByBrand, analyzeHypothesisCoverage, analyzeTags, analyzePainPoints, analyzeIpGeo, analyzeSelfVsCompetitor, analyzeDecisionJourney };