| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- const DOUYIN_API_ROOT = 'https://server.fmode.cn/api/voc-social/douyin';
- const DOUYIN_APP_BASE_URL = `${DOUYIN_API_ROOT}/app`;
- const DOUYIN_SEARCH_PATH = 'search/fetch_general_search_v2';
- const DOUYIN_VIDEO_COMMENTS_PATH = 'app/v3/fetch_video_comments';
- const DOUYIN_VIDEO_REPLIES_PATH = 'app/v3/fetch_video_comment_replies';
- function buildEndpointUrl(baseUrl, endpointPath) {
- return `${String(baseUrl || '').replace(/\/$/, '')}/${String(endpointPath || '').replace(/^\//, '')}`;
- }
- function buildUrl(baseUrl, path, query = {}) {
- const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
- Object.entries(query).forEach(([key, value]) => {
- if (value !== undefined && value !== null && value !== '') {
- url.searchParams.set(key, String(value));
- }
- });
- return url.toString();
- }
- function unwrapData(json) {
- if (!json || typeof json !== 'object') return {};
- return json.data?.data || json.data || json;
- }
- function pickBusinessStatus(json) {
- const topCode = Number(json?.code);
- if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
- return topCode;
- }
- const nestedCode = Number(json?.data?.status_code);
- if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
- return nestedCode;
- }
- return 0;
- }
- function redactSecret(value) {
- return String(value || '')
- .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
- .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
- .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
- .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
- .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
- }
- function isSuccessCode(json) {
- const topCode = Number(json?.code);
- if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
- return false;
- }
- const statusCode = Number(json?.data?.status_code);
- if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
- return false;
- }
- return true;
- }
- function classifyApiError({ response, json, message }) {
- const httpStatus = response?.status || 0;
- const businessStatus = pickBusinessStatus(json);
- const status = businessStatus || httpStatus;
- const text = String(message || '');
- if (/company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|请输入.*token|sessiontoken/i.test(text)) {
- return 'auth';
- }
- if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
- return 'auth';
- }
- if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
- return 'billing';
- }
- if (status === 403 && /余额不足|额度不足|没开通|未开通|insufficient|balance|quota|payment/i.test(text)) {
- return 'billing';
- }
- if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
- return 'request';
- }
- if (status >= 500 && /社交平台接口请求错误|request error|接口请求错误/i.test(text)) {
- return 'upstream_search';
- }
- if (status >= 500) {
- return 'upstream_search';
- }
- if (status === 403 || /permission|权限|无权限/i.test(text)) {
- return 'permission';
- }
- return 'upstream';
- }
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
- async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
- let lastError;
- for (let attempt = 0; attempt <= retries; attempt++) {
- let response;
- let text;
- try {
- response = await fetch(url, {
- method,
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: 'application/json',
- ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
- },
- body: body ? JSON.stringify(body) : undefined
- });
- text = await response.text();
- } catch (networkError) {
- lastError = networkError;
- if (attempt < retries) {
- await delay(400 * (attempt + 1));
- continue;
- }
- throw networkError;
- }
- let json;
- try {
- json = JSON.parse(text);
- } catch {
- json = undefined;
- }
- if (!response.ok || (json && !isSuccessCode(json))) {
- const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || text || `HTTP ${response.status}`;
- const error = new Error(redactSecret(message));
- error.httpStatus = pickBusinessStatus(json) || response.status || 500;
- error.kind = classifyApiError({ response, json, message });
- error.responseBody = redactSecret(text);
- if (error.httpStatus >= 500 && attempt < retries) {
- lastError = error;
- await delay(400 * (attempt + 1));
- continue;
- }
- throw error;
- }
- return { json, data: unwrapData(json), rawText: text };
- }
- throw lastError;
- }
- class DouyinApi {
- constructor(options = {}) {
- this.baseUrl = options.baseUrl || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL || DOUYIN_API_ROOT;
- this.token = options.token;
- }
- async searchVideos({
- keyword,
- cursor = 0,
- sortType = '0',
- publishTime = '0',
- filterDuration = '0',
- contentType = '0',
- searchId = '',
- backtrace = ''
- }) {
- const url = buildEndpointUrl(this.baseUrl, DOUYIN_SEARCH_PATH);
- return requestJson(url, this.token, {
- method: 'POST',
- body: {
- keyword,
- cursor,
- sort_type: sortType,
- publish_time: publishTime,
- filter_duration: filterDuration,
- content_type: contentType,
- search_id: searchId,
- backtrace
- }
- });
- }
- async getVideoComments({ awemeId, cursor = 0, count = 20 }) {
- const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_COMMENTS_PATH));
- url.searchParams.set('aweme_id', String(awemeId));
- url.searchParams.set('cursor', String(cursor));
- url.searchParams.set('count', String(count));
- return requestJson(url, this.token);
- }
- async getCommentReplies({ itemId, commentId, cursor = 0, count = 20 }) {
- const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_REPLIES_PATH));
- url.searchParams.set('item_id', String(itemId));
- url.searchParams.set('comment_id', String(commentId));
- url.searchParams.set('cursor', String(cursor));
- url.searchParams.set('count', String(count));
- return requestJson(url, this.token);
- }
- }
- module.exports = {
- DOUYIN_API_ROOT,
- DOUYIN_APP_BASE_URL,
- DouyinApi
- };
|