diff --git a/README.md b/README.md index 8c1042e..1857c35 100644 --- a/README.md +++ b/README.md @@ -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 # 通用工具 diff --git a/config/.env.example b/config/.env.example index f17f65f..87c92b9 100644 --- a/config/.env.example +++ b/config/.env.example @@ -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= diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 02b2099..a301cd5 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -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") { diff --git a/danmu_api/apis/env-api.js b/danmu_api/apis/env-api.js index 1d23c35..9d2c872 100644 --- a/danmu_api/apis/env-api.js +++ b/danmu_api/apis/env-api.js @@ -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); + } +} diff --git a/danmu_api/apis/system-api.js b/danmu_api/apis/system-api.js index 734cabd..5f7e0df 100644 --- a/danmu_api/apis/system-api.js +++ b/danmu_api/apis/system-api.js @@ -32,6 +32,7 @@ export function handleConfig(hasPermission = false) { ...globals.accessedEnvVars, localCacheValid: globals.localCacheValid, redisValid: globals.redisValid, + aiValid: globals.aiValid, deployPlatform: globals.deployPlatform }; diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index acb0947..1ab87d8 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -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配置(默认空) diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 4b430eb..a08325b 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -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: [], // 记录请求历史,包括接口/参数/请求时间 diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index b5229bf..55f9516 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -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) })); } diff --git a/danmu_api/sources/maiduidui.js b/danmu_api/sources/maiduidui.js new file mode 100644 index 0000000..e5cafbe --- /dev/null +++ b/danmu_api/sources/maiduidui.js @@ -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; diff --git a/danmu_api/ui/js/systemsettings.js b/danmu_api/ui/js/systemsettings.js index 89bbd74..0d99dae 100644 --- a/danmu_api/ui/js/systemsettings.js +++ b/danmu_api/ui/js/systemsettings.js @@ -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 = \` +