/** * 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报) * 支持 action: * analysisCreate | analysisList | analysisGet | analysisUpdate * topicCreate | topicList | topicUpdate | topicArchive * dailyReportCreate | dailyReportList | dailyReportGet * transcriptStart | transcriptGet * * 说明:本函数只负责业务资产的持久化和账号隔离。抖音原始数据抓取仍由 12-douyinManager 负责。 */ const PARSE_API_HOST = readEnv('PARSE_API_HOST') || 'https://server.fmode.cn'; const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster'; const DOUYIN_API_BASE_URL = (readEnv('DOUYIN_API_BASE_URL') || readEnv('VOC_SOCIAL_BASE_URL') || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, ''); const IS_TIKHUB_DIRECT = /api\.tikhub\.io/i.test(DOUYIN_API_BASE_URL); const LOCAL_VOC_TOKEN_FALLBACK = 'r:33c57d404c8fffc9b19199a4da0bb663'; const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK; const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ=='; const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN; const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, ''); const TRANSCRIPTION_GATEWAY_TOKEN = readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN'); const { requireSession: requireParseSession, optionalSession, assertRequestedUserMatchesSession, } = require('./_session'); async function handler(request, response) { try { const action = pickParam(request, 'action') || ''; if (action === 'diagnose') { return diagnose(request, response); } await ensureTables(); const session = await requireParseSession(request, Psql); assertRequestedUserMatchesSession(request, session); const requestedUserId = pickParam(request, 'userId') || ''; if (requestedUserId && requestedUserId !== session.userId) { return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' }); } const userId = session.userId; if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {}); if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100); if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id')); if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'), pickParam(request, 'patch', 'analysis', 'data') || {}); if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {}); if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500); if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), pickParam(request, 'patch', 'topic', 'data') || {}); if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), { status: 'archived' }); if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {}); if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100); if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'reportId', 'recordId', 'id')); if (action === 'transcriptStart') return startTranscript(request, response, session); if (action === 'transcriptGet') return getTranscript(request, response, session); response.json({ code: 400, success: false, error: `未知 action: ${action}` }); } catch (error) { console.error('douyinInsightManager failed:', error.message); response.json({ code: 500, success: false, error: error.message }); } } async function ensureTables() { for (const table of ['VideoflowViralAnalysis', 'VideoflowTopicIdea', 'VideoflowDailyReport', 'VideoflowTranscriptJob']) { await Psql.query(` CREATE TABLE IF NOT EXISTS "${table}" ( "objectId" VARCHAR(50) PRIMARY KEY, "bizId" VARCHAR(255) NOT NULL, "userId" VARCHAR(255) NOT NULL, "data" JSONB NOT NULL DEFAULT '{}', "status" VARCHAR(50) DEFAULT '', "createdAt" TIMESTAMPTZ DEFAULT NOW(), "updatedAt" TIMESTAMPTZ DEFAULT NOW() ) `); await Psql.query(`DROP INDEX IF EXISTS idx_${table.toLowerCase()}_biz`); await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`); await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`); } } async function createRow(response, table, userId, data) { const now = new Date().toISOString(); const bizId = data.id || data.bizId || generateId(); const merged = { ...data, id: bizId, userId, createdAt: data.createdAt || now, updatedAt: now }; const existing = await Psql.query( `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`, [bizId, userId] ); if (existing.length) { await Psql.query( `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`, [JSON.stringify(merged), merged.status || '', bizId, userId] ); return response.json({ code: 200, success: true, data: merged }); } await Psql.query( `INSERT INTO "${table}" ("objectId","bizId","userId","data","status") VALUES ($1,$2,$3,$4,$5)`, [generateId(), bizId, userId, JSON.stringify(merged), merged.status || ''] ); response.json({ code: 200, success: true, data: merged }); } async function listRows(response, table, userId, limit) { const parsedLimit = parseInt(limit || '100', 10); const safeLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 1000) : 100; const rows = await Psql.query( `SELECT * FROM "${table}" WHERE "userId"=$1 ORDER BY "updatedAt" DESC LIMIT $2`, [userId, safeLimit] ); response.json({ code: 200, success: true, data: rows.map(rowToObj) }); } async function getRow(response, table, userId, id) { if (!id) return response.json({ code: 400, success: false, error: '缺少 id' }); const rows = await Psql.query( `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`, [id, userId] ); if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' }); response.json({ code: 200, success: true, data: rowToObj(rows[0]) }); } async function updateRow(response, table, userId, id, patch) { if (!id) return response.json({ code: 400, success: false, error: '缺少 id' }); const rows = await Psql.query( `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`, [id, userId] ); if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' }); const merged = { ...rowToObj(rows[0]), ...patch, id, userId, updatedAt: new Date().toISOString() }; await Psql.query( `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`, [JSON.stringify(merged), merged.status || '', id, userId] ); response.json({ code: 200, success: true, data: merged }); } async function startTranscript(request, response, session) { const userId = session.userId; const transcriptionToken = transcriptionGatewayToken(session); const awemeId = clean(pickParam(request, 'awemeId')); const analysisId = clean(pickParam(request, 'analysisId')); if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' }); const now = new Date().toISOString(); const job = { id: `transcript_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, awemeId, analysisId, provider: 'iflytek-gateway', status: 'pending', warnings: [], createdAt: now, updatedAt: now, }; if (!transcriptionToken) { job.status = 'needs_provider_config'; job.warnings.push('当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。'); await upsertTranscriptJob(userId, job); return response.json({ code: 200, success: true, data: job }); } try { const detailResponse = await fetchDouyinDetail(awemeId); const detail = findAwemeDetail(detailResponse) || detailResponse; const media = selectMediaCandidate(detail, 'audio'); if (!media.url) { job.status = 'needs_media'; job.warnings.push('视频详情中未找到可直接提交转写的音频地址。'); await upsertTranscriptJob(userId, job); return response.json({ code: 200, success: true, data: job }); } const durationMs = media.durationMs || durationFromDetail(detail) || extractDurationMs(detail); if (!durationMs) { job.status = 'needs_media'; job.warnings.push('未能确认音频时长,转写网关需要 durationMs。'); await upsertTranscriptJob(userId, job); return response.json({ code: 200, success: true, data: job }); } const uploaded = await uploadGatewayAudio(media.url, durationMs, transcriptionToken, { sourceKind: media.kind, keyPath: media.keyPath, }); job.orderId = uploaded.orderId; job.estimateTime = uploaded.estimateTime || 0; job.mediaUrl = media.url; job.sourceKind = media.kind === 'audio' ? 'douyin_audio' : 'douyin_video'; job.mediaKeyPath = media.keyPath; job.durationMs = durationMs; job.warnings.push(`已提交转写任务:${uploaded.orderId}`); if (media.keyPath) job.warnings.push(`媒体来源:${media.kind || 'media'} ${media.keyPath}`); await upsertTranscriptJob(userId, job); response.json({ code: 200, success: true, data: job }); } catch (error) { job.status = 'failed'; job.errorMessage = error.message || '提交逐字稿任务失败'; job.warnings.push(job.errorMessage); await upsertTranscriptJob(userId, job); response.json({ code: 200, success: true, data: job }); } } async function getTranscript(request, response, session) { const userId = session.userId; const transcriptionToken = transcriptionGatewayToken(session); const id = clean(pickParam(request, 'jobId', 'recordId', 'id')); if (!id) return response.json({ code: 400, success: false, error: '缺少 id' }); const rows = await Psql.query( `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`, [id, userId] ); if (!rows.length) return response.json({ code: 404, success: false, error: '未找到逐字稿任务' }); const job = rowToObj(rows[0]); if (!job.orderId || job.status === 'completed' || job.status === 'failed') { return response.json({ code: 200, success: true, data: job }); } if (!transcriptionToken) { job.status = 'needs_provider_config'; job.warnings = [...(job.warnings || []), '当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。']; await upsertTranscriptJob(userId, job); return response.json({ code: 200, success: true, data: job }); } try { const data = await queryGateway(job.orderId, transcriptionToken); const status = String(gatewayValue(data, 'status') || '').toLowerCase(); const text = clean(gatewayValue(data, 'text')); const segments = normalizeGatewaySegments(gatewayValue(data, 'segments')); if (status === 'completed' || text) { job.status = 'completed'; job.text = text || segments.map(s => s.text).join('\n'); job.segments = segments; } else if (status === 'failed' || status === 'error') { job.status = 'failed'; job.errorMessage = readGatewayError(data) || '转写失败'; job.warnings = [...(job.warnings || []), job.errorMessage]; } else { job.status = 'pending'; job.warnings = [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])]; } job.updatedAt = new Date().toISOString(); await upsertTranscriptJob(userId, job); response.json({ code: 200, success: true, data: job }); } catch (error) { job.status = 'failed'; job.errorMessage = error.message || '查询逐字稿任务失败'; job.warnings = [...(job.warnings || []), job.errorMessage]; job.updatedAt = new Date().toISOString(); await upsertTranscriptJob(userId, job); response.json({ code: 200, success: true, data: job }); } } async function upsertTranscriptJob(userId, job) { const existing = await Psql.query( `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`, [job.id, userId] ); if (existing.length) { await Psql.query( `UPDATE "VideoflowTranscriptJob" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`, [JSON.stringify(job), job.status || '', job.id, userId] ); return; } await Psql.query( `INSERT INTO "VideoflowTranscriptJob" ("objectId","bizId","userId","data","status") VALUES ($1,$2,$3,$4,$5)`, [generateId(), job.id, userId, JSON.stringify(job), job.status || ''] ); } function rowToObj(row) { const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {}); return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt }; } async function fetchDouyinDetail(awemeId) { if (!DOUYIN_API_TOKEN) { throw new Error('抖音数据网关未配置有效 token:FMode voc-social 请配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN;TikHub 直连才使用 TIKHUB_TOKEN。'); } const url = new URL(`${DOUYIN_API_BASE_URL}${normalizeDouyinPath('/douyin/app/v3/fetch_one_video_v3')}`); url.searchParams.set('aweme_id', awemeId); let resp; try { resp = await fetch(url.toString(), { method: 'GET', headers: { 'Accept': 'application/json', 'Authorization': bearerAuth(DOUYIN_API_TOKEN), }, }); } catch (error) { throw new Error(`抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=/douyin/app/v3/fetch_one_video_v3`); } const data = await resp.json().catch(() => ({})); if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `抖音详情获取失败 HTTP ${resp.status}`); return data; } async function diagnose(request, response) { const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim(); const result = { code: 200, success: true, data: { parseApiHost: PARSE_API_HOST, douyinBaseUrl: maskBaseUrl(DOUYIN_API_BASE_URL), isTikhubDirect: IS_TIKHUB_DIRECT, douyinTokenConfigured: !!DOUYIN_API_TOKEN, douyinTokenSource: douyinTokenSource(), transcriptTokenConfigured: !!TRANSCRIPTION_GATEWAY_TOKEN, transcriptTokenSource: transcriptTokenSource(TRANSCRIPTION_GATEWAY_TOKEN), transcriptUsesCurrentSession: true, transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY), probe: null, } }; if (String(pickParam(request, 'probe') || '') === '1') { try { const detail = await fetchDouyinDetail(awemeId); result.data.probe = { code: detail?.code, success: detail?.success !== false, hasData: !!detail?.data, keys: detail && typeof detail === 'object' ? Object.keys(detail).slice(0, 10) : [], }; } catch (error) { result.data.probe = { code: 502, success: false, error: error && error.message ? error.message : String(error || 'probe failed'), }; } } if (String(pickParam(request, 'probeDb') || '') === '1') { try { await ensureTables(); result.data.database = { success: true }; } catch (error) { result.data.database = { success: false, error: error && error.message ? error.message : String(error || 'database probe failed'), }; } } const sessionToken = clean(pickParam(request, 'sessionToken')); if (sessionToken) { try { const session = await optionalSession({ body: { sessionToken } }, Psql); result.data.session = { success: !!session?.userId, userId: session?.userId || '', source: 'parse-helper', }; } catch (error) { result.data.session = { success: false, error: error && error.message ? error.message : String(error || 'session probe failed'), }; } } return response.json(result); } function normalizeDouyinPath(path) { if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) { return `/api/v1${path}`; } return path; } 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_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 null; } 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, durationMs: durationFromDetail(node), 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('.'), durationMs: durationFromDetail(node), 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] || { url: '', kind: '', keyPath: '', durationMs: durationFromDetail(detail) }; } function isLikelyMediaUrl(value) { if (!/^https?:\/\//i.test(String(value || ''))) return false; try { const parsed = new URL(String(value)); const pathName = parsed.pathname.toLowerCase(); const host = parsed.hostname.toLowerCase(); if (/\.(wav|mp3|m4a|aac|flac|ogg|oga|opus|pcm|mp4|mov|m4v|webm|mkv)$/i.test(pathName)) return true; if (host.includes('douyinvod.com')) return true; if (host.includes('amemv.com') && pathName.includes('/aweme/v1/play')) return true; const mime = parsed.searchParams.get('mime_type') || ''; return /audio|video_mp4|video/.test(mime); } catch { return false; } } function decodeMaybeBase64Url(value) { const text = clean(value); if (!text) return ''; if (/^https?:\/\//i.test(text)) return text; if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return ''; try { const decoded = atobCompatible(text.replace(/-/g, '+').replace(/_/g, '/')); return /^https?:\/\//i.test(decoded) ? decoded : ''; } catch { return ''; } } function atobCompatible(value) { if (typeof atob === 'function') return atob(value); if (typeof Buffer !== 'undefined') return Buffer.from(value, 'base64').toString('utf8'); return ''; } 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 extractDurationMs(value) { const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']); if (!found) return 0; return found > 1000 ? Math.round(found) : Math.round(found * 1000); } function findFirstNumber(value, keys, depth = 0) { if (!value || depth > 5) return 0; if (Array.isArray(value)) { for (const item of value) { const hit = findFirstNumber(item, keys, depth + 1); if (hit) return hit; } return 0; } if (typeof value !== 'object') return 0; for (const [key, raw] of Object.entries(value)) { if (keys.includes(key)) { const number = Number(raw); if (Number.isFinite(number) && number > 0) return number; } const hit = findFirstNumber(raw, keys, depth + 1); if (hit) return hit; } return 0; } async function uploadGatewayAudio(mediaUrl, durationMs, token, context = {}) { let mediaResp; try { mediaResp = await fetchWithTimeout(mediaUrl, { method: 'GET', headers: { Accept: '*/*', 'User-Agent': 'Mozilla/5.0', Referer: 'https://www.douyin.com/', Origin: 'https://www.douyin.com', }, }, 60000, '下载音频'); } catch (error) { throw new Error(`音频下载网络失败:${formatFetchError(error)}`); } if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`); const contentType = mediaResp.headers?.get?.('content-type') || ''; const arrayBuffer = await mediaResp.arrayBuffer(); const mimeType = normalizeAudioMime(contentType, mediaUrl); const filename = `douyin-audio-${Date.now()}${extensionFromUrl(mediaUrl, mimeType)}`; const blob = new Blob([arrayBuffer], { type: mimeType }); const form = new FormData(); form.append('audio', blob, filename); form.append('durationMs', String(durationMs)); form.append('roleType', readEnv('IFLYTEK_IST_ROLE_TYPE') || '1'); form.append('roleNum', readEnv('IFLYTEK_IST_ROLE_NUM') || '0'); if (readEnv('IFLYTEK_IST_LANGUAGE')) form.append('language', readEnv('IFLYTEK_IST_LANGUAGE')); if (readEnv('IFLYTEK_IST_PD')) form.append('pd', readEnv('IFLYTEK_IST_PD')); let resp; try { resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/upload`, { method: 'POST', headers: { Authorization: bearerAuth(token), Accept: 'application/json', }, body: form, }, 300000, '转写网关上传'); } catch (error) { const mediaSource = context.keyPath ? `,media=${context.sourceKind || 'media'}:${context.keyPath}` : ''; throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)},file=${filename},size=${arrayBuffer.byteLength}${mediaSource}`); } const text = await resp.text(); const data = parseJson(text) || { rawText: text }; if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `转写上传失败 HTTP ${resp.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 queryGateway(orderId, token) { let resp; try { resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/result`, { method: 'POST', headers: { Authorization: bearerAuth(token), Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ orderId }), }, 120000, '转写网关查询'); } catch (error) { throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)}`); } const text = await resp.text(); const data = parseJson(text) || { rawText: text }; if (!resp.ok) throw new Error(readGatewayError(data) || `转写查询失败 HTTP ${resp.status}`); return data; } function gatewayValue(data, key) { return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key]; } function readGatewayError(data) { const detail = data?.detail; if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social'; const type = clean(data?.type || data?.error?.type); const raw = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || data?.rawText || ''; const message = typeof raw === 'string' ? raw : raw && typeof raw === 'object' ? JSON.stringify(raw) : String(raw || ''); if (/unauthorized/i.test(type) || /unauthorized/i.test(message)) { return '转写网关鉴权失败:请确认当前登录用户的 VOC/APIG 权限、余额和 session token 是否有效。'; } if (/company或用户信息不存在/.test(String(message))) { return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。'; } return message; } function normalizeGatewaySegments(segments) { const arr = Array.isArray(segments) ? segments : []; return arr.map(segment => ({ start: normalizeTime(segment.start ?? segment.begin ?? segment.bg), end: normalizeTime(segment.end ?? segment.ed), text: clean(segment.text || segment.onebest || segment.content), })).filter(segment => segment.text); } function normalizeTime(value) { const number = Number(value); if (!Number.isFinite(number)) return null; return number > 1000 ? number / 1000 : number; } function bearerAuth(token) { const value = clean(token); return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`; } async function fetchWithTimeout(url, init, timeoutMs, label) { const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; try { return await fetch(url, { ...init, signal: controller ? controller.signal : init?.signal, }); } catch (error) { if (error && error.name === 'AbortError') { throw new Error(`${label || '请求'}超时(${timeoutMs}ms)`); } throw error; } finally { if (timer) clearTimeout(timer); } } function normalizeAudioMime(contentType, url) { const value = clean(contentType).split(';')[0].toLowerCase(); if (/^audio\//.test(value) || value === 'video/mp4') return value; const pathname = (() => { try { return new URL(url).pathname.toLowerCase(); } catch { return String(url || '').toLowerCase(); } })(); if (pathname.endsWith('.mp3')) return 'audio/mpeg'; if (pathname.endsWith('.wav')) return 'audio/wav'; if (pathname.endsWith('.aac')) return 'audio/aac'; if (pathname.endsWith('.m4a')) return 'audio/mp4'; return 'audio/mp4'; } function extensionFromUrl(url, mimeType) { try { const ext = new URL(url).pathname.match(/\.(mp3|m4a|aac|wav|mp4|mov|webm)$/i)?.[0]; if (ext) return ext.toLowerCase(); } catch {} const mime = clean(mimeType).toLowerCase(); if (mime.includes('mpeg')) return '.mp3'; if (mime.includes('wav')) return '.wav'; if (mime.includes('aac')) return '.aac'; return '.m4a'; } function douyinTokenSource() { if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN'; if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN'; if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN'; if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN'; if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN'; if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN'; if (IS_TIKHUB_DIRECT && readEnv('TIKHUB_TOKEN')) return 'TIKHUB_TOKEN'; if (!IS_TIKHUB_DIRECT && LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK'; return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : ''; } function transcriptionGatewayToken(session) { return clean(session?.sessionToken) || clean(TRANSCRIPTION_GATEWAY_TOKEN); } function transcriptTokenSource(token) { if (token && clean(token).startsWith('r:')) return 'current_user_sessionToken'; if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN'; if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN'; if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN'; if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN'; if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN'; if (clean(token)) return 'TRANSCRIPTION_GATEWAY_TOKEN'; return ''; } function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } } function formatFetchError(error) { const message = error && error.message ? error.message : String(error || 'fetch failed'); const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : ''; return `${message}${cause}`; } function maskBaseUrl(value) { return String(value || '').replace(/(token=)[^&]+/ig, '$1***'); } function pickParam(request, ...names) { const sources = [request.params, request.body, request]; for (const src of sources) { if (!src || typeof src !== 'object') continue; for (const name of names) { const value = src[name]; if (value !== undefined && value !== null && value !== '') return value; } } return null; } function clean(value) { return String(value || '').trim(); } function generateId() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let s = ''; for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length)); return s; } function readEnv(name) { if (typeof process !== 'undefined' && process.env && process.env[name]) { return process.env[name]; } return ''; } if (typeof module !== 'undefined' && module.exports && readEnv('NODE_ENV') === 'test') { module.exports = { __test: { findAwemeDetail, selectMediaCandidate, decodeMaybeBase64Url, durationFromDetail, transcriptionGatewayToken, transcriptTokenSource, readGatewayError, }, }; }