douyin-api.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. const DOUYIN_API_ROOT = 'https://server.fmode.cn/api/voc-social/douyin';
  2. const DOUYIN_APP_BASE_URL = `${DOUYIN_API_ROOT}/app`;
  3. const DOUYIN_SEARCH_PATH = 'search/fetch_general_search_v2';
  4. const DOUYIN_VIDEO_COMMENTS_PATH = 'app/v3/fetch_video_comments';
  5. const DOUYIN_VIDEO_REPLIES_PATH = 'app/v3/fetch_video_comment_replies';
  6. function buildEndpointUrl(baseUrl, endpointPath) {
  7. return `${String(baseUrl || '').replace(/\/$/, '')}/${String(endpointPath || '').replace(/^\//, '')}`;
  8. }
  9. function buildUrl(baseUrl, path, query = {}) {
  10. const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
  11. Object.entries(query).forEach(([key, value]) => {
  12. if (value !== undefined && value !== null && value !== '') {
  13. url.searchParams.set(key, String(value));
  14. }
  15. });
  16. return url.toString();
  17. }
  18. function unwrapData(json) {
  19. if (!json || typeof json !== 'object') return {};
  20. return json.data?.data || json.data || json;
  21. }
  22. function pickBusinessStatus(json) {
  23. const topCode = Number(json?.code);
  24. if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
  25. return topCode;
  26. }
  27. const nestedCode = Number(json?.data?.status_code);
  28. if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
  29. return nestedCode;
  30. }
  31. return 0;
  32. }
  33. function redactSecret(value) {
  34. return String(value || '')
  35. .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
  36. .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
  37. .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
  38. .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
  39. .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
  40. }
  41. function isSuccessCode(json) {
  42. const topCode = Number(json?.code);
  43. if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
  44. return false;
  45. }
  46. const statusCode = Number(json?.data?.status_code);
  47. if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
  48. return false;
  49. }
  50. return true;
  51. }
  52. function classifyApiError({ response, json, message }) {
  53. const httpStatus = response?.status || 0;
  54. const businessStatus = pickBusinessStatus(json);
  55. const status = businessStatus || httpStatus;
  56. const text = String(message || '');
  57. if (/company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|请输入.*token|sessiontoken/i.test(text)) {
  58. return 'auth';
  59. }
  60. if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
  61. return 'auth';
  62. }
  63. if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
  64. return 'billing';
  65. }
  66. if (status === 403 && /余额不足|额度不足|没开通|未开通|insufficient|balance|quota|payment/i.test(text)) {
  67. return 'billing';
  68. }
  69. if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
  70. return 'request';
  71. }
  72. if (status >= 500 && /社交平台接口请求错误|request error|接口请求错误/i.test(text)) {
  73. return 'upstream_search';
  74. }
  75. if (status >= 500) {
  76. return 'upstream_search';
  77. }
  78. if (status === 403 || /permission|权限|无权限/i.test(text)) {
  79. return 'permission';
  80. }
  81. return 'upstream';
  82. }
  83. const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
  84. async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
  85. let lastError;
  86. for (let attempt = 0; attempt <= retries; attempt++) {
  87. let response;
  88. let text;
  89. try {
  90. response = await fetch(url, {
  91. method,
  92. headers: {
  93. Authorization: `Bearer ${token}`,
  94. Accept: 'application/json',
  95. ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
  96. },
  97. body: body ? JSON.stringify(body) : undefined
  98. });
  99. text = await response.text();
  100. } catch (networkError) {
  101. lastError = networkError;
  102. if (attempt < retries) {
  103. await delay(400 * (attempt + 1));
  104. continue;
  105. }
  106. throw networkError;
  107. }
  108. let json;
  109. try {
  110. json = JSON.parse(text);
  111. } catch {
  112. json = undefined;
  113. }
  114. if (!response.ok || (json && !isSuccessCode(json))) {
  115. const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || text || `HTTP ${response.status}`;
  116. const error = new Error(redactSecret(message));
  117. error.httpStatus = pickBusinessStatus(json) || response.status || 500;
  118. error.kind = classifyApiError({ response, json, message });
  119. error.responseBody = redactSecret(text);
  120. if (error.httpStatus >= 500 && attempt < retries) {
  121. lastError = error;
  122. await delay(400 * (attempt + 1));
  123. continue;
  124. }
  125. throw error;
  126. }
  127. return { json, data: unwrapData(json), rawText: text };
  128. }
  129. throw lastError;
  130. }
  131. class DouyinApi {
  132. constructor(options = {}) {
  133. this.baseUrl = options.baseUrl || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL || DOUYIN_API_ROOT;
  134. this.token = options.token;
  135. }
  136. async searchVideos({
  137. keyword,
  138. cursor = 0,
  139. sortType = '0',
  140. publishTime = '0',
  141. filterDuration = '0',
  142. contentType = '0',
  143. searchId = '',
  144. backtrace = ''
  145. }) {
  146. const url = buildEndpointUrl(this.baseUrl, DOUYIN_SEARCH_PATH);
  147. return requestJson(url, this.token, {
  148. method: 'POST',
  149. body: {
  150. keyword,
  151. cursor,
  152. sort_type: sortType,
  153. publish_time: publishTime,
  154. filter_duration: filterDuration,
  155. content_type: contentType,
  156. search_id: searchId,
  157. backtrace
  158. }
  159. });
  160. }
  161. async getVideoComments({ awemeId, cursor = 0, count = 20 }) {
  162. const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_COMMENTS_PATH));
  163. url.searchParams.set('aweme_id', String(awemeId));
  164. url.searchParams.set('cursor', String(cursor));
  165. url.searchParams.set('count', String(count));
  166. return requestJson(url, this.token);
  167. }
  168. async getCommentReplies({ itemId, commentId, cursor = 0, count = 20 }) {
  169. const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_REPLIES_PATH));
  170. url.searchParams.set('item_id', String(itemId));
  171. url.searchParams.set('comment_id', String(commentId));
  172. url.searchParams.set('cursor', String(cursor));
  173. url.searchParams.set('count', String(count));
  174. return requestJson(url, this.token);
  175. }
  176. }
  177. module.exports = {
  178. DOUYIN_API_ROOT,
  179. DOUYIN_APP_BASE_URL,
  180. DouyinApi
  181. };