diff --git a/.env.example b/.env.example index eac0255..7067163 100644 --- a/.env.example +++ b/.env.example @@ -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 + # ==================== 代理配置 ==================== # 代理地址(用于访问受限资源) diff --git a/README.md b/README.md index a2aea3b..6fab7eb 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/danmu_api/worker.js b/danmu_api/worker.js index b60f0be..a211fef 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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} 搜索结果数组 + */ +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(//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} 分集数组 + */ +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};