| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812 |
- /**
- * 云函数: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,
- },
- };
- }
|