#!/usr/bin/env node const fs = require('fs'); const path = require('path'); function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/douyin-viral-script-analyzer.js --input --output ', ' node scripts/tools/douyin-viral-script-analyzer.js --text --output ', '', 'Outputs:', ' viral-script-analysis.json', ' viral-script-analysis.md' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function truncate(value, length = 120) { const text = cleanText(value); return text.length > length ? `${text.slice(0, length - 1)}…` : text; } function toNumber(value) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean); const text = String(value).trim(); if (!text) return []; if (text.startsWith('[')) { try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed.map(String).map(item => item.trim()).filter(Boolean) : []; } catch { return []; } } return text.split(/[,,;;|]/).map(item => item.trim()).filter(Boolean); } function loadInput(inputPath) { if (!inputPath) return {}; const absolute = path.resolve(inputPath); const stat = fs.statSync(absolute); if (stat.isDirectory()) { const candidates = [ 'daily-report.json', 'douyin-mini-voc.json', 'raw.json', '_merged.json', 'videos.json', 'keyword-videos.json' ].map(name => path.join(absolute, name)); const found = candidates.find(filePath => fs.existsSync(filePath)); if (found) return readJson(found); const jsonFiles = fs.readdirSync(absolute) .filter(name => name.endsWith('.json')) .map(name => path.join(absolute, name)); return jsonFiles.reduce((acc, filePath) => mergeData(acc, readJson(filePath)), {}); } return readJson(absolute); } function mergeData(left, right) { return { metadata: { ...(left.metadata || {}), ...(right.metadata || {}) }, videos: [ ...asArray(left.videos), ...asArray(right.videos), ...asArray(right.accountVideos), ...asArray(right.items).filter(item => item.sourceType === 'video') ], comments: [...asArray(left.comments), ...asArray(right.comments)], replies: [...asArray(left.replies), ...asArray(right.replies)], transcripts: [...asArray(left.transcripts), ...asArray(right.transcripts)] }; } function normalizeVideo(video, index) { const raw = video.raw || video; const statistics = video.statistics || raw.statistics || raw.stats || {}; const author = video.author || raw.author || {}; return { id: cleanText(video.aweme_id || video.awemeId || video.id || video.productId || raw.aweme_id || raw.awemeId || `video_${index}`), keyword: video.keyword || raw.keyword, title: cleanText(video.title || video.desc || video.content || raw.desc || raw.title), author: cleanText(typeof author === 'string' ? author : author.nickname || author.name || video.authorName), likeCount: toNumber(video.likeCount ?? video.digg_count ?? statistics.digg_count), commentCount: toNumber(video.commentCount ?? video.comment_count ?? statistics.comment_count), shareCount: toNumber(video.shareCount ?? video.share_count ?? statistics.share_count), playCount: toNumber(video.playCount ?? video.play_count ?? statistics.play_count), url: video.url || video.share_url || raw.share_url, raw }; } function normalizeComment(comment, index) { const raw = comment.raw || comment; const user = comment.user || raw.user || {}; return { id: cleanText(comment.cid || comment.commentId || comment.id || raw.cid || raw.id || `comment_${index}`), parentId: cleanText(comment.parentId || comment.aweme_id || raw.aweme_id || raw.item_id), text: cleanText(comment.text || comment.content || raw.text || raw.content), author: cleanText(comment.author || user.nickname || user.name), likeCount: toNumber(comment.likeCount ?? comment.digg_count ?? raw.digg_count), replyCount: toNumber(comment.replyCount ?? comment.reply_comment_total ?? raw.reply_comment_total), raw }; } function normalizeTranscript(transcript) { if (!transcript) return undefined; if (typeof transcript === 'string') return { text: cleanText(transcript), segments: [] }; return { awemeId: transcript.awemeId || transcript.aweme_id, text: cleanText(transcript.text), segments: asArray(transcript.segments) }; } function resolveRecords(data, args) { const videos = asArray(data.videos) .concat(asArray(data.accountVideos)) .concat(asArray(data.items).filter(item => item.sourceType === 'video')) .map(normalizeVideo); if (args.text) { videos.push({ id: args.awemeId || args['aweme-id'] || 'manual_text', keyword: args.keyword, title: cleanText(args.text), author: args.author || '', likeCount: 0, commentCount: 0, shareCount: 0, playCount: 0, url: args.url || '', raw: {} }); } const comments = asArray(data.comments).concat(asArray(data.replies)).map(normalizeComment); const transcripts = asArray(data.transcripts) .map(normalizeTranscript) .filter(Boolean); if (args.transcript) { const transcriptPath = path.resolve(args.transcript); const transcript = transcriptPath.endsWith('.json') ? readJson(transcriptPath) : { text: fs.readFileSync(transcriptPath, 'utf8') }; transcripts.push(normalizeTranscript(transcript)); } return { videos, comments, transcripts }; } function firstSentence(text) { const cleaned = cleanText(text); const parts = cleaned.split(/[。!?!?;;\n]/).map(item => item.trim()).filter(Boolean); return parts[0] || cleaned.slice(0, 40); } function inferHookType(text) { const t = cleanText(text); if (/[??]/.test(t) || /为什么|怎么|如何|到底|有没有/.test(t)) return '问题钩子'; if (/别再|不要|千万|避坑|踩雷|错了|误区/.test(t)) return '反常识/避坑钩子'; if (/我发现|亲测|真实|讲个|经历|以前|后来/.test(t)) return '个人经历钩子'; if (/\d|一[个-龥]?招|三[个-龥]?点|5个|10个|清单/.test(t)) return '清单/数字钩子'; if (/但是|其实|反而|不是.*而是|看起来/.test(t)) return '反转钩子'; if (/爆|火|涨粉|成交|转化|收入|结果/.test(t)) return '结果承诺钩子'; return '场景共鸣钩子'; } function inferConflict(text) { const t = cleanText(text); const candidates = [ ['避坑', '用户害怕踩坑,需要一个清晰判断标准'], ['焦虑', '用户有焦虑或不确定,需要降低决策风险'], ['贵', '价值感和价格接受度存在冲突'], ['没用', '用户担心结果不确定,需要证明和边界'], ['不会', '用户缺少操作路径,需要拆成步骤'], ['但是', '内容中存在反转或认知冲突,可作为口播中段转折'], ['为什么', '内容适合用问题驱动展开'] ]; const hit = candidates.find(([needle]) => t.includes(needle)); return hit ? hit[1] : '围绕用户已有认知和真实结果制造轻冲突'; } function evidenceFromStats(video) { const parts = []; if (video.playCount) parts.push(`播放 ${video.playCount}`); if (video.likeCount) parts.push(`点赞 ${video.likeCount}`); if (video.commentCount) parts.push(`评论 ${video.commentCount}`); if (video.shareCount) parts.push(`分享 ${video.shareCount}`); return parts.join(' / ') || '互动数据未提供'; } function topQuestions(comments, limit = 3) { return comments .filter(comment => /[??]|怎么|哪里|多少|能不能|有没有|适合|为什么/.test(comment.text)) .sort((a, b) => (b.likeCount + b.replyCount * 2) - (a.likeCount + a.replyCount * 2)) .slice(0, limit); } function commentsForVideo(comments, videoId, totalVideos) { const linked = comments.filter(comment => comment.parentId && comment.parentId === videoId); if (linked.length) return { scope: 'linked', comments: linked }; if (totalVideos === 1 && comments.length) return { scope: 'single_video_unlinked', comments }; return { scope: 'unlinked_ignored', comments: [] }; } function inferEmotion(comments) { const text = comments.map(comment => comment.text).join(' '); if (/贵|便宜|值|性价比|价格/.test(text)) return '价值感犹豫'; if (/怕|担心|焦虑|纠结|不敢|踩雷/.test(text)) return '风险规避'; if (/哪里|怎么买|链接|求|想要|试试/.test(text)) return '行动意愿'; if (/哈哈|笑|真实|太对|共鸣/.test(text)) return '共鸣互动'; return '好奇追问'; } function reusableFrame(video, hookType, conflict, questions) { const question = questions[0]?.text || '评论区最常见的问题'; return [ `开头:用「${truncate(firstSentence(video.title), 36)}」切入,先给出一个具体判断。`, `冲突:指出「${conflict}」,避免直接讲大道理。`, `论证:拆 2-3 个可验证原因,配合案例、数字或亲身观察。`, `互动:结尾抛出「${truncate(question, 34)}」这类问题,引导用户留言。` ]; } function analyzeVideo(video, allComments, transcripts, totalVideos) { const transcript = transcripts.find(item => item.awemeId && item.awemeId === video.id) || transcripts[0]; const sourceText = transcript?.text || video.title; const commentScope = commentsForVideo(allComments, video.id, totalVideos); const comments = commentScope.comments; const questions = topQuestions(comments); const hookType = inferHookType(sourceText); const conflict = inferConflict(sourceText); const openingPattern = firstSentence(sourceText); const frame = reusableFrame(video, hookType, conflict, questions); const warnings = []; if (!transcript?.text) warnings.push('未提供逐字稿,本条为基于视频描述、互动数据和评论的结构推断。'); if (comments.length < 3) warnings.push('评论样本较少,评论触发点需谨慎使用。'); if (commentScope.scope === 'unlinked_ignored') warnings.push('评论未匹配到该视频,未纳入本条拆解证据。'); if (commentScope.scope === 'single_video_unlinked') warnings.push('评论缺少视频 ID,仅因样本只有一条视频而暂时归并。'); return { videoId: video.id, keyword: video.keyword, title: video.title, author: video.author, url: video.url, hookType, openingPattern, conflict, proofPoint: evidenceFromStats(video), audienceEmotion: inferEmotion(comments), commentTrigger: questions.map(item => item.text), reusableFrame: frame, riskNotes: warnings, evidenceRefs: [ { type: 'video', id: video.id, summary: evidenceFromStats(video), url: video.url }, { type: 'comment_scope', scope: commentScope.scope, count: comments.length }, ...questions.slice(0, 2).map(item => ({ type: 'comment', id: item.id, text: item.text })) ] }; } function renderMarkdown(result) { const lines = []; lines.push('# 抖音爆款口播结构拆解'); lines.push(''); lines.push(`生成时间:${result.generatedAt}`); lines.push(`样本数:${result.summary.videoCount} 条视频 / ${result.summary.commentCount} 条评论 / ${result.summary.transcriptCount} 条逐字稿`); lines.push(''); result.analyses.forEach((item, index) => { lines.push(`## ${index + 1}. ${item.title || item.videoId}`); lines.push(''); lines.push(`- 作者:${item.author || '未标注'}`); lines.push(`- 关键词:${item.keyword || '未标注'}`); lines.push(`- 钩子类型:${item.hookType}`); lines.push(`- 开头模式:${item.openingPattern}`); lines.push(`- 核心冲突:${item.conflict}`); lines.push(`- 证明点:${item.proofPoint}`); lines.push(`- 用户情绪:${item.audienceEmotion}`); lines.push(''); lines.push('### 可复用口播框架'); item.reusableFrame.forEach(step => lines.push(`- ${step}`)); if (item.commentTrigger.length) { lines.push(''); lines.push('### 评论触发点'); item.commentTrigger.forEach(text => lines.push(`- ${text}`)); } if (item.riskNotes.length) { lines.push(''); lines.push('### 风险边界'); item.riskNotes.forEach(text => lines.push(`- ${text}`)); } lines.push(''); }); return lines.join('\n'); } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const outputDir = path.resolve(args.output || path.join(process.cwd(), 'douyin-viral-script-analysis')); ensureDir(outputDir); const data = args.input ? loadInput(args.input) : {}; const records = resolveRecords(data, args); const analyses = records.videos .slice(0, Number(args.limit || 10)) .map(video => analyzeVideo(video, records.comments, records.transcripts, records.videos.length)); const result = { status: analyses.length ? 'ok' : 'needs_data', generatedAt: new Date().toISOString(), summary: { videoCount: records.videos.length, commentCount: records.comments.length, transcriptCount: records.transcripts.length, analysisCount: analyses.length }, analyses }; const jsonPath = path.join(outputDir, 'viral-script-analysis.json'); const mdPath = path.join(outputDir, 'viral-script-analysis.md'); fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2), 'utf8'); fs.writeFileSync(mdPath, renderMarkdown(result), 'utf8'); const final = { ...result.summary, status: result.status, outputDir, files: [jsonPath, mdPath] }; console.log(JSON.stringify(final, null, 2)); } try { main(); } catch (error) { console.error(error.message); process.exit(1); }