|
@@ -46,6 +46,21 @@ const MANIFEST_PATH = path.join(DATA_DIR, 'manifest.json');
|
|
|
const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json');
|
|
const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json');
|
|
|
const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper');
|
|
const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper');
|
|
|
const downloadTasks = new Map();
|
|
const downloadTasks = new Map();
|
|
|
|
|
+const transcriptTasks = new Map();
|
|
|
|
|
+const TRANSCRIPT_TEMP_DIR = path.join(DATA_DIR, 'douyin-transcripts');
|
|
|
|
|
+const DOUYIN_API_BASE_URL = (process.env.DOUYIN_API_BASE_URL || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
|
|
|
|
|
+const TRANSCRIPTION_GATEWAY_BASE_URL = (process.env.IFLYTEK_GATEWAY_BASE_URL || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
|
|
|
|
|
+const DOUYIN_GATEWAY_MAX_ATTEMPTS = Math.max(1, Number(process.env.DOUYIN_GATEWAY_MAX_ATTEMPTS || 4));
|
|
|
|
|
+const DOUYIN_API_ROUTES = {
|
|
|
|
|
+ searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' },
|
|
|
|
|
+ challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' },
|
|
|
|
|
+ videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' },
|
|
|
|
|
+ userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' },
|
|
|
|
|
+ userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' },
|
|
|
|
|
+ userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' },
|
|
|
|
|
+ comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' },
|
|
|
|
|
+ replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' },
|
|
|
|
|
+};
|
|
|
const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
|
|
const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
|
|
|
const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
|
|
const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
|
|
|
const QINIU_BUCKET = 'nova-repos';
|
|
const QINIU_BUCKET = 'nova-repos';
|
|
@@ -2764,6 +2779,633 @@ app.post('/api/llm/gemini', async (req, res) => {
|
|
|
});
|
|
});
|
|
|
MIGRATED-TO-CLOUD: proxyHub — END */
|
|
MIGRATED-TO-CLOUD: proxyHub — END */
|
|
|
|
|
|
|
|
|
|
+// ==================== Douyin transcript worker ====================
|
|
|
|
|
+
|
|
|
|
|
+function cleanText(value) {
|
|
|
|
|
+ return String(value || '').trim();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getVocToken() {
|
|
|
|
|
+ return cleanText(
|
|
|
|
|
+ process.env.DOUYIN_API_TOKEN
|
|
|
|
|
+ || process.env.VOC_TOKEN
|
|
|
|
|
+ || process.env.TRANSCRIPTION_VOC_TOKEN
|
|
|
|
|
+ || process.env.VOICE_TOKEN
|
|
|
|
|
+ || process.env.OPENCLAW_VOC_TOKEN
|
|
|
|
|
+ || process.env.VOC_SOCIAL_TOKEN
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function bearerAuth(token) {
|
|
|
|
|
+ const value = cleanText(token);
|
|
|
|
|
+ return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function stripEmpty(value) {
|
|
|
|
|
+ if (!value || typeof value !== 'object') return value;
|
|
|
|
|
+ const out = Array.isArray(value) ? [] : {};
|
|
|
|
|
+ for (const [key, val] of Object.entries(value)) {
|
|
|
|
|
+ if (val === undefined || val === null || val === '') continue;
|
|
|
|
|
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
|
|
|
+ const nested = stripEmpty(val);
|
|
|
|
|
+ if (Object.keys(nested).length) out[key] = nested;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ out[key] = val;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function readGatewayError(data, fallback) {
|
|
|
|
|
+ return data?.error?.message || data?.error || data?.mess || data?.message || data?.msg || data?.detail || fallback || '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sleep(ms) {
|
|
|
|
|
+ return new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatNetworkError(error) {
|
|
|
|
|
+ const cause = error?.cause?.code || error?.cause?.message || '';
|
|
|
|
|
+ return `${error?.message || 'fetch failed'}${cause ? `;cause=${cause}` : ''}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchDouyinGatewayWithRetry(url, init, routePath) {
|
|
|
|
|
+ let lastError = null;
|
|
|
|
|
+ for (let attempt = 1; attempt <= DOUYIN_GATEWAY_MAX_ATTEMPTS; attempt += 1) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await fetch(url, init);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ lastError = error;
|
|
|
|
|
+ const reason = formatNetworkError(error);
|
|
|
|
|
+ console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${DOUYIN_GATEWAY_MAX_ATTEMPTS} failed: ${reason}`);
|
|
|
|
|
+ if (attempt < DOUYIN_GATEWAY_MAX_ATTEMPTS) {
|
|
|
|
|
+ await sleep(Math.min(300 * attempt, 1200));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw lastError || new Error('fetch failed');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function requestDouyinGateway(routeName, params = {}, payload = {}) {
|
|
|
|
|
+ const route = DOUYIN_API_ROUTES[routeName];
|
|
|
|
|
+ if (!route) {
|
|
|
|
|
+ throw Object.assign(new Error('不支持的抖音接口'), { statusCode: 400 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const token = getVocToken();
|
|
|
|
|
+ if (!token) {
|
|
|
|
|
+ throw Object.assign(new Error('本地抖音数据网关未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN。'), { statusCode: 400 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const url = new URL(`${DOUYIN_API_BASE_URL}${route.path}`);
|
|
|
|
|
+ const init = {
|
|
|
|
|
+ method: route.method,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ Accept: 'application/json',
|
|
|
|
|
+ Authorization: bearerAuth(token),
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (route.method === 'GET') {
|
|
|
|
|
+ const query = { ...(payload || {}), ...(params || {}) };
|
|
|
|
|
+ for (const [key, value] of Object.entries(query)) {
|
|
|
|
|
+ if (value !== undefined && value !== null && value !== '') {
|
|
|
|
|
+ url.searchParams.set(key, String(value));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ init.body = JSON.stringify(stripEmpty(payload || {}));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let response;
|
|
|
|
|
+ try {
|
|
|
|
|
+ response = await fetchDouyinGatewayWithRetry(url.toString(), init, route.path);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ throw Object.assign(new Error(`本地抖音数据网关网络请求失败:${formatNetworkError(error)};attempts=${DOUYIN_GATEWAY_MAX_ATTEMPTS};base=${DOUYIN_API_BASE_URL} route=${route.path}`), {
|
|
|
|
|
+ statusCode: 502,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const text = await response.text();
|
|
|
|
|
+ let data = null;
|
|
|
|
|
+ try { data = text ? JSON.parse(text) : null; } catch {}
|
|
|
|
|
+ if (!response.ok || data?.success === false) {
|
|
|
|
|
+ throw Object.assign(new Error(readGatewayError(data, text) || `抖音数据接口请求失败 HTTP ${response.status}`), {
|
|
|
|
|
+ statusCode: response.status || 500,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return data || { code: 500, success: false, error: '服务返回异常' };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/douyin/call', async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { route, params = {}, payload = {}, optional = false } = req.body || {};
|
|
|
|
|
+ const data = await requestDouyinGateway(route, params, payload);
|
|
|
|
|
+ res.json({ success: true, data });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (req.body?.optional) {
|
|
|
|
|
+ return res.json({
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ optional: true,
|
|
|
|
|
+ error: error.message || '本地抖音数据网关调用失败',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ res.status(error.statusCode || 500).json({
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ error: error.message || '本地抖音数据网关调用失败',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+app.get('/api/douyin/diagnose', async (req, res) => {
|
|
|
|
|
+ const routeName = cleanText(req.query.route || 'videoDetail');
|
|
|
|
|
+ const route = DOUYIN_API_ROUTES[routeName] || DOUYIN_API_ROUTES.videoDetail;
|
|
|
|
|
+ const result = {
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ baseUrl: DOUYIN_API_BASE_URL,
|
|
|
|
|
+ routeName,
|
|
|
|
|
+ routePath: route.path,
|
|
|
|
|
+ tokenConfigured: !!getVocToken(),
|
|
|
|
|
+ probe: null,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ if (String(req.query.probe || '') === '1') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ result.data.probe = await requestDouyinGateway(routeName, { aweme_id: cleanText(req.query.awemeId) || '7592116912205630761' });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ result.data.probe = { success: false, error: error.message || '探测失败' };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ res.json(result);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function createTranscriptJob(input) {
|
|
|
|
|
+ const now = new Date().toISOString();
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: `transcript_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
|
|
|
|
|
+ awemeId: cleanText(input.awemeId),
|
|
|
|
|
+ analysisId: cleanText(input.analysisId),
|
|
|
|
|
+ provider: cleanText(input.provider) || 'iflytek-gateway',
|
|
|
|
|
+ status: 'queued',
|
|
|
|
|
+ stageLabel: '已创建转写任务',
|
|
|
|
|
+ progress: 0,
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ createdAt: now,
|
|
|
|
|
+ updatedAt: now
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateTranscriptJob(job, patch) {
|
|
|
|
|
+ Object.assign(job, patch, { updatedAt: new Date().toISOString() });
|
|
|
|
|
+ transcriptTasks.set(job.id, job);
|
|
|
|
|
+ return job;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function findAwemeDetail(node, depth = 0) {
|
|
|
|
|
+ if (!node || depth > 8) return null;
|
|
|
|
|
+ if (Array.isArray(node)) {
|
|
|
|
|
+ for (const item of node) {
|
|
|
|
|
+ const found = findAwemeDetail(item, depth + 1);
|
|
|
|
|
+ if (found) return found;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof node !== 'object') return null;
|
|
|
|
|
+ if (node.aweme_id && node.video) return node;
|
|
|
|
|
+ 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;
|
|
|
|
|
+ for (const value of Object.values(node)) {
|
|
|
|
|
+ const found = findAwemeDetail(value, depth + 1);
|
|
|
|
|
+ if (found) return found;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function extractAwemeId(value) {
|
|
|
|
|
+ const text = cleanText(value);
|
|
|
|
|
+ const patterns = [/aweme_id=(\d+)/, /modal_id=(\d+)/, /douyin\.com\/video\/(\d+)/, /douyin\.com\/share\/video\/(\d+)/, /\b(\d{15,25})\b/];
|
|
|
|
|
+ for (const pattern of patterns) {
|
|
|
|
|
+ const match = text.match(pattern);
|
|
|
|
|
+ if (match?.[1]) return match[1];
|
|
|
|
|
+ }
|
|
|
|
|
+ return /^\d{15,25}$/.test(text) ? text : '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function decodeMaybeBase64Url(value) {
|
|
|
|
|
+ const text = cleanText(value);
|
|
|
|
|
+ if (/^https?:\/\//i.test(text)) return text;
|
|
|
|
|
+ if (!/^[A-Za-z0-9+/=_-]{20,}$/.test(text)) return '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const decoded = Buffer.from(text, 'base64').toString('utf8');
|
|
|
|
|
+ return /^https?:\/\//i.test(decoded) ? decoded : '';
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isLikelyMediaUrl(url) {
|
|
|
|
|
+ return /^https?:\/\//i.test(url) && !/\.(?:jpg|jpeg|png|webp|gif)(?:\?|$)/i.test(url);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function inferMediaKind(pathParts, url) {
|
|
|
|
|
+ const joined = pathParts.join('.').toLowerCase();
|
|
|
|
|
+ if (/audio|mp4a|music|sound/.test(joined) || /audio|mp4a/i.test(url)) return 'audio';
|
|
|
|
|
+ if (/play_addr|download_addr|bit_rate|video|media/.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;
|
|
|
|
|
+
|
|
|
|
|
+ for (const [key, value] of Object.entries(node)) {
|
|
|
|
|
+ const nextPath = [...pathParts, key];
|
|
|
|
|
+ if (key === 'url_list' && Array.isArray(value)) {
|
|
|
|
|
+ value.forEach((item, index) => {
|
|
|
|
|
+ const url = decodeMaybeBase64Url(item);
|
|
|
|
|
+ const kind = inferMediaKind(nextPath, url);
|
|
|
|
|
+ if (url && ['audio', 'video'].includes(kind)) {
|
|
|
|
|
+ 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 = inferMediaKind(nextPath, url);
|
|
|
|
|
+ if (url && ['audio', 'video'].includes(kind)) {
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ const seen = new Set();
|
|
|
|
|
+ const candidates = collectMediaCandidates(detail)
|
|
|
|
|
+ .filter(item => isLikelyMediaUrl(item.url))
|
|
|
|
|
+ .filter(item => {
|
|
|
|
|
+ if (seen.has(item.url)) return false;
|
|
|
|
|
+ seen.add(item.url);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ candidates.sort((a, b) => {
|
|
|
|
|
+ const aPreferred = a.kind === 'audio' ? 0 : 1;
|
|
|
|
|
+ const bPreferred = b.kind === 'audio' ? 0 : 1;
|
|
|
|
|
+ if (aPreferred !== bPreferred) return aPreferred - bPreferred;
|
|
|
|
|
+ const rank = item => {
|
|
|
|
|
+ const keyPath = String(item.keyPath || '').toLowerCase();
|
|
|
|
|
+ if (item.kind === 'audio' && keyPath.includes('video.dynamic_audio_list')) return 0;
|
|
|
|
|
+ if (item.kind === 'audio' && keyPath.includes('video.bit_rate_audio')) return 1;
|
|
|
|
|
+ if (item.kind === 'audio' && keyPath.includes('music.')) return 3;
|
|
|
|
|
+ return 2;
|
|
|
|
|
+ };
|
|
|
|
|
+ const aRank = rank(a);
|
|
|
|
|
+ const bRank = rank(b);
|
|
|
|
|
+ if (aRank !== bRank) return aRank - bRank;
|
|
|
|
|
+ return (a.dataSize || Number.MAX_SAFE_INTEGER) - (b.dataSize || Number.MAX_SAFE_INTEGER);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return candidates[0] || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = new URL(url);
|
|
|
|
|
+ const ext = path.extname(parsed.pathname).toLowerCase();
|
|
|
|
|
+ if (/^\.(mp4|m4a|mp3|wav|aac|mov|webm)$/i.test(ext)) return ext;
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchDouyinDetailForTranscript(input) {
|
|
|
|
|
+ const supplied = findAwemeDetail(input.detail);
|
|
|
|
|
+ if (supplied?.aweme_id && supplied.video) return supplied;
|
|
|
|
|
+
|
|
|
|
|
+ const token = getVocToken();
|
|
|
|
|
+ if (!token) {
|
|
|
|
|
+ throw Object.assign(new Error('未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN,无法获取抖音视频详情。'), {
|
|
|
|
|
+ status: 'needs_provider_config'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const awemeId = cleanText(input.awemeId) || extractAwemeId(input.sourceUrl || input.url);
|
|
|
|
|
+ if (!awemeId) {
|
|
|
|
|
+ throw Object.assign(new Error('缺少 awemeId,无法获取视频详情。'), { status: 'needs_media' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const url = new URL(`${DOUYIN_API_BASE_URL}/douyin/app/v3/fetch_one_video_v3`);
|
|
|
|
|
+ url.searchParams.set('aweme_id', awemeId);
|
|
|
|
|
+ const response = await fetch(url.toString(), {
|
|
|
|
|
+ method: 'GET',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Accept: 'application/json',
|
|
|
|
|
+ Authorization: bearerAuth(token)
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (!response.ok || data.success === false) {
|
|
|
|
|
+ throw new Error(data.error?.message || data.error || data.message || data.mess || `抖音详情获取失败 HTTP ${response.status}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const detail = findAwemeDetail(data);
|
|
|
|
|
+ if (!detail?.aweme_id) {
|
|
|
|
|
+ throw Object.assign(new Error('抖音详情响应中未找到 aweme_detail。'), { status: 'needs_media' });
|
|
|
|
|
+ }
|
|
|
|
|
+ return detail;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function downloadTranscriptMedia(candidate, job) {
|
|
|
|
|
+ fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
|
|
|
|
|
+ const ext = extensionFromUrl(candidate.url, candidate.kind === 'audio' ? '.m4a' : '.mp4');
|
|
|
|
|
+ const filePath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}-source${ext}`);
|
|
|
|
|
+ const { response } = await fetchRemoteVideoResponse([candidate.url], {
|
|
|
|
|
+ Accept: '*/*',
|
|
|
|
|
+ 'User-Agent': 'Mozilla/5.0',
|
|
|
|
|
+ Referer: 'https://www.douyin.com/',
|
|
|
|
|
+ Origin: 'https://www.douyin.com'
|
|
|
|
|
+ });
|
|
|
|
|
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath));
|
|
|
|
|
+ return filePath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function extractAudioForTranscript(inputPath, job) {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
|
|
|
|
|
+ const outputPath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}.m4a`);
|
|
|
|
|
+ const args = ['-y', '-i', inputPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
|
|
|
|
|
+ const proc = spawn('ffmpeg', args, { cwd: PROJECT_ROOT });
|
|
|
|
|
+ let stderr = '';
|
|
|
|
|
+ proc.stderr.on('data', data => { stderr += data.toString(); });
|
|
|
|
|
+ proc.on('error', err => {
|
|
|
|
|
+ reject(Object.assign(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并加入 PATH。' : err.message), {
|
|
|
|
|
+ status: 'needs_media_processing'
|
|
|
|
|
+ }));
|
|
|
|
|
+ });
|
|
|
|
|
+ proc.on('close', code => {
|
|
|
|
|
+ if (code !== 0 || !fs.existsSync(outputPath)) {
|
|
|
|
|
+ const lastLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
|
|
|
|
|
+ reject(Object.assign(new Error(`音频提取失败${lastLine ? `:${lastLine}` : ''}`), {
|
|
|
|
|
+ status: 'needs_media_processing'
|
|
|
|
|
+ }));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ resolve(outputPath);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function uploadTranscriptGateway(filePath, durationMs) {
|
|
|
|
|
+ const token = getVocToken();
|
|
|
|
|
+ if (!token) {
|
|
|
|
|
+ throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。'), {
|
|
|
|
|
+ status: 'needs_provider_config'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!durationMs) {
|
|
|
|
|
+ throw Object.assign(new Error('缺少音频时长 durationMs,无法提交转写网关。'), { status: 'needs_media' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const buffer = fs.readFileSync(filePath);
|
|
|
|
|
+ const form = new FormData();
|
|
|
|
|
+ form.append('audio', new Blob([buffer], { type: 'audio/mp4' }), path.basename(filePath));
|
|
|
|
|
+ form.append('durationMs', String(durationMs));
|
|
|
|
|
+ form.append('roleType', '1');
|
|
|
|
|
+ form.append('roleNum', '0');
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(`${TRANSCRIPTION_GATEWAY_BASE_URL}/upload`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: bearerAuth(token),
|
|
|
|
|
+ Accept: 'application/json'
|
|
|
|
|
+ },
|
|
|
|
|
+ body: form
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (!response.ok || data.success === false) {
|
|
|
|
|
+ throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${response.status}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
|
|
|
|
|
+ if (!orderId) throw new Error('转写网关未返回 orderId。');
|
|
|
|
|
+ return {
|
|
|
|
|
+ orderId,
|
|
|
|
|
+ estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0)
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function queryTranscriptGateway(orderId) {
|
|
|
|
|
+ const token = getVocToken();
|
|
|
|
|
+ if (!token) {
|
|
|
|
|
+ throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'), {
|
|
|
|
|
+ status: 'needs_provider_config'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ const response = await fetch(`${TRANSCRIPTION_GATEWAY_BASE_URL}/result`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: bearerAuth(token),
|
|
|
|
|
+ Accept: 'application/json',
|
|
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify({ orderId })
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${response.status}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return data;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function gatewayValue(data, key) {
|
|
|
|
|
+ return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeSegmentTime(value) {
|
|
|
|
|
+ const number = Number(value);
|
|
|
|
|
+ if (!Number.isFinite(number)) return null;
|
|
|
|
|
+ return number > 1000 ? number / 1000 : number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeTranscriptSegments(segments) {
|
|
|
|
|
+ return (Array.isArray(segments) ? segments : []).map(segment => ({
|
|
|
|
|
+ start: normalizeSegmentTime(segment.start ?? segment.begin ?? segment.bg),
|
|
|
|
|
+ end: normalizeSegmentTime(segment.end ?? segment.ed),
|
|
|
|
|
+ text: cleanText(segment.text || segment.onebest || segment.content)
|
|
|
|
|
+ })).filter(segment => segment.text);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pollTranscriptJob(job) {
|
|
|
|
|
+ if (!job.orderId || job.status === 'completed' || job.status === 'failed') return job;
|
|
|
|
|
+ const data = await queryTranscriptGateway(job.orderId);
|
|
|
|
|
+ const status = cleanText(gatewayValue(data, 'status')).toLowerCase();
|
|
|
|
|
+ const text = cleanText(gatewayValue(data, 'text'));
|
|
|
|
|
+ const segments = normalizeTranscriptSegments(gatewayValue(data, 'segments'));
|
|
|
|
|
+ if (status === 'completed' || text || segments.length) {
|
|
|
|
|
+ return updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'completed',
|
|
|
|
|
+ stageLabel: '转写完成',
|
|
|
|
|
+ progress: 100,
|
|
|
|
|
+ text: text || segments.map(segment => segment.text).join('\n'),
|
|
|
|
|
+ segments
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (status === 'failed' || status === 'error') {
|
|
|
|
|
+ return updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'failed',
|
|
|
|
|
+ stageLabel: '转写失败',
|
|
|
|
|
+ errorMessage: gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写网关返回失败'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'polling_provider',
|
|
|
|
|
+ stageLabel: '转写处理中',
|
|
|
|
|
+ progress: Math.max(Number(job.progress || 0), 85),
|
|
|
|
|
+ warnings: [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])]
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function runTranscriptJob(job, input) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 10 });
|
|
|
|
|
+ const detail = await fetchDouyinDetailForTranscript(input);
|
|
|
|
|
+ const awemeId = cleanText(detail.aweme_id || job.awemeId);
|
|
|
|
|
+ if (awemeId && awemeId !== job.awemeId) updateTranscriptJob(job, { awemeId });
|
|
|
|
|
+
|
|
|
|
|
+ updateTranscriptJob(job, { status: 'selecting_media', stageLabel: '正在选择可转写媒体', progress: 25 });
|
|
|
|
|
+ const suppliedMediaUrl = cleanText(input.mediaUrl);
|
|
|
|
|
+ const candidate = suppliedMediaUrl
|
|
|
|
|
+ ? { url: suppliedMediaUrl, kind: /audio|m4a|mp3|aac/i.test(suppliedMediaUrl) ? 'audio' : 'video', keyPath: 'input.mediaUrl' }
|
|
|
|
|
+ : selectMediaCandidate(detail);
|
|
|
|
|
+ if (!candidate?.url) {
|
|
|
|
|
+ updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'needs_media',
|
|
|
|
|
+ stageLabel: '未找到可转写媒体',
|
|
|
|
|
+ progress: 25,
|
|
|
|
|
+ warnings: [...(job.warnings || []), '视频详情中未找到音频或视频下载地址。']
|
|
|
|
|
+ });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'downloading_media',
|
|
|
|
|
+ stageLabel: '正在下载媒体',
|
|
|
|
|
+ progress: 45,
|
|
|
|
|
+ mediaUrl: candidate.url,
|
|
|
|
|
+ sourceKind: candidate.kind === 'audio' ? 'douyin_audio' : 'douyin_video'
|
|
|
|
|
+ });
|
|
|
|
|
+ const sourcePath = await downloadTranscriptMedia(candidate, job);
|
|
|
|
|
+ updateTranscriptJob(job, { localVideoPath: sourcePath });
|
|
|
|
|
+
|
|
|
|
|
+ const durationMs = Number(input.durationMs || durationFromDetail(detail) || 0);
|
|
|
|
|
+ let audioPath = sourcePath;
|
|
|
|
|
+ if (candidate.kind !== 'audio') {
|
|
|
|
|
+ updateTranscriptJob(job, { status: 'extracting_audio', stageLabel: '正在提取音频', progress: 65 });
|
|
|
|
|
+ audioPath = await extractAudioForTranscript(sourcePath, job);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'submitting_provider',
|
|
|
|
|
+ stageLabel: '正在提交转写网关',
|
|
|
|
|
+ progress: 80,
|
|
|
|
|
+ localAudioPath: audioPath,
|
|
|
|
|
+ durationMs
|
|
|
|
|
+ });
|
|
|
|
|
+ const uploaded = await uploadTranscriptGateway(audioPath, durationMs);
|
|
|
|
|
+ updateTranscriptJob(job, {
|
|
|
|
|
+ status: 'polling_provider',
|
|
|
|
|
+ stageLabel: '转写任务已提交',
|
|
|
|
|
+ progress: 85,
|
|
|
|
|
+ orderId: uploaded.orderId,
|
|
|
|
|
+ estimateTime: uploaded.estimateTime,
|
|
|
|
|
+ warnings: [...(job.warnings || []), `已提交转写任务:${uploaded.orderId}`]
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ updateTranscriptJob(job, {
|
|
|
|
|
+ status: error.status || 'failed',
|
|
|
|
|
+ stageLabel: '转写任务失败',
|
|
|
|
|
+ errorMessage: error.message || '转写任务失败',
|
|
|
|
|
+ warnings: [...(job.warnings || []), error.message || '转写任务失败']
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/douyin/transcript/start', (req, res) => {
|
|
|
|
|
+ const input = req.body || {};
|
|
|
|
|
+ if (!cleanText(input.awemeId) && !cleanText(input.sourceUrl) && !cleanText(input.mediaUrl)) {
|
|
|
|
|
+ return res.status(400).json({ success: false, error: '缺少 awemeId、sourceUrl 或 mediaUrl,无法创建转写任务。' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const job = createTranscriptJob(input);
|
|
|
|
|
+ transcriptTasks.set(job.id, job);
|
|
|
|
|
+ res.json({ success: true, job: { ...job } });
|
|
|
|
|
+ setImmediate(() => runTranscriptJob(job, input));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+app.get('/api/douyin/transcript/:jobId', async (req, res) => {
|
|
|
|
|
+ const job = transcriptTasks.get(req.params.jobId);
|
|
|
|
|
+ if (!job) {
|
|
|
|
|
+ return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (job.status === 'polling_provider' && job.orderId) {
|
|
|
|
|
+ await pollTranscriptJob(job);
|
|
|
|
|
+ }
|
|
|
|
|
+ res.json({ success: true, job: { ...job } });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ updateTranscriptJob(job, {
|
|
|
|
|
+ status: error.status || 'failed',
|
|
|
|
|
+ stageLabel: '查询转写结果失败',
|
|
|
|
|
+ errorMessage: error.message || '查询转写结果失败',
|
|
|
|
|
+ warnings: [...(job.warnings || []), error.message || '查询转写结果失败']
|
|
|
|
|
+ });
|
|
|
|
|
+ res.json({ success: true, job: { ...job } });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/douyin/transcript/:jobId/retry', (req, res) => {
|
|
|
|
|
+ const previous = transcriptTasks.get(req.params.jobId);
|
|
|
|
|
+ if (!previous) {
|
|
|
|
|
+ return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const body = req.body || {};
|
|
|
|
|
+ const input = {
|
|
|
|
|
+ ...body,
|
|
|
|
|
+ awemeId: body.awemeId || previous.awemeId,
|
|
|
|
|
+ analysisId: body.analysisId || previous.analysisId,
|
|
|
|
|
+ mediaUrl: body.mediaUrl || previous.mediaUrl
|
|
|
|
|
+ };
|
|
|
|
|
+ const job = createTranscriptJob(input);
|
|
|
|
|
+ transcriptTasks.set(job.id, job);
|
|
|
|
|
+ res.json({ success: true, job: { ...job } });
|
|
|
|
|
+ setImmediate(() => runTranscriptJob(job, input));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
// ==================== 健康检查 ====================
|
|
// ==================== 健康检查 ====================
|
|
|
|
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
app.get('/api/health', (req, res) => {
|
|
@@ -2774,7 +3416,16 @@ app.get('/api/health', (req, res) => {
|
|
|
services: {
|
|
services: {
|
|
|
manifest: fs.existsSync(MANIFEST_PATH),
|
|
manifest: fs.existsSync(MANIFEST_PATH),
|
|
|
whisperDir: fs.existsSync(WHISPER_DIR),
|
|
whisperDir: fs.existsSync(WHISPER_DIR),
|
|
|
- videoDir: fs.existsSync(DATA_VIDEO_DIR) || fs.existsSync(LEGACY_VIDEO_DIR)
|
|
|
|
|
|
|
+ videoDir: fs.existsSync(DATA_VIDEO_DIR) || fs.existsSync(LEGACY_VIDEO_DIR),
|
|
|
|
|
+ douyinTranscriptWorker: {
|
|
|
|
|
+ enabled: true,
|
|
|
|
|
+ tempDir: TRANSCRIPT_TEMP_DIR,
|
|
|
|
|
+ taskCount: transcriptTasks.size,
|
|
|
|
|
+ douyinGateway: DOUYIN_API_BASE_URL,
|
|
|
|
|
+ transcriptionGateway: TRANSCRIPTION_GATEWAY_BASE_URL,
|
|
|
|
|
+ douyinGatewayMaxAttempts: DOUYIN_GATEWAY_MAX_ATTEMPTS,
|
|
|
|
|
+ hasVocToken: !!getVocToken()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|