From b65d27ad9f69bafefec79c1e3cd70fb07d2f55ef Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Mon, 10 Nov 2025 22:32:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B9=E5=B9=95=E5=88=86=E7=89=87=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E8=AF=B7=E6=B1=82=E5=B7=B2=E5=8A=A0=E5=85=A5=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=9C=BA=E5=88=B6=EF=BC=8C=E9=87=8D=E8=AF=95=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=BA1=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- danmu_api/configs/globals.js | 2 +- danmu_api/sources/bahamut.js | 1 + danmu_api/sources/bilibili.js | 1 + danmu_api/sources/hanjutv.js | 1 + danmu_api/sources/iqiyi.js | 3 +- danmu_api/sources/mango.js | 2 + danmu_api/sources/renren.js | 8 +- danmu_api/sources/tencent.js | 1 + danmu_api/sources/youku.js | 3 +- danmu_api/utils/http-util.js | 383 ++++++++++++++++++++-------------- 11 files changed, 248 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 26c3974..e6c5743 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ LogVar 弹幕 API 服务器 > cf部署可能不稳定,推荐用vercel/netlify部署。 ## API食用指南 -支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud 等支持弹幕API的播放器。 +支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna 等支持弹幕API的播放器。 以`senplayer`为例: 1. 获取到部署之后的API地址,如 `http://192.168.1.7:9321/87654321` ,其中`87654321`是默认token(默认为87654321的情况下也可以不带token),如果有自定义环境变量TOKEN,请替换成相应的token @@ -490,6 +490,7 @@ danmu_api/ - /api/v2/comment接口默认限流:1分钟内同一IP只能请求3次,可通过环境变量`RATE_LIMIT_MAX_REQUESTS`调整(设置为0表示不限流)。 - TMDB源请求逻辑:search tmdb -> tmdbId -> imdbId -> doubanId -> playUrl;优点:emby通过tmdb刮削,标题通过tmdb搜索,返回的信息可能更加匹配;缺点:链条过长,请求时长5-10s左右,中间一环数据有缺失,就没有返回结果。 - TMDB源在SOURCE_ORDER添加tmdb的同时,需要添加TMDB_API_KEY环境变量 +- 弹幕分片下载请求已加入重试机制,重试次数为1次 ### 关联项目 [喂饭教程1:danmu_api vercel 自动同步部署方案 - 永远保持最新版本!实时同步原作者更新](https://github.com/xiaoyao20084321/log-var-danmu-deployment-guide) diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index d94a538..505a3a6 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -11,7 +11,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.7.3', + VERSION: '1.7.4', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index 2a3d8f3..b261769 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -360,6 +360,7 @@ export default class BahamutSource extends BaseSource { "Content-Type": "application/json", "User-Agent": "Anime/2.29.2 (7N5749MM3F.tw.com.gamer.anime; build:972; iOS 26.0.0) Alamofire/5.6.4", }, + retries: 1, }); // 将当前请求的 episodes 拼接到总数组 diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index d96213d..0fa48dc 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -638,6 +638,7 @@ export default class BilibiliSource extends BaseSource { "Cookie": globals.bilibliCookie }, base64Data: true, + retries: 1, }); return parseDanmakuBase64(res.data); diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index c99d0b0..855b26d 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -201,6 +201,7 @@ export default class HanjutvSource extends BaseSource { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, + retries: 1, }); // 将当前请求的 episodes 拼接到总数组 diff --git a/danmu_api/sources/iqiyi.js b/danmu_api/sources/iqiyi.js index 5b9dfb7..081d158 100644 --- a/danmu_api/sources/iqiyi.js +++ b/danmu_api/sources/iqiyi.js @@ -769,7 +769,8 @@ export default class IqiyiSource extends BaseSource { "Content-Type": "application/xml", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, - zlibMode: true + zlibMode: true, + retries: 1, }) ); } diff --git a/danmu_api/sources/mango.js b/danmu_api/sources/mango.js index 1666972..2a19dd7 100644 --- a/danmu_api/sources/mango.js +++ b/danmu_api/sources/mango.js @@ -577,6 +577,7 @@ export default class MangoSource extends BaseSource { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, + retries: 1, }) ); } @@ -600,6 +601,7 @@ export default class MangoSource extends BaseSource { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, + retries: 1, }) ); } diff --git a/danmu_api/sources/renren.js b/danmu_api/sources/renren.js index 63ecf4c..9bb0c91 100644 --- a/danmu_api/sources/renren.js +++ b/danmu_api/sources/renren.js @@ -146,7 +146,8 @@ export default class RenrenSource extends BaseSource { headers['Host'] = this.API_CONFIG.DANMU_HOST; const resp = await httpGet(`https://${this.API_CONFIG.DANMU_HOST}${path}`, { - headers: headers + headers: headers, + retries: 1, }); if (!resp.data) return null; @@ -217,7 +218,8 @@ export default class RenrenSource extends BaseSource { async renrenHttpGet(url, { params = {}, headers = {} } = {}) { const u = updateQueryString(url, params) const resp = await httpGet(u, { - headers: headers, + headers: headers, + retries: 1, }); return resp; } @@ -230,7 +232,7 @@ export default class RenrenSource extends BaseSource { const deviceId = this.generateDeviceId(); const headers = this.buildSignedHeaders({ method, url, params, deviceId }); const resp = await httpGet(url + "?" + sortedQueryString(params), { - headers: headers, + headers: headers, }); return resp; } diff --git a/danmu_api/sources/tencent.js b/danmu_api/sources/tencent.js index 2a287a5..5b56334 100644 --- a/danmu_api/sources/tencent.js +++ b/danmu_api/sources/tencent.js @@ -469,6 +469,7 @@ export default class TencentSource extends BaseSource { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", }, + retries: 1, }) ); } diff --git a/danmu_api/sources/youku.js b/danmu_api/sources/youku.js index f2f05b3..0631b0a 100644 --- a/danmu_api/sources/youku.js +++ b/danmu_api/sources/youku.js @@ -584,7 +584,8 @@ export default class YoukuSource extends BaseSource { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36", }, - allow_redirects: false + allow_redirects: false, + retries: 1, }); const results = []; diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index 23a5fda..873a5ae 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -6,181 +6,258 @@ import { log } from './log-util.js' // ===================== export async function httpGet(url, options = {}) { + // 从 options 中获取重试次数,默认为 0 + const maxRetries = parseInt(options.retries || '0', 10) || 0; + let lastError; - log("info", `[请求模拟] HTTP GET: ${url}`); - - // 设置超时时间(默认5秒) - const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - method: 'GET', - headers: { - ...options.headers, - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - let data; - - if (options.base64Data) { - log("info", "base64模式"); - - // 先拿二进制 - const arrayBuffer = await response.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - - // 转换为 Base64 - let binary = ''; - const chunkSize = 0x8000; // 分块防止大文件卡死 - for (let i = 0; i < uint8Array.length; i += chunkSize) { - let chunk = uint8Array.subarray(i, i + chunkSize); - binary += String.fromCharCode.apply(null, chunk); - } - data = btoa(binary); // 得到 base64 字符串 - - } else if (options.zlibMode) { - log("info", "zlib模式") - - // 获取 ArrayBuffer - const arrayBuffer = await response.arrayBuffer(); - - // 使用 DecompressionStream 进行解压 - // "deflate" 对应 zlib 的 inflate - const decompressionStream = new DecompressionStream("deflate"); - const decompressedStream = new Response( - new Blob([arrayBuffer]).stream().pipeThrough(decompressionStream) - ); - - // 读取解压后的文本 - let decodedData; - try { - decodedData = await decompressedStream.text(); - } catch (e) { - log("error", "[请求模拟] 解压缩失败", e); - throw e; - } - - data = decodedData; // 更新解压后的数据 + // 执行请求,包含重试逻辑 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + log("info", `[请求模拟] 第 ${attempt} 次重试: ${url}`); + // 可选:添加重试延迟(指数退避) + await new Promise(resolve => setTimeout(resolve, Math.min(1000 * Math.pow(2, attempt - 1), 5000))); } else { - data = await response.text(); + log("info", `[请求模拟] HTTP GET: ${url}`); } - let parsedData; + // 设置超时时间(默认5秒) + const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { - parsedData = JSON.parse(data); // 尝试将文本解析为 JSON - } catch (e) { - parsedData = data; // 如果解析失败,保留原始文本 - } + const response = await fetch(url, { + method: 'GET', + headers: { + ...options.headers, + }, + signal: controller.signal + }); - // 获取所有 headers,但特别处理 set-cookie - const headers = {}; - let setCookieValues = []; + clearTimeout(timeoutId); - // 遍历 headers 条目 - for (const [key, value] of response.headers.entries()) { - if (key.toLowerCase() === 'set-cookie') { - setCookieValues.push(value); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + let data; + + if (options.base64Data) { + log("info", "base64模式"); + + // 先拿二进制 + const arrayBuffer = await response.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + // 转换为 Base64 + let binary = ''; + const chunkSize = 0x8000; // 分块防止大文件卡死 + for (let i = 0; i < uint8Array.length; i += chunkSize) { + let chunk = uint8Array.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk); + } + data = btoa(binary); // 得到 base64 字符串 + + } else if (options.zlibMode) { + log("info", "zlib模式") + + // 获取 ArrayBuffer + const arrayBuffer = await response.arrayBuffer(); + + // 使用 DecompressionStream 进行解压 + // "deflate" 对应 zlib 的 inflate + const decompressionStream = new DecompressionStream("deflate"); + const decompressedStream = new Response( + new Blob([arrayBuffer]).stream().pipeThrough(decompressionStream) + ); + + // 读取解压后的文本 + let decodedData; + try { + decodedData = await decompressedStream.text(); + } catch (e) { + log("error", "[请求模拟] 解压缩失败", e); + throw e; + } + + data = decodedData; // 更新解压后的数据 } else { - headers[key] = value; + data = await response.text(); + } + + let parsedData; + try { + parsedData = JSON.parse(data); // 尝试将文本解析为 JSON + } catch (e) { + parsedData = data; // 如果解析失败,保留原始文本 + } + + // 获取所有 headers,但特别处理 set-cookie + const headers = {}; + let setCookieValues = []; + + // 遍历 headers 条目 + for (const [key, value] of response.headers.entries()) { + if (key.toLowerCase() === 'set-cookie') { + setCookieValues.push(value); + } else { + headers[key] = value; + } + } + + // 如果存在 set-cookie 头,将其合并为分号分隔的字符串 + if (setCookieValues.length > 0) { + headers['set-cookie'] = setCookieValues.join(';'); + } + + // 请求成功,返回结果 + if (attempt > 0) { + log("info", `[请求模拟] 重试成功`); + } + + // 模拟 iOS 环境:返回 { data: ... } 结构 + return { + data: parsedData, + status: response.status, + headers: headers + }; + + } catch (error) { + clearTimeout(timeoutId); + lastError = error; + + // 检查是否是超时错误 + if (error.name === 'AbortError') { + log("error", `[请求模拟] 请求超时:`, error.message); + log("error", '详细诊断:'); + log("error", '- URL:', url); + log("error", '- 超时时间:', `${timeout}ms`); + log("error", `- 当前尝试: ${attempt + 1}/${maxRetries + 1}`); + } else { + log("error", `[请求模拟] 请求失败:`, error.message); + log("error", '详细诊断:'); + log("error", '- URL:', url); + log("error", '- 错误类型:', error.name); + log("error", '- 消息:', error.message); + log("error", `- 当前尝试: ${attempt + 1}/${maxRetries + 1}`); + if (error.cause) { + log("error", '- 码:', error.cause.code); + log("error", '- 原因:', error.cause.message); + } + } + + // 如果还有重试机会,继续循环;否则在循环结束后抛出错误 + if (attempt < maxRetries) { + log("info", `[请求模拟] 准备重试...`); + continue; } } - - // 如果存在 set-cookie 头,将其合并为分号分隔的字符串 - if (setCookieValues.length > 0) { - headers['set-cookie'] = setCookieValues.join(';'); - } - // 模拟 iOS 环境:返回 { data: ... } 结构 - return { - data: parsedData, - status: response.status, - headers: headers - }; - - } catch (error) { - clearTimeout(timeoutId); - - // 检查是否是超时错误 - if (error.name === 'AbortError') { - log("error", `[请求模拟] 请求超时:`, error.message); - log("error", '详细诊断:'); - log("error", '- URL:', url); - log("error", '- 超时时间:', `${timeout}ms`); - throw new Error(`Request timeout after ${timeout}ms`); - } - - log("error", `[请求模拟] 请求失败:`, error.message); - log("error", '详细诊断:'); - log("error", '- URL:', url); - log("error", '- 错误类型:', error.name); - log("error", '- 消息:', error.message); - if (error.cause) { - log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' - log("error", '- 原因:', error.cause.message); - } - throw error; } + + // 所有重试都失败,抛出最后一个错误 + log("error", `[请求模拟] 所有重试均失败 (${maxRetries + 1} 次尝试)`); + throw lastError; } export async function httpPost(url, body, options = {}) { - log("info", `[请求模拟] HTTP POST: ${url}`); + // 从 options 中获取重试次数,默认为 0 + const maxRetries = parseInt(options.retries || '0', 10) || 0; + let lastError; - // 处理请求头、body 和其他参数 - const { headers = {}, params, allow_redirects = true } = options; - const fetchOptions = { - method: 'POST', - headers: { - ...headers, - }, - body: body - }; - - if (!allow_redirects) { - fetchOptions.redirect = 'manual'; // 禁止重定向 - } - - try { - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + // 执行请求,包含重试逻辑 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + log("info", `[请求模拟] 第 ${attempt} 次重试: ${url}`); + // 可选:添加重试延迟(指数退避) + await new Promise(resolve => setTimeout(resolve, Math.min(1000 * Math.pow(2, attempt - 1), 5000))); + } else { + log("info", `[请求模拟] HTTP POST: ${url}`); } - const data = await response.text(); - let parsedData; - try { - parsedData = JSON.parse(data); // 尝试将文本解析为 JSON - } catch (e) { - parsedData = data; // 如果解析失败,保留原始文本 - } + // 设置超时时间(默认5秒) + const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); - // 模拟 iOS 环境:返回 { data: ... } 结构 - return { - data: parsedData, - status: response.status, - headers: Object.fromEntries(response.headers.entries()) + // 处理请求头、body 和其他参数 + const { headers = {}, params, allow_redirects = true } = options; + const fetchOptions = { + method: 'POST', + headers: { + ...headers, + }, + body: body, + signal: controller.signal }; - } catch (error) { - log("error", `[请求模拟] 请求失败:`, error.message); - log("error", '详细诊断:'); - log("error", '- URL:', url); - log("error", '- 错误类型:', error.name); - log("error", '- 消息:', error.message); - if (error.cause) { - log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET' - log("error", '- 原因:', error.cause.message); + if (!allow_redirects) { + fetchOptions.redirect = 'manual'; // 禁止重定向 + } + + try { + const response = await fetch(url, fetchOptions); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.text(); + let parsedData; + try { + parsedData = JSON.parse(data); // 尝试将文本解析为 JSON + } catch (e) { + parsedData = data; // 如果解析失败,保留原始文本 + } + + // 请求成功,返回结果 + if (attempt > 0) { + log("info", `[请求模拟] 重试成功`); + } + + // 模拟 iOS 环境:返回 { data: ... } 结构 + return { + data: parsedData, + status: response.status, + headers: Object.fromEntries(response.headers.entries()) + }; + + } catch (error) { + clearTimeout(timeoutId); + lastError = error; + + // 检查是否是超时错误 + if (error.name === 'AbortError') { + log("error", `[请求模拟] 请求超时:`, error.message); + log("error", '详细诊断:'); + log("error", '- URL:', url); + log("error", '- 超时时间:', `${timeout}ms`); + log("error", `- 当前尝试: ${attempt + 1}/${maxRetries + 1}`); + } else { + log("error", `[请求模拟] 请求失败:`, error.message); + log("error", '详细诊断:'); + log("error", '- URL:', url); + log("error", '- 错误类型:', error.name); + log("error", '- 消息:', error.message); + log("error", `- 当前尝试: ${attempt + 1}/${maxRetries + 1}`); + if (error.cause) { + log("error", '- 码:', error.cause.code); + log("error", '- 原因:', error.cause.message); + } + } + + // 如果还有重试机会,继续循环;否则在循环结束后抛出错误 + if (attempt < maxRetries) { + log("info", `[请求模拟] 准备重试...`); + continue; + } } - throw error; } + + // 所有重试都失败,抛出最后一个错误 + log("error", `[请求模拟] 所有重试均失败 (${maxRetries + 1} 次尝试)`); + throw lastError; } export async function getPageTitle(url) {