feat: 添加手动匹配过滤功能并替换默认VOD站点 (#59)
* feat: 手动匹配时添加过滤功能并替换默认VOD站点 - 将默认VOD站点改为 https://gctf.tfdh.top - 在手动选择接口中实现过滤 - 支持剧集标题和选集列表标题的过滤 - 添加ENABLE_EPISODE_FILTER环境变量开关 - 更新所有相关文档和配置 * fix: 修复360电影和VOD高清正片被过滤的问题 - 移除DEFAULT_EPISODE_TITLE_FILTER中的'高清正片'关键词 * 高清正片是正片的标记,不应该被过滤 * 修复VOD数据中#高清正片#被误过滤的问题 - 为360电影标题添加#符号 * 统一所有数据源的标题格式为【平台】 #标题# * 修复360电影数据全部被过滤的问题 * 使extractEpTitle()能正确提取标题 - 同步更新README.md文档中的默认过滤规则示例 * refactor: 优化和扩展剧集标题过滤规则 - 合并平台规则,提高过滤精准度 * 新增灵活匹配:抢先(看|版|集|篇)?、加更(版|篇)?、会员(专享|加长|尊享|专属|版)? * 新增幕后变体:幕后(故事|花絮|独家)? * 新增直播变体:直播(陪看|回顾)? * 新增未播变体:未播(片段)? * 新增花絮变体:花絮(独家)? - 新增平台特定内容过滤 * 腾讯:解忧局、走心局、火锅局、巅峰时刻、坞里都知道、福持目标坞民 * 芒果:陪看(记)?、超前营业、超前vlog * 通用:采访 - 优化正则表达式 * .{2,}篇 → .{3,}篇(避免过度过滤) * 移除冗余的会员版(已包含在会员(版)?中) * feat: 实现搜索结果和弹幕缓存功能 - 添加内存缓存机制,支持搜索结果和弹幕数据缓存 - 实现时间戳检查,自动过期清理(默认1分钟TTL) - 添加 SEARCH_CACHE_MINUTES 环境变量配置 - 更新 README 文档和环境变量说明 修改文件: - danmu_api/worker.js: 添加缓存管理函数和集成逻辑 - .env.example: 添加缓存配置说明 - README.md: 更新功能列表、环境变量表和注意事项 性能提升: - 相同关键词的重复搜索响应时间从 API 调用时间降低到毫秒级 - 相同视频 URL 的重复弹幕请求响应时间从 API 调用时间降低到毫秒级 * feat: 实现 VOD 采集站点可配置返回模式 - 添加 VOD_RETURN_MODE 环境变量,支持两种模式: * all: 返回所有配置的 VOD 站点结果(结果较多) * fastest: 只返回最快响应的 VOD 站点结果(默认,避免结果过多) - 实现 getVodAnimesFromFastestServer() 函数 * 使用 Promise.race 获取最快响应的服务器 * 如果最快的服务器无数据,自动尝试其他服务器 - 实现 getVodAnimesFromAllServersImpl() 函数 * 使用 Promise.allSettled 并发查询所有服务器 * 单个服务器失败不影响其他服务器 - 更新默认 VOD 站点配置为 3 个站点: * 金蝉@https://zy.jinchancaiji.com * 789@https://www.caiji.cyou * 听风@https://gctf.tfdh.top - 更新配置文件和文档 * .env.example: 添加 VOD_RETURN_MODE 配置 * README.md: 更新环境变量表和使用说明 - 默认行为改为 fastest 模式,避免搜索结果过多 * feat: 添加 .env 文件支持并优化构建脚本 - 在 worker.js 和 worker.test.js 中添加 dotenv 加载 - 优化 docker-image.yml 构建脚本(合并命令,快 50%) - 修复 README 中的拼写错误(damnu_api → danmu_api, your_domian → your_domain) - 添加 Docker .env 文件使用说明和示例 * chore: bump version to 1.4.0 * feat: 禁用集标题过滤默认值并修复环境变量配置 - 将 ENABLE_EPISODE_FILTER 默认值从 true 改为 false,避免综艺节目被过度过滤 - 修复 vodReturnMode 环境变量未被添加到 envs 对象的问题 - 同步更新 .env.example 和 README.md 文档 Fixes: huangxd- review comments * refactor: 移除剧集标题 # 号包裹设计 - 移除所有剧集标题中的 # 号包裹(10 处) - 删除 extractEpTitle() 函数及所有调用(7 处) - 更新过滤逻辑以直接使用完整标题 - 简化 filterSameEpisodeTitle() 函数 - 保留【】平台前缀格式及正确的空格 修改位置: - 360kan、renren(2处)、imgo、youku、qq、hanjutv、bahamut、tencent、手动解析 - searchAnime、getBangumi、searchBangumi、fallbackMatchAniAndEp 过滤逻辑 * feat: 优化限流与缓存交互设计 - 缓存命中时不计入限流 feat: 优化限流与缓存交互设计 - 缓存命中时不计入限流 ## 问题描述 原始设计中,限流检查在缓存检查之前执行,导致即使返回缓存数据也会计入限流次数。 这会导致用户频繁访问同一弹幕时被限流,影响用户体验。 ## 解决方案 重构 GET /api/v2/comment/:commentId 接口的限流逻辑: 1. 在限流检查之前先检查缓存 2. 缓存命中时直接返回,不执行限流检查 3. 只有缓存未命中时才计入限流次数 ## 改进效果 - ✅ 缓存命中时不计入限流,用户可频繁访问同一弹幕 - ✅ 缓存未命中时正常计入限流,保护后端服务 - ✅ 添加详细的 [Rate Limit] 日志标记,便于调试 - ✅ 添加设计说明注释,便于后续维护 ## 技术细节 - 修改文件:danmu_api/worker.js (第 5415-5476 行) - 新增逻辑:缓存优先检查 + 条件限流 - 新增日志:[Rate Limit] Cache hit 和 Request counted ## 测试验证 已通过运行日志验证: - 首次请求弹幕:计入限流 ✓ - 重复请求同一弹幕:不计入限流 ✓ - 新弹幕首次请求:计入限流 ✓ - 新弹幕重复请求:不计入限流 ✓ * feat: 统一 Tencent 平台标识为 qq - 将 Tencent 集标题平台标识从【tencent】改为【qq】 - 更新 README 采集源平台列表中 Tencent 对应的平台为 qq - 从 PLATFORM_ORDER 可选平台列表中移除 tencent
This commit is contained in:
1 parent
534761c7db
commit
cc3ffe6b0b
5 files changed
+409
-58
No files matched your search
+29
-3
@@ -15,10 +15,18 @@ OTHER_SERVER=https://api.danmu.icu
|
||||
|
||||
# VOD 服务器配置(支持多服务器)
|
||||
# 格式:名称@URL,名称@URL,...
|
||||
# 示例:VOD_SERVERS=vod1@https://api1.com,vod2@https://api2.com,vod3@https://api3.com
|
||||
# 示例:VOD_SERVERS=金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top
|
||||
# 如果不指定名称,会自动生成 vod-1, vod-2 等名称
|
||||
# 默认值:vod@https://www.caiji.cyou
|
||||
VOD_SERVERS=vod@https://www.caiji.cyou
|
||||
# 默认值:金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top
|
||||
VOD_SERVERS=金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top
|
||||
|
||||
# VOD 服务器返回模式
|
||||
# 可选值:all(返回所有站点结果)、fastest(只返回最快的站点结果)
|
||||
# 默认值:fastest
|
||||
# 说明:
|
||||
# - all: 搜索时会并发查询所有配置的 VOD 站点,返回所有结果(结果较多)
|
||||
# - fastest: 搜索时只返回首先响应成功的 VOD 站点结果(结果较少,避免重复)
|
||||
VOD_RETURN_MODE=fastest
|
||||
|
||||
# VOD 服务器请求超时时间(毫秒)
|
||||
# 默认值:5000(5秒)
|
||||
@@ -79,6 +87,15 @@ GROUP_MINUTE=1
|
||||
# 设置为 0 表示不限流
|
||||
RATE_LIMIT_MAX_REQUESTS=3
|
||||
|
||||
# ==================== 集标题过滤开关 ====================
|
||||
|
||||
# 是否在手动选择接口中启用集标题过滤
|
||||
# 默认值:false(禁用)
|
||||
# 说明:GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集
|
||||
# 如需启用过滤,设置为 true
|
||||
# 可选值:true, false
|
||||
ENABLE_EPISODE_FILTER=false
|
||||
|
||||
# ==================== 代理配置 ====================
|
||||
|
||||
# 代理地址(用于访问受限资源)
|
||||
@@ -86,6 +103,15 @@ RATE_LIMIT_MAX_REQUESTS=3
|
||||
# 格式:http://proxy-server:port 或 https://proxy-server:port
|
||||
# PROXY_URL=
|
||||
|
||||
# ==================== 搜索结果缓存配置 ====================
|
||||
|
||||
# 搜索结果缓存时间(分钟)
|
||||
# 默认值:1
|
||||
# 说明:避免短期内重复的不必要 API 请求,同时保证获取最新的结果列表
|
||||
# 可根据需要调整:Vercel/Cloudflare 建议 1-5 分钟,Docker 可设置 5-30 分钟
|
||||
# 设置为 0 表示不缓存
|
||||
SEARCH_CACHE_MINUTES=1
|
||||
|
||||
# ==================== Redis 配置(Upstash)====================
|
||||
|
||||
# Upstash Redis REST URL(用于缓存)
|
||||
|
||||
@@ -36,10 +36,10 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# 构建 Docker 镜像,并指定平台为 amd64 和 arm64,推送到 Docker Hub
|
||||
# 使用单条命令同时指定两个标签,避免重复构建
|
||||
- name: Build and Push Docker image with version and latest tag
|
||||
run: |
|
||||
# 构建带版本号的镜像并推送
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t ${{ secrets.DOCKER_USERNAME }}/danmu-api:${{ env.VERSION }} --push .
|
||||
|
||||
# 构建带 latest 标签的镜像并推送
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t ${{ secrets.DOCKER_USERNAME }}/danmu-api:latest --push .
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ${{ secrets.DOCKER_USERNAME }}/danmu-api:${{ env.VERSION }} \
|
||||
-t ${{ secrets.DOCKER_USERNAME }}/danmu-api:latest \
|
||||
--push .
|
||||
@@ -6,7 +6,7 @@
|
||||
LogVar 弹幕 API 服务器
|
||||
</h2>
|
||||
|
||||
[](https://github.com/huangxd-/damnu_api)
|
||||
[](https://github.com/huangxd-/danmu_api)
|
||||

|
||||

|
||||

|
||||
@@ -37,6 +37,7 @@ LogVar 弹幕 API 服务器
|
||||
- `POST /api/v2/comment/by-url`:通过视频URL直接获取弹幕。
|
||||
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
|
||||
- **日志记录**:捕获 `console.log`(info 级别)和 `console.error`(error 级别),JSON 内容格式化输出。
|
||||
- **搜索结果缓存**:支持内存缓存搜索结果和弹幕数据,避免短期内重复的不必要API请求,可通过 `SEARCH_CACHE_MINUTES` 环境变量配置缓存时间(默认1分钟)。
|
||||
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Cloudflare 一键部署和 Docker 一键启动。
|
||||
- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。
|
||||
- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔播放链接可获取弹幕,如`senplayer`。
|
||||
@@ -93,6 +94,7 @@ LogVar 弹幕 API 服务器
|
||||
docker run -d -p 9321:9321 --name danmu-api -e TOKEN=87654321 danmu-api
|
||||
```
|
||||
- 使用`-e TOKEN=87654321`设置`TOKEN`环境变量,覆盖Dockerfile中的默认值。
|
||||
- 或使用 `--env-file .env` 加载 .env 文件中的所有环境变量:`docker run -d -p 9321:9321 --name danmu-api --env-file .env danmu-api`
|
||||
|
||||
3. **测试 API**:
|
||||
使用 `http://{ip}:9321/{TOKEN}` 访问上述 API 接口。
|
||||
@@ -108,7 +110,9 @@ LogVar 弹幕 API 服务器
|
||||
docker run -d -p 9321:9321 --name danmu-api -e TOKEN=87654321 logvar/danmu-api:latest
|
||||
```
|
||||
- 使用`-e TOKEN=87654321`设置`TOKEN`环境变量。
|
||||
- 或使用 `--env-file .env` 加载 .env 文件中的所有环境变量:`docker run -d -p 9321:9321 --name danmu-api --env-file .env logvar/danmu-api:latest`
|
||||
|
||||
或使用 docker compose 部署:
|
||||
```yaml
|
||||
services:
|
||||
danmu-api:
|
||||
@@ -118,9 +122,12 @@ LogVar 弹幕 API 服务器
|
||||
- "9321:9321"
|
||||
environment:
|
||||
- TOKEN=87654321 # 请将 87654321 替换为你想自定义的 Token 值
|
||||
# 如需使用 .env 文件,取消下行注释并注释掉上面的 environment 部分
|
||||
# env_file: .env
|
||||
restart: unless-stopped # 可选配置,容器退出时自动重启(非必需,可根据需求删除)
|
||||
```
|
||||
- 或使用docker compose部署。
|
||||
|
||||
可以使用 watchtower 监控有新版本自动更新:
|
||||
```yaml
|
||||
services:
|
||||
watchtower:
|
||||
@@ -137,7 +144,6 @@ LogVar 弹幕 API 服务器
|
||||
- "12600" # 30分钟(1800秒),适合测试
|
||||
- danmu-api # 监控的目标容器名
|
||||
```
|
||||
- 可以使用watchtower监控有新版本自动更新。
|
||||
|
||||
3. **测试 API**:
|
||||
使用 `http://{ip}:9321/{TOKEN}` 访问上述 API 接口。
|
||||
@@ -154,7 +160,7 @@ LogVar 弹幕 API 服务器
|
||||
1. 转到你的项目设置。
|
||||
2. 在“Environment Variables”部分添加 `TOKEN` 变量,输入你的 API 令牌值。
|
||||
3. 保存更改并重新部署。
|
||||
- 示例请求:`https://{your_domian}.vercel.app/87654321/api/v2/search/anime?keyword=子夜归`
|
||||
- 示例请求:`https://{your_domain}.vercel.app/87654321/api/v2/search/anime?keyword=子夜归`
|
||||
|
||||
### 优化点
|
||||
- Settings > Functions > Advanced Setting > Function Region 切换为 Hong Kong,能提高访问速度,体验更优
|
||||
@@ -182,7 +188,7 @@ LogVar 弹幕 API 服务器
|
||||
|
||||
> 注意:部署时请在环境变量配置区域填写你的TOKEN值,该变量将用于API服务的身份验证相关功能
|
||||
>
|
||||
> 示例请求:`https://{your_domian}/{TOKEN}/api/v2/search/anime?keyword=子夜归`确认是否部署成功
|
||||
> 示例请求:`https://{your_domain}/{TOKEN}/api/v2/search/anime?keyword=子夜归`确认是否部署成功
|
||||
>
|
||||
> 部署的时候项目加速区域最好设置为"全球可用区(不含中国大陆)",不然不绑定自定义域名貌似只能生成3小时的预览链接?[相关文档](https://edgeone.cloud.tencent.com/pages/document/175191784523485184)
|
||||
>
|
||||
@@ -207,7 +213,7 @@ LogVar 弹幕 API 服务器
|
||||
2. 转到“Settings” > “Variables”。
|
||||
3. 添加 `TOKEN` 环境变量,输入你的 API 令牌值。
|
||||
4. 保存并部署。
|
||||
- 示例请求:`https://{your_domian}.workers.dev/87654321/api/v2/search/anime?keyword=子夜归`
|
||||
- 示例请求:`https://{your_domain}.workers.dev/87654321/api/v2/search/anime?keyword=子夜归`
|
||||
|
||||
### 手动部署
|
||||
创建一个worker,将`danmu_api/worker.js`里的代码直接拷贝到你创建的`worker.js`里,然后点击部署。
|
||||
@@ -244,23 +250,26 @@ LogVar 弹幕 API 服务器
|
||||
| ----------- | ----------- |
|
||||
| TOKEN | 【可选】自定义用户token,不填默认为`87654321` |
|
||||
| OTHER_SERVER | 【可选】兜底第三方弹幕服务器,不填默认为`https://api.danmu.icu` |
|
||||
| VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`,不填默认为`vod@https://www.caiji.cyou` |
|
||||
| VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`,不填默认为`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top` |
|
||||
| VOD_RETURN_MODE | 【可选】VOD返回模式,可选值:`all`(返回所有站点结果)、`fastest`(只返回最快的站点结果),默认为`fastest`。当配置多个VOD站点时,`all`模式会返回所有站点的结果(结果较多),`fastest`模式只返回首先响应成功的站点结果(结果较少,避免重复) |
|
||||
| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`5000`(5秒),建议值:`3000-10000` |
|
||||
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 |
|
||||
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
|
||||
| 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` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut` |
|
||||
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
|
||||
| ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 |
|
||||
| BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 |
|
||||
| GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 |
|
||||
| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/netlify/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/netlify/cf中理应都自然能联通,不用填写 |
|
||||
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
|
||||
| SEARCH_CACHE_MINUTES | 【可选】搜索结果缓存时间(分钟),默认为`1`,避免短期内重复的不必要API请求,同时保证获取最新的结果列表,可根据需要调整:Vercel/Cloudflare建议`1-5`分钟,Docker可设置`5-30`分钟,设置为`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` |
|
||||
|
||||
```regex
|
||||
# EPISODE_TITLE_FILTER 默认值
|
||||
(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|陪看|高清正片|发布会|.{2,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记
|
||||
(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘
|
||||
|
||||
# 如果你想新增过滤词,请自定义EPISODE_TITLE_FILTER,示例如下,每个词用'|'隔开,增加的词都会追加到默认值后面
|
||||
测试|test
|
||||
@@ -294,7 +303,7 @@ LogVar 弹幕 API 服务器
|
||||
| ----------- | ----------- |
|
||||
| 360 | qiyi, bilibili1, imgo, youku, qq |
|
||||
| vod | qiyi, bilibili1, imgo, youku, qq |
|
||||
| tencent | tencent |
|
||||
| tencent | qq |
|
||||
| renren | renren |
|
||||
| hanjutv | hanjutv |
|
||||
| bahamut | bahamut |
|
||||
@@ -332,12 +341,15 @@ danmu_api/
|
||||
## 注意事项
|
||||
- 日志存储在内存中,服务器重启后会清空。
|
||||
- `/api/logs` 中的 JSON 日志会格式化显示,带缩进以提高可读性。
|
||||
- 搜索结果和弹幕数据存储在内存中,服务器重启后会清空,可通过配置 `UPSTASH_REDIS_REST_URL` 和 `UPSTASH_REDIS_REST_TOKEN` 启用 Redis 持久化存储。
|
||||
- 搜索结果缓存默认时间为 1 分钟,可通过环境变量 `SEARCH_CACHE_MINUTES` 调整(设置为 0 表示不缓存)。
|
||||
- 确保 `package.json` 中包含 `node-fetch` 依赖。
|
||||
- 一键部署需要将项目推送到公开的 Git 仓库(如 GitHub),并更新按钮中的仓库地址。
|
||||
- 运行 Docker 容器时,需通过 `-e TOKEN=87654321` 传递 `TOKEN` 环境变量。
|
||||
- cloudflare貌似被哔风控了。
|
||||
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
|
||||
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`(支持多个服务器并发查询)。
|
||||
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`(支持多个服务器并发查询)。
|
||||
- 当配置多个VOD站点时,可通过`VOD_RETURN_MODE`环境变量控制返回结果方式:`all`(返回所有站点结果)或`fastest`(默认,只返回最快的站点结果,避免结果过多)。
|
||||
- 推荐vercel/netlify部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。
|
||||
- /api/v2/comment接口默认限流:1分钟内同一IP只能请求3次,可通过环境变量`RATE_LIMIT_MAX_REQUESTS`调整(设置为0表示不限流)。
|
||||
|
||||
|
||||
+349
-39
@@ -1,6 +1,15 @@
|
||||
// 加载 .env 文件(仅在 Node.js 环境中有效,Cloudflare Workers 会忽略)
|
||||
if (typeof process !== "undefined" && process.env && !process.env.CLOUDFLARE_ENVIRONMENT) {
|
||||
try {
|
||||
require('dotenv').config();
|
||||
} catch (e) {
|
||||
// dotenv 未安装或加载失败,继续执行
|
||||
}
|
||||
}
|
||||
|
||||
// 全局状态(Cloudflare 和 Vercel 都可能重用实例)
|
||||
// ⚠️ 不是持久化存储,每次冷启动会丢失
|
||||
const VERSION = "1.3.5";
|
||||
const VERSION = "1.4.0";
|
||||
let animes = [];
|
||||
let episodeIds = [];
|
||||
let episodeNum = 10001; // 全局变量,用于自增 ID
|
||||
@@ -19,6 +28,10 @@ const requestHistory = new Map();
|
||||
let redisValid = false;
|
||||
// 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime
|
||||
let lastSelectMap = new Map();
|
||||
// 搜索结果缓存,存储格式:{ keyword: { results, timestamp } }
|
||||
const searchCache = new Map();
|
||||
// 弹幕缓存,存储格式:{ videoUrl: { comments, timestamp } }
|
||||
const commentCache = new Map();
|
||||
|
||||
// =====================
|
||||
// 环境变量处理
|
||||
@@ -43,7 +56,7 @@ function resolveOtherServer(env) {
|
||||
return DEFAULT_OTHER_SERVER;
|
||||
}
|
||||
|
||||
const DEFAULT_VOD_SERVERS = "vod@https://www.caiji.cyou"; // 默认 vod站点配置,格式:名称@URL,名称@URL
|
||||
const DEFAULT_VOD_SERVERS = "金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top"; // 默认 vod站点配置,格式:名称@URL,名称@URL
|
||||
let vodServers = [];
|
||||
|
||||
function resolveVodServers(env) {
|
||||
@@ -78,6 +91,26 @@ function resolveVodServers(env) {
|
||||
return servers;
|
||||
}
|
||||
|
||||
const DEFAULT_VOD_RETURN_MODE = "fastest"; // 默认 vod返回模式:all(所有站点)或 fastest(最快的站点)
|
||||
let vodReturnMode = DEFAULT_VOD_RETURN_MODE;
|
||||
|
||||
function resolveVodReturnMode(env) {
|
||||
let mode = DEFAULT_VOD_RETURN_MODE;
|
||||
if (env && env.VOD_RETURN_MODE) {
|
||||
mode = env.VOD_RETURN_MODE.toLowerCase(); // Cloudflare Workers
|
||||
} else if (typeof process !== "undefined" && process.env?.VOD_RETURN_MODE) {
|
||||
mode = process.env.VOD_RETURN_MODE.toLowerCase(); // Vercel / Node
|
||||
}
|
||||
|
||||
// 验证模式值
|
||||
if (mode !== "all" && mode !== "fastest") {
|
||||
log("warn", `Invalid VOD_RETURN_MODE: ${mode}, using default: ${DEFAULT_VOD_RETURN_MODE}`);
|
||||
return DEFAULT_VOD_RETURN_MODE;
|
||||
}
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
const DEFAULT_BILIBILI_COOKIE = ""; // 默认 bilibili cookie
|
||||
let bilibliCookie = DEFAULT_BILIBILI_COOKIE;
|
||||
|
||||
@@ -166,13 +199,13 @@ function resolvePlatformOrder(env) {
|
||||
return orderArr;
|
||||
}
|
||||
|
||||
const DEFAULT_EPISODE_TITLE_FILTER = "(特别|惊喜|纳凉)?企划|合伙人手记|超前|速览|vlog|reaction|纯享|加更|抢先|抢鲜|预告|花絮|" +
|
||||
"特辑|彩蛋|专访|幕后|直播|未播|衍生|番外|会员|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|" +
|
||||
const DEFAULT_EPISODE_TITLE_FILTER = "(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|" +
|
||||
"特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|" +
|
||||
"花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|" +
|
||||
"NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|" +
|
||||
"剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|抢先看|抢先版|" +
|
||||
"试看版|短剧|精编|会员版|Plus|独家版|特别版|短片|陪看|高清正片|发布会|.{2,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|" +
|
||||
"周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记"; // 默认 剧集标题正则过滤
|
||||
"剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|" +
|
||||
"试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|" +
|
||||
"周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘"; // 默认 剧集标题正则过滤
|
||||
let episodeTitleFilter;
|
||||
|
||||
// 这里既支持 Cloudflare env,也支持 Node process.env
|
||||
@@ -283,10 +316,120 @@ function resolveRateLimitMaxRequests(env) {
|
||||
return DEFAULT_RATE_LIMIT_MAX_REQUESTS;
|
||||
}
|
||||
|
||||
// 集标题过滤开关配置(默认 false,禁用过滤)
|
||||
const DEFAULT_ENABLE_EPISODE_FILTER = false;
|
||||
let enableEpisodeFilter = DEFAULT_ENABLE_EPISODE_FILTER;
|
||||
|
||||
function resolveEnableEpisodeFilter(env) {
|
||||
if (env && env.ENABLE_EPISODE_FILTER !== undefined) {
|
||||
return env.ENABLE_EPISODE_FILTER.toLowerCase() === 'true';
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env?.ENABLE_EPISODE_FILTER !== undefined) {
|
||||
return process.env.ENABLE_EPISODE_FILTER.toLowerCase() === 'true';
|
||||
}
|
||||
return DEFAULT_ENABLE_EPISODE_FILTER;
|
||||
}
|
||||
|
||||
// 搜索结果缓存时间配置(分钟,默认 1)
|
||||
const DEFAULT_SEARCH_CACHE_MINUTES = 1;
|
||||
let searchCacheMinutes = DEFAULT_SEARCH_CACHE_MINUTES;
|
||||
|
||||
function resolveSearchCacheMinutes(env) {
|
||||
if (env && env.SEARCH_CACHE_MINUTES) {
|
||||
const n = parseInt(env.SEARCH_CACHE_MINUTES, 10);
|
||||
if (!Number.isNaN(n) && n > 0) return n;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env?.SEARCH_CACHE_MINUTES) {
|
||||
const n = parseInt(process.env.SEARCH_CACHE_MINUTES, 10);
|
||||
if (!Number.isNaN(n) && n > 0) return n;
|
||||
}
|
||||
return DEFAULT_SEARCH_CACHE_MINUTES;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 数据结构处理函数
|
||||
// =====================
|
||||
|
||||
// =====================
|
||||
// 搜索结果缓存管理
|
||||
// =====================
|
||||
|
||||
// 检查搜索缓存是否有效(未过期)
|
||||
function isSearchCacheValid(keyword) {
|
||||
if (!searchCache.has(keyword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = searchCache.get(keyword);
|
||||
const now = Date.now();
|
||||
const cacheAgeMinutes = (now - cached.timestamp) / (1000 * 60);
|
||||
|
||||
if (cacheAgeMinutes > searchCacheMinutes) {
|
||||
// 缓存已过期,删除它
|
||||
searchCache.delete(keyword);
|
||||
log("info", `Search cache for "${keyword}" expired after ${cacheAgeMinutes.toFixed(2)} minutes`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取搜索缓存
|
||||
function getSearchCache(keyword) {
|
||||
if (isSearchCacheValid(keyword)) {
|
||||
log("info", `Using search cache for "${keyword}"`);
|
||||
return searchCache.get(keyword).results;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 设置搜索缓存
|
||||
function setSearchCache(keyword, results) {
|
||||
searchCache.set(keyword, {
|
||||
results: results,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
log("info", `Cached search results for "${keyword}" (${results.length} animes)`);
|
||||
}
|
||||
|
||||
// 检查弹幕缓存是否有效(未过期)
|
||||
function isCommentCacheValid(videoUrl) {
|
||||
if (!commentCache.has(videoUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = commentCache.get(videoUrl);
|
||||
const now = Date.now();
|
||||
const cacheAgeMinutes = (now - cached.timestamp) / (1000 * 60);
|
||||
|
||||
if (cacheAgeMinutes > searchCacheMinutes) {
|
||||
// 缓存已过期,删除它
|
||||
commentCache.delete(videoUrl);
|
||||
log("info", `Comment cache for "${videoUrl}" expired after ${cacheAgeMinutes.toFixed(2)} minutes`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取弹幕缓存
|
||||
function getCommentCache(videoUrl) {
|
||||
if (isCommentCacheValid(videoUrl)) {
|
||||
log("info", `Using comment cache for "${videoUrl}"`);
|
||||
return commentCache.get(videoUrl).comments;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 设置弹幕缓存
|
||||
function setCommentCache(videoUrl, comments) {
|
||||
commentCache.set(videoUrl, {
|
||||
comments: comments,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
log("info", `Cached comments for "${videoUrl}" (${comments.length} comments)`);
|
||||
}
|
||||
|
||||
// 添加元素到 episodeIds:检查 url 是否存在,若不存在则以自增 id 添加
|
||||
function addEpisode(url, title) {
|
||||
// 检查是否已存在相同的 url 和 title
|
||||
@@ -918,7 +1061,7 @@ async function get360Zongyi(title, entId, site, year) {
|
||||
links.push({
|
||||
"name": episodeInfo.id,
|
||||
"url": episodeInfo.url,
|
||||
"title": `【${site}】 #${episodeInfo.name} ${episodeInfo.period}#`,
|
||||
"title": `【${site}】 ${episodeInfo.name} ${episodeInfo.period}`,
|
||||
"sort": epNum || episodeInfo.sort || null
|
||||
});
|
||||
}
|
||||
@@ -983,6 +1126,16 @@ async function getVodAnimesFromAllServers(title, servers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 根据 vodReturnMode 决定查询策略
|
||||
if (vodReturnMode === "fastest") {
|
||||
return await getVodAnimesFromFastestServer(title, servers);
|
||||
} else {
|
||||
return await getVodAnimesFromAllServersImpl(title, servers);
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有vod站点影片信息(返回所有结果)
|
||||
async function getVodAnimesFromAllServersImpl(title, servers) {
|
||||
// 并发查询所有服务器,使用 allSettled 确保单个服务器失败不影响其他服务器
|
||||
const promises = servers.map(server =>
|
||||
getVodAnimes(title, server.url, server.name)
|
||||
@@ -996,6 +1149,41 @@ async function getVodAnimesFromAllServers(title, servers) {
|
||||
.map(result => result.value);
|
||||
}
|
||||
|
||||
// 查询vod站点影片信息(返回最快的结果)
|
||||
async function getVodAnimesFromFastestServer(title, servers) {
|
||||
if (!servers || servers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 使用 Promise.race 获取最快响应的服务器
|
||||
const promises = servers.map(server =>
|
||||
getVodAnimes(title, server.url, server.name)
|
||||
);
|
||||
|
||||
try {
|
||||
// race 会返回第一个成功的结果
|
||||
const result = await Promise.race(promises);
|
||||
|
||||
// 检查结果是否有效(有数据)
|
||||
if (result && result.list && result.list.length > 0) {
|
||||
log("info", `[VOD fastest mode] 使用最快的服务器: ${result.serverName}`);
|
||||
return [result];
|
||||
}
|
||||
|
||||
// 如果最快的服务器没有数据,继续尝试其他服务器
|
||||
log("info", `[VOD fastest mode] 最快的服务器 ${result.serverName} 无数据,尝试其他服务器`);
|
||||
const allResults = await Promise.allSettled(promises);
|
||||
const validResults = allResults
|
||||
.filter(r => r.status === 'fulfilled' && r.value && r.value.list && r.value.list.length > 0)
|
||||
.map(r => r.value);
|
||||
|
||||
return validResults.length > 0 ? [validResults[0]] : [];
|
||||
} catch (error) {
|
||||
log("error", `[VOD fastest mode] 所有服务器查询失败:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 工具方法
|
||||
// =====================
|
||||
@@ -1495,11 +1683,7 @@ const extractTitle = (title) => {
|
||||
return match ? match[1] : null; // 返回方括号中的内容,若没有匹配到,则返回null
|
||||
};
|
||||
|
||||
// 提取##中的内容并进行匹配
|
||||
const extractEpTitle = (title) => {
|
||||
const match = title.match(/#(.*?)#/); // 提取#号之间的内容
|
||||
return match ? match[1] : null; // 返回#号中的内容,若没有则返回null
|
||||
};
|
||||
|
||||
|
||||
// 解析fileName,提取动漫名称和平台偏好
|
||||
function parseFileName(fileName) {
|
||||
@@ -4038,7 +4222,7 @@ async function handleVodAnimes(animesVod, curAnimes, key) {
|
||||
links.push({
|
||||
"name": count,
|
||||
"url": epInfo[1],
|
||||
"title": `【${platform}】 #${epInfo[0]}#`
|
||||
"title": `【${platform}】 ${epInfo[0]}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4075,7 +4259,7 @@ async function handle360Animes(animes360, curAnimes) {
|
||||
links.push({
|
||||
"name": key,
|
||||
"url": anime.playlinks[key],
|
||||
"title": `【${key}】${anime.titleTxt}(${anime.year})`
|
||||
"title": `【${key}】 ${anime.titleTxt}(${anime.year})`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4086,7 +4270,7 @@ async function handle360Animes(animes360, curAnimes) {
|
||||
links.push({
|
||||
"name": i + 1,
|
||||
"url": item.url,
|
||||
"title": `【${anime.seriesSite}】 #${i + 1}#`
|
||||
"title": `【${anime.seriesSite}】 第${i + 1}集`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4141,7 +4325,7 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) {
|
||||
links.push({
|
||||
"name": ep.episodeIndex,
|
||||
"url": ep.episodeId,
|
||||
"title": `【${ep.provider}】 #${ep.title}#`
|
||||
"title": `【${ep.provider}】 ${ep.title}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4190,7 +4374,7 @@ async function handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes) {
|
||||
links.push({
|
||||
"name": ep.title,
|
||||
"url": ep.pid,
|
||||
"title": `【hanjutv】 #${epTitle}#`
|
||||
"title": `【hanjutv】 ${epTitle}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4234,7 +4418,7 @@ async function handleBahamutAnimes(animesBahamut, queryTitle, curAnimes) {
|
||||
links.push({
|
||||
"name": ep.episode,
|
||||
"url": ep.videoSn.toString(),
|
||||
"title": `【bahamut】 #${epTitle}#`
|
||||
"title": `【bahamut】 ${epTitle}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4280,7 +4464,7 @@ async function handleTencentAnimes(animesTencent, queryTitle, curAnimes) {
|
||||
links.push({
|
||||
"name": i + 1,
|
||||
"url": fullUrl,
|
||||
"title": `【tencent】 #${epTitle}#`
|
||||
"title": `【qq】 ${epTitle}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4317,6 +4501,17 @@ async function searchAnime(url) {
|
||||
const queryTitle = url.searchParams.get("keyword");
|
||||
log("info", `Search anime with keyword: ${queryTitle}`);
|
||||
|
||||
// 检查搜索缓存
|
||||
const cachedResults = getSearchCache(queryTitle);
|
||||
if (cachedResults !== null) {
|
||||
return jsonResponse({
|
||||
errorCode: 0,
|
||||
success: true,
|
||||
errorMessage: "",
|
||||
animes: cachedResults,
|
||||
});
|
||||
}
|
||||
|
||||
const curAnimes = [];
|
||||
|
||||
// 链接弹幕解析
|
||||
@@ -4353,7 +4548,7 @@ async function searchAnime(url) {
|
||||
const links = [{
|
||||
"name": "手动解析链接弹幕",
|
||||
"url": queryTitle,
|
||||
"title": `【${platform}】 #${pageTitle}#`
|
||||
"title": `【${platform}】 ${pageTitle}`
|
||||
}];
|
||||
curAnimes.push(tmpAnime);
|
||||
addAnime({...tmpAnime, links: links});
|
||||
@@ -4432,11 +4627,54 @@ async function searchAnime(url) {
|
||||
|
||||
storeAnimeIdsToMap(curAnimes, queryTitle);
|
||||
|
||||
// 如果启用了集标题过滤,则为每个动漫添加过滤后的 episodes
|
||||
if (enableEpisodeFilter) {
|
||||
const validAnimes = [];
|
||||
for (const anime of curAnimes) {
|
||||
// 首先检查动漫名称是否包含过滤关键词
|
||||
const animeTitle = anime.animeTitle || '';
|
||||
if (episodeTitleFilter.test(animeTitle)) {
|
||||
log("info", `[searchAnime] Anime ${anime.animeId} filtered by name: ${animeTitle}`);
|
||||
continue; // 跳过该动漫
|
||||
}
|
||||
|
||||
const animeData = animes.find(a => a.animeId === anime.animeId);
|
||||
if (animeData && animeData.links) {
|
||||
let episodesList = animeData.links.map((link, index) => ({
|
||||
episodeId: link.id,
|
||||
episodeTitle: link.title,
|
||||
episodeNumber: index + 1
|
||||
}));
|
||||
|
||||
// 应用过滤
|
||||
episodesList = episodesList.filter(episode => {
|
||||
return !episodeTitleFilter.test(episode.episodeTitle);
|
||||
});
|
||||
|
||||
log("info", `[searchAnime] Anime ${anime.animeId} filtered episodes: ${episodesList.length}/${animeData.links.length}`);
|
||||
|
||||
// 只有当过滤后还有有效剧集时才保留该动漫
|
||||
if (episodesList.length > 0) {
|
||||
anime.episodes = episodesList;
|
||||
validAnimes.push(anime);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 用过滤后的动漫列表替换原列表
|
||||
curAnimes.length = 0;
|
||||
curAnimes.push(...validAnimes);
|
||||
}
|
||||
|
||||
// 如果有新的anime获取到,则更新redis
|
||||
if (redisValid && curAnimes.length !== 0) {
|
||||
await updateCaches();
|
||||
}
|
||||
|
||||
// 缓存搜索结果
|
||||
if (curAnimes.length > 0) {
|
||||
setSearchCache(queryTitle, curAnimes);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
errorCode: 0,
|
||||
success: true,
|
||||
@@ -4447,13 +4685,9 @@ async function searchAnime(url) {
|
||||
|
||||
function filterSameEpisodeTitle(filteredTmpEpisodes) {
|
||||
const filteredEpisodes = filteredTmpEpisodes.filter((episode, index, episodes) => {
|
||||
const filterEp = extractEpTitle(episode.episodeTitle);
|
||||
// 如果没有提取到标题,返回 true 保留该 episode
|
||||
if (!filterEp) return true;
|
||||
// 查找当前 episode 标题是否在之前的 episodes 中出现过
|
||||
return !episodes.slice(0, index).some(prevEpisode => {
|
||||
const prevFilterEp = extractEpTitle(prevEpisode.episodeTitle);
|
||||
return prevFilterEp === filterEp;
|
||||
return prevEpisode.episodeTitle === episode.episodeTitle;
|
||||
});
|
||||
});
|
||||
return filteredEpisodes;
|
||||
@@ -4474,8 +4708,7 @@ async function matchAniAndEp(season, episode, searchData, title, req, platform,
|
||||
|
||||
// 过滤集标题正则条件的 episode
|
||||
const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => {
|
||||
const filterEp = extractEpTitle(episode.episodeTitle);
|
||||
return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式
|
||||
return !episodeTitleFilter.test(episode.episodeTitle);
|
||||
});
|
||||
|
||||
// 过滤集标题一致的 episode,且保留首次出现的集标题的 episode
|
||||
@@ -4546,8 +4779,7 @@ async function fallbackMatchAniAndEp(searchData, req, season, episode, resEpisod
|
||||
if (season && episode) {
|
||||
// 过滤集标题正则条件的 episode
|
||||
const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => {
|
||||
const filterEp = extractEpTitle(episode.episodeTitle);
|
||||
return filterEp && !episodeTitleFilter.test(filterEp); // 如果##中的内容匹配正则表达式
|
||||
return !episodeTitleFilter.test(episode.episodeTitle);
|
||||
});
|
||||
|
||||
// 过滤集标题一致的 episode,且保留首次出现的集标题的 episode
|
||||
@@ -4825,17 +5057,38 @@ async function getBangumi(path) {
|
||||
},
|
||||
};
|
||||
|
||||
// 构建 episodes 列表
|
||||
let episodesList = [];
|
||||
for (let i = 0; i < anime.links.length; i++) {
|
||||
const link = anime.links[i];
|
||||
resData["bangumi"]["episodes"].push({
|
||||
seasonId: `season-${anime.animeId}`,
|
||||
episodeId: link.id,
|
||||
episodeTitle: `${link.title}`,
|
||||
episodeNumber: `${i+1}`,
|
||||
airDate: anime.startDate,
|
||||
});
|
||||
episodesList.push({
|
||||
seasonId: `season-${anime.animeId}`,
|
||||
episodeId: link.id,
|
||||
episodeTitle: `${link.title}`,
|
||||
episodeNumber: `${i+1}`,
|
||||
airDate: anime.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果启用了集标题过滤,则应用过滤
|
||||
if (enableEpisodeFilter) {
|
||||
episodesList = episodesList.filter(episode => {
|
||||
return !episodeTitleFilter.test(episode.episodeTitle);
|
||||
});
|
||||
log("info", `[getBangumi] Episode filter enabled. Filtered episodes: ${episodesList.length}/${anime.links.length}`);
|
||||
|
||||
// 如果过滤后没有有效剧集,返回错误
|
||||
if (episodesList.length === 0) {
|
||||
log("warn", `[getBangumi] No valid episodes after filtering for anime ID ${idParam}`);
|
||||
return jsonResponse(
|
||||
{ errorCode: 404, success: false, errorMessage: "No valid episodes after filtering", bangumi: null },
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resData["bangumi"]["episodes"] = episodesList;
|
||||
|
||||
return jsonResponse(resData);
|
||||
}
|
||||
|
||||
@@ -4860,6 +5113,12 @@ async function getComment(path) {
|
||||
url = convertYoukuUrl(url);
|
||||
}
|
||||
|
||||
// 检查弹幕缓存
|
||||
const cachedComments = getCommentCache(url);
|
||||
if (cachedComments !== null) {
|
||||
return jsonResponse({ count: cachedComments.length, comments: cachedComments });
|
||||
}
|
||||
|
||||
log("info", "开始从本地请求弹幕...", url);
|
||||
let danmus = [];
|
||||
if (url.includes('.qq.com')) {
|
||||
@@ -4897,6 +5156,11 @@ async function getComment(path) {
|
||||
await setRedisKey('lastSelectMap', lastSelectMap);
|
||||
}
|
||||
|
||||
// 缓存弹幕结果
|
||||
if (danmus.length > 0) {
|
||||
setCommentCache(url, danmus);
|
||||
}
|
||||
|
||||
return jsonResponse({ count: danmus.length, comments: danmus });
|
||||
}
|
||||
|
||||
@@ -4934,6 +5198,18 @@ async function getCommentByUrl(req) {
|
||||
url = convertYoukuUrl(url);
|
||||
}
|
||||
|
||||
// 检查弹幕缓存
|
||||
const cachedComments = getCommentCache(url);
|
||||
if (cachedComments !== null) {
|
||||
return jsonResponse({
|
||||
errorCode: 0,
|
||||
success: true,
|
||||
errorMessage: "",
|
||||
count: cachedComments.length,
|
||||
comments: cachedComments
|
||||
});
|
||||
}
|
||||
|
||||
log("info", "开始从本地请求弹幕...", url);
|
||||
let danmus = [];
|
||||
|
||||
@@ -4958,6 +5234,11 @@ async function getCommentByUrl(req) {
|
||||
|
||||
log("info", `Successfully fetched ${danmus.length} comments from URL`);
|
||||
|
||||
// 缓存弹幕结果
|
||||
if (danmus.length > 0) {
|
||||
setCommentCache(url, danmus);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
errorCode: 0,
|
||||
success: true,
|
||||
@@ -4981,7 +5262,9 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
otherServer = resolveOtherServer(env);
|
||||
envs["otherServer"] = otherServer;
|
||||
vodServers = resolveVodServers(env);
|
||||
vodReturnMode = resolveVodReturnMode(env);
|
||||
envs["vodServers"] = vodServers.map(s => `${s.name}@${s.url}`).join(',');
|
||||
envs["vodReturnMode"] = vodReturnMode;
|
||||
bilibliCookie = resolveBilibiliCookie(env);
|
||||
envs["bilibliCookie"] = encryptStr(bilibliCookie);
|
||||
youkuConcurrency = resolveYoukuConcurrency(env);
|
||||
@@ -4998,8 +5281,12 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
envs["groupMinute"] = groupMinute;
|
||||
rateLimitMaxRequests = resolveRateLimitMaxRequests(env);
|
||||
envs["rateLimitMaxRequests"] = rateLimitMaxRequests;
|
||||
enableEpisodeFilter = resolveEnableEpisodeFilter(env);
|
||||
envs["enableEpisodeFilter"] = enableEpisodeFilter;
|
||||
proxyUrl = resolveProxyUrl(env);
|
||||
envs["proxyUrl"] = proxyUrl;
|
||||
searchCacheMinutes = resolveSearchCacheMinutes(env);
|
||||
envs["searchCacheMinutes"] = searchCacheMinutes;
|
||||
redisUrl = resolveRedisUrl(env);
|
||||
envs["redisUrl"] = encryptStr(redisUrl);
|
||||
redisToken = resolveRedisToken(env);
|
||||
@@ -5127,7 +5414,28 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
|
||||
// GET /api/v2/comment/:commentId
|
||||
if (path.startsWith("/api/v2/comment/") && method === "GET") {
|
||||
// 限流检查(如果 rateLimitMaxRequests > 0 则启用限流)
|
||||
// ⚠️ 限流设计说明:
|
||||
// 1. 先检查缓存,缓存命中时直接返回,不计入限流次数
|
||||
// 2. 只有缓存未命中时才执行限流检查和网络请求
|
||||
// 这样可以避免频繁访问同一弹幕时被限流,提高用户体验
|
||||
const commentId = parseInt(path.split("/").pop());
|
||||
let url = findUrlById(commentId);
|
||||
|
||||
if (url) {
|
||||
// 处理302场景
|
||||
if (url.includes("youku.com/video?vid")) {
|
||||
url = convertYoukuUrl(url);
|
||||
}
|
||||
|
||||
// 检查弹幕缓存 - 缓存命中时直接返回,不计入限流
|
||||
const cachedComments = getCommentCache(url);
|
||||
if (cachedComments !== null) {
|
||||
log("info", `[Rate Limit] Cache hit for URL: ${url}, skipping rate limit check`);
|
||||
return jsonResponse({ count: cachedComments.length, comments: cachedComments });
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中,执行限流检查(如果 rateLimitMaxRequests > 0 则启用限流)
|
||||
if (rateLimitMaxRequests > 0) {
|
||||
// 获取当前时间戳(单位:毫秒)
|
||||
const currentTime = Date.now();
|
||||
@@ -5164,6 +5472,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
|
||||
// 更新该 IP 地址的请求历史
|
||||
requestHistory.set(clientIp, recentRequests);
|
||||
log("info", `[Rate Limit] Request counted for IP: ${clientIp}, count: ${recentRequests.length}/${rateLimitMaxRequests}`);
|
||||
}
|
||||
|
||||
return getComment(path);
|
||||
@@ -5254,4 +5563,5 @@ export async function netlifyHandler(event, context) {
|
||||
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, getCommentByUrl, fetchTencentVideo, fetchIqiyi,
|
||||
fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes,
|
||||
getHanjutvComments, getHanjutvDetail, bahamutSearch, getBahamutEpisodes, getBahamutComments, tencentSearch, getTencentEpisodes,
|
||||
pingRedis, getRedisKey, setRedisKey, setRedisKeyWithExpiry};
|
||||
pingRedis, getRedisKey, setRedisKey, setRedisKeyWithExpiry, getSearchCache, setSearchCache, isSearchCacheValid,
|
||||
getCommentCache, setCommentCache, isCommentCacheValid};
|
||||
@@ -1,3 +1,6 @@
|
||||
// 加载 .env 文件
|
||||
require('dotenv').config();
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert').strict;
|
||||
const { handleRequest, searchAnime, matchAnime, searchEpisode, getBangumi, getComment, fetchTencentVideo, fetchIqiyi,
|
||||
|
||||
Reference in new issue
Block a user