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:
woleigedouer authored and GitHub committed 2025-10-16 22:39:16 +08:00
1 parent 18b78696cc
commit 8088045edb
3 files changed
+465 -47

No files matched your search

+10 -2
View File
@@ -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
# ==================== 代理配置 ====================
# 代理地址(用于访问受限资源)
+4 -3
View File
@@ -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
View File
@@ -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};