hongcheng-analyze.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. // ==============================================================================
  2. // 洪城到家 · VOC 数据分析模块
  3. // ==============================================================================
  4. const fs = require('fs');
  5. const path = require('path');
  6. const ROOT = path.resolve(__dirname, '..', '..');
  7. const RAW_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw');
  8. const MERGED_PATH = path.join(RAW_DIR, '_merged.json');
  9. const HYPOTHESES = {
  10. H1: { title: '医院地推是最高效获客场景', desc: '直接触达即将生产的精准用户,转化率远高于泛流量' },
  11. H2: { title: '价格透明是转化的关键因素', desc: '用户对月嫂价格不透明是核心痛点,价格公开透明化能显著提升转化率' },
  12. H3: { title: '短剧内容能有效提升品牌认知', desc: '借南昌万亿短剧市场政策红利,通过短剧植入能低成本实现品牌曝光' },
  13. H4: { title: '专业度信任需要"看得见"的证明', desc: '用户判断月嫂专业度的核心信号是资质证书、客户评价、实体规模' },
  14. H5: { title: '竞品对比中社区店是主要威胁', desc: '社区月嫂门店对洪诚到家的主要威胁是"价格低",而非"质量好"' },
  15. H6: { title: '老带新是低成本高转化渠道', desc: '老客户转介绍是月嫂业务最高效、最省钱的获客方式' },
  16. H7: { title: '美团/小红书是搜索承接的主力平台', desc: '用户在网络上主动搜索月嫂的主要平台是美团和小红书' },
  17. H8: { title: '"不满意能换"是重要的服务保障', desc: '用户担心"月嫂临时变更或服务不达标",服务保障承诺能显著降低决策门槛' },
  18. };
  19. const CHAPTER_HYPOTHESIS_MAP = {
  20. challenge: ['H1', 'H2', 'H4'],
  21. competitor: ['H1', 'H5', 'H7'],
  22. pain: ['H2', 'H4', 'H8'],
  23. kano: ['H2', 'H4', 'H6'],
  24. scene: ['H1', 'H3'],
  25. threeaxis: ['H5', 'H7'],
  26. opportunity: ['H1', 'H3', 'H7'],
  27. blueprint: ['H2', 'H4', 'H7', 'H8'],
  28. };
  29. const PLATFORM_LABELS = {
  30. xhs: { name: '小红书', color: '#FF2442', short: '红' },
  31. douyin: { name: '抖音', color: '#1A1A1A', short: '抖' },
  32. meituan: { name: '美团', color: '#FFD100', short: '美' },
  33. unknown: { name: '其他', color: '#888', short: '—' },
  34. };
  35. const KEYWORD_TO_PRODUCT = {
  36. '洪诚到家': '洪诚到家',
  37. '洪城到家': '洪诚到家',
  38. '南昌月嫂洪诚': '洪诚到家',
  39. '洪诚到家怎么样': '洪诚到家(口碑)',
  40. '天鹅到家月嫂': '天鹅到家',
  41. '好孕妈妈月嫂': '好孕妈妈',
  42. '多喜娃月嫂': '多喜娃',
  43. '妈咪无忧月嫂': '妈咪无忧',
  44. '新手爸妈找月嫂': '场景话题',
  45. '坐月子注意事项': '场景话题',
  46. '月嫂多少钱一个月': '价格话题',
  47. '婆婆不让请月嫂': '场景话题',
  48. '月嫂怎么找靠谱': '决策话题',
  49. '月嫂不满意可以换吗': '服务保障话题',
  50. '社区月嫂': '社区月嫂(竞品)',
  51. '南昌月嫂对比': '竞品对比',
  52. '朋友推荐月嫂': '口碑话题',
  53. '南昌月嫂价格': '价格话题',
  54. '月嫂服务': '品类话题',
  55. '职场妈妈月子': '场景话题',
  56. '新手爸妈': '场景话题',
  57. '南昌短剧': '短剧话题',
  58. '婆媳关系坐月子': '场景话题',
  59. '南昌月嫂': '品类词',
  60. '南昌月嫂哪家好': '品类词',
  61. '江西月嫂': '品类词',
  62. };
  63. const NEG_KEYS = ['差', '坑', '骗', '贵', '不专业', '不满意', '后悔', '吐槽', '失望', '糟糕', '没用', '没效果', '不靠谱', '坑人'];
  64. const POS_KEYS = ['好', '推荐', '满意', '专业', '靠谱', '放心', '值得', '不错', '棒', '优秀', '喜欢', '感谢', '回购'];
  65. const CONF_KEYS = ['但是', '可是', '纠结', '担心', '犹豫', '想又怕', '不知道.*好'];
  66. function inferSentiment(item) {
  67. if (item.sentiment && ['positive', 'negative', 'neutral', 'conflicted'].includes(item.sentiment)) return item.sentiment;
  68. const c = String(item.content || '').toLowerCase();
  69. for (const k of NEG_KEYS) if (c.includes(k.toLowerCase())) return 'negative';
  70. for (const k of POS_KEYS) if (c.includes(k.toLowerCase())) return 'positive';
  71. for (const k of CONF_KEYS) if (new RegExp(k).test(c)) return 'conflicted';
  72. return 'neutral';
  73. }
  74. const SKELETON = {
  75. meta: {
  76. collectedAt: '待采集',
  77. platforms: {},
  78. hypotheses: {},
  79. products: {},
  80. stage: 'skeleton',
  81. sourceNote: '暂无采集数据',
  82. },
  83. items: [],
  84. raw: null,
  85. };
  86. function loadMerged() {
  87. if (fs.existsSync(MERGED_PATH)) {
  88. try {
  89. const raw = JSON.parse(fs.readFileSync(MERGED_PATH, 'utf8'));
  90. const items = (raw.items || []).map((it) => ({
  91. ...it,
  92. product: KEYWORD_TO_PRODUCT[it.keyword] || it.product || it.keyword,
  93. sentiment: inferSentiment(it),
  94. }));
  95. return {
  96. meta: {
  97. sourceTier: raw.meta?.sourceTier || 'real-collected',
  98. stage: raw.meta?.stage || 'batch-real',
  99. collectedAt: raw.meta?.collectedAt || new Date().toISOString().slice(0, 10),
  100. product: '洪城到家',
  101. stats: raw.meta || {},
  102. sourceNote: 'docs/洪城到家/raw/_merged.json · 真实多平台采集',
  103. },
  104. items,
  105. raw,
  106. };
  107. } catch (err) {
  108. console.warn(`⚠ _merged.json 解析失败:${err.message}`);
  109. }
  110. }
  111. return SKELETON;
  112. }
  113. function getMeta(data) {
  114. const items = (data && data.items) || [];
  115. const platforms = {};
  116. const products = {};
  117. const hypotheses = {};
  118. const sources = {};
  119. const sentiments = {};
  120. const keywords = new Set();
  121. const tags = new Set();
  122. for (const it of items) {
  123. const pf = it.platform || 'unknown';
  124. platforms[pf] = (platforms[pf] || 0) + 1;
  125. const prod = it.product || 'unknown';
  126. products[prod] = (products[prod] || 0) + 1;
  127. const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []);
  128. for (const h of hs) hypotheses[h] = (hypotheses[h] || 0) + 1;
  129. const src = it.source || 'unknown';
  130. sources[src] = (sources[src] || 0) + 1;
  131. sentiments[it.sentiment || 'unknown'] = (sentiments[it.sentiment || 'unknown'] || 0) + 1;
  132. if (it.keyword) keywords.add(it.keyword);
  133. if (Array.isArray(it.tags)) it.tags.forEach((t) => tags.add(t));
  134. }
  135. return {
  136. comments: items.length,
  137. keywords: keywords.size,
  138. tagsTotal: tags.size,
  139. platforms,
  140. products,
  141. productsCount: Object.keys(products).length,
  142. hypotheses,
  143. sources,
  144. sentiments,
  145. stage: data?.meta?.stage || 'unknown',
  146. sourceTier: data?.meta?.sourceTier || 'unknown',
  147. collectedAt: data?.meta?.collectedAt || '待采集',
  148. sourceNote: data?.meta?.sourceNote || '',
  149. };
  150. }
  151. function filterByHypothesis(items, h) {
  152. return items.filter((it) => {
  153. const hs = Array.isArray(it.hypothesis) ? it.hypothesis : (it.hypothesis ? [it.hypothesis] : []);
  154. return hs.includes(h);
  155. });
  156. }
  157. function filterByProduct(items, product) {
  158. return items.filter((it) => (it.product || '').includes(product) || (it.keyword || '').includes(product));
  159. }
  160. function filterByKeyword(items, kw) {
  161. return items.filter((it) => it.keyword === kw);
  162. }
  163. function filterByPlatform(items, platform) {
  164. return items.filter((it) => it.platform === platform);
  165. }
  166. function filterBySentiment(items, sentiment) {
  167. return items.filter((it) => it.sentiment === sentiment);
  168. }
  169. function filterByTag(items, tag) {
  170. return items.filter((it) => Array.isArray(it.tags) && it.tags.some((t) => t.includes(tag)));
  171. }
  172. function filterByContent(items, re) {
  173. const rx = re instanceof RegExp ? re : new RegExp(String(re), 'i');
  174. return items.filter((it) => rx.test(String(it.content || '')));
  175. }
  176. function filterByMinLikes(items, min = 1) {
  177. return items.filter((it) => (it.likes || 0) >= min);
  178. }
  179. function topByLikes(items, n = 10) {
  180. return items.slice().sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, n);
  181. }
  182. function sample(items, n = 6, seed = 1) {
  183. const arr = items.slice();
  184. const result = [];
  185. let s = seed;
  186. while (result.length < n && arr.length) {
  187. s = (s * 9301 + 49297) % 233280;
  188. const idx = Math.floor((s / 233280) * arr.length);
  189. result.push(arr.splice(idx, 1)[0]);
  190. }
  191. return result;
  192. }
  193. function groupByTag(items) {
  194. const map = new Map();
  195. for (const it of items) {
  196. if (!Array.isArray(it.tags)) continue;
  197. for (const t of it.tags) {
  198. if (!map.has(t)) map.set(t, { tag: t, count: 0, items: [] });
  199. const g = map.get(t);
  200. g.count++;
  201. g.items.push(it);
  202. }
  203. }
  204. return Array.from(map.values()).sort((a, b) => b.count - a.count);
  205. }
  206. function isSubstantive(content, minChars) {
  207. const s = String(content || '').trim();
  208. if (s.length < minChars) return false;
  209. const stripped = s.replace(/\[[^\]]+\]/g, '').replace(/[\s\p{P}\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '');
  210. return stripped.length >= Math.max(4, Math.floor(minChars / 2));
  211. }
  212. function getEvidence(items, opts = {}) {
  213. const {
  214. hypothesis, product, keyword, platform, sentiment, tag,
  215. minLikes = 0, minChars = 10, contentMatch, requireContentHit = false,
  216. dedupByContent = true, dedupByNickname = false,
  217. top = 6, seed = 7, sortBy = 'likes',
  218. } = opts;
  219. let filtered = items.slice();
  220. if (hypothesis) filtered = filterByHypothesis(filtered, hypothesis);
  221. if (product) filtered = filterByProduct(filtered, product);
  222. if (keyword) filtered = filterByKeyword(filtered, keyword);
  223. if (platform) filtered = filterByPlatform(filtered, platform);
  224. if (sentiment) filtered = filterBySentiment(filtered, sentiment);
  225. if (tag) filtered = filterByTag(filtered, tag);
  226. if (minLikes) filtered = filterByMinLikes(filtered, minLikes);
  227. filtered = filtered.filter((it) => isSubstantive(it.content, minChars));
  228. if (contentMatch) filtered = filterByContent(filtered, contentMatch);
  229. if (sortBy === 'likes') {
  230. filtered = filtered.sort((a, b) => (b.likes || 0) - (a.likes || 0));
  231. }
  232. if (dedupByContent) {
  233. const seen = new Set();
  234. filtered = filtered.filter((it) => {
  235. const key = dedupByNickname
  236. ? `${(it.content || '').slice(0, 40)}|${it.nickname || ''}`
  237. : (it.content || '').slice(0, 40);
  238. if (seen.has(key)) return false;
  239. seen.add(key);
  240. return true;
  241. });
  242. }
  243. const pool = filtered.slice(0, Math.max(top * 2, top + 3));
  244. return sample(pool, Math.min(top, pool.length), seed);
  245. }
  246. function isSeed(item) { return (item?.source || '').includes('pattern') || (item?.source || '').includes('seed'); }
  247. function isReal(item) { return (item?.source || '') === 'real-collected'; }
  248. function getGlobalSourceLabel(meta) {
  249. if (!meta) return '未加载';
  250. const tier = meta.sourceTier;
  251. if (tier === 'real-collected') return '真实采集';
  252. if (tier === 'pattern-curated') return '公开模式归纳 · 种子样本';
  253. return '骨架占位';
  254. }
  255. module.exports = {
  256. HYPOTHESES,
  257. CHAPTER_HYPOTHESIS_MAP,
  258. PLATFORM_LABELS,
  259. loadMerged,
  260. getMeta,
  261. filterByHypothesis, filterByProduct, filterByKeyword, filterByPlatform,
  262. filterBySentiment, filterByTag, filterByContent, filterByMinLikes,
  263. topByLikes, sample, groupByTag,
  264. getEvidence,
  265. isSeed, isReal,
  266. getGlobalSourceLabel,
  267. };