douyin-video-transcriber.js 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const os = require('os');
  5. const http = require('http');
  6. const https = require('https');
  7. const crypto = require('crypto');
  8. const { spawnSync } = require('child_process');
  9. const VOC_API_ROOT = 'https://server.fmode.cn/api/voc-social';
  10. const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.oga', '.opus', '.pcm']);
  11. const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.webm', '.mkv']);
  12. const MEDIA_EXTENSIONS = new Set([...AUDIO_EXTENSIONS, ...VIDEO_EXTENSIONS]);
  13. function parseArgs(argv) {
  14. const args = {};
  15. for (let i = 0; i < argv.length; i++) {
  16. const token = argv[i];
  17. if (!token.startsWith('--')) continue;
  18. const eq = token.indexOf('=');
  19. if (eq >= 0) {
  20. args[token.slice(2, eq)] = token.slice(eq + 1);
  21. } else {
  22. const key = token.slice(2);
  23. const next = argv[i + 1];
  24. if (next && !next.startsWith('--')) {
  25. args[key] = next;
  26. i++;
  27. } else {
  28. args[key] = true;
  29. }
  30. }
  31. }
  32. return args;
  33. }
  34. function usage() {
  35. return [
  36. 'Usage:',
  37. ' node scripts/tools/douyin-video-transcriber.js --provider manual --text <text> --aweme-id <id> --output <out-dir>',
  38. ' node scripts/tools/douyin-video-transcriber.js --provider openai --file <audio-or-video-file> --output <out-dir>',
  39. ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-ist --file <audio-file> --duration-ms <ms> --output <out-dir>',
  40. ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-gateway --file <audio-file> --duration-ms <ms> --output <out-dir>',
  41. ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-ist --douyin-url <share-url> --output <out-dir>',
  42. ' node scripts/tools/douyin-video-transcriber.js --provider none --video-url <url> --output <out-dir>',
  43. '',
  44. 'Providers:',
  45. ' manual Wrap user-provided transcript text into transcript schema',
  46. ' openai Call OpenAI audio transcription API; requires OPENAI_API_KEY',
  47. ' iflytek-ist Call Xunfei/Iflytek long audio transcription; requires IFLYTEK_APP_ID/API_KEY/API_SECRET and accurate --duration-ms',
  48. ' iflytek-gateway Call server.fmode.cn transcription gateway; requires VOC token',
  49. ' iflytek-ast Reserved realtime WebSocket provider; use for live PCM streams, not batch files',
  50. ' none Emit a needs_transcription record without calling ASR',
  51. ' volcengine Reserved provider placeholder until credentials/resource are confirmed'
  52. ].join('\n');
  53. }
  54. function ensureDir(dirPath) {
  55. fs.mkdirSync(dirPath, { recursive: true });
  56. }
  57. function cleanText(value) {
  58. return String(value || '').replace(/\s+/g, ' ').trim();
  59. }
  60. function bool(value) {
  61. if (typeof value === 'boolean') return value;
  62. if (value === undefined || value === null) return false;
  63. return ['1', 'true', 'yes', 'y'].includes(String(value).toLowerCase());
  64. }
  65. function asArray(value) {
  66. if (!value) return [];
  67. return Array.isArray(value) ? value : [value];
  68. }
  69. function hasConcreteArg(value) {
  70. if (value === undefined || value === null) return false;
  71. const text = String(value).trim();
  72. return Boolean(text) && !/^\{\{[^}]+\}\}$/.test(text);
  73. }
  74. function firstConcrete(...values) {
  75. return values.find(hasConcreteArg) || '';
  76. }
  77. function guessMime(filePath) {
  78. const ext = path.extname(filePath).toLowerCase();
  79. return {
  80. '.mp3': 'audio/mpeg',
  81. '.mp4': 'video/mp4',
  82. '.mpeg': 'audio/mpeg',
  83. '.mpga': 'audio/mpeg',
  84. '.m4a': 'audio/mp4',
  85. '.wav': 'audio/wav',
  86. '.webm': 'audio/webm',
  87. '.ogg': 'audio/ogg',
  88. '.oga': 'audio/ogg'
  89. }[ext] || 'application/octet-stream';
  90. }
  91. function javaUrlEncode(value) {
  92. return encodeURIComponent(String(value))
  93. .replace(/%20/g, '+')
  94. .replace(/!/g, '%21')
  95. .replace(/\*/g, '%2A')
  96. .replace(/\(/g, '%28')
  97. .replace(/\)/g, '%29')
  98. .replace(/~/g, '%7E');
  99. }
  100. function buildIflytekQueryString(params) {
  101. return Object.keys(params)
  102. .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
  103. .map(key => `${javaUrlEncode(key)}=${javaUrlEncode(params[key])}`)
  104. .join('&');
  105. }
  106. function signIflytekIST(secret, params) {
  107. const sorted = {};
  108. Object.keys(params).sort().forEach(key => {
  109. if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
  110. sorted[key] = params[key];
  111. }
  112. });
  113. const baseString = buildIflytekQueryString(sorted);
  114. return crypto.createHmac('sha1', Buffer.from(secret, 'utf8')).update(baseString, 'utf8').digest('base64');
  115. }
  116. function randomString(length = 16) {
  117. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  118. let result = '';
  119. for (let i = 0; i < length; i++) result += chars.charAt(Math.floor(Math.random() * chars.length));
  120. return result;
  121. }
  122. function formatLocalDateTime() {
  123. const now = new Date();
  124. const pad = n => String(n).padStart(2, '0');
  125. const timezoneOffset = now.getTimezoneOffset();
  126. const offsetSign = timezoneOffset <= 0 ? '+' : '-';
  127. const offsetH = pad(Math.floor(Math.abs(timezoneOffset) / 60));
  128. const offsetM = pad(Math.abs(timezoneOffset) % 60);
  129. return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetH}${offsetM}`;
  130. }
  131. function sleep(ms) {
  132. return new Promise(resolve => setTimeout(resolve, ms));
  133. }
  134. function parsePositiveInt(value, fieldName) {
  135. const numeric = Number(value);
  136. if (!Number.isFinite(numeric) || numeric <= 0) {
  137. throw new Error(`${fieldName} must be a positive number`);
  138. }
  139. return Math.round(numeric);
  140. }
  141. function parseOptionalPositiveInt(value, fallback) {
  142. if (value === undefined || value === null || value === '') return fallback;
  143. const numeric = Number(value);
  144. if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
  145. return Math.round(numeric);
  146. }
  147. function gatewayMaxDurationMs(args) {
  148. return parseOptionalPositiveInt(
  149. args.gatewayMaxDurationMs
  150. || args['gateway-max-duration-ms']
  151. || args.maxDurationMs
  152. || args['max-duration-ms']
  153. || process.env.IFLYTEK_GATEWAY_MAX_DURATION_MS,
  154. 480000
  155. );
  156. }
  157. function parseIflytekISTResult(orderResult) {
  158. if (!orderResult) return { text: '', segments: [] };
  159. const parsed = typeof orderResult === 'string' ? JSON.parse(orderResult) : orderResult;
  160. const lattice = Array.isArray(parsed.lattice) ? parsed.lattice : [];
  161. const rawSegments = [];
  162. const fullTextParts = [];
  163. for (const item of lattice) {
  164. if (!item || !item.json_1best) continue;
  165. const json1best = typeof item.json_1best === 'string' ? JSON.parse(item.json_1best) : item.json_1best;
  166. const st = json1best.st || {};
  167. const rt = Array.isArray(st.rt) ? st.rt : [];
  168. let sentenceText = '';
  169. for (const rtItem of rt) {
  170. const ws = Array.isArray(rtItem?.ws) ? rtItem.ws : [];
  171. for (const wsItem of ws) {
  172. const cw = Array.isArray(wsItem?.cw) ? wsItem.cw : [];
  173. const first = cw[0];
  174. if (first && first.w) sentenceText += String(first.w);
  175. }
  176. }
  177. const text = sentenceText.trim();
  178. if (!text) continue;
  179. const startMs = st.bg != null && Number.isFinite(Number(st.bg)) ? Math.round(Number(st.bg)) : null;
  180. const endMs = st.ed != null && Number.isFinite(Number(st.ed)) ? Math.round(Number(st.ed)) : null;
  181. const speakerId = st.rl != null ? String(st.rl) : null;
  182. rawSegments.push({
  183. start: startMs == null ? null : startMs / 1000,
  184. end: endMs == null ? null : endMs / 1000,
  185. startMs,
  186. endMs,
  187. speakerId,
  188. text
  189. });
  190. fullTextParts.push(text);
  191. }
  192. const segments = [];
  193. let current = null;
  194. for (const segment of rawSegments) {
  195. if (!current) {
  196. current = { ...segment };
  197. } else if (current.speakerId === segment.speakerId) {
  198. current.text += segment.text;
  199. current.end = segment.end;
  200. current.endMs = segment.endMs;
  201. } else {
  202. segments.push(current);
  203. current = { ...segment };
  204. }
  205. }
  206. if (current) segments.push(current);
  207. return { text: fullTextParts.join(' ').trim(), segments };
  208. }
  209. function normalizeTranscript(fields) {
  210. return {
  211. awemeId: fields.awemeId || '',
  212. sourceUrl: fields.sourceUrl || '',
  213. sourceFile: fields.sourceFile || '',
  214. mediaUrl: fields.mediaUrl || '',
  215. audioFile: fields.audioFile || '',
  216. sourceKind: fields.sourceKind || '',
  217. provider: fields.provider || 'manual',
  218. model: fields.model || '',
  219. language: fields.language || 'zh',
  220. durationMs: fields.durationMs || 0,
  221. orderId: fields.orderId || '',
  222. text: cleanText(fields.text),
  223. segments: Array.isArray(fields.segments) ? fields.segments : [],
  224. words: Array.isArray(fields.words) ? fields.words : [],
  225. status: fields.status || (fields.text ? 'ok' : 'needs_transcription'),
  226. warnings: Array.isArray(fields.warnings) ? fields.warnings : [],
  227. generatedAt: new Date().toISOString()
  228. };
  229. }
  230. function requestDownload(url, redirectCount = 0, options = {}) {
  231. return new Promise((resolve, reject) => {
  232. if (redirectCount > 5) {
  233. reject(new Error('Too many redirects while downloading media'));
  234. return;
  235. }
  236. const maxBytes = options.maxBytes || 200 * 1024 * 1024;
  237. const idleTimeoutMs = options.idleTimeoutMs || 30000;
  238. const totalTimeoutMs = options.totalTimeoutMs || 120000;
  239. const parsed = new URL(url);
  240. const client = parsed.protocol === 'http:' ? http : https;
  241. let settled = false;
  242. let idleTimer;
  243. const finish = (error, value) => {
  244. if (settled) return;
  245. settled = true;
  246. clearTimeout(totalTimer);
  247. clearTimeout(idleTimer);
  248. if (error) reject(error);
  249. else resolve(value);
  250. };
  251. const refreshIdleTimer = req => {
  252. clearTimeout(idleTimer);
  253. idleTimer = setTimeout(() => {
  254. req.destroy(new Error(`Download idle timeout after ${idleTimeoutMs}ms`));
  255. }, idleTimeoutMs);
  256. };
  257. let req;
  258. const totalTimer = setTimeout(() => {
  259. if (req) req.destroy(new Error(`Download total timeout after ${totalTimeoutMs}ms`));
  260. finish(new Error(`Download total timeout after ${totalTimeoutMs}ms`));
  261. }, totalTimeoutMs);
  262. req = client.get(parsed, {
  263. headers: {
  264. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) OpenClawVOC/1.0',
  265. Accept: '*/*'
  266. }
  267. }, res => {
  268. refreshIdleTimer(req);
  269. if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
  270. const next = new URL(res.headers.location, url).toString();
  271. res.resume();
  272. requestDownload(next, redirectCount + 1, options).then(
  273. value => finish(null, value),
  274. error => finish(error)
  275. );
  276. return;
  277. }
  278. if (res.statusCode < 200 || res.statusCode >= 300) {
  279. res.resume();
  280. finish(new Error(`Download failed with HTTP ${res.statusCode}`));
  281. return;
  282. }
  283. const chunks = [];
  284. let totalBytes = 0;
  285. res.on('data', chunk => {
  286. refreshIdleTimer(req);
  287. totalBytes += chunk.length;
  288. if (totalBytes > maxBytes) {
  289. req.destroy(new Error(`Download exceeds ${Math.round(maxBytes / 1024 / 1024)}MB limit`));
  290. return;
  291. }
  292. chunks.push(chunk);
  293. });
  294. res.on('end', () => finish(null, Buffer.concat(chunks)));
  295. });
  296. refreshIdleTimer(req);
  297. req.on('error', error => finish(error));
  298. req.setTimeout(idleTimeoutMs, () => {
  299. req.destroy(new Error('Download timed out'));
  300. });
  301. });
  302. }
  303. async function resolveInputFile(args, outputDir) {
  304. const asset = await resolveInputAsset(args, outputDir);
  305. return asset.filePath;
  306. }
  307. function loadVocToken() {
  308. const envToken = process.env.VOC_TOKEN || process.env.OPENCLAW_VOC_TOKEN || process.env.VOC_SOCIAL_TOKEN;
  309. if (envToken) return envToken;
  310. const credentialsPath = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  311. if (fs.existsSync(credentialsPath)) {
  312. try {
  313. const data = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
  314. return data.vocToken || data.token || '';
  315. } catch {
  316. return '';
  317. }
  318. }
  319. return '';
  320. }
  321. async function fetchWithTimeout(url, options = {}, timeoutMs = 60000) {
  322. const controller = new AbortController();
  323. const timer = setTimeout(() => controller.abort(), timeoutMs);
  324. try {
  325. return await fetch(url, { ...options, signal: controller.signal });
  326. } catch (error) {
  327. if (error.name === 'AbortError') {
  328. throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`);
  329. }
  330. throw error;
  331. } finally {
  332. clearTimeout(timer);
  333. }
  334. }
  335. async function requestVocJson(pathUrl, query, token) {
  336. const url = new URL(`${VOC_API_ROOT}${pathUrl}`);
  337. Object.entries(query || {}).forEach(([key, value]) => {
  338. if (hasConcreteArg(value)) url.searchParams.set(key, String(value));
  339. });
  340. const response = await fetchWithTimeout(url.toString(), {
  341. headers: {
  342. Authorization: `Bearer ${token}`,
  343. Accept: 'application/json'
  344. }
  345. }, 60000);
  346. const text = await response.text();
  347. let data;
  348. try {
  349. data = JSON.parse(text);
  350. } catch {
  351. data = { rawText: text };
  352. }
  353. if (!response.ok) {
  354. throw new Error(data.mess || data.msg || data.message || data.error || `VOC request failed with HTTP ${response.status}`);
  355. }
  356. return data;
  357. }
  358. function extractAwemeId(value) {
  359. const text = String(value || '');
  360. const patterns = [
  361. /(?:aweme_id|item_id|modal_id)=([0-9]{15,})/,
  362. /\/(?:video|note)\/([0-9]{15,})/,
  363. /\b([0-9]{15,})\b/
  364. ];
  365. for (const pattern of patterns) {
  366. const match = text.match(pattern);
  367. if (match) return match[1];
  368. }
  369. return '';
  370. }
  371. function isHttpUrl(value) {
  372. try {
  373. const parsed = new URL(String(value || ''));
  374. return ['http:', 'https:'].includes(parsed.protocol);
  375. } catch {
  376. return false;
  377. }
  378. }
  379. function isDouyinShareUrl(value) {
  380. if (!isHttpUrl(value)) return false;
  381. const parsed = new URL(String(value));
  382. const host = parsed.hostname.toLowerCase();
  383. if (host.includes('douyinvod.com') || host.includes('douyinpic.com')) return false;
  384. if (host.includes('amemv.com') && parsed.pathname.includes('/aweme/v1/play')) return false;
  385. return host.includes('douyin.com') || host.includes('iesdouyin.com') || host === 'v.douyin.com';
  386. }
  387. function isLikelyMediaUrl(value) {
  388. if (!isHttpUrl(value)) return false;
  389. const parsed = new URL(String(value));
  390. const ext = path.extname(parsed.pathname).toLowerCase();
  391. const host = parsed.hostname.toLowerCase();
  392. if (MEDIA_EXTENSIONS.has(ext)) return true;
  393. if (host.includes('douyinvod.com')) return true;
  394. if (host.includes('amemv.com') && parsed.pathname.includes('/aweme/v1/play')) return true;
  395. if ((parsed.searchParams.get('mime_type') || '').includes('video_mp4')) return true;
  396. return false;
  397. }
  398. function decodeMaybeBase64Url(value) {
  399. const text = String(value || '').trim();
  400. if (!text) return '';
  401. if (isHttpUrl(text)) return text;
  402. if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
  403. const normalized = text.replace(/-/g, '+').replace(/_/g, '/');
  404. try {
  405. const decoded = Buffer.from(normalized, 'base64').toString('utf8');
  406. return isHttpUrl(decoded) ? decoded : '';
  407. } catch {
  408. return '';
  409. }
  410. }
  411. function findAwemeDetail(node, depth = 0) {
  412. if (!node || depth > 8) return undefined;
  413. if (Array.isArray(node)) {
  414. for (const item of node) {
  415. const found = findAwemeDetail(item, depth + 1);
  416. if (found) return found;
  417. }
  418. return undefined;
  419. }
  420. if (typeof node !== 'object') return undefined;
  421. if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
  422. if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
  423. if (node.aweme_id && node.video) return node;
  424. for (const value of Object.values(node)) {
  425. const found = findAwemeDetail(value, depth + 1);
  426. if (found) return found;
  427. }
  428. return undefined;
  429. }
  430. async function fetchDouyinVideoDetail(args) {
  431. const token = loadVocToken();
  432. if (!token) {
  433. throw new Error('VOC_TOKEN or ~/.openclaw/voc-credentials.json is required to resolve a Douyin share URL.');
  434. }
  435. const shareUrl = firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url);
  436. const awemeId = firstConcrete(args.awemeId, args['aweme-id']) || extractAwemeId(shareUrl);
  437. const attempts = [];
  438. if (shareUrl && isDouyinShareUrl(shareUrl)) {
  439. attempts.push({
  440. pathUrl: '/douyin/app/v3/fetch_one_video_by_share_url',
  441. query: { share_url: shareUrl }
  442. });
  443. attempts.push({
  444. pathUrl: '/douyin/app/v3/fetch_one_video_by_share_url',
  445. query: { url: shareUrl }
  446. });
  447. }
  448. if (awemeId) {
  449. attempts.push({
  450. pathUrl: '/douyin/app/v3/fetch_one_video_v3',
  451. query: { aweme_id: awemeId }
  452. });
  453. }
  454. const errors = [];
  455. for (const attempt of attempts) {
  456. try {
  457. const response = await requestVocJson(attempt.pathUrl, attempt.query, token);
  458. const detail = findAwemeDetail(response);
  459. if (detail?.aweme_id) {
  460. if (attempt.pathUrl.includes('share_url')) {
  461. try {
  462. const fullResponse = await requestVocJson('/douyin/app/v3/fetch_one_video_v3', { aweme_id: detail.aweme_id }, token);
  463. const fullDetail = findAwemeDetail(fullResponse);
  464. if (fullDetail?.aweme_id) {
  465. return { detail: fullDetail, response: fullResponse, endpoint: `${attempt.pathUrl} -> /douyin/app/v3/fetch_one_video_v3` };
  466. }
  467. } catch (error) {
  468. errors.push(`/douyin/app/v3/fetch_one_video_v3: ${error.message}`);
  469. }
  470. }
  471. return { detail, response, endpoint: attempt.pathUrl };
  472. }
  473. errors.push(`${attempt.pathUrl}: no aweme_detail in response`);
  474. } catch (error) {
  475. errors.push(`${attempt.pathUrl}: ${error.message}`);
  476. }
  477. }
  478. throw new Error(`Unable to resolve Douyin media detail. ${errors.join(' | ')}`);
  479. }
  480. function inferUrlKind(pathParts, url) {
  481. const joined = pathParts.join('.').toLowerCase();
  482. if (/cover|poster|image|thumb|avatar|sticker/.test(joined)) return 'image';
  483. if (/audio|mp4a|music|sound/.test(joined) || /media-audio|audio/.test(url)) return 'audio';
  484. if (/play_addr|download_addr|bit_rate|video|media-video/.test(joined) || isLikelyMediaUrl(url)) return 'video';
  485. return 'unknown';
  486. }
  487. function collectMediaCandidates(node, pathParts = [], out = []) {
  488. if (!node) return out;
  489. if (Array.isArray(node)) {
  490. node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
  491. return out;
  492. }
  493. if (typeof node !== 'object') return out;
  494. Object.entries(node).forEach(([key, value]) => {
  495. const nextPath = [...pathParts, key];
  496. if (key === 'url_list' && Array.isArray(value)) {
  497. value.forEach((item, index) => {
  498. const url = decodeMaybeBase64Url(item);
  499. const kind = inferUrlKind(nextPath, url);
  500. if (url && kind !== 'image') {
  501. out.push({
  502. url,
  503. kind,
  504. keyPath: nextPath.join('.'),
  505. index,
  506. dataSize: Number(node.data_size || node.size || 0),
  507. bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
  508. });
  509. }
  510. });
  511. } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
  512. const url = decodeMaybeBase64Url(value);
  513. const kind = inferUrlKind(nextPath, url);
  514. if (url && kind !== 'image') {
  515. out.push({
  516. url,
  517. kind,
  518. keyPath: nextPath.join('.'),
  519. dataSize: Number(node.data_size || node.size || 0),
  520. bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
  521. });
  522. }
  523. }
  524. collectMediaCandidates(value, nextPath, out);
  525. });
  526. return out;
  527. }
  528. function selectMediaCandidate(detail, preferred = 'audio') {
  529. const candidates = collectMediaCandidates(detail)
  530. .filter(item => ['audio', 'video'].includes(item.kind))
  531. .filter(item => isLikelyMediaUrl(item.url));
  532. const seen = new Set();
  533. const unique = candidates.filter(item => {
  534. if (seen.has(item.url)) return false;
  535. seen.add(item.url);
  536. return true;
  537. });
  538. unique.sort((a, b) => {
  539. const aPreferred = a.kind === preferred ? 0 : 1;
  540. const bPreferred = b.kind === preferred ? 0 : 1;
  541. if (aPreferred !== bPreferred) return aPreferred - bPreferred;
  542. const rank = item => {
  543. const keyPath = String(item.keyPath || '').toLowerCase();
  544. if (preferred === 'audio' && item.kind === 'audio') {
  545. if (keyPath.includes('video.dynamic_audio_list') || keyPath.includes('video.bit_rate_audio')) return 0;
  546. if (keyPath.includes('video.')) return 1;
  547. if (keyPath.includes('music.')) return 2;
  548. }
  549. if (item.kind === preferred) return 3;
  550. return 4;
  551. };
  552. const aRank = rank(a);
  553. const bRank = rank(b);
  554. if (aRank !== bRank) return aRank - bRank;
  555. const aSize = a.dataSize || Number.MAX_SAFE_INTEGER;
  556. const bSize = b.dataSize || Number.MAX_SAFE_INTEGER;
  557. if (aSize !== bSize) return aSize - bSize;
  558. return (a.bitRate || 0) - (b.bitRate || 0);
  559. });
  560. return unique[0];
  561. }
  562. function durationFromDetail(detail) {
  563. const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
  564. const numeric = Number(raw || 0);
  565. if (!Number.isFinite(numeric) || numeric <= 0) return 0;
  566. return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
  567. }
  568. function extensionFromUrl(url, fallback = '.mp4') {
  569. try {
  570. const parsed = new URL(url);
  571. const ext = path.extname(parsed.pathname).toLowerCase();
  572. if (MEDIA_EXTENSIONS.has(ext)) return ext;
  573. if (fallback === '.m4a' && /audio|mp4a/i.test(parsed.pathname)) return '.m4a';
  574. const mime = parsed.searchParams.get('mime_type') || '';
  575. if (mime.includes('audio')) return '.m4a';
  576. if (mime.includes('video')) return '.mp4';
  577. } catch {
  578. return fallback;
  579. }
  580. return fallback;
  581. }
  582. async function downloadToFile(url, outputDir, baseName, fallbackExt, args = {}) {
  583. const maxMb = Number(args.maxDownloadMb || args['max-download-mb'] || 200);
  584. const totalTimeoutMs = Number(args.downloadTimeoutMs || args['download-timeout-ms'] || 120000);
  585. const idleTimeoutMs = Number(args.downloadIdleTimeoutMs || args['download-idle-timeout-ms'] || 30000);
  586. const buffer = await requestDownload(url, 0, {
  587. maxBytes: Number.isFinite(maxMb) && maxMb > 0 ? maxMb * 1024 * 1024 : 200 * 1024 * 1024,
  588. totalTimeoutMs: Number.isFinite(totalTimeoutMs) && totalTimeoutMs > 0 ? totalTimeoutMs : 120000,
  589. idleTimeoutMs: Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0 ? idleTimeoutMs : 30000
  590. });
  591. const ext = extensionFromUrl(url, fallbackExt);
  592. const filePath = path.join(outputDir, `${baseName}${ext}`);
  593. fs.writeFileSync(filePath, buffer);
  594. return filePath;
  595. }
  596. async function resolveInputAsset(args, outputDir, options = {}) {
  597. const sourceUrl = firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url);
  598. const mediaUrl = firstConcrete(args.mediaUrl, args['media-url']);
  599. const awemeIdArg = firstConcrete(args.awemeId, args['aweme-id']);
  600. const preferredMedia = options.preferredMedia || 'audio';
  601. if (hasConcreteArg(args.file)) {
  602. return {
  603. filePath: path.resolve(args.file),
  604. sourceUrl,
  605. mediaUrl: '',
  606. awemeId: awemeIdArg || extractAwemeId(sourceUrl),
  607. durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0),
  608. sourceKind: 'file',
  609. warnings: []
  610. };
  611. }
  612. if (mediaUrl || (sourceUrl && isLikelyMediaUrl(sourceUrl) && !isDouyinShareUrl(sourceUrl))) {
  613. const directUrl = mediaUrl || sourceUrl;
  614. const filePath = await downloadToFile(directUrl, outputDir, `source-${Date.now()}`, preferredMedia === 'audio' ? '.m4a' : '.mp4', args);
  615. return {
  616. filePath,
  617. sourceUrl,
  618. mediaUrl: directUrl,
  619. awemeId: awemeIdArg || extractAwemeId(sourceUrl),
  620. durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0),
  621. sourceKind: 'direct_media_url',
  622. warnings: []
  623. };
  624. }
  625. if (sourceUrl || awemeIdArg) {
  626. const resolved = await fetchDouyinVideoDetail(args);
  627. const detailPath = path.join(outputDir, 'douyin-video-detail.json');
  628. fs.writeFileSync(detailPath, JSON.stringify(resolved.response, null, 2), 'utf8');
  629. const candidate = selectMediaCandidate(resolved.detail, preferredMedia);
  630. if (!candidate) {
  631. throw new Error('Resolved Douyin video detail, but no playable media URL was found.');
  632. }
  633. const filePath = await downloadToFile(candidate.url, outputDir, `source-${resolved.detail.aweme_id || Date.now()}`, candidate.kind === 'audio' ? '.m4a' : '.mp4', args);
  634. return {
  635. filePath,
  636. sourceUrl: sourceUrl || resolved.detail.share_url || '',
  637. mediaUrl: candidate.url,
  638. awemeId: resolved.detail.aweme_id || awemeIdArg,
  639. durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0) || durationFromDetail(resolved.detail),
  640. sourceKind: `douyin_${candidate.kind}`,
  641. mediaCandidate: candidate,
  642. detailPath,
  643. warnings: [`Douyin media resolved via ${resolved.endpoint} (${candidate.kind}: ${candidate.keyPath}).`]
  644. };
  645. }
  646. return {
  647. filePath: '',
  648. sourceUrl: '',
  649. mediaUrl: '',
  650. awemeId: awemeIdArg,
  651. durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0),
  652. sourceKind: '',
  653. warnings: []
  654. };
  655. }
  656. function commandAvailable(command) {
  657. const result = spawnSync(command, ['-version'], { encoding: 'utf8', windowsHide: true });
  658. return !result.error && result.status === 0;
  659. }
  660. function probeDurationMs(filePath, args) {
  661. const ffprobe = args.ffprobePath || args['ffprobe-path'] || 'ffprobe';
  662. if (!commandAvailable(ffprobe)) return 0;
  663. const result = spawnSync(ffprobe, [
  664. '-v', 'error',
  665. '-show_entries', 'format=duration',
  666. '-of', 'default=noprint_wrappers=1:nokey=1',
  667. filePath
  668. ], { encoding: 'utf8', windowsHide: true });
  669. const seconds = Number(String(result.stdout || '').trim());
  670. return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : 0;
  671. }
  672. function extractAudioWithFfmpeg(filePath, outputDir, args) {
  673. const ffmpeg = args.ffmpegPath || args['ffmpeg-path'] || 'ffmpeg';
  674. if (!commandAvailable(ffmpeg)) return { status: 'missing_ffmpeg' };
  675. const audioPath = path.join(outputDir, 'source-audio.wav');
  676. const result = spawnSync(ffmpeg, [
  677. '-y',
  678. '-i', filePath,
  679. '-vn',
  680. '-ac', '1',
  681. '-ar', '16000',
  682. '-sample_fmt', 's16',
  683. audioPath
  684. ], { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 * 20 });
  685. if (result.status !== 0) {
  686. return {
  687. status: 'failed',
  688. message: String(result.stderr || result.stdout || '').trim()
  689. };
  690. }
  691. return { status: 'ok', audioPath };
  692. }
  693. async function transcribeOpenAI(args, outputDir) {
  694. const apiKey = process.env.OPENAI_API_KEY || args.apiKey || args['api-key'];
  695. const model = args.model || 'gpt-4o-mini-transcribe';
  696. if (!apiKey) {
  697. return normalizeTranscript({
  698. awemeId: args.awemeId || args['aweme-id'],
  699. sourceUrl: args['video-url'] || args.videoUrl || args.url,
  700. sourceFile: args.file ? path.resolve(args.file) : '',
  701. provider: 'openai',
  702. model,
  703. language: args.language || 'zh',
  704. status: 'needs_provider_config',
  705. warnings: ['provider=openai 需要配置 OPENAI_API_KEY;未调用 ASR。']
  706. });
  707. }
  708. const asset = await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' });
  709. const filePath = asset.filePath;
  710. if (!filePath) throw new Error('provider=openai requires --file, --video-url, --media-url or --douyin-url');
  711. const stat = fs.statSync(filePath);
  712. const limitBytes = 25 * 1024 * 1024;
  713. if (stat.size > limitBytes) {
  714. throw new Error(`OpenAI transcription upload limit is 25MB; file is ${Math.round(stat.size / 1024 / 1024)}MB`);
  715. }
  716. const buffer = fs.readFileSync(filePath);
  717. const form = new FormData();
  718. form.append('file', new Blob([buffer], { type: guessMime(filePath) }), path.basename(filePath));
  719. form.append('model', model);
  720. if (args.language) form.append('language', args.language);
  721. const wantsVerbose = bool(args.timestamps) || args['response-format'] === 'verbose_json' || args.responseFormat === 'verbose_json';
  722. if (wantsVerbose) form.append('response_format', 'verbose_json');
  723. else form.append('response_format', 'json');
  724. const response = await fetchWithTimeout('https://api.openai.com/v1/audio/transcriptions', {
  725. method: 'POST',
  726. headers: { Authorization: `Bearer ${apiKey}` },
  727. body: form
  728. }, 300000);
  729. const text = await response.text();
  730. let data;
  731. try {
  732. data = JSON.parse(text);
  733. } catch {
  734. data = { text };
  735. }
  736. if (!response.ok) {
  737. throw new Error(data.error?.message || data.message || `OpenAI transcription failed with HTTP ${response.status}`);
  738. }
  739. return normalizeTranscript({
  740. awemeId: asset.awemeId || args.awemeId || args['aweme-id'],
  741. sourceUrl: asset.sourceUrl || args['video-url'] || args.videoUrl || args.url,
  742. sourceFile: filePath,
  743. mediaUrl: asset.mediaUrl,
  744. sourceKind: asset.sourceKind,
  745. provider: 'openai',
  746. model,
  747. language: args.language || 'zh',
  748. durationMs: data.duration ? Math.round(Number(data.duration) * 1000) : 0,
  749. text: data.text || '',
  750. segments: Array.isArray(data.segments) ? data.segments.map(segment => ({
  751. start: segment.start,
  752. end: segment.end,
  753. text: segment.text
  754. })) : [],
  755. words: Array.isArray(data.words) ? data.words : [],
  756. warnings: wantsVerbose && model !== 'whisper-1'
  757. ? ['OpenAI 新转写模型可能不返回词级时间戳;如需稳定时间戳可切换 whisper-1。']
  758. : []
  759. });
  760. }
  761. function transcribeManual(args) {
  762. const text = args.text || (args.transcript ? fs.readFileSync(path.resolve(args.transcript), 'utf8') : '');
  763. if (!text) throw new Error('provider=manual requires --text or --transcript');
  764. return normalizeTranscript({
  765. awemeId: args.awemeId || args['aweme-id'],
  766. sourceUrl: args['video-url'] || args.videoUrl || args.url,
  767. sourceFile: args.transcript ? path.resolve(args.transcript) : '',
  768. provider: 'manual',
  769. model: 'manual',
  770. language: args.language || 'zh',
  771. text,
  772. warnings: ['该逐字稿由用户或上游流程提供,未经过本脚本自动 ASR 校验。']
  773. });
  774. }
  775. function transcribeNone(args) {
  776. return normalizeTranscript({
  777. awemeId: args.awemeId || args['aweme-id'],
  778. sourceUrl: args['video-url'] || args.videoUrl || args.url,
  779. sourceFile: args.file ? path.resolve(args.file) : '',
  780. provider: args.provider || 'none',
  781. model: '',
  782. language: args.language || 'zh',
  783. text: '',
  784. status: 'needs_transcription',
  785. warnings: ['未调用 ASR provider;请配置讯飞 IST、OpenAI、火山或提供人工逐字稿。']
  786. });
  787. }
  788. function transcribeVolcengine() {
  789. return normalizeTranscript({
  790. provider: 'volcengine',
  791. status: 'needs_provider_config',
  792. warnings: [
  793. '火山豆包语音 ASR provider 尚未接入。需要确认 AppId、AccessToken、资源权限和请求签名方式后实现。'
  794. ]
  795. });
  796. }
  797. async function transcribeNoneResolved(args, outputDir) {
  798. const shouldResolve = bool(args.downloadMedia || args['download-media'] || args.resolveMedia || args['resolve-media']);
  799. const asset = shouldResolve
  800. ? await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' })
  801. : {
  802. filePath: hasConcreteArg(args.file) ? path.resolve(args.file) : '',
  803. sourceUrl: firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url),
  804. mediaUrl: '',
  805. awemeId: firstConcrete(args.awemeId, args['aweme-id']),
  806. durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0),
  807. sourceKind: '',
  808. warnings: []
  809. };
  810. return normalizeTranscript({
  811. awemeId: asset.awemeId || firstConcrete(args.awemeId, args['aweme-id']),
  812. sourceUrl: asset.sourceUrl,
  813. sourceFile: asset.filePath,
  814. mediaUrl: asset.mediaUrl,
  815. sourceKind: asset.sourceKind,
  816. provider: args.provider || 'none',
  817. model: '',
  818. language: args.language || 'zh',
  819. durationMs: asset.durationMs || 0,
  820. text: '',
  821. status: shouldResolve && asset.filePath ? 'media_resolved' : 'needs_transcription',
  822. warnings: [
  823. ...(Array.isArray(asset.warnings) ? asset.warnings : []),
  824. shouldResolve && asset.filePath
  825. ? 'Media was resolved/downloaded; choose an ASR provider to transcribe it.'
  826. : 'ASR provider was not called; configure iflytek-ist/openai/volcengine or provide a manual transcript.'
  827. ]
  828. });
  829. }
  830. function transcribeIflytekAST(args = {}) {
  831. return normalizeTranscript({
  832. awemeId: args.awemeId || args['aweme-id'],
  833. sourceUrl: args['video-url'] || args.videoUrl || args.url,
  834. sourceFile: args.file ? path.resolve(args.file) : '',
  835. provider: 'iflytek-ast',
  836. status: 'needs_provider_config',
  837. warnings: [
  838. '讯飞 AST 实时转录是 WebSocket PCM16LE 音频流接口,适合直播/麦克风采集;当前批量抖音视频转写请使用 iflytek-ist。'
  839. ]
  840. });
  841. }
  842. async function fetchJson(url, options) {
  843. const response = await fetchWithTimeout(url, options, 120000);
  844. const text = await response.text();
  845. let data;
  846. try {
  847. data = JSON.parse(text);
  848. } catch {
  849. data = { rawText: text };
  850. }
  851. if (!response.ok) {
  852. throw new Error(data.descInfo || data.message || `HTTP ${response.status}`);
  853. }
  854. return data;
  855. }
  856. function buildIflytekISTRequest(url, secret, params) {
  857. const signature = signIflytekIST(secret, params);
  858. const queryString = buildIflytekQueryString(params);
  859. return {
  860. url: `${url}?${queryString}`,
  861. signature
  862. };
  863. }
  864. function getIflytekISTConfig(args) {
  865. const appId = process.env.IFLYTEK_APP_ID || args.appId || args['app-id'];
  866. const apiKey = process.env.IFLYTEK_API_KEY || args.apiKey || args['api-key'] || args.accessKeyId || args['access-key-id'];
  867. const apiSecret = process.env.IFLYTEK_API_SECRET || args.apiSecret || args['api-secret'] || args.accessKeySecret || args['access-key-secret'];
  868. if (!appId || !apiKey || !apiSecret) {
  869. return {
  870. missing: [
  871. !appId ? 'IFLYTEK_APP_ID' : '',
  872. !apiKey ? 'IFLYTEK_API_KEY' : '',
  873. !apiSecret ? 'IFLYTEK_API_SECRET' : ''
  874. ].filter(Boolean)
  875. };
  876. }
  877. return {
  878. appId,
  879. apiKey,
  880. apiSecret,
  881. uploadUrl: process.env.IFLYTEK_IST_UPLOAD_URL || args.uploadUrl || args['upload-url'] || 'https://office-api-ist-dx.iflyaisol.com/v2/upload',
  882. resultUrl: process.env.IFLYTEK_IST_RESULT_URL || args.resultUrl || args['result-url'] || 'https://office-api-ist-dx.iflyaisol.com/v2/getResult'
  883. };
  884. }
  885. async function uploadIflytekIST({ config, fileBuffer, fileName, durationMs, args }) {
  886. const params = {
  887. appId: config.appId,
  888. accessKeyId: config.apiKey,
  889. dateTime: formatLocalDateTime(),
  890. signatureRandom: randomString(16),
  891. fileSize: String(fileBuffer.length),
  892. fileName,
  893. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  894. duration: String(durationMs),
  895. pd: args.pd || process.env.IFLYTEK_IST_PD || 'com',
  896. roleType: String(args.roleType || args['role-type'] || process.env.IFLYTEK_IST_ROLE_TYPE || 1),
  897. roleNum: String(args.roleNum || args['role-num'] || process.env.IFLYTEK_IST_ROLE_NUM || 0)
  898. };
  899. const request = buildIflytekISTRequest(config.uploadUrl, config.apiSecret, params);
  900. const data = await fetchJson(request.url, {
  901. method: 'POST',
  902. headers: {
  903. 'Content-Type': 'application/octet-stream',
  904. signature: request.signature
  905. },
  906. body: fileBuffer
  907. });
  908. if (data.code !== '000000') {
  909. throw new Error(`讯飞 IST 上传失败: ${data.code || ''} ${data.descInfo || data.message || ''}`.trim());
  910. }
  911. const orderId = data.content?.orderId;
  912. if (!orderId) throw new Error('讯飞 IST 上传成功但未返回 orderId');
  913. return {
  914. orderId,
  915. estimateTime: Number(data.content?.taskEstimateTime || 0)
  916. };
  917. }
  918. async function queryIflytekIST({ config, orderId }) {
  919. const params = {
  920. accessKeyId: config.apiKey,
  921. dateTime: formatLocalDateTime(),
  922. signatureRandom: randomString(16),
  923. orderId,
  924. resultType: 'transfer'
  925. };
  926. const request = buildIflytekISTRequest(config.resultUrl, config.apiSecret, params);
  927. const data = await fetchJson(request.url, {
  928. method: 'POST',
  929. headers: {
  930. 'Content-Type': 'application/json',
  931. signature: request.signature
  932. },
  933. body: '{}'
  934. });
  935. if (data.code !== '000000') {
  936. throw new Error(`讯飞 IST 查询失败: ${data.code || ''} ${data.descInfo || data.message || ''}`.trim());
  937. }
  938. const orderInfo = data.content?.orderInfo || {};
  939. const status = Number(orderInfo.status);
  940. if (status === 4) {
  941. return {
  942. status: 'completed',
  943. ...parseIflytekISTResult(data.content?.orderResult)
  944. };
  945. }
  946. if (status === -1) {
  947. return {
  948. status: 'failed',
  949. failType: orderInfo.failType,
  950. message: orderInfo.originalResult || orderInfo.failReason || data.descInfo || ''
  951. };
  952. }
  953. return { status: status === 3 ? 'processing' : 'pending' };
  954. }
  955. async function transcribeIflytekIST(args, outputDir) {
  956. const config = getIflytekISTConfig(args);
  957. if (config.missing) {
  958. return normalizeTranscript({
  959. awemeId: args.awemeId || args['aweme-id'],
  960. sourceUrl: args['video-url'] || args.videoUrl || args.url,
  961. sourceFile: args.file ? path.resolve(args.file) : '',
  962. provider: 'iflytek-ist',
  963. model: 'iflytek-ist',
  964. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  965. status: 'needs_provider_config',
  966. warnings: [`provider=iflytek-ist 需要配置 ${config.missing.join(', ')};未调用 ASR。`]
  967. });
  968. }
  969. const asset = await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' });
  970. let filePath = asset.filePath;
  971. if (!filePath) throw new Error('provider=iflytek-ist requires --file, --video-url, --media-url or --douyin-url');
  972. const warnings = Array.isArray(asset.warnings) ? [...asset.warnings] : [];
  973. const originalExt = path.extname(filePath).toLowerCase();
  974. const shouldExtractAudio = bool(args.extractAudio || args['extract-audio'])
  975. || VIDEO_EXTENSIONS.has(originalExt)
  976. || asset.sourceKind === 'douyin_video';
  977. if (shouldExtractAudio) {
  978. const extraction = extractAudioWithFfmpeg(filePath, outputDir, args);
  979. if (extraction.status === 'missing_ffmpeg') {
  980. return normalizeTranscript({
  981. awemeId: asset.awemeId || args.awemeId || args['aweme-id'],
  982. sourceUrl: asset.sourceUrl || args['video-url'] || args.videoUrl || args.url,
  983. sourceFile: filePath,
  984. mediaUrl: asset.mediaUrl,
  985. sourceKind: asset.sourceKind,
  986. provider: 'iflytek-ist',
  987. model: 'iflytek-ist',
  988. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  989. durationMs: asset.durationMs || 0,
  990. status: 'needs_media_processing',
  991. warnings: [...warnings, 'ffmpeg is required to extract audio from video before iflytek-ist transcription.']
  992. });
  993. }
  994. if (extraction.status !== 'ok') {
  995. throw new Error(`ffmpeg audio extraction failed: ${extraction.message || 'unknown error'}`);
  996. }
  997. warnings.push(`Audio extracted with ffmpeg: ${extraction.audioPath}`);
  998. filePath = extraction.audioPath;
  999. }
  1000. const durationValue = args.durationMs || args['duration-ms'] || args.duration || asset.durationMs || probeDurationMs(filePath, args);
  1001. if (!durationValue || String(durationValue).startsWith('{{')) {
  1002. throw new Error('provider=iflytek-ist requires accurate --duration-ms; provide it or install ffprobe / use a resolvable Douyin detail duration.');
  1003. }
  1004. const durationMs = parsePositiveInt(durationValue, '--duration-ms');
  1005. const stat = fs.statSync(filePath);
  1006. const limitBytes = 100 * 1024 * 1024;
  1007. if (stat.size > limitBytes) {
  1008. throw new Error(`讯飞 IST 文件上限为 100MB;当前文件约 ${Math.round(stat.size / 1024 / 1024)}MB`);
  1009. }
  1010. const ext = path.extname(filePath).toLowerCase();
  1011. if (!['.wav', '.mp3', '.pcm'].includes(ext)) {
  1012. warnings.push('讯飞 IST 文档推荐 PCM WAV 16kHz/16bit/单声道;非 WAV/MP3/PCM 文件需要先确认服务兼容性。');
  1013. }
  1014. const fileBuffer = fs.readFileSync(filePath);
  1015. const upload = await uploadIflytekIST({
  1016. config,
  1017. fileBuffer,
  1018. fileName: path.basename(filePath),
  1019. durationMs,
  1020. args
  1021. });
  1022. const pollIntervalMs = parseOptionalPositiveInt(args.pollIntervalMs || args['poll-interval-ms'], 4000);
  1023. const maxPolls = parseOptionalPositiveInt(args.maxPolls || args['max-polls'], 30);
  1024. if (upload.estimateTime > 0) await sleep(Math.min(upload.estimateTime, pollIntervalMs));
  1025. let result = { status: 'pending' };
  1026. for (let attempt = 0; attempt < maxPolls; attempt++) {
  1027. result = await queryIflytekIST({ config, orderId: upload.orderId });
  1028. if (result.status === 'completed') break;
  1029. if (result.status === 'failed') {
  1030. throw new Error(`讯飞 IST 转写失败${result.failType ? ` failType=${result.failType}` : ''}${result.message ? `: ${result.message}` : ''}`);
  1031. }
  1032. await sleep(pollIntervalMs);
  1033. }
  1034. if (result.status !== 'completed') {
  1035. throw new Error(`讯飞 IST 转写轮询超时:orderId=${upload.orderId}`);
  1036. }
  1037. return normalizeTranscript({
  1038. awemeId: asset.awemeId || args.awemeId || args['aweme-id'],
  1039. sourceUrl: asset.sourceUrl || args['video-url'] || args.videoUrl || args.url,
  1040. sourceFile: filePath,
  1041. mediaUrl: asset.mediaUrl,
  1042. audioFile: filePath,
  1043. sourceKind: asset.sourceKind,
  1044. provider: 'iflytek-ist',
  1045. model: 'iflytek-ist',
  1046. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1047. durationMs,
  1048. orderId: upload.orderId,
  1049. text: result.text,
  1050. segments: result.segments,
  1051. warnings
  1052. });
  1053. }
  1054. function getGatewayBaseUrl(args) {
  1055. return String(args.gatewayBaseUrl || args['gateway-base-url'] || process.env.IFLYTEK_GATEWAY_BASE_URL || 'https://server.fmode.cn/api/apig/transcription')
  1056. .replace(/\/+$/, '');
  1057. }
  1058. function gatewayPayloadValue(data, key) {
  1059. return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
  1060. }
  1061. function normalizeGatewaySegments(segments) {
  1062. return asArray(segments).map(segment => {
  1063. const startMs = Number(segment.startMs ?? segment.begin ?? segment.bg ?? segment.start ?? 0);
  1064. const endMs = Number(segment.endMs ?? segment.end ?? segment.ed ?? 0);
  1065. return {
  1066. start: Number.isFinite(startMs) ? (startMs > 1000 ? startMs / 1000 : startMs) : null,
  1067. end: Number.isFinite(endMs) ? (endMs > 1000 ? endMs / 1000 : endMs) : null,
  1068. startMs: Number.isFinite(startMs) ? Math.round(startMs) : null,
  1069. endMs: Number.isFinite(endMs) ? Math.round(endMs) : null,
  1070. speakerId: segment.speakerId || segment.spk || segment.role || null,
  1071. text: cleanText(segment.text || segment.onebest || segment.content || '')
  1072. };
  1073. }).filter(segment => segment.text);
  1074. }
  1075. async function uploadIflytekGateway({ token, baseUrl, filePath, durationMs, args }) {
  1076. const buffer = fs.readFileSync(filePath);
  1077. const form = new FormData();
  1078. form.append('audio', new Blob([buffer], { type: guessMime(filePath) }), path.basename(filePath));
  1079. form.append('durationMs', String(durationMs));
  1080. form.append('roleType', String(args.roleType || args['role-type'] || process.env.IFLYTEK_IST_ROLE_TYPE || 1));
  1081. form.append('roleNum', String(args.roleNum || args['role-num'] || process.env.IFLYTEK_IST_ROLE_NUM || 0));
  1082. if (args.language || process.env.IFLYTEK_IST_LANGUAGE) form.append('language', args.language || process.env.IFLYTEK_IST_LANGUAGE);
  1083. if (args.pd || process.env.IFLYTEK_IST_PD) form.append('pd', args.pd || process.env.IFLYTEK_IST_PD);
  1084. const response = await fetchWithTimeout(`${baseUrl}/upload`, {
  1085. method: 'POST',
  1086. headers: {
  1087. Authorization: `Bearer ${token}`,
  1088. Accept: 'application/json'
  1089. },
  1090. body: form
  1091. }, 300000);
  1092. const text = await response.text();
  1093. let data;
  1094. try {
  1095. data = JSON.parse(text);
  1096. } catch {
  1097. data = { rawText: text };
  1098. }
  1099. if (!response.ok || data.success === false) {
  1100. throw new Error(data.error?.message || data.error || data.message || data.rawText || `gateway upload failed with HTTP ${response.status}`);
  1101. }
  1102. const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
  1103. if (!orderId) throw new Error('gateway upload succeeded but did not return orderId');
  1104. return {
  1105. orderId,
  1106. estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0),
  1107. raw: data
  1108. };
  1109. }
  1110. async function queryIflytekGateway({ token, baseUrl, orderId }) {
  1111. const response = await fetchWithTimeout(`${baseUrl}/result`, {
  1112. method: 'POST',
  1113. headers: {
  1114. Authorization: `Bearer ${token}`,
  1115. Accept: 'application/json',
  1116. 'Content-Type': 'application/json'
  1117. },
  1118. body: JSON.stringify({ orderId })
  1119. }, 120000);
  1120. const text = await response.text();
  1121. let data;
  1122. try {
  1123. data = JSON.parse(text);
  1124. } catch {
  1125. data = { rawText: text };
  1126. }
  1127. if (!response.ok) {
  1128. throw new Error(data.error?.message || data.error || data.message || data.rawText || `gateway result failed with HTTP ${response.status}`);
  1129. }
  1130. return data;
  1131. }
  1132. async function prepareAudioAssetForGateway(args, outputDir) {
  1133. const asset = await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' });
  1134. let filePath = asset.filePath;
  1135. if (!filePath) throw new Error('provider=iflytek-gateway requires --file, --video-url, --media-url, --douyin-url or --order-id');
  1136. const warnings = Array.isArray(asset.warnings) ? [...asset.warnings] : [];
  1137. const originalExt = path.extname(filePath).toLowerCase();
  1138. const shouldExtractAudio = bool(args.extractAudio || args['extract-audio'])
  1139. || VIDEO_EXTENSIONS.has(originalExt)
  1140. || asset.sourceKind === 'douyin_video';
  1141. if (shouldExtractAudio) {
  1142. const extraction = extractAudioWithFfmpeg(filePath, outputDir, args);
  1143. if (extraction.status === 'missing_ffmpeg') {
  1144. return { asset, filePath, warnings, missingFfmpeg: true };
  1145. }
  1146. if (extraction.status !== 'ok') {
  1147. throw new Error(`ffmpeg audio extraction failed: ${extraction.message || 'unknown error'}`);
  1148. }
  1149. warnings.push(`Audio extracted with ffmpeg: ${extraction.audioPath}`);
  1150. filePath = extraction.audioPath;
  1151. }
  1152. return { asset, filePath, warnings };
  1153. }
  1154. async function transcribeIflytekGateway(args, outputDir) {
  1155. const token = loadVocToken();
  1156. if (!token) {
  1157. return normalizeTranscript({
  1158. awemeId: args.awemeId || args['aweme-id'],
  1159. sourceUrl: args['video-url'] || args.videoUrl || args.url || args.douyinUrl || args['douyin-url'],
  1160. provider: 'iflytek-gateway',
  1161. model: 'iflytek-gateway',
  1162. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1163. status: 'needs_provider_config',
  1164. warnings: ['provider=iflytek-gateway requires VOC_TOKEN or ~/.openclaw/voc-credentials.json; ASR was not called.']
  1165. });
  1166. }
  1167. const baseUrl = getGatewayBaseUrl(args);
  1168. const existingOrderId = firstConcrete(args.orderId, args['order-id']);
  1169. const defaultAsset = {
  1170. awemeId: firstConcrete(args.awemeId, args['aweme-id']),
  1171. sourceUrl: firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url),
  1172. mediaUrl: firstConcrete(args.mediaUrl, args['media-url']),
  1173. sourceKind: 'gateway_order',
  1174. durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0)
  1175. };
  1176. let asset = defaultAsset;
  1177. let filePath = hasConcreteArg(args.file) ? path.resolve(args.file) : '';
  1178. let warnings = [];
  1179. let orderId = existingOrderId;
  1180. let estimateTime = 0;
  1181. if (!orderId) {
  1182. const prepared = await prepareAudioAssetForGateway(args, outputDir);
  1183. asset = prepared.asset;
  1184. filePath = prepared.filePath;
  1185. warnings = prepared.warnings;
  1186. if (prepared.missingFfmpeg) {
  1187. return normalizeTranscript({
  1188. awemeId: asset.awemeId,
  1189. sourceUrl: asset.sourceUrl,
  1190. sourceFile: filePath,
  1191. mediaUrl: asset.mediaUrl,
  1192. sourceKind: asset.sourceKind,
  1193. provider: 'iflytek-gateway',
  1194. model: 'iflytek-gateway',
  1195. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1196. durationMs: asset.durationMs || 0,
  1197. status: 'needs_media_processing',
  1198. warnings: [...warnings, 'ffmpeg is required to extract audio from video before gateway transcription.']
  1199. });
  1200. }
  1201. const durationValue = args.durationMs || args['duration-ms'] || args.duration || asset.durationMs || probeDurationMs(filePath, args);
  1202. if (!durationValue || String(durationValue).startsWith('{{')) {
  1203. throw new Error('provider=iflytek-gateway requires accurate --duration-ms; provide it or use a resolvable Douyin detail duration.');
  1204. }
  1205. asset.durationMs = parsePositiveInt(durationValue, '--duration-ms');
  1206. const maxDurationMs = gatewayMaxDurationMs(args);
  1207. if (maxDurationMs && asset.durationMs > maxDurationMs) {
  1208. return normalizeTranscript({
  1209. awemeId: asset.awemeId,
  1210. sourceUrl: asset.sourceUrl,
  1211. sourceFile: filePath,
  1212. mediaUrl: asset.mediaUrl,
  1213. audioFile: filePath,
  1214. sourceKind: asset.sourceKind,
  1215. provider: 'iflytek-gateway',
  1216. model: 'iflytek-gateway',
  1217. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1218. durationMs: asset.durationMs,
  1219. status: 'needs_media_processing',
  1220. warnings: [
  1221. ...warnings,
  1222. `iflytek-gateway 单次转写上限按 ${Math.round(maxDurationMs / 60000)} 分钟处理;当前视频约 ${Math.ceil(asset.durationMs / 60000)} 分钟,请先分段或选择更短视频。`
  1223. ]
  1224. });
  1225. }
  1226. const upload = await uploadIflytekGateway({ token, baseUrl, filePath, durationMs: asset.durationMs, args });
  1227. orderId = upload.orderId;
  1228. estimateTime = upload.estimateTime;
  1229. }
  1230. const pollIntervalMs = parseOptionalPositiveInt(args.pollIntervalMs || args['poll-interval-ms'], 4000);
  1231. const maxPolls = parseOptionalPositiveInt(args.maxPolls || args['max-polls'], 30);
  1232. if (estimateTime > 0 && !existingOrderId) await sleep(Math.min(estimateTime, pollIntervalMs));
  1233. let data = {};
  1234. for (let attempt = 0; attempt < maxPolls; attempt++) {
  1235. data = await queryIflytekGateway({ token, baseUrl, orderId });
  1236. const status = String(gatewayPayloadValue(data, 'status') || '').toLowerCase();
  1237. if (['completed', 'failed', 'error'].includes(status)) break;
  1238. await sleep(pollIntervalMs);
  1239. }
  1240. const status = String(gatewayPayloadValue(data, 'status') || '').toLowerCase();
  1241. const text = gatewayPayloadValue(data, 'text') || '';
  1242. const segments = normalizeGatewaySegments(gatewayPayloadValue(data, 'segments'));
  1243. if (status === 'failed' || status === 'error') {
  1244. return normalizeTranscript({
  1245. awemeId: asset.awemeId,
  1246. sourceUrl: asset.sourceUrl,
  1247. sourceFile: filePath,
  1248. mediaUrl: asset.mediaUrl,
  1249. sourceKind: asset.sourceKind,
  1250. provider: 'iflytek-gateway',
  1251. model: 'iflytek-gateway',
  1252. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1253. durationMs: asset.durationMs || 0,
  1254. orderId,
  1255. status: 'transcription_failed',
  1256. warnings: [...warnings, gatewayPayloadValue(data, 'error') || gatewayPayloadValue(data, 'message') || 'gateway transcription failed']
  1257. });
  1258. }
  1259. if (status !== 'completed') {
  1260. return normalizeTranscript({
  1261. awemeId: asset.awemeId,
  1262. sourceUrl: asset.sourceUrl,
  1263. sourceFile: filePath,
  1264. mediaUrl: asset.mediaUrl,
  1265. sourceKind: asset.sourceKind,
  1266. provider: 'iflytek-gateway',
  1267. model: 'iflytek-gateway',
  1268. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1269. durationMs: asset.durationMs || 0,
  1270. orderId,
  1271. status: 'transcription_pending',
  1272. warnings: [...warnings, 'gateway transcription is still pending; rerun with --order-id to query later']
  1273. });
  1274. }
  1275. return normalizeTranscript({
  1276. awemeId: asset.awemeId,
  1277. sourceUrl: asset.sourceUrl,
  1278. sourceFile: filePath,
  1279. mediaUrl: asset.mediaUrl,
  1280. audioFile: filePath,
  1281. sourceKind: asset.sourceKind,
  1282. provider: 'iflytek-gateway',
  1283. model: 'iflytek-gateway',
  1284. language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect',
  1285. durationMs: asset.durationMs || 0,
  1286. orderId,
  1287. text,
  1288. segments,
  1289. warnings
  1290. });
  1291. }
  1292. function renderText(transcript) {
  1293. const lines = [];
  1294. lines.push(`# transcript ${transcript.awemeId || ''}`.trim());
  1295. lines.push('');
  1296. lines.push(`provider: ${transcript.provider}`);
  1297. lines.push(`status: ${transcript.status}`);
  1298. if (transcript.sourceUrl) lines.push(`sourceUrl: ${transcript.sourceUrl}`);
  1299. if (transcript.mediaUrl) lines.push(`mediaUrl: ${transcript.mediaUrl}`);
  1300. if (transcript.sourceFile) lines.push(`sourceFile: ${transcript.sourceFile}`);
  1301. if (transcript.audioFile && transcript.audioFile !== transcript.sourceFile) lines.push(`audioFile: ${transcript.audioFile}`);
  1302. if (transcript.durationMs) lines.push(`durationMs: ${transcript.durationMs}`);
  1303. if (transcript.orderId) lines.push(`orderId: ${transcript.orderId}`);
  1304. lines.push('');
  1305. if (transcript.text) lines.push(transcript.text);
  1306. else lines.push('(needs transcription)');
  1307. if (transcript.warnings.length) {
  1308. lines.push('');
  1309. lines.push('warnings:');
  1310. transcript.warnings.forEach(warning => lines.push(`- ${warning}`));
  1311. }
  1312. return lines.join('\n');
  1313. }
  1314. async function main() {
  1315. const args = parseArgs(process.argv.slice(2));
  1316. if (args.help) {
  1317. console.log(usage());
  1318. return;
  1319. }
  1320. const outputDir = path.resolve(args.output || path.join(process.cwd(), 'douyin-video-transcript'));
  1321. ensureDir(outputDir);
  1322. const provider = String(args.provider || 'none').toLowerCase();
  1323. let transcript;
  1324. if (provider === 'manual') transcript = transcribeManual(args);
  1325. else if (provider === 'openai') transcript = await transcribeOpenAI(args, outputDir);
  1326. else if (['iflytek', 'iflytek-ist', 'xunfei', 'xunfei-ist'].includes(provider)) transcript = await transcribeIflytekIST(args, outputDir);
  1327. else if (['iflytek-gateway', 'xunfei-gateway'].includes(provider)) transcript = await transcribeIflytekGateway(args, outputDir);
  1328. else if (['iflytek-ast', 'xunfei-ast'].includes(provider)) transcript = transcribeIflytekAST(args);
  1329. else if (provider === 'volcengine') transcript = transcribeVolcengine(args);
  1330. else transcript = await transcribeNoneResolved(args, outputDir);
  1331. const jsonPath = path.join(outputDir, 'transcript.json');
  1332. const txtPath = path.join(outputDir, 'transcript.txt');
  1333. fs.writeFileSync(jsonPath, JSON.stringify(transcript, null, 2), 'utf8');
  1334. fs.writeFileSync(txtPath, renderText(transcript), 'utf8');
  1335. console.log(JSON.stringify({
  1336. status: transcript.status,
  1337. provider: transcript.provider,
  1338. outputDir,
  1339. files: [jsonPath, txtPath],
  1340. orderId: transcript.orderId,
  1341. awemeId: transcript.awemeId,
  1342. sourceFile: transcript.sourceFile,
  1343. audioFile: transcript.audioFile,
  1344. mediaUrl: transcript.mediaUrl,
  1345. sourceKind: transcript.sourceKind,
  1346. durationMs: transcript.durationMs,
  1347. textLength: transcript.text.length,
  1348. segmentCount: transcript.segments.length,
  1349. warningCount: transcript.warnings.length
  1350. }, null, 2));
  1351. }
  1352. main().catch(error => {
  1353. console.error(error.message);
  1354. process.exit(1);
  1355. });