feat: 支持可配置的API限流功能并修复内存泄漏 (#50)
* feat: 支持可配置的API限流功能并修复内存泄漏 1. 新增限流配置 - 添加 RATE_LIMIT_MAX_REQUESTS 环境变量 - 默认值为 3(保持向后兼容) - 设置为 0 可关闭限流 2. Bug 修复 - 修复 getComment 中 title 为 null 时的崩溃 3. 性能优化 - 优化 requestHistory Map 内存管理 - 自动清理过期的 IP 记录,避免内存泄漏 4. 文档更新 - 更新 .env.example 和 README.md * feat: 添加腾讯视频源支持并优化ID处理 - 新增腾讯视频搜索、过滤、分集获取功能 - 修复ID哈希冲突问题,改用字符串ID - 统一所有源的ID比较逻辑(使用.toString()) - 更新默认源顺序,添加tencent源 * 恢复默认值
This commit is contained in:
1 parent
18b78696cc
commit
8088045edb
3 files changed
+465
-47
No files matched your search
+10
-2
@@ -34,8 +34,9 @@ YOUKU_CONCURRENCY=8
|
||||
|
||||
# ==================== 源排序配置 ====================
|
||||
|
||||
# 数据源查询顺序(可选值:360, vod, renren, hanjutv, bahamut)
|
||||
# 默认值:360,vod,renren,hanjutv
|
||||
# 数据源查询顺序(可选值:360, vod, tencent, renren, hanjutv, bahamut)
|
||||
# 默认值:360,vod,renren,hanjutv(不包含 tencent)
|
||||
# 如需使用腾讯源,请手动添加:SOURCE_ORDER=360,vod,tencent,renren,hanjutv
|
||||
# 注意:bahamut 仅在 Cloudflare 和 Vercel 平台自动启用
|
||||
SOURCE_ORDER=360,vod,renren,hanjutv
|
||||
|
||||
@@ -65,6 +66,13 @@ SOURCE_ORDER=360,vod,renren,hanjutv
|
||||
# 默认值:1
|
||||
GROUP_MINUTE=1
|
||||
|
||||
# ==================== 限流配置 ====================
|
||||
|
||||
# API 限流:1分钟内同一IP最大请求次数
|
||||
# 默认值:3(1分钟内最多3次请求)
|
||||
# 设置为 0 表示不限流
|
||||
RATE_LIMIT_MAX_REQUESTS=3
|
||||
|
||||
# ==================== 代理配置 ====================
|
||||
|
||||
# 代理地址(用于访问受限资源)
|
||||
|
||||
@@ -229,12 +229,13 @@ LogVar 弹幕 API 服务器
|
||||
| VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`,不填默认为`vod@https://www.caiji.cyou` |
|
||||
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 |
|
||||
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
|
||||
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`vod,360,renren,hanjutv`,表示vod数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `vod,vod2,360,renren,hanjutv,bahamut` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut` |
|
||||
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tencent,renren,hanjutv,bahamut` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, tencent, renren, hanjutv, bahamut` |
|
||||
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
|
||||
| BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 |
|
||||
| GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 |
|
||||
| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/cf中理应都自然能联通,不用填写 |
|
||||
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
|
||||
| UPSTASH_REDIS_REST_URL | 【可选】update redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
|
||||
| UPSTASH_REDIS_REST_TOKEN | 【可选】update redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
|
||||
|
||||
@@ -304,7 +305,7 @@ danmu_api/
|
||||
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
|
||||
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`(支持多个服务器并发查询)。
|
||||
- 推荐vercel部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。
|
||||
- /api/v2/comment接口1分钟内同一IP只能请求三次。
|
||||
- /api/v2/comment接口默认限流:1分钟内同一IP只能请求3次,可通过环境变量`RATE_LIMIT_MAX_REQUESTS`调整(设置为0表示不限流)。
|
||||
|
||||
### 关联项目
|
||||
[喂饭教程1:danmu_api vercel 自动同步部署方案 - 永远保持最新版本!实时同步原作者更新](https://github.com/xiaoyao20084321/log-var-danmu-deployment-guide)
|
||||
|
||||
+451
-42
@@ -1,6 +1,6 @@
|
||||
// 全局状态(Cloudflare 和 Vercel 都可能重用实例)
|
||||
// ⚠️ 不是持久化存储,每次冷启动会丢失
|
||||
const VERSION = "1.3.2";
|
||||
const VERSION = "1.3.3";
|
||||
let animes = [];
|
||||
let episodeIds = [];
|
||||
let episodeNum = 10001; // 全局变量,用于自增 ID
|
||||
@@ -104,7 +104,7 @@ function resolveYoukuConcurrency(env) {
|
||||
return Math.min(DEFAULT_YOUKU_CONCURRENCY, 16);
|
||||
}
|
||||
|
||||
const DEFAULT_SOURCE_ORDER = "360,vod,renren,hanjutv"; // 默认 源排序
|
||||
const DEFAULT_SOURCE_ORDER = "360,vod,renren,hanjutv"; // 默认 源排序(不包含tencent,需要时请通过SOURCE_ORDER环境变量添加)
|
||||
let sourceOrderArr = [];
|
||||
|
||||
function resolveSourceOrder(env, deployPlatform) {
|
||||
@@ -121,7 +121,7 @@ function resolveSourceOrder(env, deployPlatform) {
|
||||
}
|
||||
|
||||
// 解析并校验 sourceOrder(移除 vod2,因为已合并到 vod)
|
||||
const allowedSources = ['360', 'vod', 'renren', "hanjutv", "bahamut"];
|
||||
const allowedSources = ['360', 'vod', "tencent", 'renren', "hanjutv", "bahamut"];
|
||||
|
||||
// 转换为数组并去除空格,过滤无效项
|
||||
const orderArr = sourceOrder
|
||||
@@ -267,6 +267,22 @@ function resolveRedisToken(env) {
|
||||
return DEFAULT_UPSTASH_REDIS_REST_TOKEN;
|
||||
}
|
||||
|
||||
// 限流配置:时间窗口内最大请求次数(默认 3,0表示不限流)
|
||||
const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 3;
|
||||
let rateLimitMaxRequests = DEFAULT_RATE_LIMIT_MAX_REQUESTS;
|
||||
|
||||
function resolveRateLimitMaxRequests(env) {
|
||||
if (env && env.RATE_LIMIT_MAX_REQUESTS) {
|
||||
const n = parseInt(env.RATE_LIMIT_MAX_REQUESTS, 10);
|
||||
if (!Number.isNaN(n) && n >= 0) return n;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env?.RATE_LIMIT_MAX_REQUESTS) {
|
||||
const n = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10);
|
||||
if (!Number.isNaN(n) && n >= 0) return n;
|
||||
}
|
||||
return DEFAULT_RATE_LIMIT_MAX_REQUESTS;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 数据结构处理函数
|
||||
// =====================
|
||||
@@ -1529,6 +1545,319 @@ function encryptStr(str) {
|
||||
return '*'.repeat(str.length);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 腾讯视频搜索和分集
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 搜索腾讯视频
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @returns {Promise<Array>} 搜索结果数组
|
||||
*/
|
||||
async function tencentSearch(keyword) {
|
||||
try {
|
||||
log("info", `[Tencent] 开始搜索: ${keyword}`);
|
||||
|
||||
const searchUrl = "https://pbaccess.video.qq.com/trpc.videosearch.mobile_search.MultiTerminalSearch/MbSearch?vplatform=2";
|
||||
const payload = {
|
||||
version: "25071701",
|
||||
clientType: 1,
|
||||
filterValue: "",
|
||||
uuid: "0379274D-05A0-4EB6-A89C-878C9A460426",
|
||||
query: keyword,
|
||||
retry: 0,
|
||||
pagenum: 0,
|
||||
isPrefetch: true,
|
||||
pagesize: 30,
|
||||
queryFrom: 0,
|
||||
searchDatakey: "",
|
||||
transInfo: "",
|
||||
isneedQc: true,
|
||||
preQid: "",
|
||||
adClientInfo: "",
|
||||
extraInfo: {
|
||||
multi_terminal_pc: "1",
|
||||
themeType: "1",
|
||||
sugRelatedIds: "{}",
|
||||
appVersion: ""
|
||||
}
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://v.qq.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Referer': `https://v.qq.com/x/search/?q=${encodeURIComponent(keyword)}&stag=&smartbox_ab=`,
|
||||
'H38': '220496a1fb1498325e9be6d938',
|
||||
'H42': '335a00a80ab9bbbef56793d8e7a97e87b9341dee34ebd83d61afc0cdb303214caaece3',
|
||||
'Uk': '8e91af25d3af99d0f0640327e7307666',
|
||||
'Cookie': 'tvfe_boss_uuid=ee8f05103d59226f; pgv_pvid=3155633511; video_platform=2; ptag=v_qq_com; main_login=qq'
|
||||
};
|
||||
|
||||
const response = await httpPost(searchUrl, JSON.stringify(payload), { headers });
|
||||
|
||||
if (!response || !response.data) {
|
||||
log("info", "[Tencent] 搜索响应为空");
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
|
||||
|
||||
if (data.ret !== 0) {
|
||||
log("error", `[Tencent] API返回错误: ${data.msg} (ret: ${data.ret})`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let itemList = [];
|
||||
|
||||
// 优先从 MainNeed box 获取结果
|
||||
if (data.data && data.data.areaBoxList) {
|
||||
for (const box of data.data.areaBoxList) {
|
||||
if (box.boxId === "MainNeed" && box.itemList) {
|
||||
log("info", `[Tencent] 从 MainNeed box 找到 ${box.itemList.length} 个项目`);
|
||||
itemList = box.itemList;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 normalList
|
||||
if (itemList.length === 0 && data.data && data.data.normalList && data.data.normalList.itemList) {
|
||||
log("info", "[Tencent] MainNeed box 未找到,使用 normalList");
|
||||
itemList = data.data.normalList.itemList;
|
||||
}
|
||||
|
||||
if (itemList.length === 0) {
|
||||
log("info", "[Tencent] 搜索无结果");
|
||||
return [];
|
||||
}
|
||||
|
||||
// 过滤和处理搜索结果
|
||||
const results = [];
|
||||
for (const item of itemList) {
|
||||
const filtered = filterTencentSearchItem(item, keyword);
|
||||
if (filtered) {
|
||||
results.push(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
log("info", `[Tencent] 搜索找到 ${results.length} 个有效结果`);
|
||||
return results;
|
||||
|
||||
} catch (error) {
|
||||
log("error", "[Tencent] 搜索出错:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤腾讯视频搜索项
|
||||
* @param {Object} item - 搜索项
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @returns {Object|null} 过滤后的结果
|
||||
*/
|
||||
function filterTencentSearchItem(item, keyword) {
|
||||
if (!item.videoInfo || !item.doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoInfo = item.videoInfo;
|
||||
const mediaId = item.doc.id; // cid
|
||||
|
||||
// 过滤无年份信息
|
||||
if (!videoInfo.year || videoInfo.year === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 过滤"全网搜"结果
|
||||
if (videoInfo.subTitle === "全网搜" || videoInfo.playFlag === 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 清理标题(移除HTML标签)
|
||||
let title = videoInfo.title.replace(/<em>/g, '').replace(/<\/em>/g, '');
|
||||
|
||||
if (!title || !mediaId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 内容类型过滤
|
||||
const contentType = videoInfo.typeName;
|
||||
if (contentType.includes("短剧")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 类型白名单(与360/vod保持一致,使用中文类型)
|
||||
const allowedTypes = ["电视剧", "动漫", "电影", "纪录片", "综艺", "综艺节目"];
|
||||
if (!allowedTypes.includes(contentType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 过滤非腾讯视频内容
|
||||
const allSites = (videoInfo.playSites || []).concat(videoInfo.episodeSites || []);
|
||||
if (allSites.length > 0 && !allSites.some(site => site.enName === 'qq')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 电影非正片内容过滤
|
||||
if (contentType === "电影") {
|
||||
const nonFormalKeywords = ["花絮", "彩蛋", "幕后", "独家", "解说", "特辑", "探班", "拍摄", "制作", "导演", "记录", "回顾", "盘点", "混剪", "解析", "抢先"];
|
||||
if (nonFormalKeywords.some(kw => title.includes(kw))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const episodeCount = contentType === '电影' ? 1 : (videoInfo.subjectDoc ? videoInfo.subjectDoc.videoNum : 0);
|
||||
|
||||
return {
|
||||
provider: "tencent",
|
||||
mediaId: mediaId,
|
||||
title: title,
|
||||
type: contentType, // 使用中文类型,与360/vod保持一致
|
||||
year: videoInfo.year,
|
||||
imageUrl: videoInfo.imgUrl,
|
||||
episodeCount: episodeCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取腾讯视频分集列表
|
||||
* @param {string} cid - 作品ID
|
||||
* @returns {Promise<Array>} 分集数组
|
||||
*/
|
||||
async function getTencentEpisodes(cid) {
|
||||
try {
|
||||
log("info", `[Tencent] 获取分集列表: cid=${cid}`);
|
||||
|
||||
const episodesUrl = "https://pbaccess.video.qq.com/trpc.universal_backend_service.page_server_rpc.PageServer/GetPageData?video_appid=3000010&vversion_name=8.2.96&vversion_platform=2";
|
||||
|
||||
// 先获取分页信息
|
||||
const payload = {
|
||||
has_cache: 1,
|
||||
page_params: {
|
||||
req_from: "web_vsite",
|
||||
page_id: "vsite_episode_list",
|
||||
page_type: "detail_operation",
|
||||
id_type: "1",
|
||||
page_size: "",
|
||||
cid: cid,
|
||||
vid: "",
|
||||
lid: "",
|
||||
page_num: "",
|
||||
page_context: `cid=${cid}&detail_page_type=1&req_from=web_vsite&req_from_second_type=&req_type=0`,
|
||||
detail_page_type: "1"
|
||||
}
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://v.qq.com',
|
||||
'Referer': `https://v.qq.com/x/cover/${cid}.html`,
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9'
|
||||
};
|
||||
|
||||
const response = await httpPost(episodesUrl, JSON.stringify(payload), { headers });
|
||||
|
||||
if (!response || !response.data) {
|
||||
log("info", "[Tencent] 分集响应为空");
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
|
||||
|
||||
if (data.ret !== 0) {
|
||||
log("error", `[Tencent] 分集API返回错误: ret=${data.ret}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解析分页tabs
|
||||
let tabs = [];
|
||||
if (data.data && data.data.module_list_datas) {
|
||||
for (const moduleListData of data.data.module_list_datas) {
|
||||
for (const moduleData of moduleListData.module_datas) {
|
||||
if (moduleData.module_params && moduleData.module_params.tabs) {
|
||||
try {
|
||||
tabs = JSON.parse(moduleData.module_params.tabs);
|
||||
break;
|
||||
} catch (e) {
|
||||
log("error", "[Tencent] 解析tabs失败:", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tabs.length > 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
log("info", "[Tencent] 未找到分页信息");
|
||||
return [];
|
||||
}
|
||||
|
||||
log("info", `[Tencent] 找到 ${tabs.length} 个分页`);
|
||||
|
||||
// 获取所有分页的分集
|
||||
const allEpisodes = [];
|
||||
for (const tab of tabs) {
|
||||
if (!tab.page_context) continue;
|
||||
|
||||
const tabPayload = {
|
||||
has_cache: 1,
|
||||
page_params: {
|
||||
req_from: "web_vsite",
|
||||
page_id: "vsite_episode_list",
|
||||
page_type: "detail_operation",
|
||||
id_type: "1",
|
||||
page_size: "",
|
||||
cid: cid,
|
||||
vid: "",
|
||||
lid: "",
|
||||
page_num: "",
|
||||
page_context: tab.page_context,
|
||||
detail_page_type: "1"
|
||||
}
|
||||
};
|
||||
|
||||
const tabResponse = await httpPost(episodesUrl, JSON.stringify(tabPayload), { headers });
|
||||
|
||||
if (!tabResponse || !tabResponse.data) continue;
|
||||
|
||||
const tabData = typeof tabResponse.data === "string" ? JSON.parse(tabResponse.data) : tabResponse.data;
|
||||
|
||||
if (tabData.ret !== 0 || !tabData.data) continue;
|
||||
|
||||
// 提取分集
|
||||
if (tabData.data.module_list_datas) {
|
||||
for (const moduleListData of tabData.data.module_list_datas) {
|
||||
for (const moduleData of moduleListData.module_datas) {
|
||||
if (moduleData.item_data_lists && moduleData.item_data_lists.item_datas) {
|
||||
for (const item of moduleData.item_data_lists.item_datas) {
|
||||
if (item.item_params && item.item_params.vid && item.item_params.is_trailer !== "1") {
|
||||
allEpisodes.push({
|
||||
vid: item.item_params.vid,
|
||||
title: item.item_params.title,
|
||||
unionTitle: item.item_params.union_title || item.item_params.title
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("info", `[Tencent] 共获取 ${allEpisodes.length} 集`);
|
||||
return allEpisodes;
|
||||
|
||||
} catch (error) {
|
||||
log("error", "[Tencent] 获取分集出错:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 获取腾讯弹幕
|
||||
// =====================
|
||||
@@ -3591,7 +3920,7 @@ async function handleVodAnimes(animesVod, curAnimes, key) {
|
||||
};
|
||||
|
||||
curAnimes.push(transformedAnime);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
|
||||
if (!exists) {
|
||||
const transformedAnimeCopy = {...transformedAnime, links: links};
|
||||
addAnime(transformedAnimeCopy);
|
||||
@@ -3659,7 +3988,7 @@ async function handle360Animes(animes360, curAnimes) {
|
||||
};
|
||||
|
||||
curAnimes.push(transformedAnime);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
|
||||
if (!exists) {
|
||||
const transformedAnimeCopy = {...transformedAnime, links: links};
|
||||
addAnime(transformedAnimeCopy);
|
||||
@@ -3702,7 +4031,7 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) {
|
||||
|
||||
curAnimes.push(transformedAnime);
|
||||
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
|
||||
if (!exists) {
|
||||
const transformedAnimeCopy = {...transformedAnime, links: links};
|
||||
addAnime(transformedAnimeCopy);
|
||||
@@ -3755,7 +4084,7 @@ async function handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes) {
|
||||
|
||||
curAnimes.push(transformedAnime);
|
||||
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
|
||||
if (!exists) {
|
||||
const transformedAnimeCopy = {...transformedAnime, links: links};
|
||||
addAnime(transformedAnimeCopy);
|
||||
@@ -3803,7 +4132,7 @@ async function handleBahamutAnimes(animesBahamut, queryTitle, curAnimes) {
|
||||
|
||||
curAnimes.push(transformedAnime);
|
||||
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId);
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
|
||||
if (!exists) {
|
||||
const transformedAnimeCopy = {...transformedAnime, links: links};
|
||||
addAnime(transformedAnimeCopy);
|
||||
@@ -3817,6 +4146,57 @@ async function handleBahamutAnimes(animesBahamut, queryTitle, curAnimes) {
|
||||
return processBahamutAnimes;
|
||||
}
|
||||
|
||||
async function handleTencentAnimes(animesTencent, queryTitle, curAnimes) {
|
||||
// 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成
|
||||
const processTencentAnimes = await Promise.all(animesTencent
|
||||
.filter(s => s.title.includes(queryTitle))
|
||||
.map(async (anime) => {
|
||||
const eps = await getTencentEpisodes(anime.mediaId);
|
||||
let links = [];
|
||||
|
||||
for (let i = 0; i < eps.length; i++) {
|
||||
const ep = eps[i];
|
||||
const epTitle = ep.unionTitle || ep.title || `第${i + 1}集`;
|
||||
// 构建完整URL: https://v.qq.com/x/cover/{cid}/{vid}.html
|
||||
const fullUrl = `https://v.qq.com/x/cover/${anime.mediaId}/${ep.vid}.html`;
|
||||
links.push({
|
||||
"name": i + 1,
|
||||
"url": fullUrl,
|
||||
"title": `【tencent】${anime.title}(${anime.year}) #${epTitle}#`
|
||||
});
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
// 直接使用mediaId作为animeId和bangumiId
|
||||
let transformedAnime = {
|
||||
animeId: anime.mediaId,
|
||||
bangumiId: anime.mediaId,
|
||||
animeTitle: `${anime.title}(${anime.year})【${anime.type}】from tencent`,
|
||||
type: anime.type,
|
||||
typeDescription: anime.type,
|
||||
imageUrl: anime.imageUrl,
|
||||
startDate: `${anime.year}-01-01T00:00:00`,
|
||||
episodeCount: links.length,
|
||||
rating: 0,
|
||||
isFavorited: true,
|
||||
};
|
||||
|
||||
curAnimes.push(transformedAnime);
|
||||
|
||||
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
|
||||
if (!exists) {
|
||||
const transformedAnimeCopy = {...transformedAnime, links: links};
|
||||
addAnime(transformedAnimeCopy);
|
||||
}
|
||||
|
||||
if (animes.length > MAX_ANIMES) removeEarliestAnime();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return processTencentAnimes;
|
||||
}
|
||||
|
||||
// Extracted function for GET /api/v2/search/anime
|
||||
async function searchAnime(url) {
|
||||
const queryTitle = url.searchParams.get("keyword");
|
||||
@@ -3833,6 +4213,7 @@ async function searchAnime(url) {
|
||||
if (source === "renren") return renrenSearch(queryTitle);
|
||||
if (source === "hanjutv") return hanjutvSearch(queryTitle);
|
||||
if (source === "bahamut") return bahamutSearch(traditionalized(queryTitle));
|
||||
if (source === "tencent") return tencentSearch(queryTitle);
|
||||
});
|
||||
|
||||
// 执行所有请求并等待结果
|
||||
@@ -3847,7 +4228,7 @@ async function searchAnime(url) {
|
||||
});
|
||||
|
||||
// 解构出返回的结果
|
||||
const { vod: animesVodResults, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut } = resultData;
|
||||
const { vod: animesVodResults, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut, tencent: animesTencent } = resultData;
|
||||
|
||||
// 按顺序处理每个来源的结果
|
||||
for (const key of sourceOrderArr) {
|
||||
@@ -3872,6 +4253,9 @@ async function searchAnime(url) {
|
||||
} else if (key === 'bahamut') {
|
||||
// 等待处理Bahamut来源
|
||||
await handleBahamutAnimes(animesBahamut, traditionalized(queryTitle), curAnimes);
|
||||
} else if (key === 'tencent') {
|
||||
// 等待处理Tencent来源
|
||||
await handleTencentAnimes(animesTencent, queryTitle, curAnimes);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -4222,16 +4606,29 @@ async function searchEpisodes(url) {
|
||||
|
||||
// Extracted function for GET /api/v2/bangumi/:animeId
|
||||
async function getBangumi(path) {
|
||||
const animeId = parseInt(path.split("/").pop());
|
||||
const anime = animes.find((a) => a.animeId.toString() === animeId.toString());
|
||||
const idParam = path.split("/").pop();
|
||||
const animeId = parseInt(idParam);
|
||||
|
||||
// 尝试通过 animeId(数字) 或 bangumiId(字符串) 查找
|
||||
let anime;
|
||||
if (!isNaN(animeId)) {
|
||||
// 如果是有效数字,先尝试通过 animeId 查找
|
||||
anime = animes.find((a) => a.animeId.toString() === animeId.toString());
|
||||
}
|
||||
|
||||
// 如果通过 animeId 未找到,尝试通过 bangumiId 查找
|
||||
if (!anime) {
|
||||
log("error", `Anime with ID ${animeId} not found`);
|
||||
anime = animes.find((a) => a.bangumiId === idParam);
|
||||
}
|
||||
|
||||
if (!anime) {
|
||||
log("error", `Anime with ID ${idParam} not found`);
|
||||
return jsonResponse(
|
||||
{ errorCode: 404, success: false, errorMessage: "Anime not found", bangumi: null },
|
||||
404
|
||||
);
|
||||
}
|
||||
log("info", `Fetched details for anime ID: ${animeId}`);
|
||||
log("info", `Fetched details for anime ID: ${idParam}`);
|
||||
|
||||
let resData = {
|
||||
errorCode: 0,
|
||||
@@ -4279,7 +4676,7 @@ async function getComment(path) {
|
||||
const commentId = parseInt(path.split("/").pop());
|
||||
let url = findUrlById(commentId);
|
||||
let title = findTitleById(commentId);
|
||||
let plat = (title.match(/【(.*?)】/) || [null])[0]?.replace(/[【】]/g, '');
|
||||
let plat = title ? (title.match(/【(.*?)】/) || [null])[0]?.replace(/[【】]/g, '') : null;
|
||||
log("info", "comment url...", url);
|
||||
log("info", "comment title...", title);
|
||||
log("info", "comment platform...", plat);
|
||||
@@ -4431,6 +4828,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
envs["blockedWords"] = blockedWords;
|
||||
groupMinute = resolveGroupMinute(env);
|
||||
envs["groupMinute"] = groupMinute;
|
||||
rateLimitMaxRequests = resolveRateLimitMaxRequests(env);
|
||||
envs["rateLimitMaxRequests"] = rateLimitMaxRequests;
|
||||
proxyUrl = resolveProxyUrl(env);
|
||||
envs["proxyUrl"] = proxyUrl;
|
||||
redisUrl = resolveRedisUrl(env);
|
||||
@@ -4560,35 +4959,45 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
|
||||
// GET /api/v2/comment/:commentId
|
||||
if (path.startsWith("/api/v2/comment/") && method === "GET") {
|
||||
// 获取当前时间戳(单位:毫秒)
|
||||
const currentTime = Date.now();
|
||||
const oneMinute = 60 * 1000; // 1分钟 = 60000 毫秒
|
||||
// 限流检查(如果 rateLimitMaxRequests > 0 则启用限流)
|
||||
if (rateLimitMaxRequests > 0) {
|
||||
// 获取当前时间戳(单位:毫秒)
|
||||
const currentTime = Date.now();
|
||||
const oneMinute = 60 * 1000; // 1分钟 = 60000 毫秒
|
||||
|
||||
// 检查该 IP 地址的历史请求
|
||||
if (!requestHistory.has(clientIp)) {
|
||||
// 如果该 IP 地址没有请求历史,初始化一个空队列
|
||||
requestHistory.set(clientIp, []);
|
||||
// 检查该 IP 地址的历史请求
|
||||
if (!requestHistory.has(clientIp)) {
|
||||
// 如果该 IP 地址没有请求历史,初始化一个空队列
|
||||
requestHistory.set(clientIp, []);
|
||||
}
|
||||
|
||||
const history = requestHistory.get(clientIp);
|
||||
|
||||
// 过滤掉已经超出 1 分钟的请求
|
||||
const recentRequests = history.filter(timestamp => currentTime - timestamp <= oneMinute);
|
||||
|
||||
// 如果最近的请求数量大于等于配置的限制次数,则限制请求
|
||||
if (recentRequests.length >= rateLimitMaxRequests) {
|
||||
// 清理过期记录(如果为空则删除键,避免内存泄漏)
|
||||
if (recentRequests.length === 0) {
|
||||
requestHistory.delete(clientIp);
|
||||
} else {
|
||||
requestHistory.set(clientIp, recentRequests);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
status: 429, // HTTP 429 Too Many Requests
|
||||
body: `1分钟内同一IP只能请求弹幕${rateLimitMaxRequests}次,请稍后重试`,
|
||||
});
|
||||
}
|
||||
|
||||
// 将当前请求的时间戳添加到该 IP 地址的请求历史队列中
|
||||
recentRequests.push(currentTime);
|
||||
|
||||
// 更新该 IP 地址的请求历史
|
||||
requestHistory.set(clientIp, recentRequests);
|
||||
}
|
||||
|
||||
const history = requestHistory.get(clientIp);
|
||||
|
||||
// 过滤掉已经超出 1 分钟的请求
|
||||
const recentRequests = history.filter(timestamp => currentTime - timestamp <= oneMinute);
|
||||
|
||||
// 如果最近的请求数量大于等于 3 次,则限制请求
|
||||
if (recentRequests.length >= 3) {
|
||||
return jsonResponse({
|
||||
status: 429, // HTTP 429 Too Many Requests
|
||||
body: `1分钟内同一IP只能请求弹幕3次,请稍后重试`,
|
||||
});
|
||||
}
|
||||
|
||||
// 将当前请求的时间戳添加到该 IP 地址的请求历史队列中
|
||||
recentRequests.push(currentTime);
|
||||
|
||||
// 更新该 IP 地址的请求历史
|
||||
requestHistory.set(clientIp, recentRequests);
|
||||
|
||||
return getComment(path);
|
||||
}
|
||||
|
||||
@@ -4643,5 +5052,5 @@ export async function vercelHandler(req, res) {
|
||||
// 为了测试导出 handleRequest
|
||||
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, getCommentByUrl, fetchTencentVideo, fetchIqiyi,
|
||||
fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes,
|
||||
getHanjutvComments, getHanjutvDetail, bahamutSearch, getBahamutEpisodes, getBahamutComments, pingRedis, getRedisKey,
|
||||
setRedisKey, setRedisKeyWithExpiry};
|
||||
getHanjutvComments, getHanjutvDetail, bahamutSearch, getBahamutEpisodes, getBahamutComments, tencentSearch, getTencentEpisodes,
|
||||
pingRedis, getRedisKey, setRedisKey, setRedisKeyWithExpiry};
|
||||
Reference in new issue
Block a user