| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- #!/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 <douyin-json|dir> --output <out-dir>',
- ' node scripts/tools/douyin-viral-script-analyzer.js --text <transcript-or-desc> --output <out-dir>',
- '',
- '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);
- }
|