transcribe_centrum_douyin_videos.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { spawnSync } = require('child_process');
  5. const { DouyinApi } = require('../claude-code/claude-code-voc-intelligence/mcp/src/providers/douyin-api');
  6. function parseArgs(argv) {
  7. const args = {};
  8. for (let i = 0; i < argv.length; i += 1) {
  9. const token = argv[i];
  10. if (!token.startsWith('--')) continue;
  11. const eq = token.indexOf('=');
  12. if (eq > 0) {
  13. args[token.slice(2, eq)] = token.slice(eq + 1);
  14. continue;
  15. }
  16. const key = token.slice(2);
  17. const next = argv[i + 1];
  18. if (next && !next.startsWith('--')) {
  19. args[key] = next;
  20. i += 1;
  21. } else {
  22. args[key] = true;
  23. }
  24. }
  25. return args;
  26. }
  27. function ensureDir(dir) {
  28. fs.mkdirSync(dir, { recursive: true });
  29. }
  30. function clean(value) {
  31. return String(value || '').replace(/\s+/g, ' ').trim();
  32. }
  33. function numberArg(value, fallback) {
  34. const number = Number(value);
  35. return Number.isFinite(number) && number >= 0 ? Math.round(number) : fallback;
  36. }
  37. function splitList(value) {
  38. if (!value) return [];
  39. return String(value)
  40. .split(/[,,、\s]+/)
  41. .map(item => item.trim())
  42. .filter(Boolean);
  43. }
  44. function isBrandRelevant(video) {
  45. const text = [
  46. video.title,
  47. video.author,
  48. video.keyword,
  49. video.skuGroup,
  50. video.ruleHits,
  51. ].map(clean).join(' ');
  52. if (/善存|Centrum|银善存|小佳维|紫瓶|每日营养包|PRO营养包|复合维生素/i.test(text)) {
  53. return true;
  54. }
  55. return false;
  56. }
  57. function isWeakOrInvalid(video) {
  58. const text = [video.title, video.author, video.skuGroup].map(clean).join(' ');
  59. if (/善存|Centrum|银善存|小佳维|紫瓶/i.test(text)) return false;
  60. return /安利|GNC|膳食套餐|磷虾油|牡蛎片|猴父子|Swisse/i.test(text);
  61. }
  62. function priorityScore(video) {
  63. const riskBoost = { '高': 5000, '中高': 3000, '中': 1200, '低': 0 }[video.riskLevel] || 0;
  64. const skuBoost = /营养包|PRO|每日营养包/.test(video.skuGroup || '') ? 2000 : 0;
  65. const officialBoost = /善存.*(旗舰店|官方|店铺|专卖店)/.test(video.author || '') ? 900 : 0;
  66. const commentBoost = Math.min(Number(video.commentCount || 0) * 2, 2000);
  67. return Number(video.score || 0) + riskBoost + skuBoost + officialBoost + commentBoost;
  68. }
  69. function decodeMaybeBase64Url(value) {
  70. const text = String(value || '').trim();
  71. if (!text) return '';
  72. if (/^https?:\/\//i.test(text)) return text;
  73. if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
  74. try {
  75. const decoded = Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
  76. return /^https?:\/\//i.test(decoded) ? decoded : '';
  77. } catch {
  78. return '';
  79. }
  80. }
  81. function findAweme(node, awemeId, depth = 0) {
  82. if (!node || depth > 8) return null;
  83. if (Array.isArray(node)) {
  84. for (const item of node) {
  85. const found = findAweme(item, awemeId, depth + 1);
  86. if (found) return found;
  87. }
  88. return null;
  89. }
  90. if (typeof node !== 'object') return null;
  91. const id = String(node.aweme_id || node.id || '');
  92. if (id === String(awemeId)) return node;
  93. for (const value of Object.values(node)) {
  94. const found = findAweme(value, awemeId, depth + 1);
  95. if (found) return found;
  96. }
  97. return null;
  98. }
  99. function durationFromAweme(item) {
  100. const raw = item?.video?.duration || item?.duration || item?.video_duration || 0;
  101. const numeric = Number(raw || 0);
  102. if (!Number.isFinite(numeric) || numeric <= 0) return 0;
  103. return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
  104. }
  105. function collectVideoCandidates(item) {
  106. const out = [];
  107. const video = item?.video || {};
  108. const bitRates = Array.isArray(video.bit_rate) ? video.bit_rate : [];
  109. bitRates.forEach((rate, rateIndex) => {
  110. const urls = rate?.play_addr?.url_list || [];
  111. urls.forEach((value, urlIndex) => {
  112. const url = decodeMaybeBase64Url(value);
  113. if (!url) return;
  114. out.push({
  115. url,
  116. kind: 'video',
  117. keyPath: `video.bit_rate.${rateIndex}.play_addr.url_list.${urlIndex}`,
  118. bitRate: Number(rate.bit_rate || rate.bitrate || rate.real_bitrate || 0),
  119. dataSize: Number(rate.play_addr?.data_size || rate.data_size || 0),
  120. });
  121. });
  122. });
  123. const downloadUrls = video.download_addr?.url_list || [];
  124. downloadUrls.forEach((value, index) => {
  125. const url = decodeMaybeBase64Url(value);
  126. if (url) out.push({ url, kind: 'video', keyPath: `video.download_addr.url_list.${index}`, bitRate: 0, dataSize: Number(video.download_addr?.data_size || 0) });
  127. });
  128. const unique = [];
  129. const seen = new Set();
  130. for (const item of out) {
  131. if (seen.has(item.url)) continue;
  132. seen.add(item.url);
  133. unique.push(item);
  134. }
  135. unique.sort((a, b) => {
  136. const hostRank = item => {
  137. try {
  138. const host = new URL(item.url).hostname.toLowerCase();
  139. if (host.includes('api-play.amemv.com')) return 0;
  140. if (host.includes('api.amemv.com')) return 1;
  141. if (host.includes('douyinvod.com')) return 2;
  142. return 3;
  143. } catch {
  144. return 4;
  145. }
  146. };
  147. const aHostRank = hostRank(a);
  148. const bHostRank = hostRank(b);
  149. if (aHostRank !== bHostRank) return aHostRank - bHostRank;
  150. const aSize = a.dataSize || Number.MAX_SAFE_INTEGER;
  151. const bSize = b.dataSize || Number.MAX_SAFE_INTEGER;
  152. if (aSize !== bSize) return aSize - bSize;
  153. return (a.bitRate || Number.MAX_SAFE_INTEGER) - (b.bitRate || Number.MAX_SAFE_INTEGER);
  154. });
  155. return unique;
  156. }
  157. async function resolveMedia(api, video) {
  158. const attempts = [
  159. video.keyword,
  160. clean(video.title).replace(/#.*$/g, '').slice(0, 50),
  161. clean(video.title).split(/[,,。#]/)[0].slice(0, 50),
  162. ].filter(Boolean);
  163. const tried = [];
  164. for (const keyword of attempts) {
  165. if (tried.includes(keyword)) continue;
  166. tried.push(keyword);
  167. for (let retry = 0; retry < 3; retry += 1) {
  168. try {
  169. const response = await api.searchVideos({
  170. keyword,
  171. cursor: 0,
  172. sortType: '1',
  173. publishTime: '180',
  174. filterDuration: '0',
  175. contentType: '1',
  176. });
  177. const aweme = findAweme(response.data, video.videoId);
  178. if (!aweme) continue;
  179. const candidates = collectVideoCandidates(aweme);
  180. if (!candidates.length) continue;
  181. return {
  182. status: 'ok',
  183. keyword,
  184. durationMs: durationFromAweme(aweme),
  185. candidate: candidates[0],
  186. candidates,
  187. candidateCount: candidates.length,
  188. };
  189. } catch (error) {
  190. tried.push(`${keyword}: ${error.message}`);
  191. if (retry < 2) {
  192. await new Promise(resolve => setTimeout(resolve, 1500 * (retry + 1)));
  193. }
  194. }
  195. }
  196. }
  197. return { status: 'not_found', tried };
  198. }
  199. function runTranscriber({ video, media, outDir, ffmpegPath, transcriberPath, nodePath, env }) {
  200. const videoDir = path.join(outDir, 'transcripts', String(video.videoId));
  201. ensureDir(videoDir);
  202. const args = [
  203. transcriberPath,
  204. '--provider', 'iflytek-gateway',
  205. '--media-url', media.candidate.url,
  206. '--aweme-id', String(video.videoId),
  207. '--duration-ms', String(media.durationMs || 60000),
  208. '--output', videoDir,
  209. '--ffmpeg-path', ffmpegPath,
  210. '--gateway-max-duration-ms', '600000',
  211. '--max-download-mb', '160',
  212. '--max-polls', '60',
  213. '--poll-interval-ms', '4000',
  214. ];
  215. const result = spawnSync(nodePath, args, {
  216. cwd: process.cwd(),
  217. env,
  218. encoding: 'utf8',
  219. windowsHide: true,
  220. maxBuffer: 1024 * 1024 * 20,
  221. timeout: 420000,
  222. });
  223. let parsed = {};
  224. const stdout = String(result.stdout || '').trim();
  225. if (stdout) {
  226. try {
  227. parsed = JSON.parse(stdout.split(/\r?\n/).pop());
  228. } catch {
  229. parsed = { rawStdout: stdout.slice(-1000) };
  230. }
  231. }
  232. return {
  233. exitCode: result.status,
  234. error: result.error ? result.error.message : '',
  235. stderr: String(result.stderr || '').trim().slice(-2000),
  236. stdout: stdout.slice(-2000),
  237. parsed,
  238. videoDir,
  239. };
  240. }
  241. async function main() {
  242. const args = parseArgs(process.argv.slice(2));
  243. const token = process.env.VOC_TOKEN || process.env.VOC_DOUYIN_TOKEN || process.env.DOUYIN_TOKEN;
  244. if (!token) throw new Error('VOC_TOKEN is required in environment');
  245. const inputPath = path.resolve(args.input || 'outputs/centrum_douyin_audit_2026-06-08/live-full/live-analysis-data.json');
  246. const outDir = path.resolve(args.output || 'outputs/centrum_douyin_audit_2026-06-08/transcript-full');
  247. const limit = numberArg(args.limit, 1);
  248. const nodePath = process.execPath;
  249. const transcriberPath = path.resolve(args.transcriber || 'scripts/tools/douyin-video-transcriber.js');
  250. const ffmpegPath = path.resolve(args.ffmpegPath || args['ffmpeg-path'] || '.codex-run/ffmpeg-installer/node_modules/@ffmpeg-installer/win32-x64/ffmpeg.exe');
  251. ensureDir(outDir);
  252. const data = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
  253. const allVideos = Array.isArray(data.videoRows) ? data.videoRows : [];
  254. const excluded = allVideos.filter(video => !isBrandRelevant(video) || isWeakOrInvalid(video));
  255. const requestedIds = new Set(splitList(args.ids || args.videoIds || args['video-ids']));
  256. const eligible = allVideos
  257. .filter(video => isBrandRelevant(video) && !isWeakOrInvalid(video))
  258. .map(video => ({ ...video, transcriptPriority: priorityScore(video) }))
  259. .sort((a, b) => b.transcriptPriority - a.transcriptPriority);
  260. const selected = requestedIds.size
  261. ? eligible.filter(video => requestedIds.has(String(video.videoId)))
  262. : eligible.slice(0, limit);
  263. const pool = {
  264. generatedAt: new Date().toISOString(),
  265. inputPath,
  266. limit,
  267. selectedCount: selected.length,
  268. excludedCount: excluded.length,
  269. selected: selected.map(video => ({
  270. videoId: video.videoId,
  271. title: video.title,
  272. author: video.author,
  273. keyword: video.keyword,
  274. skuGroup: video.skuGroup,
  275. riskLevel: video.riskLevel,
  276. score: video.score,
  277. transcriptPriority: video.transcriptPriority,
  278. })),
  279. excluded: excluded.map(video => ({
  280. videoId: video.videoId,
  281. title: video.title,
  282. author: video.author,
  283. keyword: video.keyword,
  284. skuGroup: video.skuGroup,
  285. reason: isWeakOrInvalid(video) ? 'weak_or_invalid_non_centrum' : 'no_clear_brand_signal',
  286. })),
  287. };
  288. fs.writeFileSync(path.join(outDir, 'transcript-sample-pool.json'), JSON.stringify(pool, null, 2), 'utf8');
  289. const api = new DouyinApi({ token });
  290. const runLog = [];
  291. const safeEnv = { ...process.env, VOC_TOKEN: token };
  292. for (const video of selected) {
  293. const existing = path.join(outDir, 'transcripts', String(video.videoId), 'transcript.json');
  294. if (fs.existsSync(existing) && !args.force) {
  295. const transcript = JSON.parse(fs.readFileSync(existing, 'utf8'));
  296. runLog.push({ videoId: video.videoId, status: 'skipped_existing', transcriptStatus: transcript.status, textLength: clean(transcript.text).length });
  297. continue;
  298. }
  299. const media = await resolveMedia(api, video);
  300. if (media.status !== 'ok') {
  301. runLog.push({ videoId: video.videoId, status: 'media_not_found', media });
  302. continue;
  303. }
  304. const candidateAttempts = [];
  305. let result = null;
  306. const candidates = Array.isArray(media.candidates) && media.candidates.length
  307. ? media.candidates.slice(0, numberArg(args.candidateLimit || args['candidate-limit'], 8))
  308. : [media.candidate];
  309. for (const candidate of candidates) {
  310. const attemptMedia = { ...media, candidate };
  311. result = runTranscriber({ video, media: attemptMedia, outDir, ffmpegPath, transcriberPath, nodePath, env: safeEnv });
  312. const transcriptPath = path.join(result.videoDir, 'transcript.json');
  313. let attemptTranscriptStatus = '';
  314. let attemptTextLength = 0;
  315. if (fs.existsSync(transcriptPath)) {
  316. try {
  317. const transcript = JSON.parse(fs.readFileSync(transcriptPath, 'utf8'));
  318. attemptTranscriptStatus = transcript.status;
  319. attemptTextLength = clean(transcript.text).length;
  320. } catch {
  321. attemptTranscriptStatus = 'unreadable_transcript';
  322. }
  323. }
  324. candidateAttempts.push({
  325. keyPath: candidate.keyPath,
  326. exitCode: result.exitCode,
  327. transcriptStatus: attemptTranscriptStatus,
  328. textLength: attemptTextLength,
  329. stderr: result.stderr,
  330. });
  331. if (result.exitCode === 0 && attemptTranscriptStatus) break;
  332. }
  333. let transcriptStatus = '';
  334. let textLength = 0;
  335. const transcriptPath = path.join(result.videoDir, 'transcript.json');
  336. if (fs.existsSync(transcriptPath)) {
  337. try {
  338. const transcript = JSON.parse(fs.readFileSync(transcriptPath, 'utf8'));
  339. transcriptStatus = transcript.status;
  340. textLength = clean(transcript.text).length;
  341. } catch {
  342. transcriptStatus = 'unreadable_transcript';
  343. }
  344. }
  345. runLog.push({
  346. videoId: video.videoId,
  347. title: video.title,
  348. author: video.author,
  349. status: result.exitCode === 0 ? 'transcriber_finished' : 'transcriber_error',
  350. transcriptStatus,
  351. textLength,
  352. mediaKeyword: media.keyword,
  353. mediaKeyPath: media.candidate.keyPath,
  354. durationMs: media.durationMs,
  355. candidateAttempts,
  356. exitCode: result.exitCode,
  357. error: result.error,
  358. stderr: result.stderr,
  359. parsed: result.parsed,
  360. });
  361. fs.writeFileSync(path.join(outDir, 'transcription-run-log.json'), JSON.stringify(runLog, null, 2), 'utf8');
  362. }
  363. const summary = {
  364. status: 'ok',
  365. outDir,
  366. selectedCount: selected.length,
  367. excludedCount: excluded.length,
  368. okCount: runLog.filter(item => item.transcriptStatus === 'ok').length,
  369. pendingCount: runLog.filter(item => item.transcriptStatus === 'transcription_pending').length,
  370. failedCount: runLog.filter(item => item.status !== 'skipped_existing' && item.transcriptStatus !== 'ok').length,
  371. runLogPath: path.join(outDir, 'transcription-run-log.json'),
  372. poolPath: path.join(outDir, 'transcript-sample-pool.json'),
  373. };
  374. fs.writeFileSync(summary.runLogPath, JSON.stringify(runLog, null, 2), 'utf8');
  375. console.log(JSON.stringify(summary, null, 2));
  376. }
  377. main().catch(error => {
  378. console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
  379. process.exit(1);
  380. });