/** * 云函数:douyinManager * 代理抖音/TikHub 相关接口,避免前端暴露 TikHub 地址和 token。 * * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 douyin。 */ 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 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' }, }; async function handler(request, response) { try { const action = pickParam(request, 'action') || 'call'; if (action === 'diagnose') { return diagnose(request, response); } if (action !== 'call') { return response.json({ code: 400, success: false, error: `未知 action: ${action}` }); } const routeName = String(pickParam(request, 'route') || '').trim(); const route = ROUTES[routeName]; if (!route) { return response.json({ code: 400, success: false, error: '不支持的抖音接口' }); } const params = pickParam(request, 'params') || {}; const body = pickParam(request, 'payload', 'data') || {}; const data = route.method === 'GET' ? await requestJson('GET', route.path, { ...body, ...params }) : await requestJson('POST', route.path, body); response.json(data); } catch (error) { console.error('douyinManager failed:', error && error.message ? error.message : error); response.json({ code: 500, success: false, error: error && error.message ? error.message : '抖音服务调用失败' }); } } async function requestJson(method, path, payload) { if (!DOUYIN_API_TOKEN) { return { code: 400, success: false, 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(path)}`); const init = { method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': bearerAuth(DOUYIN_API_TOKEN), }, }; if (method === 'GET') { for (const [key, value] of Object.entries(payload || {})) { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, String(value)); } } } else { init.body = JSON.stringify(stripEmpty(payload || {})); } let r; try { r = await fetch(url.toString(), init); } catch (error) { return { code: 502, success: false, error: `抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=${path}` }; } const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch {} if (!r.ok) { return { code: r.status, success: false, error: readError(data, text) || `抖音数据接口请求失败 HTTP ${r.status}` }; } return data || { code: 500, success: false, error: '服务返回异常' }; } async function diagnose(request, response) { const routeName = String(pickParam(request, 'route') || 'videoDetail').trim(); const route = ROUTES[routeName] || ROUTES.videoDetail; const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim(); const payload = routeName === 'videoDetail' ? { aweme_id: awemeId } : {}; const result = { code: 200, success: true, data: { baseUrl: maskBaseUrl(DOUYIN_API_BASE_URL), isTikhubDirect: IS_TIKHUB_DIRECT, tokenConfigured: !!DOUYIN_API_TOKEN, tokenSource: tokenSource(), routeName, routePath: route.path, normalizedPath: normalizeDouyinPath(route.path), probe: null, } }; if (String(pickParam(request, 'probe') || '') === '1') { result.data.probe = await requestJson(route.method, route.path, payload); } return response.json(result); } function normalizeDouyinPath(path) { if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) { return `/api/v1${path}`; } return path; } function pickParam(request, ...names) { const sources = [request.params, request.body, request]; for (const src of sources) { if (!src || typeof src !== 'object') continue; for (const n of names) { const v = src[n]; if (v !== undefined && v !== null && v !== '') return v; } } return null; } 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 readError(data, fallback) { const detail = data?.detail; if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social'; const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || fallback || ''; if (/company或用户信息不存在/.test(String(message))) { return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。'; } return message; } function bearerAuth(token) { const value = String(token || '').trim(); return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`; } function tokenSource() { 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 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 readEnv(name) { if (typeof process !== 'undefined' && process.env && process.env[name]) { return process.env[name]; } return ''; }