弹幕分片下载请求已加入重试机制,重试次数为1次

This commit is contained in:
huangxd- committed 2025-11-10 22:32:12 +08:00
1 parent 9f7b6334fd
commit b65d27ad9f
11 files changed
+248 -160

No files matched your search

+2 -1
View File
@@ -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)
+1 -1
View File
@@ -11,7 +11,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.7.3',
VERSION: '1.7.4',
MAX_LOGS: 500, // 日志存储,最多保存 500 行
MAX_ANIMES: 100,
+1
View File
@@ -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 拼接到总数组
+1
View File
@@ -638,6 +638,7 @@ export default class BilibiliSource extends BaseSource {
"Cookie": globals.bilibliCookie
},
base64Data: true,
retries: 1,
});
return parseDanmakuBase64(res.data);
+1
View File
@@ -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 拼接到总数组
+2 -1
View File
@@ -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,
})
);
}
+2
View File
@@ -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,
})
);
}
+5 -3
View File
@@ -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;
}
+1
View File
@@ -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,
})
);
}
+2 -1
View File
@@ -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 = [];
+230 -153
View File
@@ -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) {