/** * 把 /replies/*.json 的子回复合并进顶级评论 JSON * 用法: node merge_replies.js [awemeId] */ const fs = require('fs'); const path = require('path'); const inFile = process.argv[2]; const repliesDir = process.argv[3]; const outDir = process.argv[4] || path.dirname(inFile); const awemeId = process.argv[5] || '7552202593888603410'; const comments = JSON.parse(fs.readFileSync(inFile, 'utf8')); const byId = {}; comments.forEach(c => { byId[c.cid] = c; c.replies = c.replies || []; }); const stats = { files: 0, badFiles: 0, rawReplies: 0, uniqueReplies: 0, commentsMatched: 0, orphan: 0 }; let files = []; if (fs.existsSync(repliesDir)) { files = fs.readdirSync(repliesDir).filter(f => f.endsWith('.json')); } console.log('回复页面文件:', files.length, '个'); // 按 cid 分组,保证按 cursor 顺序处理 const byComment = {}; files.forEach(f => { const m = /^(\d+)_(\d+)\.json$/.exec(f); if (!m) return; const cid = m[1], cursor = parseInt(m[2], 10); (byComment[cid] = byComment[cid] || []).push({ f, cursor }); }); console.log('涉及评论数:', Object.keys(byComment).length); const seenReply = {}; for (const cid of Object.keys(byComment)) { byComment[cid].sort((a, b) => a.cursor - b.cursor); const parent = byId[cid]; if (!parent) { stats.orphan++; continue; } let n = 0; for (const { f } of byComment[cid]) { stats.files++; let j; try { j = JSON.parse(fs.readFileSync(path.join(repliesDir, f), 'utf8')); } catch (e) { stats.badFiles++; continue; } if (j.code !== 0 || !j.data) continue; for (const r of (j.data.comments || [])) { stats.rawReplies++; if (!r || !r.id || seenReply[r.id]) continue; seenReply[r.id] = 1; stats.uniqueReplies++; const u = r.user || {}; parent.replies.push({ cid: r.id, text: r.text || '', user: u.unique_id || u.nickname || '匿名', nickname: u.nickname || '', userId: u.id || '', region: u.region || '', likes: parseInt(r.digg_count, 10) || 0, createTime: r.create_time || 0, replyCount: parseInt(r.reply_total, 10) || 0 }); n++; } } if (n > 0) stats.commentsMatched++; } console.log('合并统计:', JSON.stringify(stats, null, 2)); // 排序:每个父评论的回复按点赞降序 comments.forEach(c => c.replies.sort((a, b) => b.likes - a.likes)); const withR = comments.filter(c => (c.replies || []).length > 0); const totalReplies = comments.reduce((s, c) => s + (c.replies || []).length, 0); fs.mkdirSync(outDir, { recursive: true }); const base = path.join(outDir, 'tiktok_' + awemeId); fs.writeFileSync(base + '_comments.json', JSON.stringify(comments, null, 2), 'utf8'); const L = []; L.push('视频ID: ' + awemeId); L.push('来源: tikwm镜像(www.tikwm.com) 顶级评论 ' + comments.length + ' 条'); L.push('子回复: ' + totalReplies + ' 条(覆盖 ' + withR.length + ' 条评论)'); L.push('抓取时间: ' + new Date().toISOString()); L.push(''); comments.forEach((c, i) => { L.push((i + 1) + '. [' + c.user + ' | 赞' + c.likes + ' | 回复' + c.replyCount + ' | 地区' + c.region + '] ' + c.text); (c.replies || []).forEach(r => { L.push(' └─ [' + r.user + ' | 赞' + r.likes + '] ' + r.text); }); }); fs.writeFileSync(base + '_comments.txt', L.join('\n'), 'utf8'); const summary = JSON.parse(fs.readFileSync(base + '_stats.json', 'utf8')); summary.repliesFetched = totalReplies; summary.commentsWithRepliesFetched = withR.length; summary.replyFiles = stats.files; summary.mergedAt = new Date().toISOString(); summary.note = 'tikwm 顶级评论上限约 10369;子回复按热度抓取,可在云机断点续传'; fs.writeFileSync(base + '_stats.json', JSON.stringify(summary, null, 2), 'utf8'); console.log('\n已写出:'); ['_comments.json', '_comments.txt', '_stats.json'].forEach(s => { const p = base + s; console.log(' ' + p + ' (' + fs.statSync(p).size + ' bytes)'); });