douyin-viral-script-analyzer.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. function parseArgs(argv) {
  5. const args = {};
  6. for (let i = 0; i < argv.length; i++) {
  7. const token = argv[i];
  8. if (!token.startsWith('--')) continue;
  9. const eq = token.indexOf('=');
  10. if (eq >= 0) {
  11. args[token.slice(2, eq)] = token.slice(eq + 1);
  12. } else {
  13. const key = token.slice(2);
  14. const next = argv[i + 1];
  15. if (next && !next.startsWith('--')) {
  16. args[key] = next;
  17. i++;
  18. } else {
  19. args[key] = true;
  20. }
  21. }
  22. }
  23. return args;
  24. }
  25. function usage() {
  26. return [
  27. 'Usage:',
  28. ' node scripts/tools/douyin-viral-script-analyzer.js --input <douyin-json|dir> --output <out-dir>',
  29. ' node scripts/tools/douyin-viral-script-analyzer.js --text <transcript-or-desc> --output <out-dir>',
  30. '',
  31. 'Outputs:',
  32. ' viral-script-analysis.json',
  33. ' viral-script-analysis.md'
  34. ].join('\n');
  35. }
  36. function ensureDir(dirPath) {
  37. fs.mkdirSync(dirPath, { recursive: true });
  38. }
  39. function readJson(filePath) {
  40. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  41. }
  42. function asArray(value) {
  43. if (!value) return [];
  44. return Array.isArray(value) ? value : [value];
  45. }
  46. function cleanText(value) {
  47. return String(value || '').replace(/\s+/g, ' ').trim();
  48. }
  49. function truncate(value, length = 120) {
  50. const text = cleanText(value);
  51. return text.length > length ? `${text.slice(0, length - 1)}…` : text;
  52. }
  53. function toNumber(value) {
  54. const number = Number(value || 0);
  55. return Number.isFinite(number) ? number : 0;
  56. }
  57. function splitList(value) {
  58. if (!value) return [];
  59. if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
  60. const text = String(value).trim();
  61. if (!text) return [];
  62. if (text.startsWith('[')) {
  63. try {
  64. const parsed = JSON.parse(text);
  65. return Array.isArray(parsed) ? parsed.map(String).map(item => item.trim()).filter(Boolean) : [];
  66. } catch {
  67. return [];
  68. }
  69. }
  70. return text.split(/[,,;;|]/).map(item => item.trim()).filter(Boolean);
  71. }
  72. function loadInput(inputPath) {
  73. if (!inputPath) return {};
  74. const absolute = path.resolve(inputPath);
  75. const stat = fs.statSync(absolute);
  76. if (stat.isDirectory()) {
  77. const candidates = [
  78. 'daily-report.json',
  79. 'douyin-mini-voc.json',
  80. 'raw.json',
  81. '_merged.json',
  82. 'videos.json',
  83. 'keyword-videos.json'
  84. ].map(name => path.join(absolute, name));
  85. const found = candidates.find(filePath => fs.existsSync(filePath));
  86. if (found) return readJson(found);
  87. const jsonFiles = fs.readdirSync(absolute)
  88. .filter(name => name.endsWith('.json'))
  89. .map(name => path.join(absolute, name));
  90. return jsonFiles.reduce((acc, filePath) => mergeData(acc, readJson(filePath)), {});
  91. }
  92. return readJson(absolute);
  93. }
  94. function mergeData(left, right) {
  95. return {
  96. metadata: { ...(left.metadata || {}), ...(right.metadata || {}) },
  97. videos: [
  98. ...asArray(left.videos),
  99. ...asArray(right.videos),
  100. ...asArray(right.accountVideos),
  101. ...asArray(right.items).filter(item => item.sourceType === 'video')
  102. ],
  103. comments: [...asArray(left.comments), ...asArray(right.comments)],
  104. replies: [...asArray(left.replies), ...asArray(right.replies)],
  105. transcripts: [...asArray(left.transcripts), ...asArray(right.transcripts)]
  106. };
  107. }
  108. function normalizeVideo(video, index) {
  109. const raw = video.raw || video;
  110. const statistics = video.statistics || raw.statistics || raw.stats || {};
  111. const author = video.author || raw.author || {};
  112. return {
  113. id: cleanText(video.aweme_id || video.awemeId || video.id || video.productId || raw.aweme_id || raw.awemeId || `video_${index}`),
  114. keyword: video.keyword || raw.keyword,
  115. title: cleanText(video.title || video.desc || video.content || raw.desc || raw.title),
  116. author: cleanText(typeof author === 'string' ? author : author.nickname || author.name || video.authorName),
  117. likeCount: toNumber(video.likeCount ?? video.digg_count ?? statistics.digg_count),
  118. commentCount: toNumber(video.commentCount ?? video.comment_count ?? statistics.comment_count),
  119. shareCount: toNumber(video.shareCount ?? video.share_count ?? statistics.share_count),
  120. playCount: toNumber(video.playCount ?? video.play_count ?? statistics.play_count),
  121. url: video.url || video.share_url || raw.share_url,
  122. raw
  123. };
  124. }
  125. function normalizeComment(comment, index) {
  126. const raw = comment.raw || comment;
  127. const user = comment.user || raw.user || {};
  128. return {
  129. id: cleanText(comment.cid || comment.commentId || comment.id || raw.cid || raw.id || `comment_${index}`),
  130. parentId: cleanText(comment.parentId || comment.aweme_id || raw.aweme_id || raw.item_id),
  131. text: cleanText(comment.text || comment.content || raw.text || raw.content),
  132. author: cleanText(comment.author || user.nickname || user.name),
  133. likeCount: toNumber(comment.likeCount ?? comment.digg_count ?? raw.digg_count),
  134. replyCount: toNumber(comment.replyCount ?? comment.reply_comment_total ?? raw.reply_comment_total),
  135. raw
  136. };
  137. }
  138. function normalizeTranscript(transcript) {
  139. if (!transcript) return undefined;
  140. if (typeof transcript === 'string') return { text: cleanText(transcript), segments: [] };
  141. return {
  142. awemeId: transcript.awemeId || transcript.aweme_id,
  143. text: cleanText(transcript.text),
  144. segments: asArray(transcript.segments)
  145. };
  146. }
  147. function resolveRecords(data, args) {
  148. const videos = asArray(data.videos)
  149. .concat(asArray(data.accountVideos))
  150. .concat(asArray(data.items).filter(item => item.sourceType === 'video'))
  151. .map(normalizeVideo);
  152. if (args.text) {
  153. videos.push({
  154. id: args.awemeId || args['aweme-id'] || 'manual_text',
  155. keyword: args.keyword,
  156. title: cleanText(args.text),
  157. author: args.author || '',
  158. likeCount: 0,
  159. commentCount: 0,
  160. shareCount: 0,
  161. playCount: 0,
  162. url: args.url || '',
  163. raw: {}
  164. });
  165. }
  166. const comments = asArray(data.comments).concat(asArray(data.replies)).map(normalizeComment);
  167. const transcripts = asArray(data.transcripts)
  168. .map(normalizeTranscript)
  169. .filter(Boolean);
  170. if (args.transcript) {
  171. const transcriptPath = path.resolve(args.transcript);
  172. const transcript = transcriptPath.endsWith('.json')
  173. ? readJson(transcriptPath)
  174. : { text: fs.readFileSync(transcriptPath, 'utf8') };
  175. transcripts.push(normalizeTranscript(transcript));
  176. }
  177. return { videos, comments, transcripts };
  178. }
  179. function firstSentence(text) {
  180. const cleaned = cleanText(text);
  181. const parts = cleaned.split(/[。!?!?;;\n]/).map(item => item.trim()).filter(Boolean);
  182. return parts[0] || cleaned.slice(0, 40);
  183. }
  184. function inferHookType(text) {
  185. const t = cleanText(text);
  186. if (/[??]/.test(t) || /为什么|怎么|如何|到底|有没有/.test(t)) return '问题钩子';
  187. if (/别再|不要|千万|避坑|踩雷|错了|误区/.test(t)) return '反常识/避坑钩子';
  188. if (/我发现|亲测|真实|讲个|经历|以前|后来/.test(t)) return '个人经历钩子';
  189. if (/\d|一[个-龥]?招|三[个-龥]?点|5个|10个|清单/.test(t)) return '清单/数字钩子';
  190. if (/但是|其实|反而|不是.*而是|看起来/.test(t)) return '反转钩子';
  191. if (/爆|火|涨粉|成交|转化|收入|结果/.test(t)) return '结果承诺钩子';
  192. return '场景共鸣钩子';
  193. }
  194. function inferConflict(text) {
  195. const t = cleanText(text);
  196. const candidates = [
  197. ['避坑', '用户害怕踩坑,需要一个清晰判断标准'],
  198. ['焦虑', '用户有焦虑或不确定,需要降低决策风险'],
  199. ['贵', '价值感和价格接受度存在冲突'],
  200. ['没用', '用户担心结果不确定,需要证明和边界'],
  201. ['不会', '用户缺少操作路径,需要拆成步骤'],
  202. ['但是', '内容中存在反转或认知冲突,可作为口播中段转折'],
  203. ['为什么', '内容适合用问题驱动展开']
  204. ];
  205. const hit = candidates.find(([needle]) => t.includes(needle));
  206. return hit ? hit[1] : '围绕用户已有认知和真实结果制造轻冲突';
  207. }
  208. function evidenceFromStats(video) {
  209. const parts = [];
  210. if (video.playCount) parts.push(`播放 ${video.playCount}`);
  211. if (video.likeCount) parts.push(`点赞 ${video.likeCount}`);
  212. if (video.commentCount) parts.push(`评论 ${video.commentCount}`);
  213. if (video.shareCount) parts.push(`分享 ${video.shareCount}`);
  214. return parts.join(' / ') || '互动数据未提供';
  215. }
  216. function topQuestions(comments, limit = 3) {
  217. return comments
  218. .filter(comment => /[??]|怎么|哪里|多少|能不能|有没有|适合|为什么/.test(comment.text))
  219. .sort((a, b) => (b.likeCount + b.replyCount * 2) - (a.likeCount + a.replyCount * 2))
  220. .slice(0, limit);
  221. }
  222. function commentsForVideo(comments, videoId, totalVideos) {
  223. const linked = comments.filter(comment => comment.parentId && comment.parentId === videoId);
  224. if (linked.length) return { scope: 'linked', comments: linked };
  225. if (totalVideos === 1 && comments.length) return { scope: 'single_video_unlinked', comments };
  226. return { scope: 'unlinked_ignored', comments: [] };
  227. }
  228. function inferEmotion(comments) {
  229. const text = comments.map(comment => comment.text).join(' ');
  230. if (/贵|便宜|值|性价比|价格/.test(text)) return '价值感犹豫';
  231. if (/怕|担心|焦虑|纠结|不敢|踩雷/.test(text)) return '风险规避';
  232. if (/哪里|怎么买|链接|求|想要|试试/.test(text)) return '行动意愿';
  233. if (/哈哈|笑|真实|太对|共鸣/.test(text)) return '共鸣互动';
  234. return '好奇追问';
  235. }
  236. function reusableFrame(video, hookType, conflict, questions) {
  237. const question = questions[0]?.text || '评论区最常见的问题';
  238. return [
  239. `开头:用「${truncate(firstSentence(video.title), 36)}」切入,先给出一个具体判断。`,
  240. `冲突:指出「${conflict}」,避免直接讲大道理。`,
  241. `论证:拆 2-3 个可验证原因,配合案例、数字或亲身观察。`,
  242. `互动:结尾抛出「${truncate(question, 34)}」这类问题,引导用户留言。`
  243. ];
  244. }
  245. function analyzeVideo(video, allComments, transcripts, totalVideos) {
  246. const transcript = transcripts.find(item => item.awemeId && item.awemeId === video.id) || transcripts[0];
  247. const sourceText = transcript?.text || video.title;
  248. const commentScope = commentsForVideo(allComments, video.id, totalVideos);
  249. const comments = commentScope.comments;
  250. const questions = topQuestions(comments);
  251. const hookType = inferHookType(sourceText);
  252. const conflict = inferConflict(sourceText);
  253. const openingPattern = firstSentence(sourceText);
  254. const frame = reusableFrame(video, hookType, conflict, questions);
  255. const warnings = [];
  256. if (!transcript?.text) warnings.push('未提供逐字稿,本条为基于视频描述、互动数据和评论的结构推断。');
  257. if (comments.length < 3) warnings.push('评论样本较少,评论触发点需谨慎使用。');
  258. if (commentScope.scope === 'unlinked_ignored') warnings.push('评论未匹配到该视频,未纳入本条拆解证据。');
  259. if (commentScope.scope === 'single_video_unlinked') warnings.push('评论缺少视频 ID,仅因样本只有一条视频而暂时归并。');
  260. return {
  261. videoId: video.id,
  262. keyword: video.keyword,
  263. title: video.title,
  264. author: video.author,
  265. url: video.url,
  266. hookType,
  267. openingPattern,
  268. conflict,
  269. proofPoint: evidenceFromStats(video),
  270. audienceEmotion: inferEmotion(comments),
  271. commentTrigger: questions.map(item => item.text),
  272. reusableFrame: frame,
  273. riskNotes: warnings,
  274. evidenceRefs: [
  275. { type: 'video', id: video.id, summary: evidenceFromStats(video), url: video.url },
  276. { type: 'comment_scope', scope: commentScope.scope, count: comments.length },
  277. ...questions.slice(0, 2).map(item => ({ type: 'comment', id: item.id, text: item.text }))
  278. ]
  279. };
  280. }
  281. function renderMarkdown(result) {
  282. const lines = [];
  283. lines.push('# 抖音爆款口播结构拆解');
  284. lines.push('');
  285. lines.push(`生成时间:${result.generatedAt}`);
  286. lines.push(`样本数:${result.summary.videoCount} 条视频 / ${result.summary.commentCount} 条评论 / ${result.summary.transcriptCount} 条逐字稿`);
  287. lines.push('');
  288. result.analyses.forEach((item, index) => {
  289. lines.push(`## ${index + 1}. ${item.title || item.videoId}`);
  290. lines.push('');
  291. lines.push(`- 作者:${item.author || '未标注'}`);
  292. lines.push(`- 关键词:${item.keyword || '未标注'}`);
  293. lines.push(`- 钩子类型:${item.hookType}`);
  294. lines.push(`- 开头模式:${item.openingPattern}`);
  295. lines.push(`- 核心冲突:${item.conflict}`);
  296. lines.push(`- 证明点:${item.proofPoint}`);
  297. lines.push(`- 用户情绪:${item.audienceEmotion}`);
  298. lines.push('');
  299. lines.push('### 可复用口播框架');
  300. item.reusableFrame.forEach(step => lines.push(`- ${step}`));
  301. if (item.commentTrigger.length) {
  302. lines.push('');
  303. lines.push('### 评论触发点');
  304. item.commentTrigger.forEach(text => lines.push(`- ${text}`));
  305. }
  306. if (item.riskNotes.length) {
  307. lines.push('');
  308. lines.push('### 风险边界');
  309. item.riskNotes.forEach(text => lines.push(`- ${text}`));
  310. }
  311. lines.push('');
  312. });
  313. return lines.join('\n');
  314. }
  315. function main() {
  316. const args = parseArgs(process.argv.slice(2));
  317. if (args.help) {
  318. console.log(usage());
  319. return;
  320. }
  321. const outputDir = path.resolve(args.output || path.join(process.cwd(), 'douyin-viral-script-analysis'));
  322. ensureDir(outputDir);
  323. const data = args.input ? loadInput(args.input) : {};
  324. const records = resolveRecords(data, args);
  325. const analyses = records.videos
  326. .slice(0, Number(args.limit || 10))
  327. .map(video => analyzeVideo(video, records.comments, records.transcripts, records.videos.length));
  328. const result = {
  329. status: analyses.length ? 'ok' : 'needs_data',
  330. generatedAt: new Date().toISOString(),
  331. summary: {
  332. videoCount: records.videos.length,
  333. commentCount: records.comments.length,
  334. transcriptCount: records.transcripts.length,
  335. analysisCount: analyses.length
  336. },
  337. analyses
  338. };
  339. const jsonPath = path.join(outputDir, 'viral-script-analysis.json');
  340. const mdPath = path.join(outputDir, 'viral-script-analysis.md');
  341. fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2), 'utf8');
  342. fs.writeFileSync(mdPath, renderMarkdown(result), 'utf8');
  343. const final = { ...result.summary, status: result.status, outputDir, files: [jsonPath, mdPath] };
  344. console.log(JSON.stringify(final, null, 2));
  345. }
  346. try {
  347. main();
  348. } catch (error) {
  349. console.error(error.message);
  350. process.exit(1);
  351. }