diff --git a/.env.example b/.env.example index c1b0caa..760c432 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,7 @@ YOUKU_CONCURRENCY=8 # 数据源查询顺序(可选值:360, vod, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut) # 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo 和 bilibili) # 如需使用腾讯源、优酷源、爱奇艺源、芒果源或B站源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv -# 注意:bahamut 仅在 Cloudflare 和 Vercel 平台自动启用 +# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut SOURCE_ORDER=360,vod,renren,hanjutv # ==================== 平台排序配置 ==================== @@ -70,9 +70,14 @@ SOURCE_ORDER=360,vod,renren,hanjutv # ==================== 剧集标题过滤 ==================== # 剧集标题过滤正则表达式(用于过滤花絮、预告等非正片内容) -# 默认值:包含大量关键词的复杂正则表达式 -# 如需自定义,请参考代码中的 DEFAULT_EPISODE_TITLE_FILTER -# EPISODE_TITLE_FILTER= +# 默认值:下方完整规则 +# 说明: +# - 如果设置了此环境变量,将完全覆盖默认的过滤规则(不是增量添加) +# - 格式:使用 | 分隔多个关键词,例如:预告|花絮|特辑|番外 +# - 可以直接修改下方规则,按需增删关键词 +# 示例:只过滤预告和花絮 +# EPISODE_TITLE_FILTER=预告|花絮 +EPISODE_TITLE_FILTER=(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘 # ==================== 屏蔽词配置 ==================== @@ -185,7 +190,7 @@ COMMENT_CACHE_MINUTES=1 # 默认值:100 # 说明:lastSelectMap 最多保存的条目数,超过限制时删除最早的条目(FIFO) # 用于存储查询关键字上次选择的 animeId,下次 match 自动匹配时优先选择该 anime -# MAX_LAST_SELECT_MAP=100 +MAX_LAST_SELECT_MAP=100 # ==================== Redis 配置(Upstash)==================== diff --git a/README.md b/README.md index fef9a12..4dd9d3a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ LogVar 弹幕 API 服务器 - `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。 - `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。 - `GET /api/v2/comment/:commentId?format=json`:获取指定弹幕评论,支持返回相关评论和字符转换。 - - `POST /api/v2/comment/by-url`:通过视频URL直接获取弹幕。 + - `GET /api/v2/comment?url=${videoUrl}&format=json`:通过视频URL直接获取弹幕(兼容第三方弹幕服务器格式)。 - `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。 - **弹幕格式输出**:支持 JSON 和 XML 两种格式输出,通过以下方式配置: - 环境变量:`DANMU_OUTPUT_FORMAT=json|xml`(默认:json) @@ -97,7 +97,7 @@ LogVar 弹幕 API 服务器 - `GET http://{ip}:9321/87654321/api/v2/search/episodes?anime=生万物` - `GET http://{ip}:9321/87654321/api/v2/bangumi/1` - `GET http://{ip}:9321/87654321/api/v2/comment/1?format=json` - - `POST http://{ip}:9321/87654321/api/v2/comment/by-url` + - `GET http://{ip}:9321/87654321/api/v2/comment?url=https://v.qq.com/x/cover/xxx.html&format=json` - `GET http://{ip}:9321/87654321/api/logs` ## 使用 Docker 运行 @@ -289,7 +289,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 **使用示例**: - 获取 JSON 格式:`GET /api/v2/comment/10001` - 获取 XML 格式:`GET /api/v2/comment/10001?format=xml` -- 通过 URL 获取 XML:`POST /api/v2/comment/by-url?format=xml` (body: `{"videoUrl":"..."}`) +- 通过 URL 获取弹幕:`GET /api/v2/comment?url=https://v.qq.com/x/cover/xxx.html&format=json` > 注意: > @@ -321,7 +321,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | CONVERT_TOP_BOTTOM_TO_SCROLL | 【可选】是否将顶部和底部弹幕转换为浮动弹幕,默认为`false`(不转换),启用后顶部弹幕(ct=5)和底部弹幕(ct=4)会被转换为浮动弹幕(ct=1),可选值:`true`、`false` | | CONVERT_COLOR_TO_WHITE | 【可选】是否将彩色弹幕转换为纯白弹幕,默认为`false`(不转换),启用后所有非白色的弹幕颜色会被转换为纯白色(16777215),可选值:`true`、`false` | | DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 | -| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/netlify/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/netlify/cf中理应都自然能联通,不用填写 | +| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/netlify/cf中理应都自然能联通,不用填写 | | TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后当巴哈第一次搜索无结果时自动从TMDB获取日语原名搜索,可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) | | RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 | | LOG_LEVEL | 【可选】日志级别,默认为`info`,可选值:`error`(仅错误)、`warn`(错误和警告)、`info`(所有日志),生产环境建议使用`warn`,调试时使用`info` | diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index cff3816..2aad42f 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -753,8 +753,8 @@ export async function getComment(path, queryFormat) { return formatDanmuResponse(responseData, queryFormat); } -// Extracted function for POST /api/v2/comment/by-url -export async function getCommentByUrl(req, queryFormat) { +// Extracted function for GET /api/v2/comment?url=xxx +export async function getCommentByUrl(videoUrl, queryFormat) { const tencentSource = new TencentSource(); const iqiyiSource = new IqiyiSource(); const mangoSource = new MangoSource(); @@ -763,25 +763,22 @@ export async function getCommentByUrl(req, queryFormat) { const otherSource = new OtherSource(); try { - // 获取请求体 - const body = await req.json(); - - // 验证请求体是否有效 - if (!body || !body.videoUrl) { - log("error", "Missing videoUrl parameter in request body"); + // 验证URL参数 + if (!videoUrl || typeof videoUrl !== 'string') { + log("error", "Missing or invalid url parameter"); return jsonResponse( - { errorCode: 400, success: false, errorMessage: "Missing videoUrl parameter", count: 0, comments: [] }, + { errorCode: 400, success: false, errorMessage: "Missing or invalid url parameter", count: 0, comments: [] }, 400 ); } - const videoUrl = body.videoUrl.trim(); + videoUrl = videoUrl.trim(); // 验证URL格式 if (!videoUrl.startsWith('http')) { - log("error", "Invalid videoUrl format"); + log("error", "Invalid url format, must start with http or https"); return jsonResponse( - { errorCode: 400, success: false, errorMessage: "Invalid videoUrl format", count: 0, comments: [] }, + { errorCode: 400, success: false, errorMessage: "Invalid url format, must start with http or https", count: 0, comments: [] }, 400 ); } @@ -844,7 +841,7 @@ export async function getCommentByUrl(req, queryFormat) { }; return formatDanmuResponse(responseData, queryFormat); } catch (error) { - // 处理 JSON 解析错误或其他异常 + // 处理异常 log("error", `Failed to process comment by URL request: ${error.message}`); return jsonResponse( { errorCode: 500, success: false, errorMessage: "Internal server error", count: 0, comments: [] }, diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index d930faf..343b5ef 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -106,9 +106,6 @@ export class Envs { */ static resolveSourceOrder(env, deployPlatform) { let sourceOrder = this.get('SOURCE_ORDER', '360,vod,renren,hanjutv', 'string'); - if (['cloudflare', 'vercel', 'netlify'].includes(deployPlatform)) { - sourceOrder += ',bahamut'; - } const orderArr = sourceOrder .split(',') @@ -147,14 +144,12 @@ export class Envs { '花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|' + 'NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|' + '剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|' + - '试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|' + + '试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|观察室|上班那点事儿|' + '周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘'; - let keywords = defaultFilter; - const customFilter = this.get('EPISODE_TITLE_FILTER', '', 'string', false).replace(/^\|+|\|+$/g, ''); - if (customFilter) { - keywords = `${keywords}|${customFilter}`; - } + // 读取环境变量,如果设置了则完全覆盖默认值 + const customFilter = this.get('EPISODE_TITLE_FILTER', '', 'string', false).trim(); + let keywords = customFilter || defaultFilter; this.accessedEnvVars.set('EPISODE_TITLE_FILTER', keywords); diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index abf693b..c1dc8e0 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -11,7 +11,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.6.0', + VERSION: '1.6.1', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, MAX_LAST_SELECT_MAP: 100, diff --git a/danmu_api/sources/base.js b/danmu_api/sources/base.js index d207141..b89de9b 100644 --- a/danmu_api/sources/base.js +++ b/danmu_api/sources/base.js @@ -67,7 +67,13 @@ export default class BaseSource { return 0; }) .forEach(anime => { - curAnimes.push(anime); + // 检查 curAnimes 中是否已存在相同 animeId 的动漫 + const existingIndex = curAnimes.findIndex(a => a.animeId === anime.animeId); + if (existingIndex === -1) { + // 不存在则添加 + curAnimes.push(anime); + } + // 如果已存在则跳过,避免重复 }); } } \ No newline at end of file diff --git a/danmu_api/worker.js b/danmu_api/worker.js index fcdba81..d457362 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -123,19 +123,70 @@ async function handleRequest(req, env, deployPlatform, clientIp) { return getBangumi(path); } - // POST /api/v2/comment/by-url - if (path === "/api/v2/comment/by-url" && method === "POST") { + // GET /api/v2/comment/:commentId or /api/v2/comment?url=xxx + if (path.startsWith("/api/v2/comment") && method === "GET") { const queryFormat = url.searchParams.get('format'); - return getCommentByUrl(req, queryFormat); - } + const videoUrl = url.searchParams.get('url'); - // GET /api/v2/comment/:commentId - if (path.startsWith("/api/v2/comment/") && method === "GET") { // ⚠️ 限流设计说明: // 1. 先检查缓存,缓存命中时直接返回,不计入限流次数 // 2. 只有缓存未命中时才执行限流检查和网络请求 - // 这样可以避免频繁访问同一弹幕时被限流,提高用户体验 - const queryFormat = url.searchParams.get('format'); + // 3. 这样可以避免频繁访问同一弹幕时被限流,提高用户体验 + + // 如果有url参数,则通过URL获取弹幕 + if (videoUrl) { + // 先检查缓存 + const cachedComments = getCommentCache(videoUrl); + if (cachedComments !== null) { + log("info", `[Rate Limit] Cache hit for URL: ${videoUrl}, skipping rate limit check`); + const responseData = { count: cachedComments.length, comments: cachedComments }; + return formatDanmuResponse(responseData, queryFormat); + } + + // 缓存未命中,执行限流检查(如果 rateLimitMaxRequests > 0 则启用限流) + if (globals.rateLimitMaxRequests > 0) { + const currentTime = Date.now(); + const oneMinute = 60 * 1000; + + // 清理所有过期的 IP 记录 + cleanupExpiredIPs(currentTime); + + // 检查该 IP 地址的历史请求 + if (!globals.requestHistory.has(clientIp)) { + globals.requestHistory.set(clientIp, []); + } + + const history = globals.requestHistory.get(clientIp); + const recentRequests = history.filter(timestamp => currentTime - timestamp <= oneMinute); + + // 如果最近 1 分钟内的请求次数超过限制,返回 429 错误 + if (recentRequests.length >= globals.rateLimitMaxRequests) { + log("warn", `[Rate Limit] IP ${clientIp} exceeded rate limit (${recentRequests.length}/${globals.rateLimitMaxRequests} requests in 1 minute)`); + return jsonResponse( + { errorCode: 429, success: false, errorMessage: "Too many requests, please try again later" }, + 429 + ); + } + + // 记录本次请求时间戳 + recentRequests.push(currentTime); + globals.requestHistory.set(clientIp, recentRequests); + log("info", `[Rate Limit] IP ${clientIp} request count: ${recentRequests.length}/${globals.rateLimitMaxRequests}`); + } + + // 通过URL获取弹幕 + return getCommentByUrl(videoUrl, queryFormat); + } + + // 否则通过commentId获取弹幕 + if (!path.startsWith("/api/v2/comment/")) { + log("error", "Missing commentId or url parameter"); + return jsonResponse( + { errorCode: 400, success: false, errorMessage: "Missing commentId or url parameter" }, + 400 + ); + } + const commentId = parseInt(path.split("/").pop()); let urlForComment = findUrlById(commentId); @@ -171,30 +222,17 @@ async function handleRequest(req, env, deployPlatform, clientIp) { // 如果最近的请求数量大于等于配置的限制次数,则限制请求 if (recentRequests.length >= globals.rateLimitMaxRequests) { - // 更新请求历史(清理过期记录) - if (recentRequests.length === 0) { - globals.requestHistory.delete(clientIp); - } else { - globals.requestHistory.set(clientIp, recentRequests); - } - - return jsonResponse({ - status: 429, // HTTP 429 Too Many Requests - body: `1分钟内同一IP只能请求弹幕${globals.rateLimitMaxRequests}次,请稍后重试`, - }); + log("warn", `[Rate Limit] IP ${clientIp} exceeded rate limit (${recentRequests.length}/${globals.rateLimitMaxRequests} requests in 1 minute)`); + return jsonResponse( + { errorCode: 429, success: false, errorMessage: "Too many requests, please try again later" }, + 429 + ); } - // 将当前请求的时间戳添加到该 IP 地址的请求历史队列中 + // 记录本次请求时间戳 recentRequests.push(currentTime); - - // 更新该 IP 地址的请求历史 - if (recentRequests.length === 0) { - // 如果没有最近的请求,删除该 IP 的记录以避免内存泄漏 - globals.requestHistory.delete(clientIp); - } else { - globals.requestHistory.set(clientIp, recentRequests); - } - log("info", `[Rate Limit] Request counted for IP: ${clientIp}, count: ${recentRequests.length}/${globals.rateLimitMaxRequests}`); + globals.requestHistory.set(clientIp, recentRequests); + log("info", `[Rate Limit] IP ${clientIp} request count: ${recentRequests.length}/${globals.rateLimitMaxRequests}`); } return getComment(path, queryFormat);