hongcheng-enhance-v3-report.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. #!/usr/bin/env node
  2. /**
  3. * 洪城到家 · VOC报告v3数据增强脚本
  4. *
  5. * 功能:
  6. * 1. 读取v3报告中的占位符数据
  7. * 2. 用真实XHS+Douyin数据替换
  8. * 3. 优化短剧幻觉问题说明
  9. * 4. 保留v3原有结构和样式
  10. *
  11. * 用法:
  12. * node scripts/tools/enhance-v3-report.js
  13. */
  14. const fs = require('fs');
  15. const path = require('path');
  16. const ROOT = path.resolve(__dirname, '..', '..');
  17. const XHS_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw', 'xhs');
  18. const DOUYIN_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw', 'douyin');
  19. const DRAMA_DIR = path.join(DOUYIN_DIR, 'drama');
  20. const REPORT_V3 = path.join(ROOT, 'reports', 'hongcheng-voc-insight-report-v3.html');
  21. const REPORT_OUT = path.join(ROOT, 'reports', 'hongcheng-voc-insight-report-v3.html');
  22. function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
  23. function fmt(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); }
  24. function truncate(s, n=120) { s = String(s ?? ''); return s.length > n ? s.slice(0,n-1)+'…' : s; }
  25. function loadXHSData() {
  26. const files = fs.readdirSync(XHS_DIR).filter(f => f.endsWith('.json'));
  27. const all = { notes: [], comments: [], byBrand: {}, stats: { notes: 0, comments: 0, likes: 0 } };
  28. for (const file of files) {
  29. const brand = file.replace('.json', '');
  30. const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, file), 'utf8'));
  31. all.byBrand[brand] = raw;
  32. for (const note of (raw.top_notes || [])) {
  33. note.brand = brand;
  34. all.notes.push(note);
  35. all.stats.notes++;
  36. all.stats.likes += note.liked_count || 0;
  37. for (const c of (note.comments || [])) {
  38. c.note_id = note.note_id;
  39. c.brand = brand;
  40. all.comments.push(c);
  41. all.stats.comments++;
  42. }
  43. }
  44. }
  45. return all;
  46. }
  47. function loadDouyinData() {
  48. if (!fs.existsSync(DOUYIN_DIR)) return { videos: [], stats: { videos: 0, likes: 0 } };
  49. const files = fs.readdirSync(DOUYIN_DIR).filter(f => f.endsWith('.json') && f !== 'audit.log');
  50. const all = { videos: [], byKeyword: {}, stats: { videos: 0, likes: 0, comments: 0 } };
  51. for (const file of files) {
  52. const keyword = file.replace('.json', '');
  53. const raw = JSON.parse(fs.readFileSync(path.join(DOUYIN_DIR, file), 'utf8'));
  54. all.byKeyword[keyword] = raw;
  55. for (const v of (raw.top_videos || [])) {
  56. v.keyword = keyword;
  57. all.videos.push(v);
  58. all.stats.videos++;
  59. all.stats.likes += v.statistics?.digg_count || 0;
  60. all.stats.comments += v.statistics?.comment_count || 0;
  61. }
  62. }
  63. return all;
  64. }
  65. function loadDramaData() {
  66. if (!fs.existsSync(DRAMA_DIR)) return { dramas: [], stats: { videos: 0, likes: 0, topLikes: 0 } };
  67. const files = fs.readdirSync(DRAMA_DIR).filter(f => f.endsWith('.json'));
  68. const all = { dramas: [], stats: { videos: 0, likes: 0, topLikes: 0 } };
  69. for (const file of files) {
  70. const raw = JSON.parse(fs.readFileSync(path.join(DRAMA_DIR, file), 'utf8'));
  71. for (const v of (raw.top_videos || [])) {
  72. v.dramaKeyword = file.replace('.json', '');
  73. all.dramas.push(v);
  74. all.stats.videos++;
  75. const likes = v.statistics?.digg_count || 0;
  76. all.stats.likes += likes;
  77. if (likes > all.stats.topLikes) all.stats.topLikes = likes;
  78. }
  79. }
  80. return all;
  81. }
  82. function analyzeData(xhs, dy, drama) {
  83. const sentiment = { positive: 0, negative: 0, neutral: 0, conflicted: 0 };
  84. for (const n of xhs.notes) sentiment[n.sentiment || 'neutral']++;
  85. const tagStats = {};
  86. for (const n of xhs.notes) for (const t of (n.inferred_tags || [])) tagStats[t] = (tagStats[t] || 0) + 1;
  87. const topTags = Object.entries(tagStats).sort((a, b) => b[1] - a[1]).slice(0, 12);
  88. const brandStats = {};
  89. for (const [brand, data] of Object.entries(xhs.byBrand)) {
  90. brandStats[brand] = {
  91. notes: data.notes_processed || 0,
  92. comments: data.total_comments || 0,
  93. likes: (data.top_notes || []).reduce((s, n) => s + (n.liked_count || 0), 0)
  94. };
  95. }
  96. const topNotes = [...xhs.notes].sort((a, b) => (b.liked_count || 0) - (a.liked_count || 0)).slice(0, 6);
  97. const topComments = [...xhs.comments].sort((a, b) => (b.comment_likes || 0) - (a.comment_likes || 0)).slice(0, 4);
  98. const topDyVideos = [...dy.videos].sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0)).slice(0, 4);
  99. const topDramas = [...drama.dramas].sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0)).slice(0, 6);
  100. return {
  101. sentiment, topTags, topNotes, topComments, topDyVideos, topDramas, brandStats,
  102. xhsStats: xhs.stats, dyStats: dy.stats, dramaStats: drama.stats,
  103. totalNotes: xhs.notes.length, totalComments: xhs.comments.length,
  104. totalDyVideos: dy.videos.length, totalDramas: drama.dramas.length
  105. };
  106. }
  107. function vocCard(item, type = 'note') {
  108. const senColors = { positive: '#00DC82', negative: '#EF4444', neutral: '#6B6B6B', conflicted: '#F97316' };
  109. const pColor = type === 'note' ? '#FF2442' : type === 'comment' ? '#FF2442' : '#8B5CF6';
  110. const pIcon = type === 'note' ? '红' : type === 'comment' ? '评' : '剧';
  111. let content, likes, author, sentiment, tags;
  112. if (type === 'note') {
  113. content = item.content || item.title || '';
  114. likes = item.liked_count || 0;
  115. author = item.author || '匿名';
  116. sentiment = item.sentiment || 'neutral';
  117. tags = item.inferred_tags || [];
  118. } else if (type === 'comment') {
  119. content = item.content || '';
  120. likes = item.comment_likes || 0;
  121. author = item.user_nickname || '匿名';
  122. sentiment = item.sentiment || 'neutral';
  123. tags = item.tags || [];
  124. } else {
  125. content = item.desc || '';
  126. likes = item.statistics?.digg_count || 0;
  127. author = item.author?.nickname || '匿名';
  128. sentiment = 'neutral';
  129. tags = [];
  130. }
  131. 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;">
  132. <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;">
  133. <div style="display:flex;align-items:center;gap:8px;">
  134. <span style="width:20px;height:20px;border-radius:50%;background:${pColor};color:#fff;font-size:0.55rem;font-weight:700;display:flex;align-items:center;justify-content:center;">${pIcon}</span>
  135. <span style="font-weight:600;font-size:0.8rem;">${esc(author)}</span>
  136. </div>
  137. <div style="display:flex;align-items:center;gap:8px;">
  138. <span style="font-size:0.68rem;font-weight:600;color:${senColors[sentiment]};">● ${sentiment}</span>
  139. <span style="font-size:0.68rem;color:var(--text-3);font-family:var(--font-mono);">♥${fmt(likes)}</span>
  140. </div>
  141. </div>
  142. <div style="font-size:0.85rem;line-height:1.6;color:var(--text-1);">"${esc(truncate(content, 140))}"</div>
  143. ${tags.length ? `<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px;">${tags.slice(0,3).map(t=>`<span style="padding:2px 6px;background:${senColors[sentiment]}15;color:${senColors[sentiment]};border-radius:3px;font-size:0.6rem;font-weight:500;">${esc(t)}</span>`).join('')}</div>` : ''}
  144. </div>`;
  145. }
  146. function generateXHSSection(stats) {
  147. const { sentiment, topTags, topNotes, brandStats, totalNotes, totalComments, xhsStats, dramaStats, topDramas } = stats;
  148. const totalLikes = Object.values(brandStats).reduce((s, b) => s + b.likes, 0);
  149. const brandRows = Object.entries(brandStats).sort((a, b) => b[1].likes - a[1].likes).map(([brand, s]) =>
  150. `<tr><td style="padding:10px 12px;border-bottom:1px solid var(--border);font-weight:600;font-size:0.85rem;">${esc(brand)}</td>
  151. <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">${s.notes}</td>
  152. <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">${s.comments}</td>
  153. <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">♥ ${fmt(s.likes)}</td></tr>`
  154. ).join('');
  155. const sentimentHtml = Object.entries(sentiment).map(([s, cnt]) => {
  156. const colors = {positive:'#00DC82',negative:'#EF4444',neutral:'#6B6B6B',conflicted:'#F97316'};
  157. const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'};
  158. const pct = totalNotes ? Math.round((cnt / totalNotes) * 100) : 0;
  159. return `<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
  160. <span style="width:50px;font-size:0.75rem;color:var(--text-2);">${labels[s]}</span>
  161. <div style="flex:1;height:8px;background:var(--bg-3);border-radius:4px;overflow:hidden;">
  162. <div style="width:${pct}%;height:100%;background:${colors[s]};border-radius:4px;"></div>
  163. </div>
  164. <span style="width:40px;font-size:0.72rem;color:var(--text-3);text-align:right;">${pct}%</span>
  165. </div>`;
  166. }).join('');
  167. const tagCloud = topTags.map(([tag, cnt]) =>
  168. `<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;background:var(--bg-2);border:1px solid var(--border);border-radius:4px;font-size:0.72rem;margin:3px;">
  169. <span style="color:var(--text-2);">${esc(tag)}</span>
  170. <span style="color:var(--amber);font-weight:600;">${cnt}</span>
  171. </span>`
  172. ).join('');
  173. const notesHtml = topNotes.slice(0, 3).map(n => vocCard(n, 'note')).join('');
  174. const dramaHtml = topDramas.slice(0, 2).map(v => vocCard(v, 'drama')).join('');
  175. return `
  176. <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;">
  177. <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
  178. <span style="width:36px;height:36px;border-radius:10px;background:#FF2442;color:#fff;font-size:0.7rem;font-weight:700;display:flex;align-items:center;justify-content:center;">红</span>
  179. <div>
  180. <div style="font-weight:700;font-size:1.1rem;color:var(--text-1);">小红书(XHS)真实数据</div>
  181. <div style="font-size:0.72rem;color:var(--text-3);">基于${totalNotes}篇笔记 · ${totalComments}条评论 · ♥${fmt(totalLikes)}总互动</div>
  182. </div>
  183. </div>
  184. <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px;">
  185. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  186. <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">${totalNotes}</div>
  187. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">采集笔记</div>
  188. </div>
  189. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  190. <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">${totalComments}</div>
  191. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">采集评论</div>
  192. </div>
  193. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  194. <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">${Object.keys(brandStats).length}</div>
  195. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">品牌覆盖</div>
  196. </div>
  197. <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
  198. <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">♥${fmt(totalLikes)}</div>
  199. <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">总点赞</div>
  200. </div>
  201. </div>
  202. <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
  203. <div>
  204. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">品牌声量排行</div>
  205. <table style="width:100%;border-collapse:collapse;font-size:0.8rem;">
  206. <thead><tr style="border-bottom:1px solid var(--border-strong);">
  207. <th style="text-align:left;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">品牌</th>
  208. <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">笔记</th>
  209. <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">评论</th>
  210. <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">点赞</th>
  211. </tr></thead>
  212. <tbody>${brandRows}</tbody>
  213. </table>
  214. </div>
  215. <div>
  216. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">情感分布</div>
  217. ${sentimentHtml}
  218. <div style="margin-top:16px;">
  219. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:10px;">热门标签</div>
  220. <div style="display:flex;flex-wrap:wrap;gap:4px;">${tagCloud}</div>
  221. </div>
  222. </div>
  223. </div>
  224. </div>
  225. <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
  226. <div>
  227. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">高赞笔记精选</div>
  228. ${notesHtml}
  229. </div>
  230. <div>
  231. <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">抖音短剧精选</div>
  232. ${dramaHtml}
  233. </div>
  234. </div>`;
  235. }
  236. function main() {
  237. console.log('📊 加载数据...');
  238. const xhs = loadXHSData();
  239. const dy = loadDouyinData();
  240. const drama = loadDramaData();
  241. console.log(` XHS: ${xhs.stats.notes}笔记/${xhs.stats.comments}评论`);
  242. console.log(` 抖音: ${dy.stats.videos}视频`);
  243. console.log(` 短剧: ${drama.stats.videos}视频(${fmt(drama.stats.topLikes)}最高点赞)`);
  244. console.log('🔧 分析数据...');
  245. const stats = analyzeData(xhs, dy, drama);
  246. console.log('📄 读取v3报告...');
  247. let html = fs.readFileSync(REPORT_V3, 'utf8');
  248. const xhsSection = generateXHSSection(stats);
  249. const xhsPlaceholderPattern = /<div style="background:linear-gradient\(180deg,rgba\(255,36,66,0\.06\)[^]*?<\/div>\s*<\/div>\s*<\/div>\s*<\/div>\s*<\/div>\s*<\/div>/s;
  250. if (xhsPlaceholderPattern.test(html)) {
  251. html = html.replace(xhsPlaceholderPattern, xhsSection);
  252. console.log('✅ 替换XHS数据区块完成');
  253. } else {
  254. console.log('⚠️ 未找到XHS占位符,数据区块已生成但未替换');
  255. }
  256. const shortDramaWarning = `<div style="background:linear-gradient(135deg,rgba(249,115,22,0.08),rgba(249,115,22,0.03));border:1px solid rgba(249,115,22,0.25);border-radius:var(--radius-md);padding:20px;margin-bottom:20px;">
  257. <div style="display:flex;align-items:flex-start;gap:12px;">
  258. <span style="font-size:1.2rem;">⚠️</span>
  259. <div>
  260. <div style="font-weight:700;font-size:0.9rem;color:var(--orange);margin-bottom:8px;">数据说明(破除幻觉)</div>
  261. <div style="font-size:0.82rem;color:var(--text-2);line-height:1.7;">
  262. <p style="margin-bottom:6px;">• 短剧VOC ≠ 短剧ROI:${drama.stats.videos}条短剧数据反映用户对内容的反应,不等于广告转化效果</p>
  263. <p style="margin-bottom:6px;">• 高点赞短剧(♥${fmt(drama.stats.topLikes)})验证了"月嫂+婆媳"题材的流量基础</p>
  264. <p>• 建议:以小规模测试验证ROI后再规模化投放</p>
  265. </div>
  266. </div>
  267. </div>
  268. </div>`;
  269. const dramaSectionPattern = /(<div style="grid-template-columns:repeat\(3,1fr\);[^]*?<div style="margin-top:14px;">)[\s\S]*?(<\/div>\s*<\/div>\s*<\/section>)/;
  270. if (dramaSectionPattern.test(html)) {
  271. html = html.replace(dramaSectionPattern, `$1${shortDramaWarning}$2`);
  272. console.log('✅ 增强短剧数据说明完成');
  273. }
  274. console.log('💾 保存报告...');
  275. fs.writeFileSync(REPORT_OUT, html, 'utf8');
  276. console.log(`✅ 报告已更新: ${REPORT_OUT}`);
  277. console.log('\n📊 数据摘要:');
  278. console.log(` XHS: ${stats.totalNotes}笔记/${stats.totalComments}评论`);
  279. console.log(` 抖音: ${stats.totalDyVideos}视频`);
  280. console.log(` 短剧: ${stats.totalDramas}视频(最高♥${fmt(drama.stats.topLikes)})`);
  281. console.log(` 情感:`, stats.sentiment);
  282. }
  283. if (require.main === module) {
  284. try { main(); } catch (e) { console.error('fatal:', e); process.exit(1); }
  285. }
  286. module.exports = { loadXHSData, loadDouyinData, loadDramaData, analyzeData };