| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- #!/usr/bin/env node
- /**
- * 洪城到家 · VOC报告v3数据增强脚本
- *
- * 功能:
- * 1. 读取v3报告中的占位符数据
- * 2. 用真实XHS+Douyin数据替换
- * 3. 优化短剧幻觉问题说明
- * 4. 保留v3原有结构和样式
- *
- * 用法:
- * node scripts/tools/enhance-v3-report.js
- */
- const fs = require('fs');
- const path = require('path');
- const ROOT = path.resolve(__dirname, '..', '..');
- const XHS_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw', 'xhs');
- const DOUYIN_DIR = path.join(ROOT, 'docs', '洪城到家', 'raw', 'douyin');
- const DRAMA_DIR = path.join(DOUYIN_DIR, 'drama');
- const REPORT_V3 = path.join(ROOT, 'reports', 'hongcheng-voc-insight-report-v3.html');
- const REPORT_OUT = path.join(ROOT, 'reports', 'hongcheng-voc-insight-report-v3.html');
- function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
- 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); }
- function truncate(s, n=120) { s = String(s ?? ''); return s.length > n ? s.slice(0,n-1)+'…' : s; }
- function loadXHSData() {
- const files = fs.readdirSync(XHS_DIR).filter(f => f.endsWith('.json'));
- const all = { notes: [], comments: [], byBrand: {}, stats: { notes: 0, comments: 0, likes: 0 } };
- for (const file of files) {
- const brand = file.replace('.json', '');
- const raw = JSON.parse(fs.readFileSync(path.join(XHS_DIR, file), 'utf8'));
- all.byBrand[brand] = raw;
- for (const note of (raw.top_notes || [])) {
- note.brand = brand;
- all.notes.push(note);
- all.stats.notes++;
- all.stats.likes += note.liked_count || 0;
- for (const c of (note.comments || [])) {
- c.note_id = note.note_id;
- c.brand = brand;
- all.comments.push(c);
- all.stats.comments++;
- }
- }
- }
- return all;
- }
- function loadDouyinData() {
- if (!fs.existsSync(DOUYIN_DIR)) return { videos: [], stats: { videos: 0, likes: 0 } };
- const files = fs.readdirSync(DOUYIN_DIR).filter(f => f.endsWith('.json') && f !== 'audit.log');
- const all = { videos: [], byKeyword: {}, stats: { videos: 0, likes: 0, comments: 0 } };
- for (const file of files) {
- const keyword = file.replace('.json', '');
- const raw = JSON.parse(fs.readFileSync(path.join(DOUYIN_DIR, file), 'utf8'));
- all.byKeyword[keyword] = raw;
- for (const v of (raw.top_videos || [])) {
- v.keyword = keyword;
- all.videos.push(v);
- all.stats.videos++;
- all.stats.likes += v.statistics?.digg_count || 0;
- all.stats.comments += v.statistics?.comment_count || 0;
- }
- }
- return all;
- }
- function loadDramaData() {
- if (!fs.existsSync(DRAMA_DIR)) return { dramas: [], stats: { videos: 0, likes: 0, topLikes: 0 } };
- const files = fs.readdirSync(DRAMA_DIR).filter(f => f.endsWith('.json'));
- const all = { dramas: [], stats: { videos: 0, likes: 0, topLikes: 0 } };
- for (const file of files) {
- const raw = JSON.parse(fs.readFileSync(path.join(DRAMA_DIR, file), 'utf8'));
- for (const v of (raw.top_videos || [])) {
- v.dramaKeyword = file.replace('.json', '');
- all.dramas.push(v);
- all.stats.videos++;
- const likes = v.statistics?.digg_count || 0;
- all.stats.likes += likes;
- if (likes > all.stats.topLikes) all.stats.topLikes = likes;
- }
- }
- return all;
- }
- function analyzeData(xhs, dy, drama) {
- const sentiment = { positive: 0, negative: 0, neutral: 0, conflicted: 0 };
- for (const n of xhs.notes) sentiment[n.sentiment || 'neutral']++;
- const tagStats = {};
- for (const n of xhs.notes) for (const t of (n.inferred_tags || [])) tagStats[t] = (tagStats[t] || 0) + 1;
- const topTags = Object.entries(tagStats).sort((a, b) => b[1] - a[1]).slice(0, 12);
- const brandStats = {};
- for (const [brand, data] of Object.entries(xhs.byBrand)) {
- brandStats[brand] = {
- notes: data.notes_processed || 0,
- comments: data.total_comments || 0,
- likes: (data.top_notes || []).reduce((s, n) => s + (n.liked_count || 0), 0)
- };
- }
- const topNotes = [...xhs.notes].sort((a, b) => (b.liked_count || 0) - (a.liked_count || 0)).slice(0, 6);
- const topComments = [...xhs.comments].sort((a, b) => (b.comment_likes || 0) - (a.comment_likes || 0)).slice(0, 4);
- const topDyVideos = [...dy.videos].sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0)).slice(0, 4);
- const topDramas = [...drama.dramas].sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0)).slice(0, 6);
- return {
- sentiment, topTags, topNotes, topComments, topDyVideos, topDramas, brandStats,
- xhsStats: xhs.stats, dyStats: dy.stats, dramaStats: drama.stats,
- totalNotes: xhs.notes.length, totalComments: xhs.comments.length,
- totalDyVideos: dy.videos.length, totalDramas: drama.dramas.length
- };
- }
- function vocCard(item, type = 'note') {
- const senColors = { positive: '#00DC82', negative: '#EF4444', neutral: '#6B6B6B', conflicted: '#F97316' };
- const pColor = type === 'note' ? '#FF2442' : type === 'comment' ? '#FF2442' : '#8B5CF6';
- const pIcon = type === 'note' ? '红' : type === 'comment' ? '评' : '剧';
- let content, likes, author, sentiment, tags;
- if (type === 'note') {
- content = item.content || item.title || '';
- likes = item.liked_count || 0;
- author = item.author || '匿名';
- sentiment = item.sentiment || 'neutral';
- tags = item.inferred_tags || [];
- } else if (type === 'comment') {
- content = item.content || '';
- likes = item.comment_likes || 0;
- author = item.user_nickname || '匿名';
- sentiment = item.sentiment || 'neutral';
- tags = item.tags || [];
- } else {
- content = item.desc || '';
- likes = item.statistics?.digg_count || 0;
- author = item.author?.nickname || '匿名';
- sentiment = 'neutral';
- tags = [];
- }
- 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;">
- <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;">
- <div style="display:flex;align-items:center;gap:8px;">
- <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>
- <span style="font-weight:600;font-size:0.8rem;">${esc(author)}</span>
- </div>
- <div style="display:flex;align-items:center;gap:8px;">
- <span style="font-size:0.68rem;font-weight:600;color:${senColors[sentiment]};">● ${sentiment}</span>
- <span style="font-size:0.68rem;color:var(--text-3);font-family:var(--font-mono);">♥${fmt(likes)}</span>
- </div>
- </div>
- <div style="font-size:0.85rem;line-height:1.6;color:var(--text-1);">"${esc(truncate(content, 140))}"</div>
- ${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>` : ''}
- </div>`;
- }
- function generateXHSSection(stats) {
- const { sentiment, topTags, topNotes, brandStats, totalNotes, totalComments, xhsStats, dramaStats, topDramas } = stats;
- const totalLikes = Object.values(brandStats).reduce((s, b) => s + b.likes, 0);
- const brandRows = Object.entries(brandStats).sort((a, b) => b[1].likes - a[1].likes).map(([brand, s]) =>
- `<tr><td style="padding:10px 12px;border-bottom:1px solid var(--border);font-weight:600;font-size:0.85rem;">${esc(brand)}</td>
- <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">${s.notes}</td>
- <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">${s.comments}</td>
- <td style="padding:10px 12px;border-bottom:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums;">♥ ${fmt(s.likes)}</td></tr>`
- ).join('');
- const sentimentHtml = Object.entries(sentiment).map(([s, cnt]) => {
- const colors = {positive:'#00DC82',negative:'#EF4444',neutral:'#6B6B6B',conflicted:'#F97316'};
- const labels = {positive:'正向',negative:'负向',neutral:'中立',conflicted:'矛盾'};
- const pct = totalNotes ? Math.round((cnt / totalNotes) * 100) : 0;
- return `<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
- <span style="width:50px;font-size:0.75rem;color:var(--text-2);">${labels[s]}</span>
- <div style="flex:1;height:8px;background:var(--bg-3);border-radius:4px;overflow:hidden;">
- <div style="width:${pct}%;height:100%;background:${colors[s]};border-radius:4px;"></div>
- </div>
- <span style="width:40px;font-size:0.72rem;color:var(--text-3);text-align:right;">${pct}%</span>
- </div>`;
- }).join('');
- const tagCloud = topTags.map(([tag, cnt]) =>
- `<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;">
- <span style="color:var(--text-2);">${esc(tag)}</span>
- <span style="color:var(--amber);font-weight:600;">${cnt}</span>
- </span>`
- ).join('');
- const notesHtml = topNotes.slice(0, 3).map(n => vocCard(n, 'note')).join('');
- const dramaHtml = topDramas.slice(0, 2).map(v => vocCard(v, 'drama')).join('');
- return `
- <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;">
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
- <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>
- <div>
- <div style="font-weight:700;font-size:1.1rem;color:var(--text-1);">小红书(XHS)真实数据</div>
- <div style="font-size:0.72rem;color:var(--text-3);">基于${totalNotes}篇笔记 · ${totalComments}条评论 · ♥${fmt(totalLikes)}总互动</div>
- </div>
- </div>
- <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px;">
- <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
- <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">${totalNotes}</div>
- <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">采集笔记</div>
- </div>
- <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
- <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">${totalComments}</div>
- <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">采集评论</div>
- </div>
- <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
- <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">${Object.keys(brandStats).length}</div>
- <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">品牌覆盖</div>
- </div>
- <div style="background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center;">
- <div style="font-family:var(--font-head);font-size:1.5rem;font-weight:800;color:#FF2442;">♥${fmt(totalLikes)}</div>
- <div style="font-size:0.68rem;color:var(--text-3);margin-top:4px;">总点赞</div>
- </div>
- </div>
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
- <div>
- <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">品牌声量排行</div>
- <table style="width:100%;border-collapse:collapse;font-size:0.8rem;">
- <thead><tr style="border-bottom:1px solid var(--border-strong);">
- <th style="text-align:left;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">品牌</th>
- <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">笔记</th>
- <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">评论</th>
- <th style="text-align:right;padding:8px 12px;font-weight:600;color:var(--text-3);font-size:0.68rem;">点赞</th>
- </tr></thead>
- <tbody>${brandRows}</tbody>
- </table>
- </div>
- <div>
- <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">情感分布</div>
- ${sentimentHtml}
- <div style="margin-top:16px;">
- <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:10px;">热门标签</div>
- <div style="display:flex;flex-wrap:wrap;gap:4px;">${tagCloud}</div>
- </div>
- </div>
- </div>
- </div>
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
- <div>
- <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">高赞笔记精选</div>
- ${notesHtml}
- </div>
- <div>
- <div style="font-size:0.78rem;font-weight:600;color:var(--text-2);margin-bottom:12px;">抖音短剧精选</div>
- ${dramaHtml}
- </div>
- </div>`;
- }
- function main() {
- console.log('📊 加载数据...');
- const xhs = loadXHSData();
- const dy = loadDouyinData();
- const drama = loadDramaData();
- console.log(` XHS: ${xhs.stats.notes}笔记/${xhs.stats.comments}评论`);
- console.log(` 抖音: ${dy.stats.videos}视频`);
- console.log(` 短剧: ${drama.stats.videos}视频(${fmt(drama.stats.topLikes)}最高点赞)`);
- console.log('🔧 分析数据...');
- const stats = analyzeData(xhs, dy, drama);
- console.log('📄 读取v3报告...');
- let html = fs.readFileSync(REPORT_V3, 'utf8');
- const xhsSection = generateXHSSection(stats);
- 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;
- if (xhsPlaceholderPattern.test(html)) {
- html = html.replace(xhsPlaceholderPattern, xhsSection);
- console.log('✅ 替换XHS数据区块完成');
- } else {
- console.log('⚠️ 未找到XHS占位符,数据区块已生成但未替换');
- }
- 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;">
- <div style="display:flex;align-items:flex-start;gap:12px;">
- <span style="font-size:1.2rem;">⚠️</span>
- <div>
- <div style="font-weight:700;font-size:0.9rem;color:var(--orange);margin-bottom:8px;">数据说明(破除幻觉)</div>
- <div style="font-size:0.82rem;color:var(--text-2);line-height:1.7;">
- <p style="margin-bottom:6px;">• 短剧VOC ≠ 短剧ROI:${drama.stats.videos}条短剧数据反映用户对内容的反应,不等于广告转化效果</p>
- <p style="margin-bottom:6px;">• 高点赞短剧(♥${fmt(drama.stats.topLikes)})验证了"月嫂+婆媳"题材的流量基础</p>
- <p>• 建议:以小规模测试验证ROI后再规模化投放</p>
- </div>
- </div>
- </div>
- </div>`;
- const dramaSectionPattern = /(<div style="grid-template-columns:repeat\(3,1fr\);[^]*?<div style="margin-top:14px;">)[\s\S]*?(<\/div>\s*<\/div>\s*<\/section>)/;
- if (dramaSectionPattern.test(html)) {
- html = html.replace(dramaSectionPattern, `$1${shortDramaWarning}$2`);
- console.log('✅ 增强短剧数据说明完成');
- }
- console.log('💾 保存报告...');
- fs.writeFileSync(REPORT_OUT, html, 'utf8');
- console.log(`✅ 报告已更新: ${REPORT_OUT}`);
- console.log('\n📊 数据摘要:');
- console.log(` XHS: ${stats.totalNotes}笔记/${stats.totalComments}评论`);
- console.log(` 抖音: ${stats.totalDyVideos}视频`);
- console.log(` 短剧: ${stats.totalDramas}视频(最高♥${fmt(drama.stats.topLikes)})`);
- console.log(` 情感:`, stats.sentiment);
- }
- if (require.main === module) {
- try { main(); } catch (e) { console.error('fatal:', e); process.exit(1); }
- }
- module.exports = { loadXHSData, loadDouyinData, loadDramaData, analyzeData };
|