| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444 |
- #!/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 <text> --aweme-id <id> --output <out-dir>',
- ' node scripts/tools/douyin-video-transcriber.js --provider openai --file <audio-or-video-file> --output <out-dir>',
- ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-ist --file <audio-file> --duration-ms <ms> --output <out-dir>',
- ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-gateway --file <audio-file> --duration-ms <ms> --output <out-dir>',
- ' node scripts/tools/douyin-video-transcriber.js --provider iflytek-ist --douyin-url <share-url> --output <out-dir>',
- ' node scripts/tools/douyin-video-transcriber.js --provider none --video-url <url> --output <out-dir>',
- '',
- '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);
- });
|