#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const https = require('https'); const crypto = require('crypto'); const { spawnSync } = require('child_process'); const VOC_API_ROOT = 'https://server.fmode.cn/api/voc-social'; const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.oga', '.opus', '.pcm']); const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.webm', '.mkv']); const MEDIA_EXTENSIONS = new Set([...AUDIO_EXTENSIONS, ...VIDEO_EXTENSIONS]); function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); } else { const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; } function usage() { return [ 'Usage:', ' node scripts/tools/douyin-video-transcriber.js --provider manual --text --aweme-id --output ', ' node scripts/tools/douyin-video-transcriber.js --provider openai --file --output ', ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-ist --file --duration-ms --output ', ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-gateway --file --duration-ms --output ', ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-ist --douyin-url --output ', ' node scripts/tools/douyin-video-transcriber.js --provider none --video-url --output ', '', 'Providers:', ' manual Wrap user-provided transcript text into transcript schema', ' openai Call OpenAI audio transcription API; requires OPENAI_API_KEY', ' iflytek-ist Call Xunfei/Iflytek long audio transcription; requires IFLYTEK_APP_ID/API_KEY/API_SECRET and accurate --duration-ms', ' iflytek-gateway Call server.fmode.cn transcription gateway; requires VOC token', ' iflytek-ast Reserved realtime WebSocket provider; use for live PCM streams, not batch files', ' none Emit a needs_transcription record without calling ASR', ' volcengine Reserved provider placeholder until credentials/resource are confirmed' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function bool(value) { if (typeof value === 'boolean') return value; if (value === undefined || value === null) return false; return ['1', 'true', 'yes', 'y'].includes(String(value).toLowerCase()); } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function hasConcreteArg(value) { if (value === undefined || value === null) return false; const text = String(value).trim(); return Boolean(text) && !/^\{\{[^}]+\}\}$/.test(text); } function firstConcrete(...values) { return values.find(hasConcreteArg) || ''; } function guessMime(filePath) { const ext = path.extname(filePath).toLowerCase(); return { '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.mpeg': 'audio/mpeg', '.mpga': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.webm': 'audio/webm', '.ogg': 'audio/ogg', '.oga': 'audio/ogg' }[ext] || 'application/octet-stream'; } function javaUrlEncode(value) { return encodeURIComponent(String(value)) .replace(/%20/g, '+') .replace(/!/g, '%21') .replace(/\*/g, '%2A') .replace(/\(/g, '%28') .replace(/\)/g, '%29') .replace(/~/g, '%7E'); } function buildIflytekQueryString(params) { return Object.keys(params) .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') .map(key => `${javaUrlEncode(key)}=${javaUrlEncode(params[key])}`) .join('&'); } function signIflytekIST(secret, params) { const sorted = {}; Object.keys(params).sort().forEach(key => { if (params[key] !== undefined && params[key] !== null && params[key] !== '') { sorted[key] = params[key]; } }); const baseString = buildIflytekQueryString(sorted); return crypto.createHmac('sha1', Buffer.from(secret, 'utf8')).update(baseString, 'utf8').digest('base64'); } function randomString(length = 16) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) result += chars.charAt(Math.floor(Math.random() * chars.length)); return result; } function formatLocalDateTime() { const now = new Date(); const pad = n => String(n).padStart(2, '0'); const timezoneOffset = now.getTimezoneOffset(); const offsetSign = timezoneOffset <= 0 ? '+' : '-'; const offsetH = pad(Math.floor(Math.abs(timezoneOffset) / 60)); const offsetM = pad(Math.abs(timezoneOffset) % 60); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetH}${offsetM}`; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function parsePositiveInt(value, fieldName) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { throw new Error(`${fieldName} must be a positive number`); } return Math.round(numeric); } function parseOptionalPositiveInt(value, fallback) { if (value === undefined || value === null || value === '') return fallback; const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) return fallback; return Math.round(numeric); } function gatewayMaxDurationMs(args) { return parseOptionalPositiveInt( args.gatewayMaxDurationMs || args['gateway-max-duration-ms'] || args.maxDurationMs || args['max-duration-ms'] || process.env.IFLYTEK_GATEWAY_MAX_DURATION_MS, 480000 ); } function parseIflytekISTResult(orderResult) { if (!orderResult) return { text: '', segments: [] }; const parsed = typeof orderResult === 'string' ? JSON.parse(orderResult) : orderResult; const lattice = Array.isArray(parsed.lattice) ? parsed.lattice : []; const rawSegments = []; const fullTextParts = []; for (const item of lattice) { if (!item || !item.json_1best) continue; const json1best = typeof item.json_1best === 'string' ? JSON.parse(item.json_1best) : item.json_1best; const st = json1best.st || {}; const rt = Array.isArray(st.rt) ? st.rt : []; let sentenceText = ''; for (const rtItem of rt) { const ws = Array.isArray(rtItem?.ws) ? rtItem.ws : []; for (const wsItem of ws) { const cw = Array.isArray(wsItem?.cw) ? wsItem.cw : []; const first = cw[0]; if (first && first.w) sentenceText += String(first.w); } } const text = sentenceText.trim(); if (!text) continue; const startMs = st.bg != null && Number.isFinite(Number(st.bg)) ? Math.round(Number(st.bg)) : null; const endMs = st.ed != null && Number.isFinite(Number(st.ed)) ? Math.round(Number(st.ed)) : null; const speakerId = st.rl != null ? String(st.rl) : null; rawSegments.push({ start: startMs == null ? null : startMs / 1000, end: endMs == null ? null : endMs / 1000, startMs, endMs, speakerId, text }); fullTextParts.push(text); } const segments = []; let current = null; for (const segment of rawSegments) { if (!current) { current = { ...segment }; } else if (current.speakerId === segment.speakerId) { current.text += segment.text; current.end = segment.end; current.endMs = segment.endMs; } else { segments.push(current); current = { ...segment }; } } if (current) segments.push(current); return { text: fullTextParts.join(' ').trim(), segments }; } function normalizeTranscript(fields) { return { awemeId: fields.awemeId || '', sourceUrl: fields.sourceUrl || '', sourceFile: fields.sourceFile || '', mediaUrl: fields.mediaUrl || '', audioFile: fields.audioFile || '', sourceKind: fields.sourceKind || '', provider: fields.provider || 'manual', model: fields.model || '', language: fields.language || 'zh', durationMs: fields.durationMs || 0, orderId: fields.orderId || '', text: cleanText(fields.text), segments: Array.isArray(fields.segments) ? fields.segments : [], words: Array.isArray(fields.words) ? fields.words : [], status: fields.status || (fields.text ? 'ok' : 'needs_transcription'), warnings: Array.isArray(fields.warnings) ? fields.warnings : [], generatedAt: new Date().toISOString() }; } function requestDownload(url, redirectCount = 0, options = {}) { return new Promise((resolve, reject) => { if (redirectCount > 5) { reject(new Error('Too many redirects while downloading media')); return; } const maxBytes = options.maxBytes || 200 * 1024 * 1024; const idleTimeoutMs = options.idleTimeoutMs || 30000; const totalTimeoutMs = options.totalTimeoutMs || 120000; const parsed = new URL(url); const client = parsed.protocol === 'http:' ? http : https; let settled = false; let idleTimer; const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(totalTimer); clearTimeout(idleTimer); if (error) reject(error); else resolve(value); }; const refreshIdleTimer = req => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { req.destroy(new Error(`Download idle timeout after ${idleTimeoutMs}ms`)); }, idleTimeoutMs); }; let req; const totalTimer = setTimeout(() => { if (req) req.destroy(new Error(`Download total timeout after ${totalTimeoutMs}ms`)); finish(new Error(`Download total timeout after ${totalTimeoutMs}ms`)); }, totalTimeoutMs); req = client.get(parsed, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) OpenClawVOC/1.0', Accept: '*/*' } }, res => { refreshIdleTimer(req); if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { const next = new URL(res.headers.location, url).toString(); res.resume(); requestDownload(next, redirectCount + 1, options).then( value => finish(null, value), error => finish(error) ); return; } if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); finish(new Error(`Download failed with HTTP ${res.statusCode}`)); return; } const chunks = []; let totalBytes = 0; res.on('data', chunk => { refreshIdleTimer(req); totalBytes += chunk.length; if (totalBytes > maxBytes) { req.destroy(new Error(`Download exceeds ${Math.round(maxBytes / 1024 / 1024)}MB limit`)); return; } chunks.push(chunk); }); res.on('end', () => finish(null, Buffer.concat(chunks))); }); refreshIdleTimer(req); req.on('error', error => finish(error)); req.setTimeout(idleTimeoutMs, () => { req.destroy(new Error('Download timed out')); }); }); } async function resolveInputFile(args, outputDir) { const asset = await resolveInputAsset(args, outputDir); return asset.filePath; } function loadVocToken() { const envToken = process.env.VOC_TOKEN || process.env.OPENCLAW_VOC_TOKEN || process.env.VOC_SOCIAL_TOKEN; if (envToken) return envToken; const credentialsPath = path.join(os.homedir(), '.openclaw', 'voc-credentials.json'); if (fs.existsSync(credentialsPath)) { try { const data = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')); return data.vocToken || data.token || ''; } catch { return ''; } } return ''; } async function fetchWithTimeout(url, options = {}, timeoutMs = 60000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } catch (error) { if (error.name === 'AbortError') { throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`); } throw error; } finally { clearTimeout(timer); } } async function requestVocJson(pathUrl, query, token) { const url = new URL(`${VOC_API_ROOT}${pathUrl}`); Object.entries(query || {}).forEach(([key, value]) => { if (hasConcreteArg(value)) url.searchParams.set(key, String(value)); }); const response = await fetchWithTimeout(url.toString(), { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }, 60000); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { rawText: text }; } if (!response.ok) { throw new Error(data.mess || data.msg || data.message || data.error || `VOC request failed with HTTP ${response.status}`); } return data; } function extractAwemeId(value) { const text = String(value || ''); const patterns = [ /(?:aweme_id|item_id|modal_id)=([0-9]{15,})/, /\/(?:video|note)\/([0-9]{15,})/, /\b([0-9]{15,})\b/ ]; for (const pattern of patterns) { const match = text.match(pattern); if (match) return match[1]; } return ''; } function isHttpUrl(value) { try { const parsed = new URL(String(value || '')); return ['http:', 'https:'].includes(parsed.protocol); } catch { return false; } } function isDouyinShareUrl(value) { if (!isHttpUrl(value)) return false; const parsed = new URL(String(value)); const host = parsed.hostname.toLowerCase(); if (host.includes('douyinvod.com') || host.includes('douyinpic.com')) return false; if (host.includes('amemv.com') && parsed.pathname.includes('/aweme/v1/play')) return false; return host.includes('douyin.com') || host.includes('iesdouyin.com') || host === 'v.douyin.com'; } function isLikelyMediaUrl(value) { if (!isHttpUrl(value)) return false; const parsed = new URL(String(value)); const ext = path.extname(parsed.pathname).toLowerCase(); const host = parsed.hostname.toLowerCase(); if (MEDIA_EXTENSIONS.has(ext)) return true; if (host.includes('douyinvod.com')) return true; if (host.includes('amemv.com') && parsed.pathname.includes('/aweme/v1/play')) return true; if ((parsed.searchParams.get('mime_type') || '').includes('video_mp4')) return true; return false; } function decodeMaybeBase64Url(value) { const text = String(value || '').trim(); if (!text) return ''; if (isHttpUrl(text)) return text; if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return ''; const normalized = text.replace(/-/g, '+').replace(/_/g, '/'); try { const decoded = Buffer.from(normalized, 'base64').toString('utf8'); return isHttpUrl(decoded) ? decoded : ''; } catch { return ''; } } function findAwemeDetail(node, depth = 0) { if (!node || depth > 8) return undefined; if (Array.isArray(node)) { for (const item of node) { const found = findAwemeDetail(item, depth + 1); if (found) return found; } return undefined; } if (typeof node !== 'object') return undefined; if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail; if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info; if (node.aweme_id && node.video) return node; for (const value of Object.values(node)) { const found = findAwemeDetail(value, depth + 1); if (found) return found; } return undefined; } async function fetchDouyinVideoDetail(args) { const token = loadVocToken(); if (!token) { throw new Error('VOC_TOKEN or ~/.openclaw/voc-credentials.json is required to resolve a Douyin share URL.'); } const shareUrl = firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url); const awemeId = firstConcrete(args.awemeId, args['aweme-id']) || extractAwemeId(shareUrl); const attempts = []; if (shareUrl && isDouyinShareUrl(shareUrl)) { attempts.push({ pathUrl: '/douyin/app/v3/fetch_one_video_by_share_url', query: { share_url: shareUrl } }); attempts.push({ pathUrl: '/douyin/app/v3/fetch_one_video_by_share_url', query: { url: shareUrl } }); } if (awemeId) { attempts.push({ pathUrl: '/douyin/app/v3/fetch_one_video_v3', query: { aweme_id: awemeId } }); } const errors = []; for (const attempt of attempts) { try { const response = await requestVocJson(attempt.pathUrl, attempt.query, token); const detail = findAwemeDetail(response); if (detail?.aweme_id) { if (attempt.pathUrl.includes('share_url')) { try { const fullResponse = await requestVocJson('/douyin/app/v3/fetch_one_video_v3', { aweme_id: detail.aweme_id }, token); const fullDetail = findAwemeDetail(fullResponse); if (fullDetail?.aweme_id) { return { detail: fullDetail, response: fullResponse, endpoint: `${attempt.pathUrl} -> /douyin/app/v3/fetch_one_video_v3` }; } } catch (error) { errors.push(`/douyin/app/v3/fetch_one_video_v3: ${error.message}`); } } return { detail, response, endpoint: attempt.pathUrl }; } errors.push(`${attempt.pathUrl}: no aweme_detail in response`); } catch (error) { errors.push(`${attempt.pathUrl}: ${error.message}`); } } throw new Error(`Unable to resolve Douyin media detail. ${errors.join(' | ')}`); } function inferUrlKind(pathParts, url) { const joined = pathParts.join('.').toLowerCase(); if (/cover|poster|image|thumb|avatar|sticker/.test(joined)) return 'image'; if (/audio|mp4a|music|sound/.test(joined) || /media-audio|audio/.test(url)) return 'audio'; if (/play_addr|download_addr|bit_rate|video|media-video/.test(joined) || isLikelyMediaUrl(url)) return 'video'; return 'unknown'; } function collectMediaCandidates(node, pathParts = [], out = []) { if (!node) return out; if (Array.isArray(node)) { node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out)); return out; } if (typeof node !== 'object') return out; Object.entries(node).forEach(([key, value]) => { const nextPath = [...pathParts, key]; if (key === 'url_list' && Array.isArray(value)) { value.forEach((item, index) => { const url = decodeMaybeBase64Url(item); const kind = inferUrlKind(nextPath, url); if (url && kind !== 'image') { out.push({ url, kind, keyPath: nextPath.join('.'), index, dataSize: Number(node.data_size || node.size || 0), bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0) }); } }); } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') { const url = decodeMaybeBase64Url(value); const kind = inferUrlKind(nextPath, url); if (url && kind !== 'image') { out.push({ url, kind, keyPath: nextPath.join('.'), dataSize: Number(node.data_size || node.size || 0), bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0) }); } } collectMediaCandidates(value, nextPath, out); }); return out; } function selectMediaCandidate(detail, preferred = 'audio') { const candidates = collectMediaCandidates(detail) .filter(item => ['audio', 'video'].includes(item.kind)) .filter(item => isLikelyMediaUrl(item.url)); const seen = new Set(); const unique = candidates.filter(item => { if (seen.has(item.url)) return false; seen.add(item.url); return true; }); unique.sort((a, b) => { const aPreferred = a.kind === preferred ? 0 : 1; const bPreferred = b.kind === preferred ? 0 : 1; if (aPreferred !== bPreferred) return aPreferred - bPreferred; const rank = item => { const keyPath = String(item.keyPath || '').toLowerCase(); if (preferred === 'audio' && item.kind === 'audio') { if (keyPath.includes('video.dynamic_audio_list') || keyPath.includes('video.bit_rate_audio')) return 0; if (keyPath.includes('video.')) return 1; if (keyPath.includes('music.')) return 2; } if (item.kind === preferred) return 3; return 4; }; const aRank = rank(a); const bRank = rank(b); if (aRank !== bRank) return aRank - bRank; const aSize = a.dataSize || Number.MAX_SAFE_INTEGER; const bSize = b.dataSize || Number.MAX_SAFE_INTEGER; if (aSize !== bSize) return aSize - bSize; return (a.bitRate || 0) - (b.bitRate || 0); }); return unique[0]; } function durationFromDetail(detail) { const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs; const numeric = Number(raw || 0); if (!Number.isFinite(numeric) || numeric <= 0) return 0; return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000); } function extensionFromUrl(url, fallback = '.mp4') { try { const parsed = new URL(url); const ext = path.extname(parsed.pathname).toLowerCase(); if (MEDIA_EXTENSIONS.has(ext)) return ext; if (fallback === '.m4a' && /audio|mp4a/i.test(parsed.pathname)) return '.m4a'; const mime = parsed.searchParams.get('mime_type') || ''; if (mime.includes('audio')) return '.m4a'; if (mime.includes('video')) return '.mp4'; } catch { return fallback; } return fallback; } async function downloadToFile(url, outputDir, baseName, fallbackExt, args = {}) { const maxMb = Number(args.maxDownloadMb || args['max-download-mb'] || 200); const totalTimeoutMs = Number(args.downloadTimeoutMs || args['download-timeout-ms'] || 120000); const idleTimeoutMs = Number(args.downloadIdleTimeoutMs || args['download-idle-timeout-ms'] || 30000); const buffer = await requestDownload(url, 0, { maxBytes: Number.isFinite(maxMb) && maxMb > 0 ? maxMb * 1024 * 1024 : 200 * 1024 * 1024, totalTimeoutMs: Number.isFinite(totalTimeoutMs) && totalTimeoutMs > 0 ? totalTimeoutMs : 120000, idleTimeoutMs: Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0 ? idleTimeoutMs : 30000 }); const ext = extensionFromUrl(url, fallbackExt); const filePath = path.join(outputDir, `${baseName}${ext}`); fs.writeFileSync(filePath, buffer); return filePath; } async function resolveInputAsset(args, outputDir, options = {}) { const sourceUrl = firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url); const mediaUrl = firstConcrete(args.mediaUrl, args['media-url']); const awemeIdArg = firstConcrete(args.awemeId, args['aweme-id']); const preferredMedia = options.preferredMedia || 'audio'; if (hasConcreteArg(args.file)) { return { filePath: path.resolve(args.file), sourceUrl, mediaUrl: '', awemeId: awemeIdArg || extractAwemeId(sourceUrl), durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0), sourceKind: 'file', warnings: [] }; } if (mediaUrl || (sourceUrl && isLikelyMediaUrl(sourceUrl) && !isDouyinShareUrl(sourceUrl))) { const directUrl = mediaUrl || sourceUrl; const filePath = await downloadToFile(directUrl, outputDir, `source-${Date.now()}`, preferredMedia === 'audio' ? '.m4a' : '.mp4', args); return { filePath, sourceUrl, mediaUrl: directUrl, awemeId: awemeIdArg || extractAwemeId(sourceUrl), durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0), sourceKind: 'direct_media_url', warnings: [] }; } if (sourceUrl || awemeIdArg) { const resolved = await fetchDouyinVideoDetail(args); const detailPath = path.join(outputDir, 'douyin-video-detail.json'); fs.writeFileSync(detailPath, JSON.stringify(resolved.response, null, 2), 'utf8'); const candidate = selectMediaCandidate(resolved.detail, preferredMedia); if (!candidate) { throw new Error('Resolved Douyin video detail, but no playable media URL was found.'); } const filePath = await downloadToFile(candidate.url, outputDir, `source-${resolved.detail.aweme_id || Date.now()}`, candidate.kind === 'audio' ? '.m4a' : '.mp4', args); return { filePath, sourceUrl: sourceUrl || resolved.detail.share_url || '', mediaUrl: candidate.url, awemeId: resolved.detail.aweme_id || awemeIdArg, durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0) || durationFromDetail(resolved.detail), sourceKind: `douyin_${candidate.kind}`, mediaCandidate: candidate, detailPath, warnings: [`Douyin media resolved via ${resolved.endpoint} (${candidate.kind}: ${candidate.keyPath}).`] }; } return { filePath: '', sourceUrl: '', mediaUrl: '', awemeId: awemeIdArg, durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0), sourceKind: '', warnings: [] }; } function commandAvailable(command) { const result = spawnSync(command, ['-version'], { encoding: 'utf8', windowsHide: true }); return !result.error && result.status === 0; } function probeDurationMs(filePath, args) { const ffprobe = args.ffprobePath || args['ffprobe-path'] || 'ffprobe'; if (!commandAvailable(ffprobe)) return 0; const result = spawnSync(ffprobe, [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath ], { encoding: 'utf8', windowsHide: true }); const seconds = Number(String(result.stdout || '').trim()); return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : 0; } function extractAudioWithFfmpeg(filePath, outputDir, args) { const ffmpeg = args.ffmpegPath || args['ffmpeg-path'] || 'ffmpeg'; if (!commandAvailable(ffmpeg)) return { status: 'missing_ffmpeg' }; const audioPath = path.join(outputDir, 'source-audio.wav'); const result = spawnSync(ffmpeg, [ '-y', '-i', filePath, '-vn', '-ac', '1', '-ar', '16000', '-sample_fmt', 's16', audioPath ], { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 * 20 }); if (result.status !== 0) { return { status: 'failed', message: String(result.stderr || result.stdout || '').trim() }; } return { status: 'ok', audioPath }; } async function transcribeOpenAI(args, outputDir) { const apiKey = process.env.OPENAI_API_KEY || args.apiKey || args['api-key']; const model = args.model || 'gpt-4o-mini-transcribe'; if (!apiKey) { return normalizeTranscript({ awemeId: args.awemeId || args['aweme-id'], sourceUrl: args['video-url'] || args.videoUrl || args.url, sourceFile: args.file ? path.resolve(args.file) : '', provider: 'openai', model, language: args.language || 'zh', status: 'needs_provider_config', warnings: ['provider=openai 需要配置 OPENAI_API_KEY;未调用 ASR。'] }); } const asset = await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' }); const filePath = asset.filePath; if (!filePath) throw new Error('provider=openai requires --file, --video-url, --media-url or --douyin-url'); const stat = fs.statSync(filePath); const limitBytes = 25 * 1024 * 1024; if (stat.size > limitBytes) { throw new Error(`OpenAI transcription upload limit is 25MB; file is ${Math.round(stat.size / 1024 / 1024)}MB`); } const buffer = fs.readFileSync(filePath); const form = new FormData(); form.append('file', new Blob([buffer], { type: guessMime(filePath) }), path.basename(filePath)); form.append('model', model); if (args.language) form.append('language', args.language); const wantsVerbose = bool(args.timestamps) || args['response-format'] === 'verbose_json' || args.responseFormat === 'verbose_json'; if (wantsVerbose) form.append('response_format', 'verbose_json'); else form.append('response_format', 'json'); const response = await fetchWithTimeout('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: form }, 300000); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { text }; } if (!response.ok) { throw new Error(data.error?.message || data.message || `OpenAI transcription failed with HTTP ${response.status}`); } return normalizeTranscript({ awemeId: asset.awemeId || args.awemeId || args['aweme-id'], sourceUrl: asset.sourceUrl || args['video-url'] || args.videoUrl || args.url, sourceFile: filePath, mediaUrl: asset.mediaUrl, sourceKind: asset.sourceKind, provider: 'openai', model, language: args.language || 'zh', durationMs: data.duration ? Math.round(Number(data.duration) * 1000) : 0, text: data.text || '', segments: Array.isArray(data.segments) ? data.segments.map(segment => ({ start: segment.start, end: segment.end, text: segment.text })) : [], words: Array.isArray(data.words) ? data.words : [], warnings: wantsVerbose && model !== 'whisper-1' ? ['OpenAI 新转写模型可能不返回词级时间戳;如需稳定时间戳可切换 whisper-1。'] : [] }); } function transcribeManual(args) { const text = args.text || (args.transcript ? fs.readFileSync(path.resolve(args.transcript), 'utf8') : ''); if (!text) throw new Error('provider=manual requires --text or --transcript'); return normalizeTranscript({ awemeId: args.awemeId || args['aweme-id'], sourceUrl: args['video-url'] || args.videoUrl || args.url, sourceFile: args.transcript ? path.resolve(args.transcript) : '', provider: 'manual', model: 'manual', language: args.language || 'zh', text, warnings: ['该逐字稿由用户或上游流程提供,未经过本脚本自动 ASR 校验。'] }); } function transcribeNone(args) { return normalizeTranscript({ awemeId: args.awemeId || args['aweme-id'], sourceUrl: args['video-url'] || args.videoUrl || args.url, sourceFile: args.file ? path.resolve(args.file) : '', provider: args.provider || 'none', model: '', language: args.language || 'zh', text: '', status: 'needs_transcription', warnings: ['未调用 ASR provider;请配置讯飞 IST、OpenAI、火山或提供人工逐字稿。'] }); } function transcribeVolcengine() { return normalizeTranscript({ provider: 'volcengine', status: 'needs_provider_config', warnings: [ '火山豆包语音 ASR provider 尚未接入。需要确认 AppId、AccessToken、资源权限和请求签名方式后实现。' ] }); } async function transcribeNoneResolved(args, outputDir) { const shouldResolve = bool(args.downloadMedia || args['download-media'] || args.resolveMedia || args['resolve-media']); const asset = shouldResolve ? await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' }) : { filePath: hasConcreteArg(args.file) ? path.resolve(args.file) : '', sourceUrl: firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url), mediaUrl: '', awemeId: firstConcrete(args.awemeId, args['aweme-id']), durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0), sourceKind: '', warnings: [] }; return normalizeTranscript({ awemeId: asset.awemeId || firstConcrete(args.awemeId, args['aweme-id']), sourceUrl: asset.sourceUrl, sourceFile: asset.filePath, mediaUrl: asset.mediaUrl, sourceKind: asset.sourceKind, provider: args.provider || 'none', model: '', language: args.language || 'zh', durationMs: asset.durationMs || 0, text: '', status: shouldResolve && asset.filePath ? 'media_resolved' : 'needs_transcription', warnings: [ ...(Array.isArray(asset.warnings) ? asset.warnings : []), shouldResolve && asset.filePath ? 'Media was resolved/downloaded; choose an ASR provider to transcribe it.' : 'ASR provider was not called; configure iflytek-ist/openai/volcengine or provide a manual transcript.' ] }); } function transcribeIflytekAST(args = {}) { return normalizeTranscript({ awemeId: args.awemeId || args['aweme-id'], sourceUrl: args['video-url'] || args.videoUrl || args.url, sourceFile: args.file ? path.resolve(args.file) : '', provider: 'iflytek-ast', status: 'needs_provider_config', warnings: [ '讯飞 AST 实时转录是 WebSocket PCM16LE 音频流接口,适合直播/麦克风采集;当前批量抖音视频转写请使用 iflytek-ist。' ] }); } async function fetchJson(url, options) { const response = await fetchWithTimeout(url, options, 120000); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { rawText: text }; } if (!response.ok) { throw new Error(data.descInfo || data.message || `HTTP ${response.status}`); } return data; } function buildIflytekISTRequest(url, secret, params) { const signature = signIflytekIST(secret, params); const queryString = buildIflytekQueryString(params); return { url: `${url}?${queryString}`, signature }; } function getIflytekISTConfig(args) { const appId = process.env.IFLYTEK_APP_ID || args.appId || args['app-id']; const apiKey = process.env.IFLYTEK_API_KEY || args.apiKey || args['api-key'] || args.accessKeyId || args['access-key-id']; const apiSecret = process.env.IFLYTEK_API_SECRET || args.apiSecret || args['api-secret'] || args.accessKeySecret || args['access-key-secret']; if (!appId || !apiKey || !apiSecret) { return { missing: [ !appId ? 'IFLYTEK_APP_ID' : '', !apiKey ? 'IFLYTEK_API_KEY' : '', !apiSecret ? 'IFLYTEK_API_SECRET' : '' ].filter(Boolean) }; } return { appId, apiKey, apiSecret, uploadUrl: process.env.IFLYTEK_IST_UPLOAD_URL || args.uploadUrl || args['upload-url'] || 'https://office-api-ist-dx.iflyaisol.com/v2/upload', resultUrl: process.env.IFLYTEK_IST_RESULT_URL || args.resultUrl || args['result-url'] || 'https://office-api-ist-dx.iflyaisol.com/v2/getResult' }; } async function uploadIflytekIST({ config, fileBuffer, fileName, durationMs, args }) { const params = { appId: config.appId, accessKeyId: config.apiKey, dateTime: formatLocalDateTime(), signatureRandom: randomString(16), fileSize: String(fileBuffer.length), fileName, language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', duration: String(durationMs), pd: args.pd || process.env.IFLYTEK_IST_PD || 'com', roleType: String(args.roleType || args['role-type'] || process.env.IFLYTEK_IST_ROLE_TYPE || 1), roleNum: String(args.roleNum || args['role-num'] || process.env.IFLYTEK_IST_ROLE_NUM || 0) }; const request = buildIflytekISTRequest(config.uploadUrl, config.apiSecret, params); const data = await fetchJson(request.url, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', signature: request.signature }, body: fileBuffer }); if (data.code !== '000000') { throw new Error(`讯飞 IST 上传失败: ${data.code || ''} ${data.descInfo || data.message || ''}`.trim()); } const orderId = data.content?.orderId; if (!orderId) throw new Error('讯飞 IST 上传成功但未返回 orderId'); return { orderId, estimateTime: Number(data.content?.taskEstimateTime || 0) }; } async function queryIflytekIST({ config, orderId }) { const params = { accessKeyId: config.apiKey, dateTime: formatLocalDateTime(), signatureRandom: randomString(16), orderId, resultType: 'transfer' }; const request = buildIflytekISTRequest(config.resultUrl, config.apiSecret, params); const data = await fetchJson(request.url, { method: 'POST', headers: { 'Content-Type': 'application/json', signature: request.signature }, body: '{}' }); if (data.code !== '000000') { throw new Error(`讯飞 IST 查询失败: ${data.code || ''} ${data.descInfo || data.message || ''}`.trim()); } const orderInfo = data.content?.orderInfo || {}; const status = Number(orderInfo.status); if (status === 4) { return { status: 'completed', ...parseIflytekISTResult(data.content?.orderResult) }; } if (status === -1) { return { status: 'failed', failType: orderInfo.failType, message: orderInfo.originalResult || orderInfo.failReason || data.descInfo || '' }; } return { status: status === 3 ? 'processing' : 'pending' }; } async function transcribeIflytekIST(args, outputDir) { const config = getIflytekISTConfig(args); if (config.missing) { return normalizeTranscript({ awemeId: args.awemeId || args['aweme-id'], sourceUrl: args['video-url'] || args.videoUrl || args.url, sourceFile: args.file ? path.resolve(args.file) : '', provider: 'iflytek-ist', model: 'iflytek-ist', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', status: 'needs_provider_config', warnings: [`provider=iflytek-ist 需要配置 ${config.missing.join(', ')};未调用 ASR。`] }); } const asset = await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' }); let filePath = asset.filePath; if (!filePath) throw new Error('provider=iflytek-ist requires --file, --video-url, --media-url or --douyin-url'); const warnings = Array.isArray(asset.warnings) ? [...asset.warnings] : []; const originalExt = path.extname(filePath).toLowerCase(); const shouldExtractAudio = bool(args.extractAudio || args['extract-audio']) || VIDEO_EXTENSIONS.has(originalExt) || asset.sourceKind === 'douyin_video'; if (shouldExtractAudio) { const extraction = extractAudioWithFfmpeg(filePath, outputDir, args); if (extraction.status === 'missing_ffmpeg') { return normalizeTranscript({ awemeId: asset.awemeId || args.awemeId || args['aweme-id'], sourceUrl: asset.sourceUrl || args['video-url'] || args.videoUrl || args.url, sourceFile: filePath, mediaUrl: asset.mediaUrl, sourceKind: asset.sourceKind, provider: 'iflytek-ist', model: 'iflytek-ist', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs: asset.durationMs || 0, status: 'needs_media_processing', warnings: [...warnings, 'ffmpeg is required to extract audio from video before iflytek-ist transcription.'] }); } if (extraction.status !== 'ok') { throw new Error(`ffmpeg audio extraction failed: ${extraction.message || 'unknown error'}`); } warnings.push(`Audio extracted with ffmpeg: ${extraction.audioPath}`); filePath = extraction.audioPath; } const durationValue = args.durationMs || args['duration-ms'] || args.duration || asset.durationMs || probeDurationMs(filePath, args); if (!durationValue || String(durationValue).startsWith('{{')) { throw new Error('provider=iflytek-ist requires accurate --duration-ms; provide it or install ffprobe / use a resolvable Douyin detail duration.'); } const durationMs = parsePositiveInt(durationValue, '--duration-ms'); const stat = fs.statSync(filePath); const limitBytes = 100 * 1024 * 1024; if (stat.size > limitBytes) { throw new Error(`讯飞 IST 文件上限为 100MB;当前文件约 ${Math.round(stat.size / 1024 / 1024)}MB`); } const ext = path.extname(filePath).toLowerCase(); if (!['.wav', '.mp3', '.pcm'].includes(ext)) { warnings.push('讯飞 IST 文档推荐 PCM WAV 16kHz/16bit/单声道;非 WAV/MP3/PCM 文件需要先确认服务兼容性。'); } const fileBuffer = fs.readFileSync(filePath); const upload = await uploadIflytekIST({ config, fileBuffer, fileName: path.basename(filePath), durationMs, args }); const pollIntervalMs = parseOptionalPositiveInt(args.pollIntervalMs || args['poll-interval-ms'], 4000); const maxPolls = parseOptionalPositiveInt(args.maxPolls || args['max-polls'], 30); if (upload.estimateTime > 0) await sleep(Math.min(upload.estimateTime, pollIntervalMs)); let result = { status: 'pending' }; for (let attempt = 0; attempt < maxPolls; attempt++) { result = await queryIflytekIST({ config, orderId: upload.orderId }); if (result.status === 'completed') break; if (result.status === 'failed') { throw new Error(`讯飞 IST 转写失败${result.failType ? ` failType=${result.failType}` : ''}${result.message ? `: ${result.message}` : ''}`); } await sleep(pollIntervalMs); } if (result.status !== 'completed') { throw new Error(`讯飞 IST 转写轮询超时:orderId=${upload.orderId}`); } return normalizeTranscript({ awemeId: asset.awemeId || args.awemeId || args['aweme-id'], sourceUrl: asset.sourceUrl || args['video-url'] || args.videoUrl || args.url, sourceFile: filePath, mediaUrl: asset.mediaUrl, audioFile: filePath, sourceKind: asset.sourceKind, provider: 'iflytek-ist', model: 'iflytek-ist', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs, orderId: upload.orderId, text: result.text, segments: result.segments, warnings }); } function getGatewayBaseUrl(args) { return String(args.gatewayBaseUrl || args['gateway-base-url'] || process.env.IFLYTEK_GATEWAY_BASE_URL || 'https://server.fmode.cn/api/apig/transcription') .replace(/\/+$/, ''); } function gatewayPayloadValue(data, key) { return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key]; } function normalizeGatewaySegments(segments) { return asArray(segments).map(segment => { const startMs = Number(segment.startMs ?? segment.begin ?? segment.bg ?? segment.start ?? 0); const endMs = Number(segment.endMs ?? segment.end ?? segment.ed ?? 0); return { start: Number.isFinite(startMs) ? (startMs > 1000 ? startMs / 1000 : startMs) : null, end: Number.isFinite(endMs) ? (endMs > 1000 ? endMs / 1000 : endMs) : null, startMs: Number.isFinite(startMs) ? Math.round(startMs) : null, endMs: Number.isFinite(endMs) ? Math.round(endMs) : null, speakerId: segment.speakerId || segment.spk || segment.role || null, text: cleanText(segment.text || segment.onebest || segment.content || '') }; }).filter(segment => segment.text); } async function uploadIflytekGateway({ token, baseUrl, filePath, durationMs, args }) { const buffer = fs.readFileSync(filePath); const form = new FormData(); form.append('audio', new Blob([buffer], { type: guessMime(filePath) }), path.basename(filePath)); form.append('durationMs', String(durationMs)); form.append('roleType', String(args.roleType || args['role-type'] || process.env.IFLYTEK_IST_ROLE_TYPE || 1)); form.append('roleNum', String(args.roleNum || args['role-num'] || process.env.IFLYTEK_IST_ROLE_NUM || 0)); if (args.language || process.env.IFLYTEK_IST_LANGUAGE) form.append('language', args.language || process.env.IFLYTEK_IST_LANGUAGE); if (args.pd || process.env.IFLYTEK_IST_PD) form.append('pd', args.pd || process.env.IFLYTEK_IST_PD); const response = await fetchWithTimeout(`${baseUrl}/upload`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, body: form }, 300000); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { rawText: text }; } if (!response.ok || data.success === false) { throw new Error(data.error?.message || data.error || data.message || data.rawText || `gateway upload failed with HTTP ${response.status}`); } const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId; if (!orderId) throw new Error('gateway upload succeeded but did not return orderId'); return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0), raw: data }; } async function queryIflytekGateway({ token, baseUrl, orderId }) { const response = await fetchWithTimeout(`${baseUrl}/result`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId }) }, 120000); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { rawText: text }; } if (!response.ok) { throw new Error(data.error?.message || data.error || data.message || data.rawText || `gateway result failed with HTTP ${response.status}`); } return data; } async function prepareAudioAssetForGateway(args, outputDir) { const asset = await resolveInputAsset(args, outputDir, { preferredMedia: 'audio' }); let filePath = asset.filePath; if (!filePath) throw new Error('provider=iflytek-gateway requires --file, --video-url, --media-url, --douyin-url or --order-id'); const warnings = Array.isArray(asset.warnings) ? [...asset.warnings] : []; const originalExt = path.extname(filePath).toLowerCase(); const shouldExtractAudio = bool(args.extractAudio || args['extract-audio']) || VIDEO_EXTENSIONS.has(originalExt) || asset.sourceKind === 'douyin_video'; if (shouldExtractAudio) { const extraction = extractAudioWithFfmpeg(filePath, outputDir, args); if (extraction.status === 'missing_ffmpeg') { return { asset, filePath, warnings, missingFfmpeg: true }; } if (extraction.status !== 'ok') { throw new Error(`ffmpeg audio extraction failed: ${extraction.message || 'unknown error'}`); } warnings.push(`Audio extracted with ffmpeg: ${extraction.audioPath}`); filePath = extraction.audioPath; } return { asset, filePath, warnings }; } async function transcribeIflytekGateway(args, outputDir) { const token = loadVocToken(); if (!token) { return normalizeTranscript({ awemeId: args.awemeId || args['aweme-id'], sourceUrl: args['video-url'] || args.videoUrl || args.url || args.douyinUrl || args['douyin-url'], provider: 'iflytek-gateway', model: 'iflytek-gateway', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', status: 'needs_provider_config', warnings: ['provider=iflytek-gateway requires VOC_TOKEN or ~/.openclaw/voc-credentials.json; ASR was not called.'] }); } const baseUrl = getGatewayBaseUrl(args); const existingOrderId = firstConcrete(args.orderId, args['order-id']); const defaultAsset = { awemeId: firstConcrete(args.awemeId, args['aweme-id']), sourceUrl: firstConcrete(args.douyinUrl, args['douyin-url'], args['video-url'], args.videoUrl, args.url), mediaUrl: firstConcrete(args.mediaUrl, args['media-url']), sourceKind: 'gateway_order', durationMs: Number(args.durationMs || args['duration-ms'] || args.duration || 0) }; let asset = defaultAsset; let filePath = hasConcreteArg(args.file) ? path.resolve(args.file) : ''; let warnings = []; let orderId = existingOrderId; let estimateTime = 0; if (!orderId) { const prepared = await prepareAudioAssetForGateway(args, outputDir); asset = prepared.asset; filePath = prepared.filePath; warnings = prepared.warnings; if (prepared.missingFfmpeg) { return normalizeTranscript({ awemeId: asset.awemeId, sourceUrl: asset.sourceUrl, sourceFile: filePath, mediaUrl: asset.mediaUrl, sourceKind: asset.sourceKind, provider: 'iflytek-gateway', model: 'iflytek-gateway', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs: asset.durationMs || 0, status: 'needs_media_processing', warnings: [...warnings, 'ffmpeg is required to extract audio from video before gateway transcription.'] }); } const durationValue = args.durationMs || args['duration-ms'] || args.duration || asset.durationMs || probeDurationMs(filePath, args); if (!durationValue || String(durationValue).startsWith('{{')) { throw new Error('provider=iflytek-gateway requires accurate --duration-ms; provide it or use a resolvable Douyin detail duration.'); } asset.durationMs = parsePositiveInt(durationValue, '--duration-ms'); const maxDurationMs = gatewayMaxDurationMs(args); if (maxDurationMs && asset.durationMs > maxDurationMs) { return normalizeTranscript({ awemeId: asset.awemeId, sourceUrl: asset.sourceUrl, sourceFile: filePath, mediaUrl: asset.mediaUrl, audioFile: filePath, sourceKind: asset.sourceKind, provider: 'iflytek-gateway', model: 'iflytek-gateway', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs: asset.durationMs, status: 'needs_media_processing', warnings: [ ...warnings, `iflytek-gateway 单次转写上限按 ${Math.round(maxDurationMs / 60000)} 分钟处理;当前视频约 ${Math.ceil(asset.durationMs / 60000)} 分钟,请先分段或选择更短视频。` ] }); } const upload = await uploadIflytekGateway({ token, baseUrl, filePath, durationMs: asset.durationMs, args }); orderId = upload.orderId; estimateTime = upload.estimateTime; } const pollIntervalMs = parseOptionalPositiveInt(args.pollIntervalMs || args['poll-interval-ms'], 4000); const maxPolls = parseOptionalPositiveInt(args.maxPolls || args['max-polls'], 30); if (estimateTime > 0 && !existingOrderId) await sleep(Math.min(estimateTime, pollIntervalMs)); let data = {}; for (let attempt = 0; attempt < maxPolls; attempt++) { data = await queryIflytekGateway({ token, baseUrl, orderId }); const status = String(gatewayPayloadValue(data, 'status') || '').toLowerCase(); if (['completed', 'failed', 'error'].includes(status)) break; await sleep(pollIntervalMs); } const status = String(gatewayPayloadValue(data, 'status') || '').toLowerCase(); const text = gatewayPayloadValue(data, 'text') || ''; const segments = normalizeGatewaySegments(gatewayPayloadValue(data, 'segments')); if (status === 'failed' || status === 'error') { return normalizeTranscript({ awemeId: asset.awemeId, sourceUrl: asset.sourceUrl, sourceFile: filePath, mediaUrl: asset.mediaUrl, sourceKind: asset.sourceKind, provider: 'iflytek-gateway', model: 'iflytek-gateway', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs: asset.durationMs || 0, orderId, status: 'transcription_failed', warnings: [...warnings, gatewayPayloadValue(data, 'error') || gatewayPayloadValue(data, 'message') || 'gateway transcription failed'] }); } if (status !== 'completed') { return normalizeTranscript({ awemeId: asset.awemeId, sourceUrl: asset.sourceUrl, sourceFile: filePath, mediaUrl: asset.mediaUrl, sourceKind: asset.sourceKind, provider: 'iflytek-gateway', model: 'iflytek-gateway', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs: asset.durationMs || 0, orderId, status: 'transcription_pending', warnings: [...warnings, 'gateway transcription is still pending; rerun with --order-id to query later'] }); } return normalizeTranscript({ awemeId: asset.awemeId, sourceUrl: asset.sourceUrl, sourceFile: filePath, mediaUrl: asset.mediaUrl, audioFile: filePath, sourceKind: asset.sourceKind, provider: 'iflytek-gateway', model: 'iflytek-gateway', language: args.language || process.env.IFLYTEK_IST_LANGUAGE || 'autodialect', durationMs: asset.durationMs || 0, orderId, text, segments, warnings }); } function renderText(transcript) { const lines = []; lines.push(`# transcript ${transcript.awemeId || ''}`.trim()); lines.push(''); lines.push(`provider: ${transcript.provider}`); lines.push(`status: ${transcript.status}`); if (transcript.sourceUrl) lines.push(`sourceUrl: ${transcript.sourceUrl}`); if (transcript.mediaUrl) lines.push(`mediaUrl: ${transcript.mediaUrl}`); if (transcript.sourceFile) lines.push(`sourceFile: ${transcript.sourceFile}`); if (transcript.audioFile && transcript.audioFile !== transcript.sourceFile) lines.push(`audioFile: ${transcript.audioFile}`); if (transcript.durationMs) lines.push(`durationMs: ${transcript.durationMs}`); if (transcript.orderId) lines.push(`orderId: ${transcript.orderId}`); lines.push(''); if (transcript.text) lines.push(transcript.text); else lines.push('(needs transcription)'); if (transcript.warnings.length) { lines.push(''); lines.push('warnings:'); transcript.warnings.forEach(warning => lines.push(`- ${warning}`)); } return lines.join('\n'); } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const outputDir = path.resolve(args.output || path.join(process.cwd(), 'douyin-video-transcript')); ensureDir(outputDir); const provider = String(args.provider || 'none').toLowerCase(); let transcript; if (provider === 'manual') transcript = transcribeManual(args); else if (provider === 'openai') transcript = await transcribeOpenAI(args, outputDir); else if (['iflytek', 'iflytek-ist', 'xunfei', 'xunfei-ist'].includes(provider)) transcript = await transcribeIflytekIST(args, outputDir); else if (['iflytek-gateway', 'xunfei-gateway'].includes(provider)) transcript = await transcribeIflytekGateway(args, outputDir); else if (['iflytek-ast', 'xunfei-ast'].includes(provider)) transcript = transcribeIflytekAST(args); else if (provider === 'volcengine') transcript = transcribeVolcengine(args); else transcript = await transcribeNoneResolved(args, outputDir); const jsonPath = path.join(outputDir, 'transcript.json'); const txtPath = path.join(outputDir, 'transcript.txt'); fs.writeFileSync(jsonPath, JSON.stringify(transcript, null, 2), 'utf8'); fs.writeFileSync(txtPath, renderText(transcript), 'utf8'); console.log(JSON.stringify({ status: transcript.status, provider: transcript.provider, outputDir, files: [jsonPath, txtPath], orderId: transcript.orderId, awemeId: transcript.awemeId, sourceFile: transcript.sourceFile, audioFile: transcript.audioFile, mediaUrl: transcript.mediaUrl, sourceKind: transcript.sourceKind, durationMs: transcript.durationMs, textLength: transcript.text.length, segmentCount: transcript.segments.length, warningCount: transcript.warnings.length }, null, 2)); } main().catch(error => { console.error(error.message); process.exit(1); });