增加埋堆堆源并增加ai匹配 (#197)

* 增加埋堆堆源

* match自动匹配接口系只能ai匹配功能,需根据自身使用模型配置AI_BASE_URL/AI_MODEL/AI_API_KEY/AI_MATCH_PROMPT四个环境变量

* 增加ai连通性测试按钮

* 优化ai match自动匹配

* hanjutv增加弹幕点赞数显示
This commit is contained in:
huangxd- authored and GitHub committed 2026-02-23 22:27:27 +08:00
1 parent bb75600818
commit 0153ebc929
15 files changed
+949 -42

No files matched your search

+53 -6
View File
@@ -49,7 +49,7 @@ LogVar 弹幕 API 服务器
## 功能
- **API 接口**:
- `GET /api/v2/search/anime?keyword=${queryTitle}`:根据关键字搜索动漫。
- `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`;已支持从网盘资源命名,如`无忧渡.S01E01.2160p.WEB-DL.H265.DDP.5.1`中提取 title/season/episode);已支持外语标题匹配,如`Blood.River.S01E05`,需配置环境变量`TITLE_TO_CHINESE`使用;已适配该格式`爱情公寓.ipartment.2009.S03E05.H.265.25fps.mkv`标题
- `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`;已支持从网盘资源命名,如`无忧渡.S01E01.2160p.WEB-DL.H265.DDP.5.1`中提取 title/season/episode);已支持外语标题匹配,如`Blood.River.S01E05`,需配置环境变量`TITLE_TO_CHINESE`使用;已适配该格式`爱情公寓.ipartment.2009.S03E05.H.265.25fps.mkv`标题;已支持AI自动匹配,需配合AI相关环境变量使用
- `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。
- `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。
- `GET /api/v2/comment/:commentId?format=json`:获取指定弹幕评论,支持返回相关评论和字符转换。
@@ -373,9 +373,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 |
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 |
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko,custom` |
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom` |
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko,imgo`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,animeko` |
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,renren,hanjutv,bahamut,dandan,animeko,custom` |
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, renren, hanjutv, bahamut, dandan, animeko, custom` |
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko,imgo`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,renren,hanjutv,bahamut,dandan,animeko` |
| ANIME_TITLE_FILTER | 【可选】剧名过滤规则,用于按正则表达式对剧名进行过滤,适用于过滤一些不需要的剧集,需开启ENABLE_ANIME_EPISODE_FILTER,默认值:空(不过滤),格式:使用 \| 分隔多个关键词,例如:广告\|预告\|无关剧名 |
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
| ENABLE_ANIME_EPISODE_FILTER | 【可选】控制手动搜索的时候是否根据ANIME_TITLE_FILTER进行剧名过滤以及根据EPISODE_TITLE_FILTER进行集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 |
@@ -404,6 +404,10 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| DEPLOY_PLATFROM_PROJECT | 【可选】部署项目名称,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| DEPLOY_PLATFROM_TOKEN | 【可选】部署平台token,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| NODE_TLS_REJECT_UNAUTHORIZED | 【可选】在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略,默认为1 |
| AI_BASE_URL | 【可选】AI服务的基础URL地址,用于配置AI相关功能的API端点,不填默认为https://api.openai.com/v1 |
| AI_MODEL | 【可选】AI模型名称,指定使用的AI模型,不填默认为gpt-4o |
| AI_API_KEY | 【可选】AI服务的API密钥,用于身份验证,默认为空,需手动填写 |
| AI_MATCH_PROMPT | 【可选】AI匹配提示词,用于自定义AI匹配行为,不填提供默认提示词,提示词如下 |
```regex
# EPISODE_TITLE_FILTER 默认值
@@ -415,7 +419,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
```regex
# BLOCKED_WORDS 示例值
/.{20,}/,/^\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\u4e00-\u9fa5])\1{2,}/,/[0-9]+\.*[0-9]*\s*(w|万)+\s*(\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\d{5,}$/,/^(\d)\1{2,}$/,/\d{1,4}/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\d{1,2}[.-]\d{1,2}/,/[@#&$%^*+\|/\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\d]+刷/,/第[一二三四五六七八九十百\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/
/.{20,}/,/^\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\u4e00-\u9fa5])\1{2,}/,/[0-9]+\.*[0-9]*\s*(w|万)+\s*(\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\d{5,}$/,/^(\d)\1{2,}$/,/^\d{1,4}$/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\d{1,2}[.-]\d{1,2}/,/[@#&$%^*+\|/\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\d]+刷/,/第[一二三四五六七八九十百\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/
# 注释如下:
/.{20,}/ # 屏蔽20字符及以上的弹幕
@@ -426,7 +430,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/ # 屏蔽键盘连续行的弹幕
/^\d{5,}$/ # 屏蔽5位及以上纯数字的弹幕
/^(\d)\1{2,}$/ # 屏蔽三个及以上相同数字重复的弹幕
/\d{1,4}/ # 屏蔽1-4位数字的弹幕
/^\d{1,4}$/ # 屏蔽1-4位数字的弹幕
/(20[0-3][0-9])/ # 屏蔽2000-2039年份相关的弹幕
/(0?[1-9]|1[0-2])月/ # 屏蔽月份表述的弹幕
/\d{1,2}[.-]\d{1,2}/ # 屏蔽类似时间或日期分隔的数字弹幕
@@ -436,6 +440,46 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/ # 屏蔽常见互动、报到或口语化弹幕词汇
```
```shell
# AI_MATCH_PROMPT 默认值
你是一个专业的影视匹配专家,你的的任务是根据用户提供的 JSON 数据,从候选动漫列表中匹配最符合条件的动漫及集数。
输入字段说明:
- title: 查询标题
- season: 季数(可为 null)
- episode: 集数(可为 null)
- year: 年份(可为 null)
- dynamicPlatformOrder: 平台偏好列表(可为 null)
- preferAnimeId: 偏好动漫 ID(可为 null)
- animes: 候选动漫列表
- animeId: 动漫id
animeTitle: 动漫标题
type: 类型
year: 发布年份
episodeCount: 总集数
source: 弹幕来源
匹配规则 (按优先级排序):
1. 如果preferAnimeId非空,且animes存在该animeId,则返回该id对应的anime和episode
2. 标题相似度: 优先匹配标题相似度最高的条目
3. 季度严格匹配: 如果指定了季度,必须严格匹配
4. 类型匹配: episode为空则优先匹配电影,非空则匹配电视剧等
5. 年份接近: 优先选择年份接近的
6. 平台匹配:如果有多个高度相似的结果且dynamicPlatformOrder非空,则从前往后选择相对应的平台
7. 集数完整: 如果有多个高度相似的结果,选择集数最完整的
请分析哪个动漫最符合查询条件,如果指定了季数和集数,请也返回对应的集信息。
请严格按照以下 JSON 格式返回结果,不要包含任何其他内容:
{
"animeIndex": 匹配的动漫在列表中的索引(从0开始) 或 null
}
如果没有找到合适的匹配,返回:
{
"animeIndex": null
}
```
## 采集源及对应平台列表
| 采集源 | 平台列表 |
| ----------- | ----------- |
@@ -452,6 +496,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| sohu | sohu |
| leshi | leshi |
| xigua | xigua |
| maiduidui| maiduidui |
| renren | renren |
| hanjutv | hanjutv |
| bahamut | bahamut |
@@ -507,6 +552,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ │ ├── iqiyi.js # 爱奇艺源
│ │ ├── kan360.js # 360看源
│ │ ├── leshi.js # 乐视视频源
│ │ ├── maiduidui.js # 埋堆堆源
│ │ ├── mango.js # 芒果TV源
│ │ ├── migu.js # 咪咕视频源
│ │ ├── other.js # 第三方弹幕服务器
@@ -534,6 +580,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ │ ├── requestrecords.js # 请求记录脚本
│ │ └── systemsettings.js # 系统设置脚本
│ └── utils/
│ ├── ai-util.js # AI相关处理工具
│ ├── cache-util.js # 缓存数据处理工具
│ ├── codec-util.js # 编解码工具
│ ├── common-util.js # 通用工具
+23 -5
View File
@@ -73,9 +73,9 @@ YOUKU_CONCURRENCY=8
# ==================== 源排序配置 ====================
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko, custom)
# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, migu, sohu , leshi, xigua等)
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源、乐视源、西瓜源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,migu,renren,hanjutv,sohu,leshi,xigua
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, animeko, custom)
# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, migu, sohu , leshi, xigua, maiduidui等)
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源、乐视源、西瓜源、埋堆堆源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,migu,renren,hanjutv,sohu,leshi,xigua,maiduidui
# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut
# 如需使用弹弹play源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,dandan
# 如需使用豆瓣源,请手动添加:SOURCE_ORDER=360,vod,douban,renren,hanjutv
@@ -86,12 +86,12 @@ SOURCE_ORDER=360,vod,renren,hanjutv
# 平台优先级排序(用于自动匹配)
# 默认值:空(自动匹配)
# 可选值:qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
# 可选值:qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, renren, hanjutv, bahamut, dandan, animeko, custom 或 qiyi&bilibili1&imgo,dandan&animeko
# 说明:平台排序配置,可以配置自动匹配时的优选平台,当配置合并平台的时候可以指定期望的合并源
# PLATFORM_ORDER=
# ==================== 源合并配置 ====================
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, animeko)
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, migu, renren, hanjutv, bahamut, dandan, sohu, leshi, xigua, maiduidui, animeko)
# 默认值:空
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环
# 格式:源字段&源字段&源字段,源字段
@@ -308,3 +308,21 @@ ANIME_TITLE_SIMPLIFIED=false
# 默认值:空(不过滤)
# 说明:使用 | 分隔多个关键词,例如:广告|预告|无关剧名
ANIME_TITLE_FILTER=
# ==================== AI 配置 ====================
# AI服务的基础URL地址,用于配置AI相关功能的API端点
# 默认值:https://api.openai.com/v1
# AI_BASE_URL=
# AI模型名称,指定使用的AI模型
# 默认值:gpt-4o
# AI_MODEL=
# AI服务的API密钥,用于身份验证
# 默认值:空(不使用AI功能)
# AI_API_KEY=
# AI匹配提示词,用于自定义AI匹配行为
# 默认值:空(使用默认提示词)
# AI_MATCH_PROMPT=
+141 -14
View File
@@ -12,6 +12,7 @@ import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.j
import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, extractYear } from "../utils/common-util.js";
import { getTMDBChineseTitle } from "../utils/tmdb-util.js";
import { applyMergeLogic, mergeDanmakuList, MERGE_DELIMITER } from "../utils/merge-util.js";
import AIClient from '../utils/ai-util.js';
import Kan360Source from "../sources/kan360.js";
import VodSource from "../sources/vod.js";
import TmdbSource from "../sources/tmdb.js";
@@ -30,6 +31,7 @@ import YoukuSource from "../sources/youku.js";
import SohuSource from "../sources/sohu.js";
import LeshiSource from "../sources/leshi.js";
import XiguaSource from "../sources/xigua.js";
import MaiduiduiSource from "../sources/maiduidui.js";
import AnimekoSource from "../sources/animeko.js";
import OtherSource from "../sources/other.js";
import { Anime, AnimeMatch, Episodes, Bangumi } from "../models/dandan-model.js";
@@ -54,6 +56,7 @@ const miguSource = new MiguSource();
const sohuSource = new SohuSource();
const leshiSource = new LeshiSource();
const xiguaSource = new XiguaSource();
const maiduiduiSource = new MaiduiduiSource();
const animekoSource = new AnimekoSource();
const otherSource = new OtherSource();
const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource, miguSource);
@@ -175,6 +178,8 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
platform = "leshi";
} else if (queryTitle.includes('.douyin.com') || queryTitle.includes('.ixigua.com')) {
platform = "xigua";
} else if (queryTitle.includes('.mddcloud.com.cn')) {
platform = "maiduidui";
}
const pageTitle = await getPageTitle(queryTitle);
@@ -227,6 +232,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
if (source === "sohu") return sohuSource.search(queryTitle);
if (source === "leshi") return leshiSource.search(queryTitle);
if (source === "xigua") return xiguaSource.search(queryTitle);
if (source === "maiduidui") return maiduiduiSource.search(queryTitle);
if (source === "animeko") return animekoSource.search(queryTitle);
});
@@ -246,7 +252,8 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
vod: animesVodResults, 360: animes360, tmdb: animesTmdb, douban: animesDouban, renren: animesRenren,
hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom,
tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili,
migu: animesMigu, sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, animeko: animesAnimeko
migu: animesMigu, sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, maiduidui: animesMaiduidui,
animeko: animesAnimeko
} = resultData;
// 按顺序处理每个来源的结果
@@ -311,6 +318,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
} else if (key === 'xigua') {
// 等待处理Xigua来源
await xiguaSource.handleAnimes(animesXigua, queryTitle, curAnimes);
} else if (key === 'maiduidui') {
// 等待处理Maiduidui来源
await maiduiduiSource.handleAnimes(animesMaiduidui, queryTitle, curAnimes);
} else if (key === 'animeko') {
// 等待处理Animeko来源
await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes);
@@ -511,6 +521,107 @@ function findEpisodeByNumber(filteredEpisodes, targetEpisode, platform = null) {
return null;
}
async function matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId) {
const aiBaseUrl = globals.aiBaseUrl;
const aiModel = globals.aiModel;
const aiApiKey = globals.aiApiKey;
const aiMatchPrompt = globals.aiMatchPrompt;
if (!globals.aiValid || !aiMatchPrompt) {
log("warn", "AI configuration is incomplete, falling back to normal matching");
return { resEpisode: null, resAnime: null };
}
const aiClient = new AIClient({
apiKey: aiApiKey,
baseURL: aiBaseUrl,
model: aiModel,
systemPrompt: aiMatchPrompt
});
const matchData = {
title,
season,
episode,
year,
dynamicPlatformOrder,
preferAnimeId,
animes: searchData.animes.map(anime => {
const normalizedAnimeTitle = anime.animeTitle || '';
const match = normalizedAnimeTitle.match(/^(.*?)\(\d{4}\)/);
const title = match ? match[1].trim() : normalizedAnimeTitle.split("(")[0].trim();
return {
animeId: anime.animeId,
animeTitle: title,
type: anime.type,
year: anime.startDate ? anime.startDate.slice(0, 4) : null,
episodeCount: anime.episodeCount,
source: anime.source
};
})
};
try {
// userPrompt 只传入结构化数据
const userPrompt = JSON.stringify(matchData, null, 2);
const aiResponse = await aiClient.ask(userPrompt);
// const aiResponse = '{ "animeIndex": 0 }';
log("info", `AI match response: ${aiResponse}`);
let parsedResponse;
try {
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```|```([\s\S]*?)\s*```|({[\s\S]*})/);
const jsonString = jsonMatch ? (jsonMatch[1] || jsonMatch[2] || jsonMatch[3]) : aiResponse;
parsedResponse = JSON.parse(jsonString.trim());
} catch (parseError) {
log("error", `Failed to parse AI response: ${parseError.message}`);
return { resEpisode: null, resAnime: null };
}
const animeIndex = parsedResponse.animeIndex;
if (animeIndex === null || animeIndex === undefined) {
return { resEpisode: null, resAnime: null };
}
const selectedAnime = searchData.animes[animeIndex];
if (!selectedAnime) {
log("error", `AI returned invalid anime index: ${animeIndex}`);
return { resEpisode: null, resAnime: null };
}
let originBangumiUrl = new URL(req.url.replace("/match", `bangumi/${selectedAnime.animeId}`));
const bangumiRes = await getBangumi(originBangumiUrl.pathname);
const bangumiData = await bangumiRes.json();
let filteredEpisode = null;
if (season && episode) {
// 剧集模式逻辑
const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(episode => {
return !globals.episodeTitleFilter.test(episode.episodeTitle);
});
const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes);
log("info", "过滤后的集标题", filteredEpisodes.map(episode => episode.episodeTitle));
// 匹配集数 (注意:findEpisodeByNumber 已增强支持模糊平台匹配)
filteredEpisode = findEpisodeByNumber(filteredEpisodes, episode);
} else {
// 电影模式逻辑
if (bangumiData.bangumi.episodes.length > 0) {
filteredEpisode = bangumiData.bangumi.episodes[0];
}
}
return { resEpisode: filteredEpisode, resAnime: selectedAnime };
} catch (error) {
log("error", `AI matching failed: ${error.message}`);
return { resEpisode: null, resAnime: null };
}
}
async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId) {
// 定义最佳匹配结果容器
let bestRes = {
@@ -829,22 +940,31 @@ export async function matchAnime(url, req) {
log("info", `Dynamic platformOrder: ${dynamicPlatformOrder}`);
log("info", `Preferred platform: ${preferredPlatform || 'none'}`);
for (const platform of dynamicPlatformOrder) {
const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
// 尝试使用AI进行匹配
const aiMatchResult = await matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId);
if (aiMatchResult.resAnime) {
resAnime = aiMatchResult.resAnime;
resEpisode = aiMatchResult.resEpisode;
log("info", `AI match found: ${resAnime.animeTitle}; episode: ${resEpisode.episodeTitle}`);
} else {
// AI匹配失败或未配置,使用传统匹配方式
for (const platform of dynamicPlatformOrder) {
const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
if (resAnime) {
log("info", `Found match with platform: ${platform || 'default'}`);
break;
if (resAnime) {
log("info", `Found match with platform: ${platform || 'default'}`);
break;
}
}
}
// 如果都没有找到则返回第一个满足剧集数的剧集
if (!resAnime) {
const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
// 如果都没有找到则返回第一个满足剧集数的剧集
if (!resAnime) {
const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
}
}
let resData = {
@@ -1135,6 +1255,7 @@ async function fetchMergedComments(url) {
else if (sourceName === 'sohu') sourceInstance = sohuSource;
else if (sourceName === 'leshi') sourceInstance = leshiSource;
else if (sourceName === 'xigua') sourceInstance = xiguaSource;
else if (sourceName === 'maiduidui') sourceInstance = maiduiduiSource;
else if (sourceName === 'animeko') sourceInstance = animekoSource;
// 如有新增允许的源合并,在此处添加
@@ -1231,6 +1352,8 @@ export async function getComment(path, queryFormat, segmentFlag) {
danmus = await leshiSource.getComments(url, plat, segmentFlag);
} else if (url.includes('.douyin.com') || url.includes('.ixigua.com')) {
danmus = await xiguaSource.getComments(url, plat, segmentFlag);
} else if (url.includes('.mddcloud.com.cn')) {
danmus = await maiduiduiSource.getComments(url, plat, segmentFlag);
}
// 请求其他平台弹幕
@@ -1346,6 +1469,8 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) {
danmus = await leshiSource.getComments(url, "leshi", segmentFlag);
} else if (url.includes('.douyin.com') || url.includes('.ixigua.com')) {
danmus = await xiguaSource.getComments(url, "xigua", segmentFlag);
} else if (url.includes('.mddcloud.com.cn')) {
danmus = await maiduiduiSource.getComments(url, "maiduidui", segmentFlag);
} else {
// 如果不是已知平台,尝试第三方弹幕服务器
const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i;
@@ -1433,6 +1558,8 @@ export async function getSegmentComment(segment, queryFormat) {
danmus = await leshiSource.getSegmentComments(segment);
} else if (platform === "xigua") {
danmus = await xiguaSource.getSegmentComments(segment);
} else if (platform === "maiduidui") {
danmus = await maiduiduiSource.getSegmentComments(segment);
} else if (platform === "hanjutv") {
danmus = await hanjutvSource.getSegmentComments(segment);
} else if (platform === "bahamut") {
+55
View File
@@ -1,6 +1,7 @@
import { jsonResponse } from '../utils/http-util.js';
import { HandlerFactory } from '../configs/handlers/handler-factory.js';
import { globals } from '../configs/globals.js';
import AIClient from '../utils/ai-util.js';
/**
* 处理设置环境变量的请求
@@ -94,3 +95,57 @@ export async function handleDelEnv(request) {
return jsonResponse({ success: false, message: `删除环境变量失败: ${error.message}` }, 500);
}
}
/**
* 处理AI连通性验证请求
*/
export async function handleAiVerify(request) {
try {
const body = await request.json();
// 从请求体获取配置,如果没有则使用全局配置
const aiBaseUrl = body.aiBaseUrl || globals.aiBaseUrl || 'https://api.openai.com/v1';
const aiModel = body.aiModel || globals.aiModel || 'gpt-4o';
const aiApiKey = body.aiApiKey || globals.aiApiKey;
if (!aiApiKey) {
return jsonResponse({
success: false,
ok: false,
message: 'AI_API_KEY 未配置'
}, 400);
}
// 创建 AI 客户端
const ai = new AIClient({
baseURL: aiBaseUrl,
model: aiModel,
apiKey: aiApiKey,
systemPrompt: '回答尽量简洁',
});
// 执行验证
const status = await ai.verify();
if (status.ok) {
return jsonResponse({
success: true,
ok: true,
message: 'AI 服务连通性测试成功'
});
} else {
return jsonResponse({
success: false,
ok: false,
message: `AI 服务连通性测试失败: ${status.error}`
}, 200);
}
} catch (error) {
console.error('AI 连通性验证失败:', error);
return jsonResponse({
success: false,
ok: false,
message: `AI 连通性验证失败: ${error.message}`
}, 500);
}
}
+1
View File
@@ -32,6 +32,7 @@ export function handleConfig(hasPermission = false) {
...globals.accessedEnvVars,
localCacheValid: globals.localCacheValid,
redisValid: globals.redisValid,
aiValid: globals.aiValid,
deployPlatform: globals.deployPlatform
};
+48 -4
View File
@@ -9,10 +9,46 @@ export class Envs {
static originalEnvVars = new Map();
static accessedEnvVars = new Map();
static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi', 'xigua']; // vod允许的播放平台
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko', 'custom']; // 全部源允许的播放平台
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko', 'custom']; // 允许的源
static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'animeko']; // 允许的源合并
static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui']; // vod允许的播放平台
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'animeko', 'custom']; // 全部源允许的播放平台
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'animeko', 'custom']; // 允许的源
static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'xigua', 'maiduidui', 'animeko']; // 允许的源合并
static DEFAULT_AI_MATCH_PROMPT = `你是一个专业的影视匹配专家,你的的任务是根据用户提供的 JSON 数据,从候选动漫列表中匹配最符合条件的动漫及集数。
输入字段说明:
- title: 查询标题
- season: 季数(可为 null)
- episode: 集数(可为 null)
- year: 年份(可为 null)
- dynamicPlatformOrder: 平台偏好列表(可为 null)
- preferAnimeId: 偏好动漫 ID(可为 null)
- animes: 候选动漫列表
- animeId: 动漫id
animeTitle: 动漫标题,(年份)前面才是真实的标题
type: 类型
startDate: 发布日期,有年份
episodeCount: 总集数
source: 弹幕来源
匹配规则 (按优先级排序):
1. 如果preferAnimeId非空,且animes存在该animeId,则返回该id对应的anime和episode
2. 标题相似度: 优先匹配标题相似度最高的条目
3. 季度严格匹配: 如果指定了季度,必须严格匹配
4. 类型匹配: episode为空则优先匹配电影,非空则匹配电视剧等
5. 年份接近: 优先选择年份接近的
6. 平台匹配:如果有多个高度相似的结果且dynamicPlatformOrder非空,则从前往后选择相对应的平台
7. 集数完整: 如果有多个高度相似的结果,选择集数最完整的
请分析哪个动漫最符合查询条件,如果指定了季数和集数,请也返回对应的集信息。
请严格按照以下 JSON 格式返回结果,不要包含任何其他内容:
{
"animeIndex": 匹配的动漫在列表中的索引(从0开始) 或 null
}
如果没有找到合适的匹配,返回:
{
"animeIndex": null
}`;
/**
* 获取环境变量
@@ -351,6 +387,10 @@ export class Envs {
'TITLE_TO_CHINESE': { category: 'match', type: 'boolean', description: '外语标题转换中文开关' },
'ANIME_TITLE_SIMPLIFIED': { category: 'match', type: 'boolean', description: '搜索的剧名标题自动繁转简' },
'TITLE_MAPPING_TABLE': { category: 'match', type: 'map', description: '剧名映射表,用于自动匹配时替换标题进行搜索,格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华"' },
'AI_BASE_URL': { category: 'match', type: 'text', description: 'AI服务基础URL,不填默认为https://api.openai.com/v1' },
'AI_MODEL': { category: 'match', type: 'text', description: 'AI模型名称,不填默认为gpt-4o' },
'AI_API_KEY': { category: 'match', type: 'text', description: 'AI服务API密钥,默认为空,需手动填写' },
'AI_MATCH_PROMPT': { category: 'match', type: 'text', description: 'AI自动匹配提示词模板,不填提供默认提示词,默认提示词请查看README' },
// 弹幕配置
'BLOCKED_WORDS': { category: 'danmu', type: 'text', description: '屏蔽词列表' },
@@ -418,6 +458,10 @@ export class Envs {
titleToChinese: this.get('TITLE_TO_CHINESE', false, 'boolean'), // 外语标题转换中文开关
animeTitleSimplified: this.get('ANIME_TITLE_SIMPLIFIED', false, 'boolean'), // 搜索的剧名标题自动繁转简
titleMappingTable: this.resolveTitleMappingTable(), // 剧名映射表,用于自动匹配时替换标题进行搜索
aiBaseUrl: this.get('AI_BASE_URL', 'https://api.openai.com/v1', 'string'), // AI服务基础URL
aiModel: this.get('AI_MODEL', 'gpt-4o', 'string'), // AI模型名称
aiApiKey: this.get('AI_API_KEY', '', 'string', true), // AI服务API密钥
aiMatchPrompt: this.get('AI_MATCH_PROMPT', this.DEFAULT_AI_MATCH_PROMPT, 'string'), // AI自动匹配提示词模板
rememberLastSelect: this.get('REMEMBER_LAST_SELECT', true, 'boolean'), // 是否记住手动选择结果,用于match自动匹配时优选上次的选择(默认 true,记住)
MAX_LAST_SELECT_MAP: this.get('MAX_LAST_SELECT_MAP', 100, 'number'), // 记住上次选择映射缓存大小限制(默认 100)
deployPlatformAccount: this.get('DEPLOY_PLATFROM_ACCOUNT', '', 'string', true), // 部署平台账号ID配置(默认空)
+2 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.14.4',
VERSION: '1.15.0',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_ANIMES: 100,
MAX_RECORDS: 100, // 请求记录最大数量
@@ -27,6 +27,7 @@ export const Globals = {
localCacheValid: false, // 本地缓存是否生效
localCacheInitialized: false, // 本地缓存是否已初始化
redisValid: false, // redis是否生效
aiValid: false, // AI配置是否生效
redisCacheInitialized: false, // redis 缓存是否已初始化
lastSelectMap: new Map(), // 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime
reqRecords: [], // 记录请求历史,包括接口/参数/请求时间
+1 -1
View File
@@ -253,7 +253,7 @@ export default class HanjutvSource extends BaseSource {
return comments.map(c => ({
cid: Number(c.did),
p: `${(c.t / 1000).toFixed(2)},${c.tp === 2 ? 5 : c.tp},${Number(c.sc)},[hanjutv]`,
m: c.con,
m: c.lc ? `${c.con} 👍${c.lc}` : c.con,
t: Math.round(c.t / 1000)
}));
}
+389
View File
@@ -0,0 +1,389 @@
import BaseSource from './base.js';
import { globals } from '../configs/globals.js';
import { log } from "../utils/log-util.js";
import { httpPost, buildQueryString } from "../utils/http-util.js";
import { convertToAsciiSum, md5 } from "../utils/codec-util.js";
import { hexToInt } from "../utils/danmu-util.js";
import { generateValidStartDate } from "../utils/time-util.js";
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
import { printFirst200Chars, titleMatches } from "../utils/common-util.js";
import { SegmentListResponse } from '../models/dandan-model.js';
// =====================
// 获取埋堆堆弹幕
// =====================
class MaiduiduiSource extends BaseSource {
constructor() {
super();
this.domain = "https://mob.mddcloud.com.cn";
this.headers = {
"user-agent": "Mdd/5.8.00 (Android+32+)",
"Content-Type": "application/json",
"version": "5.8.00",
"Referer": "mdd"
}
}
getPlayload(urlSuffix, dataBody) {
const time = Date.now();
const queryStr = buildQueryString(dataBody, false);
const rawInput = (
`os:Android|version:5.8.00|action:${urlSuffix}` +
`|time:${time}|appToken:|privateKey:e1be6b4cf4021b3d181170d1879a530a9e4130b69032144d5568abfd6cd6c1c2` +
`|data:${queryStr}&`
);
return {
"channel": "1000",
"data": dataBody,
"deviceNum": "853BDD7A1DC011F1C341455071C03AEB",
"deviceType": 0,
"jsonSign": 0,
"os": "Android",
"sign": md5(rawInput),
"sourceVersion": 0,
"terminalType": "APP",
"thirdStatus": 0,
"time": time,
"version": "5.8.00",
"visitorStatus": 0
}
}
extractByRegex(url) {
const vodUuidMatch = url.match(/\/video\/([a-f0-9]+)\.html/i);
const uuidMatch = url.match(/[?&]uuid=([a-f0-9]+)/i);
return {
vodUuid: vodUuidMatch ? vodUuidMatch[1] : null,
uuid: uuidMatch ? uuidMatch[1] : null,
success: !!(vodUuidMatch && uuidMatch)
};
}
async search(keyword) {
try {
const urlSuffix = "/searchApi/search/getAllSearchResult4820.action";
const searchUrl = `${this.domain}${urlSuffix}`;
const dataBody = {
"keyWord": keyword
};
const response = await httpPost(searchUrl, JSON.stringify(this.getPlayload(urlSuffix, dataBody)), {
headers: this.headers
});
if (!response || !response.data) {
log("info", "[Maiduidui] 搜索响应为空");
return [];
}
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
const animes = [];
const typeList = data?.data;
for (const typeItem of typeList) {
if (typeItem?.typeName === "剧集" || typeItem?.typeName === "电影" || typeItem?.typeName === "综艺") {
for (const vodItem of typeItem?.vodList) {
animes.push({
name: vodItem.name,
type: typeItem.typeName,
year: vodItem.yearName,
img: vodItem.downImage,
url: vodItem.uuid
});
}
}
}
// 正常情况下输出 JSON 字符串
log("info", `[Maiduidui] 搜索找到 ${animes.length} 个有效结果`);
return animes;
} catch (error) {
// 捕获请求中的错误
log("error", "getMaiduiduiAnimes error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return [];
}
}
async getDetail(id) {
try {
const idInfo = this.extractByRegex(id);
const uuid = idInfo.uuid;
const vodUuid = idInfo.vodUuid;
const urlSuffix = "/api/vod/listVodSactions.action";
const searchUrl = `${this.domain}${urlSuffix}`;
const dataBody = {
"hasIntroduction": 0,
"vodUuid": vodUuid
};
const response = await httpPost(searchUrl, JSON.stringify(this.getPlayload(urlSuffix, dataBody)), {
headers: this.headers
});
if (!response || !response.data) {
log("info", "[Maiduidui] 获取详情信息响应为空");
return [];
}
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
for (const epInfo of data?.data) {
if (epInfo.uuid === uuid) {
return epInfo.duration;
}
}
return 0;
} catch (error) {
// 捕获请求中的错误
log("error", "getMaiduiduiDetail error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return 0;
}
}
async getEpisodes(id) {
try {
const urlSuffix = "/api/vod/listVodSactions.action";
const searchUrl = `${this.domain}${urlSuffix}`;
const dataBody = {
"hasIntroduction": 0,
"vodUuid": id
};
const response = await httpPost(searchUrl, JSON.stringify(this.getPlayload(urlSuffix, dataBody)), {
headers: this.headers
});
if (!response || !response.data) {
log("info", "[Maiduidui] 获取集信息响应为空");
return [];
}
const eps = [];
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
data?.data.forEach(epInfo => {
eps.push({
title: epInfo.name,
episodeId: epInfo.uuid
});
});
return eps;
} catch (error) {
// 捕获请求中的错误
log("error", "getMaiduiduiEposides error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return [];
}
}
async handleAnimes(sourceAnimes, queryTitle, curAnimes) {
const tmpAnimes = [];
// 添加错误处理,确保sourceAnimes是数组
if (!sourceAnimes || !Array.isArray(sourceAnimes)) {
log("error", "[Maiduidui] sourceAnimes is not a valid array");
return [];
}
// 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成
const processMaiduiduiAnimes = await Promise.all(sourceAnimes
.filter(s => titleMatches(s.name, queryTitle))
.map(async (anime) => {
try {
const eps = await this.getEpisodes(anime.url);
let links = [];
for (const ep of eps) {
const epTitle = ep.title;
links.push({
"name": epTitle,
"url": `https://www.mddcloud.com.cn/video/${anime.url}.html?uuid=${ep.episodeId}`,
"title": `【maiduidui】 ${epTitle}`
});
}
if (links.length > 0) {
let transformedAnime = {
animeId: convertToAsciiSum(anime.url),
bangumiId: anime.url,
animeTitle: `${anime.name}(${anime.year})【${anime.type}】from maiduidui`,
type: anime.type,
typeDescription: anime.type,
imageUrl: anime.img,
startDate: generateValidStartDate(anime.year),
episodeCount: links.length,
rating: 0,
isFavorited: true,
source: "maiduidui",
};
tmpAnimes.push(transformedAnime);
addAnime({...transformedAnime, links: links});
if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime();
}
} catch (error) {
log("error", `[Maiduidui] Error processing anime: ${error.message}`);
}
})
);
this.sortAndPushAnimesByYear(tmpAnimes, curAnimes);
return processMaiduiduiAnimes;
}
async getEpisodeDanmu(id) {
log("info", "开始从本地请求埋堆堆弹幕...", id);
// 获取弹幕分段数据
const segmentResult = await this.getEpisodeDanmuSegments(id);
if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) {
return [];
}
const segmentList = segmentResult.segmentList;
log("info", `弹幕分段数量: ${segmentList.length}`);
// 并发请求所有弹幕段,限制并发数量为20
const MAX_CONCURRENT = 20;
const allComments = [];
// 将segmentList分批处理,每批最多MAX_CONCURRENT个请求
for (let i = 0; i < segmentList.length; i += MAX_CONCURRENT) {
const batch = segmentList.slice(i, i + MAX_CONCURRENT);
// 并发处理当前批次的请求
const batchPromises = batch.map(segment => this.getEpisodeSegmentDanmu(segment));
const batchResults = await Promise.allSettled(batchPromises);
// 处理结果
for (let j = 0; j < batchResults.length; j++) {
const result = batchResults[j];
const segment = batch[j];
const start = segment.segment_start;
const end = segment.segment_end;
if (result.status === 'fulfilled') {
const comments = result.value;
if (comments && comments.length > 0) {
allComments.push(...comments);
}
} else {
log("error", `获取弹幕段失败 (${start}-${end}s):`, result.reason.message);
}
}
// 批次之间稍作延迟,避免过于频繁的请求
if (i + MAX_CONCURRENT < segmentList.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
if (allComments.length === 0) {
log("info", `埋堆堆: 该视频暂无弹幕数据 (vid=${id})`);
return [];
}
printFirst200Chars(allComments);
return allComments;
}
async getEpisodeDanmuSegments(id) {
log("info", "获取埋堆堆弹幕分段列表...", id);
const idInfo = this.extractByRegex(id);
const uuid = idInfo.uuid;
const duration = await this.getDetail(id);
log("info", "uuid:", uuid);
log("info", "duration:", duration);
const segmentDuration = 60; // 每个分片1分钟
const segmentList = [];
for (let i = 0; i < duration; i += segmentDuration) {
const segmentStart = i;
const segmentEnd = Math.min(i + segmentDuration, duration); // 不超过总时长
segmentList.push({
"type": "maiduidui",
"segment_start": segmentStart,
"segment_end": segmentEnd,
"url": id
});
}
return new SegmentListResponse({
"type": "maiduidui",
"segmentList": segmentList
});
}
async getEpisodeSegmentDanmu(segment) {
try {
const idInfo = this.extractByRegex(segment.url);
const uuid = idInfo.uuid;
const vodUuid = idInfo.vodUuid;
const urlSuffix = "/api/barrage/vodBarrage396.action";
const searchUrl = `${this.domain}${urlSuffix}`;
const dataBody = {
"sactionUuid": uuid,
"times": segment.segment_start,
"vodUuid": vodUuid
};
const response = await httpPost(searchUrl, JSON.stringify(this.getPlayload(urlSuffix, dataBody)), {
headers: this.headers,
retries: 1,
});
// 处理响应数据并返回 contents 格式的弹幕
let contents = [];
if (response && response.data) {
const parsedData = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
const danmakuList = parsedData.data ?? [];
danmakuList.forEach(danmakuItem => {
contents.push(...danmakuItem.barrageList);
});
}
return contents;
} catch (error) {
log("error", "请求分片弹幕失败:", error);
return []; // 返回空数组而不是抛出错误,保持与getEpisodeDanmu一致的行为
}
}
formatComments(comments) {
return comments.map(c => ({
cid: Number(c.uuid),
p: `${(c.times).toFixed(2)},1,${hexToInt(c.color.replace("#", ""))},[maiduidui]`,
m: c.content,
t: c.times
}));
}
}
export default MaiduiduiSource;
+75 -1
View File
@@ -558,8 +558,28 @@ function renderValueInput(item) {
// 文本输入
const currentKey = document.getElementById('env-key') ? document.getElementById('env-key').value : '';
const isBilibiliCookie = currentKey === 'BILIBILI_COOKIE';
const isAiApiKey = currentKey === 'AI_API_KEY';
if (isBilibiliCookie) {
if (isAiApiKey) {
// AI API Key 专用编辑界面
container.innerHTML = \`
<div class="ai-apikey-editor">
<label>API Key 值</label>
<textarea class="form-group" id="text-value" placeholder="请输入 AI API Key" rows="3">\${value}</textarea>
<div class="form-help">支持 OpenAI 兼容的 API,需配合 AI_BASE_URL 和 AI_MODEL 配置使用</div>
<div class="ai-apikey-status" id="ai-apikey-status">
<span class="ai-status-icon">🔍</span>
<span class="ai-status-text">点击下方按钮测试连通性</span>
</div>
<div class="ai-apikey-actions" style="margin-bottom: 15px;">
<button type="button" class="btn btn-primary btn-sm" id="ai-verify-btn" onclick="verifyAiConnection()">
🧪 测试连通性
</button>
</div>
</div>
\`;
} else if (isBilibiliCookie) {
// Bilibili Cookie 专用编辑界面
const rows = value && value.length > 50 ? Math.min(Math.max(Math.ceil(value.length / 50), 3), 8) : 3;
container.innerHTML = \`
@@ -1803,4 +1823,58 @@ function showBilibiliCookieSaveHint(text) {
const msg = text || '请点击保存按钮,Vercel等平台需重新部署后生效';
statusEl.innerHTML = \`<span class="bili-status-icon">💾</span><span class="bili-status-text">\${msg}</span>\`;
}
/* ========================================
AI API Key 连通性测试功能
======================================== */
async function verifyAiConnection() {
const statusEl = document.getElementById('ai-apikey-status');
const btn = document.getElementById('ai-verify-btn');
const textInput = document.getElementById('text-value');
if (!statusEl || !textInput) return;
const apiKey = textInput.value.trim();
// 如果输入框为空,提示未配置
if (!apiKey) {
statusEl.innerHTML = '<span class="ai-status-icon">⚠️</span><span class="ai-status-text">请先输入 API Key</span>';
return;
}
// 设置按钮为加载状态
const originalText = btn.innerHTML;
btn.innerHTML = '<span class="loading-spinner-small"></span>';
btn.disabled = true;
statusEl.innerHTML = '<span class="ai-status-icon">🔍</span><span class="ai-status-text">正在测试连通性...</span>';
// 检查是否为脱敏后的 *...*
const isMasked = /^[*]+$/.test(apiKey);
try {
const response = await fetch(buildApiUrl('/api/ai/verify', true), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(isMasked ? {} : { 'aiApiKey': apiKey })
});
const result = await response.json();
if (result.ok) {
statusEl.innerHTML = '<span class="ai-status-icon">✅</span><span class="ai-status-text">' + (result.message || 'AI 服务连通性测试成功') + '</span>';
statusEl.style.color = 'var(--success-color, #28a745)';
} else {
statusEl.innerHTML = '<span class="ai-status-icon">❌</span><span class="ai-status-text">' + (result.message || '连通性测试失败') + '</span>';
statusEl.style.color = 'var(--danger-color, #dc3545)';
}
} catch (error) {
statusEl.innerHTML = '<span class="ai-status-icon">⚠️</span><span class="ai-status-text">测试请求失败: ' + error.message + '</span>';
statusEl.style.color = 'var(--warning-color, #ffc107)';
} finally {
// 恢复按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
}
}
`;
+84
View File
@@ -0,0 +1,84 @@
import { httpPost } from "./http-util.js";
export default class AIClient {
constructor({ apiKey, baseURL = 'https://api.openai.com/v1', model = 'gpt-4o', systemPrompt = '' }) {
this.apiKey = apiKey
this.baseURL = baseURL.replace(/\/$/, '')
this.model = model
this.systemPrompt = systemPrompt
}
async chat(messages, options = {}) {
const body = {
model: options.model || this.model,
messages,
temperature: options.temperature ?? 0.0,
max_tokens: options.maxTokens ?? 8192,
stream: false,
}
const res = await httpPost(`${this.baseURL}/chat/completions`, JSON.stringify(body), {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
timeout: 60000
});
const data = res.data;
if (data?.error) {
const err = data.error.message
throw new Error(`AI API error ${res.status}: ${err}`)
}
return data.choices[0].message.content;
}
// 单轮对话快捷方法
async ask(prompt, options = {}) {
const messages = []
if (this.systemPrompt) {
messages.push({ role: 'system', content: this.systemPrompt })
}
messages.push({ role: 'user', content: prompt })
return this.chat(messages, options)
}
// 多轮对话,自动维护历史
session(options = {}) {
const history = []
const chat = this.chat.bind(this) // 在这里固定好 this
const systemPrompt = this.systemPrompt
if (systemPrompt) {
history.push({ role: 'system', content: systemPrompt })
}
return {
async send(userMessage) {
history.push({ role: 'user', content: userMessage })
const reply = await chat(history, options) // 直接用 chat,不依赖 this
history.push({ role: 'assistant', content: reply })
return reply
},
clear() {
history.length = 0
if (systemPrompt) {
history.push({ role: 'system', content: systemPrompt })
}
},
getHistory() {
return [...history]
},
}
}
async verify() {
try {
const result = await this.ask('hi', { maxTokens: 1 })
return { ok: true, reply: result }
} catch (err) {
return { ok: false, error: err.message }
}
}
}
+4 -3
View File
@@ -200,7 +200,7 @@ export async function httpPost(url, body, options = {}) {
}
// 设置超时时间(默认5秒)
const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000;
const timeout = parseInt(options.timeout || globals.vodRequestTimeout || '5000', 10) || 5000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -437,7 +437,8 @@ export function xmlResponse(data, status = 200) {
});
}
export function buildQueryString(params) {
export function buildQueryString(params, encode = true) {
const encodeFn = encode ? encodeURIComponent : (v) => v;
let queryString = '';
// 遍历 params 对象的每个属性
@@ -449,7 +450,7 @@ export function buildQueryString(params) {
}
// 将 key 和 value 使用 encodeURIComponent 编码,并拼接成查询字符串
queryString += encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
queryString += encodeFn(key) + '=' + encodeFn(params[key]);
}
}
+24 -3
View File
@@ -4,9 +4,10 @@ import { log, formatLogMessage } from './utils/log-util.js'
import { getRedisCaches, judgeRedisValid } from "./utils/redis-util.js";
import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js";
import { formatDanmuResponse } from "./utils/danmu-util.js";
import AIClient from './utils/ai-util.js';
import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js";
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords } from "./apis/system-api.js";
import { handleSetEnv, handleAddEnv, handleDelEnv } from "./apis/env-api.js";
import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js";
import { Segment } from "./models/dandan-model.js"
import {
handleCookieStatus,
@@ -31,6 +32,19 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
await judgeLocalCacheValid(path, deployPlatform);
}
await judgeRedisValid(path);
if (!globals.aiValid && globals.aiBaseUrl && globals.aiModel && globals.aiApiKey && path !== "/favicon.ico" && path !== "/robots.txt") {
const ai = new AIClient({
baseURL: globals.aiBaseUrl,
model: globals.aiModel,
apiKey: globals.aiApiKey,
systemPrompt: '回答尽量简洁',
})
const status = await ai.verify()
if (status.ok) {
globals.aiValid = true;
}
}
log("info", `request url: ${JSON.stringify(url)}`);
log("info", `request path: ${path}`);
@@ -206,7 +220,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
// 智能处理API路径前缀,确保最终有一个正确的 /api/v2
if (path !== "/" && path !== "/api/logs" && !path.startsWith('/api/env')
&& !path.startsWith('/api/deploy') && !path.startsWith('/api/cache')
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')) {
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
&& !path.startsWith('/api/ai')) {
log("info", `[Path Check] Starting path normalization for: "${path}"`);
const pathBeforeCleanup = path; // 保存清理前的路径检查是否修改
@@ -228,7 +243,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
const pathBeforePrefixCheck = path;
if (!path.startsWith('/api/v2') && path !== '/' && !path.startsWith('/api/logs')
&& !path.startsWith('/api/env') && !path.startsWith('/api/cache')
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')) {
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
&& !path.startsWith('/api/ai')) {
log("info", `[Path Check] Path is missing /api/v2 prefix. Adding...`);
path = '/api/v2' + path;
}
@@ -475,6 +491,11 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
return handleCookieSave(req);
}
// POST /api/ai/verify - 验证AI连通性
if (path === "/api/ai/verify" && method === "POST") {
return handleAiVerify(req);
}
return jsonResponse({ message: "Not found" }, 404);
}
+45
View File
@@ -10,6 +10,7 @@ import { getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry } from "./ut
import { getImdbepisodes } from "./utils/imdb-util.js";
import { getTMDBChineseTitle, getTmdbJpDetail, searchTmdbTitles } from "./utils/tmdb-util.js";
import { getDoubanDetail, getDoubanInfoByImdbId, searchDoubanTitles } from "./utils/douban-util.js";
import AIClient from './utils/ai-util.js';
import RenrenSource from "./sources/renren.js";
import HanjutvSource from "./sources/hanjutv.js";
import BahamutSource from "./sources/bahamut.js";
@@ -22,6 +23,7 @@ import MiguSource from "./sources/migu.js";
import SohuSource from "./sources/sohu.js";
import LeshiSource from "./sources/leshi.js";
import XiguaSource from "./sources/xigua.js";
import MaiduiduiSource from "./sources/maiduidui.js";
import AnimekoSource from "./sources/animeko.js";
import OtherSource from "./sources/other.js";
import { NodeHandler } from "./configs/handlers/node-handler.js";
@@ -67,6 +69,7 @@ test('worker.js API endpoints', async (t) => {
const sohuSource = new SohuSource();
const leshiSource = new LeshiSource();
const xiguaSource = new XiguaSource();
const maiduiduiSource = new MaiduiduiSource();
const animekoSource = new AnimekoSource();
const otherSource = new OtherSource();
@@ -100,6 +103,25 @@ test('worker.js API endpoints', async (t) => {
assert(title === "宇宙Marry Me?" && season == 2 && episode == 8, `Expected title === "宇宙Marry Me?" && season == 2 && episode == 8, but got ${title} ${season} ${episode}`);
});
// await t.test('Test ai cilent', async () => {
// const ai = new AIClient({
// apiKey: 'xxxxxxxxxxxxxxxxxxxxx',
// baseURL: 'https://open.bigmodel.cn/api/paas/v4', // 换成任意兼容 OpenAI 协议的地址
// model: 'GLM-4.7-FlashX',
// systemPrompt: '回答尽量简洁',
// })
// // const answer = await ai.ask('你好')
// // console.log(answer);
// const status = await ai.verify()
// if (status.ok) {
// console.log('连接正常:', status)
// } else {
// console.log('连接失败:', status.error)
// }
// });
// await t.test('GET tencent danmu', async () => {
// const res = await tencentSource.getComments("http://v.qq.com/x/cover/rjae621myqca41h/j0032ubhl9s.html", "qq");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
@@ -302,6 +324,29 @@ test('worker.js API endpoints', async (t) => {
// assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`);
// });
// await t.test('GET maiduidui danmu', async () => {
// const res = await maiduiduiSource.getComments("https://www.mddcloud.com.cn/video/ff8080817410d5a5017490f5f4d311de.html?num=2&uuid=ff8080817410d5a5017490f5f4d311e0", "maiduidui");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
// });
// await t.test('GET maiduidui danmu segments', async () => {
// const res = await maiduiduiSource.getComments("https://www.mddcloud.com.cn/video/ff8080817410d5a5017490f5f4d311de.html?num=2&uuid=ff8080817410d5a5017490f5f4d311e0", "maiduidui", true);
// console.log(res.segmentList);
// assert(res.type === "maiduidui", `Expected res.type === "maiduidui", but got ${res.type === "maiduidui"}`);
// assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`);
// });
// await t.test('GET maiduidui segment danmu', async () => {
// const segment = Segment.fromJson({
// type: 'maiduidui',
// segment_start: 120,
// segment_end: 180,
// url: 'https://www.mddcloud.com.cn/video/ff8080817410d5a5017490f5f4d311de.html?num=2&uuid=ff8080817410d5a5017490f5f4d311e0'
// });
// const res = await maiduiduiSource.getSegmentComments(segment);
// assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`);
// });
// await t.test('GET other_server danmu', async () => {
// const res = await otherSource.getComments("https://www.bilibili.com/bangumi/play/ep1231564");
// assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`);
+4 -4
View File
@@ -17,7 +17,7 @@ WidgetMetadata = {
// 源配置
{
name: "sourceOrder",
title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'sohu', 'leshi', 'xigua', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
type: "input",
placeholders: [
{
@@ -152,12 +152,12 @@ WidgetMetadata = {
// 匹配配置
{
name: "platformOrder",
title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi, 'xigua', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi, 'xigua', 'maiduidui', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']",
type: "input",
placeholders: [
{
title: "配置1",
value: "qq,qiyi,imgo,bilibili1,youku,migu,sohu,leshi,xigua,renren,hanjutv,bahamut,dandan,custom",
value: "qq,qiyi,imgo,bilibili1,youku,migu,sohu,leshi,xigua,maiduidui,renren,hanjutv,bahamut,dandan,custom",
},
{
title: "配置2",
@@ -260,7 +260,7 @@ WidgetMetadata = {
placeholders: [
{
title: "示例",
value: "/.{20,}/,/^\\d{2,4}[-/.]\\d{1,2}[-/.]\\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\u4e00-\u9fa5])\\1{2,}/,/[0-9]+\\.*[0-9]*\\s*(w|万)+\\s*(\\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\\d{5,}$/,/^(\\d)\\1{2,}$/,/\\d{1,4}/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\\d{1,2}[.-]\\d{1,2}/,/[@#&$%^*+\\|/\\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\\d]+刷/,/第[一二三四五六七八九十百\\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/",
value: "/.{20,}/,/^\\d{2,4}[-/.]\\d{1,2}[-/.]\\d{1,2}([日号.]*)?$/,/^(?!哈+$)([a-zA-Z\u4e00-\u9fa5])\\1{2,}/,/[0-9]+\\.*[0-9]*\\s*(w|万)+\\s*(\\+|个|人|在看)+/,/^[a-z]{6,}$/,/^(?:qwertyuiop|asdfghjkl|zxcvbnm)$/,/^\\d{5,}$/,/^(\\d)\\1{2,}$/,/^\\d{1,4}$/,/(20[0-3][0-9])/,/(0?[1-9]|1[0-2])月/,/\\d{1,2}[.-]\\d{1,2}/,/[@#&$%^*+\\|/\\-_=<>°◆◇■□●○★☆▼▲♥♦♠♣①②③④⑤⑥⑦⑧⑨⑩]/,/[一二三四五六七八九十百\\d]+刷/,/第[一二三四五六七八九十百\\d]+/,/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/",
},
],
},