hongcheng-xhs-to-v3-report.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. #!/usr/bin/env node
  2. /**
  3. * 洪城到家 · XHS 数据融入 VOC 洞察报告 v3
  4. *
  5. * 功能:
  6. * 1. 读取 docs/洪城到家/raw/xhs/*.json 的XHS数据
  7. * 2. 读取 hongcheng-voc-insight-report-v3.html
  8. * 3. 替换报告中的占位符为真实XHS数据
  9. *
  10. * 用法:
  11. * node scripts/tools/xhs-to-v3-report.js
  12. */
  13. const fs = require('fs');
  14. const path = require('path');
  15. const ROOT = path.resolve(__dirname, '..', '..');
  16. const XHS_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw', 'xhs');
  17. const REPORT_PATH = path.join(ROOT, 'reports', 'hongcheng-voc-insight-report-v3.html');
  18. function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
  19. function fmtLikes(n) { if (n == null) return '0'; if (n >= 10000) return (n/10000).toFixed(1).replace(/\.0$/,'')+'w'; if (n >= 1000) return (n/1000).toFixed(1).replace(/\.0$/,'')+'k'; return String(n); }
  20. function truncate(s, n=120) { s = String(s ?? ''); return s.length > n ? s.slice(0,n-1)+'…' : s; }
  21. function loadXHSData() {
  22. const files = fs.readdirSync(XHS_DIR).filter(f => f.endsWith('.json'));
  23. const allData = { notes: [], comments: [], byBrand: {} };
  24. for (const file of files) {
  25. const brand = file.replace('.json', '');
  26. const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, file), 'utf8'));
  27. allData.byBrand[brand] = raw;
  28. for (const note of (raw.top_notes || [])) {
  29. note.brand = brand;
  30. allData.notes.push(note);
  31. for (const comment of (note.comments || [])) {
  32. comment.note_id = note.note_id;
  33. comment.brand = brand;
  34. allData.comments.push(comment);
  35. }
  36. }
  37. }
  38. return allData;
  39. }
  40. function analyzeXHS(xhsData) {
  41. const { notes, comments, byBrand } = xhsData;
  42. const brandStats = {};
  43. for (const [brand, data] of Object.entries(byBrand)) {
  44. const notes_count = data.notes_processed || 0;
  45. const comments_count = data.total_comments || 0;
  46. const total_likes = (data.top_notes || []).reduce((s, n) => s + (n.liked_count || 0), 0);
  47. brandStats[brand] = { notes_count, comments_count, total_likes };
  48. }
  49. const topNotes = [...notes].sort((a, b) => (b.liked_count || 0) - (a.liked_count || 0)).slice(0, 6);
  50. const topComments = [...comments].sort((a, b) => (b.comment_likes || 0) - (a.comment_likes || 0)).slice(0, 4);
  51. const sentimentStats = { positive: 0, negative: 0, neutral: 0, conflicted: 0 };
  52. for (const note of notes) {
  53. const s = note.sentiment || 'neutral';
  54. sentimentStats[s] = (sentimentStats[s] || 0) + 1;
  55. }
  56. const tagStats = {};
  57. for (const note of notes) {
  58. for (const tag of (note.inferred_tags || [])) {
  59. tagStats[tag] = (tagStats[tag] || 0) + 1;
  60. }
  61. }
  62. const topTags = Object.entries(tagStats).sort((a, b) => b[1] - a[1]).slice(0, 8);
  63. return { brandStats, topNotes, topComments, sentimentStats, tagStats, topTags, totalNotes: notes.length, totalComments: comments.length };
  64. }
  65. function vocCardXHS(item, type = 'note') {
  66. const senColor = {positive:'#00DC82',neutral:'#6B6B6B',negative:'#EF4444',conflicted:'#F97316'}[item.sentiment]||'#6B6B6B';
  67. const platformIcon = type === 'note' ? '红' : '评';
  68. const likes = type === 'note' ? (item.liked_count || 0) : (item.comment_likes || 0);
  69. const content = type === 'note' ? (item.content || item.title || '') : (item.content || '');
  70. return `<div style="background:linear-gradient(180deg,rgba(255,255,255,0.03) 0%,rgba(255,255,255,0.01) 100%),var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 18px;backdrop-filter:blur(8px);margin-bottom:12px;">
  71. <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;">
  72. <div style="display:flex;align-items:center;gap:8px;">
  73. <span style="width:20px;height:20px;border-radius:50%;background:#FF2442;color:#fff;font-size:0.55rem;font-weight:700;display:flex;align-items:center;justify-content:center;">${platformIcon}</span>
  74. <span style="font-weight:600;font-size:0.8rem;">${esc(item.author || item.user_nickname || '匿名')}</span>
  75. ${item.ip_location ? `<span style="font-size:0.68rem;color:var(--text-3);">· ${esc(item.ip_location)}</span>` : ''}
  76. </div>
  77. <div style="display:flex;align-items:center;gap:8px;">
  78. <span style="font-size:0.68rem;font-weight:600;color:${senColor};">${item.sentiment === 'positive' ? '▲' : item.sentiment === 'negative' ? '▼' : '●'} ${item.sentiment}</span>
  79. <span style="font-size:0.68rem;color:var(--text-3);font-family:var(--font-mono);">♥${fmtLikes(likes)}</span>
  80. </div>
  81. </div>
  82. <div style="font-size:0.85rem;line-height:1.6;color:var(--text-1);">"${esc(truncate(content, 150))}"</div>
  83. ${item.inferred_tags && item.inferred_tags.length ? `<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px;">${item.inferred_tags.slice(0,3).map(t=>`<span style="padding:2px 6px;background:${senColor}15;color:${senColor};border-radius:3px;font-size:0.6rem;font-weight:500;">${esc(t)}</span>`).join('')}</div>` : ''}
  84. </div>`;
  85. }
  86. function brandTableRow(brand, stats) {
  87. return `<tr>
  88. <td style="padding:10px 12px;border-bottom:1px solid var(--border);font-weight:600;font-size:0.85rem;">${esc(brand)}</td>
  89. <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">${stats.notes_count}</td>
  90. <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">${stats.comments_count}</td>
  91. <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">♥ ${fmtLikes(stats.total_likes)}</td>
  92. </tr>`;
  93. }
  94. function generateXHSSection(stats) {
  95. const { brandStats, topNotes, totalNotes, totalComments, sentimentStats, topTags } = stats;
  96. const selfBrand = brandStats['洪诚到家月嫂'] || { notes_count: 0, comments_count: 0, total_likes: 0 };
  97. const otherBrands = Object.entries(brandStats).filter(([k]) => k !== '洪诚到家月嫂');
  98. const totalLikes = Object.values(brandStats).reduce((s, b) => s + b.total_likes, 0);
  99. const sentimentHtml = Object.entries(sentimentStats).map(([s, cnt]) => {
  100. const colors = {positive:'#00DC82',negative:'#EF4444',neutral:'#6B6B6B',conflicted:'#F97316'};
  101. const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'};
  102. const pct = totalNotes ? Math.round((cnt / totalNotes) * 100) : 0;
  103. return `<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
  104. <span style="width:50px;font-size:0.75rem;color:var(--text-2);">${labels[s]}</span>
  105. <div style="flex:1;height:8px;background:var(--bg-3);border-radius:4px;overflow:hidden;">
  106. <div style="width:${pct}%;height:100%;background:${colors[s]};border-radius:4px;"></div>
  107. </div>
  108. <span style="width:40px;font-size:0.72rem;color:var(--text-3);text-align:right;">${pct}%</span>
  109. </div>`;
  110. }).join('');
  111. const tagHtml = topTags.map(([tag, cnt]) =>
  112. `<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:var(--bg-2);border:1px solid var(--border);border-radius:4px;font-size:0.72rem;margin:3px;">
  113. <span style="color:var(--text-2);">${esc(tag)}</span>
  114. <span style="color:var(--amber);font-weight:600;">${cnt}</span>
  115. </span>`
  116. ).join('');
  117. const notesHtml = topNotes.slice(0, 4).map(n => vocCardXHS(n, 'note')).join('');
  118. const brandsHtml = brandTableRow('洪诚到家', selfBrand) +
  119. otherBrands.map(([b, s]) => brandTableRow(b, s)).join('');
  120. return `
  121. <div style="background:linear-gradient(180deg,rgba(255,36,66,0.06) 0%,rgba(255,36,66,0.02) 100%);border:1px solid rgba(255,36,66,0.2);border-radius:var(--radius-md);padding:24px;margin-bottom:24px;">
  122. <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
  123. <span style="width:32px;height:32px;border-radius:8px;background:#FF2442;color:#fff;font-size:0.7rem;font-weight:700;display:flex;align-items:center;justify-content:center;">红</span>
  124. <div>
  125. <div style="font-weight:700;font-size:1rem;color:var(--text-1);">小红书(XHS)真实数据</div>
  126. <div style="font-size:0.72rem;color:var(--text-3);">基于${totalNotes}篇笔记 · ${totalComments}条评论</div>
  127. </div>
  128. </div>
  129. <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px;">
  130. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  131. <div style="font-family:var(--font-head);font-size:1.6rem;font-weight:800;color:#FF2442;">${totalNotes}</div>
  132. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">采集笔记</div>
  133. </div>
  134. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  135. <div style="font-family:var(--font-head);font-size:1.6rem;font-weight:800;color:#FF2442;">${totalComments}</div>
  136. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">采集评论</div>
  137. </div>
  138. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  139. <div style="font-family:var(--font-head);font-size:1.6rem;font-weight:800;color:#FF2442;">${Object.keys(brandStats).length}</div>
  140. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">品牌覆盖</div>
  141. </div>
  142. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  143. <div style="font-family:var(--font-head);font-size:1.6rem;font-weight:800;color:#FF2442;">♥ ${fmtLikes(totalLikes)}</div>
  144. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">总点赞数</div>
  145. </div>
  146. </div>
  147. <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
  148. <div>
  149. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;letter-spacing:0.05em;">品牌声量排行</div>
  150. <table style="width:100%;border-collapse:collapse;font-size:0.8rem;">
  151. <thead>
  152. <tr style="border-bottom:1px solid var(--border-strong);">
  153. <th style="text-align:left;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">品牌</th>
  154. <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">笔记</th>
  155. <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">评论</th>
  156. <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">点赞</th>
  157. </tr>
  158. </thead>
  159. <tbody>${brandsHtml}</tbody>
  160. </table>
  161. </div>
  162. <div>
  163. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;letter-spacing:0.05em;">情感分布</div>
  164. ${sentimentHtml}
  165. <div style="margin-top:16px;">
  166. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:10px;letter-spacing:0.05em;">热门标签</div>
  167. ${tagHtml}
  168. </div>
  169. </div>
  170. </div>
  171. </div>
  172. <div style="margin-bottom:24px;">
  173. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:14px;letter-spacing:0.05em;">高赞笔记精选</div>
  174. <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;">${notesHtml}</div>
  175. </div>`;
  176. }
  177. function main() {
  178. console.log('📊 加载XHS数据...');
  179. const xhsData = loadXHSData();
  180. console.log(` 加载 ${xhsData.notes.length} 篇笔记, ${xhsData.comments.length} 条评论`);
  181. console.log('🔧 分析XHS数据...');
  182. const stats = analyzeXHS(xhsData);
  183. console.log('📝 生成XHS数据区块...');
  184. const xhsSectionHtml = generateXHSSection(stats);
  185. console.log('📄 读取v3报告...');
  186. let reportHtml = fs.readFileSync(REPORT_PATH, 'utf8');
  187. const placeholderPattern = /<div style="background:linear-gradient\(180deg,rgba\(255,36,66,0\.06[^>]*>[\s\S]*?<div style="margin-bottom:24px;">[\s\S]*?<\/div>\n <\/div>/;
  188. if (placeholderPattern.test(reportHtml)) {
  189. reportHtml = reportHtml.replace(placeholderPattern, xhsSectionHtml);
  190. console.log('✅ 替换占位符完成');
  191. } else {
  192. const insertMarker = '<!-- XHS数据填充区域 -->';
  193. if (reportHtml.includes(insertMarker)) {
  194. reportHtml = reportHtml.replace(insertMarker, insertMarker + '\n' + xhsSectionHtml);
  195. console.log('✅ 插入XHS数据区块完成');
  196. } else {
  197. console.log('⚠️ 未找到占位符,将在报告末尾添加XHS数据区块');
  198. reportHtml = reportHtml.replace('</body>', xhsSectionHtml + '\n</body>');
  199. }
  200. }
  201. const outPath = REPORT_PATH;
  202. fs.writeFileSync(outPath, reportHtml, 'utf8');
  203. console.log(`✅ 报告已更新: ${outPath}`);
  204. console.log('\n📊 XHS数据摘要:');
  205. console.log(` 笔记总数: ${stats.totalNotes}`);
  206. console.log(` 评论总数: ${stats.totalComments}`);
  207. console.log(` 品牌数量: ${Object.keys(stats.brandStats).length}`);
  208. console.log(` 总点赞: ♥ ${fmtLikes(Object.values(stats.brandStats).reduce((s, b) => s + b.total_likes, 0))}`);
  209. console.log(` 情感分布:`, stats.sentimentStats);
  210. console.log(` 热门标签:`, stats.topTags.slice(0, 5).map(([t, c]) => `${t}(${c})`).join(', '));
  211. }
  212. if (require.main === module) {
  213. try {
  214. main();
  215. } catch (e) {
  216. console.error('fatal:', e);
  217. process.exit(1);
  218. }
  219. }
  220. module.exports = { loadXHSData, analyzeXHS, generateXHSSection };