12-douyinManager.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * 云函数:douyinManager
  3. * 代理抖音/TikHub 相关接口,避免前端暴露 TikHub 地址和 token。
  4. *
  5. * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 douyin。
  6. */
  7. const DOUYIN_API_BASE_URL = (readEnv('DOUYIN_API_BASE_URL') || readEnv('VOC_SOCIAL_BASE_URL') || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
  8. const IS_TIKHUB_DIRECT = /api\.tikhub\.io/i.test(DOUYIN_API_BASE_URL);
  9. const LOCAL_VOC_TOKEN_FALLBACK = 'r:33c57d404c8fffc9b19199a4da0bb663';
  10. 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;
  11. const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
  12. const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
  13. const ROUTES = {
  14. searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' },
  15. challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' },
  16. videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' },
  17. userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' },
  18. userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' },
  19. userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' },
  20. comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' },
  21. replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' },
  22. };
  23. async function handler(request, response) {
  24. try {
  25. const action = pickParam(request, 'action') || 'call';
  26. if (action === 'diagnose') {
  27. return diagnose(request, response);
  28. }
  29. if (action !== 'call') {
  30. return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
  31. }
  32. const routeName = String(pickParam(request, 'route') || '').trim();
  33. const route = ROUTES[routeName];
  34. if (!route) {
  35. return response.json({ code: 400, success: false, error: '不支持的抖音接口' });
  36. }
  37. const params = pickParam(request, 'params') || {};
  38. const body = pickParam(request, 'payload', 'data') || {};
  39. const data = route.method === 'GET'
  40. ? await requestJson('GET', route.path, { ...body, ...params })
  41. : await requestJson('POST', route.path, body);
  42. response.json(data);
  43. } catch (error) {
  44. console.error('douyinManager failed:', error && error.message ? error.message : error);
  45. response.json({ code: 500, success: false, error: error && error.message ? error.message : '抖音服务调用失败' });
  46. }
  47. }
  48. async function requestJson(method, path, payload) {
  49. if (!DOUYIN_API_TOKEN) {
  50. return {
  51. code: 400,
  52. success: false,
  53. error: '抖音数据网关未配置有效 token:FMode voc-social 请配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN;TikHub 直连才使用 TIKHUB_TOKEN。'
  54. };
  55. }
  56. const url = new URL(`${DOUYIN_API_BASE_URL}${normalizeDouyinPath(path)}`);
  57. const init = {
  58. method,
  59. headers: {
  60. 'Content-Type': 'application/json',
  61. 'Accept': 'application/json',
  62. 'Authorization': bearerAuth(DOUYIN_API_TOKEN),
  63. },
  64. };
  65. if (method === 'GET') {
  66. for (const [key, value] of Object.entries(payload || {})) {
  67. if (value !== undefined && value !== null && value !== '') {
  68. url.searchParams.set(key, String(value));
  69. }
  70. }
  71. } else {
  72. init.body = JSON.stringify(stripEmpty(payload || {}));
  73. }
  74. let r;
  75. try {
  76. r = await fetch(url.toString(), init);
  77. } catch (error) {
  78. return {
  79. code: 502,
  80. success: false,
  81. error: `抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=${path}`
  82. };
  83. }
  84. const text = await r.text();
  85. let data = null;
  86. try { data = text ? JSON.parse(text) : null; } catch {}
  87. if (!r.ok) {
  88. return { code: r.status, success: false, error: readError(data, text) || `抖音数据接口请求失败 HTTP ${r.status}` };
  89. }
  90. return data || { code: 500, success: false, error: '服务返回异常' };
  91. }
  92. async function diagnose(request, response) {
  93. const routeName = String(pickParam(request, 'route') || 'videoDetail').trim();
  94. const route = ROUTES[routeName] || ROUTES.videoDetail;
  95. const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim();
  96. const payload = routeName === 'videoDetail' ? { aweme_id: awemeId } : {};
  97. const result = {
  98. code: 200,
  99. success: true,
  100. data: {
  101. baseUrl: maskBaseUrl(DOUYIN_API_BASE_URL),
  102. isTikhubDirect: IS_TIKHUB_DIRECT,
  103. tokenConfigured: !!DOUYIN_API_TOKEN,
  104. tokenSource: tokenSource(),
  105. routeName,
  106. routePath: route.path,
  107. normalizedPath: normalizeDouyinPath(route.path),
  108. probe: null,
  109. }
  110. };
  111. if (String(pickParam(request, 'probe') || '') === '1') {
  112. result.data.probe = await requestJson(route.method, route.path, payload);
  113. }
  114. return response.json(result);
  115. }
  116. function normalizeDouyinPath(path) {
  117. if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) {
  118. return `/api/v1${path}`;
  119. }
  120. return path;
  121. }
  122. function pickParam(request, ...names) {
  123. const sources = [request.params, request.body, request];
  124. for (const src of sources) {
  125. if (!src || typeof src !== 'object') continue;
  126. for (const n of names) {
  127. const v = src[n];
  128. if (v !== undefined && v !== null && v !== '') return v;
  129. }
  130. }
  131. return null;
  132. }
  133. function stripEmpty(value) {
  134. if (!value || typeof value !== 'object') return value;
  135. const out = Array.isArray(value) ? [] : {};
  136. for (const [key, val] of Object.entries(value)) {
  137. if (val === undefined || val === null || val === '') continue;
  138. if (val && typeof val === 'object' && !Array.isArray(val)) {
  139. const nested = stripEmpty(val);
  140. if (Object.keys(nested).length) out[key] = nested;
  141. } else {
  142. out[key] = val;
  143. }
  144. }
  145. return out;
  146. }
  147. function readError(data, fallback) {
  148. const detail = data?.detail;
  149. if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
  150. const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || fallback || '';
  151. if (/company或用户信息不存在/.test(String(message))) {
  152. return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
  153. }
  154. return message;
  155. }
  156. function bearerAuth(token) {
  157. const value = String(token || '').trim();
  158. return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
  159. }
  160. function tokenSource() {
  161. if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
  162. if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
  163. if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
  164. if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
  165. if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
  166. if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
  167. if (IS_TIKHUB_DIRECT && readEnv('TIKHUB_TOKEN')) return 'TIKHUB_TOKEN';
  168. if (!IS_TIKHUB_DIRECT && LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
  169. return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
  170. }
  171. function formatFetchError(error) {
  172. const message = error && error.message ? error.message : String(error || 'fetch failed');
  173. const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
  174. return `${message}${cause}`;
  175. }
  176. function maskBaseUrl(value) {
  177. return String(value || '').replace(/(token=)[^&]+/ig, '$1***');
  178. }
  179. function readEnv(name) {
  180. if (typeof process !== 'undefined' && process.env && process.env[name]) {
  181. return process.env[name];
  182. }
  183. return '';
  184. }