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 = \` +
+ + +
支持 OpenAI 兼容的 API,需配合 AI_BASE_URL 和 AI_MODEL 配置使用
+ +
+ 🔍 + 点击下方按钮测试连通性 +
+
+ +
+
+ \`; + } 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 = \`💾\${msg}\`; } + +/* ======================================== + 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 = '⚠️请先输入 API Key'; + return; + } + + // 设置按钮为加载状态 + const originalText = btn.innerHTML; + btn.innerHTML = ''; + btn.disabled = true; + + statusEl.innerHTML = '🔍正在测试连通性...'; + + // 检查是否为脱敏后的 *...* + 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 = '✅' + (result.message || 'AI 服务连通性测试成功') + ''; + statusEl.style.color = 'var(--success-color, #28a745)'; + } else { + statusEl.innerHTML = '❌' + (result.message || '连通性测试失败') + ''; + statusEl.style.color = 'var(--danger-color, #dc3545)'; + } + } catch (error) { + statusEl.innerHTML = '⚠️测试请求失败: ' + error.message + ''; + statusEl.style.color = 'var(--warning-color, #ffc107)'; + } finally { + // 恢复按钮状态 + btn.innerHTML = originalText; + btn.disabled = false; + } +} `; diff --git a/danmu_api/utils/ai-util.js b/danmu_api/utils/ai-util.js new file mode 100644 index 0000000..6331eb5 --- /dev/null +++ b/danmu_api/utils/ai-util.js @@ -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 } + } + } +} \ No newline at end of file diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index e50e994..dd13325 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -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]); } } diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 1727a3b..ba65906 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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); } diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index fbc7df4..12a8700 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -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}`); diff --git a/forward/forward-widget.js b/forward/forward-widget.js index 2c4da0a..ccc156d 100644 --- a/forward/forward-widget.js +++ b/forward/forward-widget.js @@ -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|啊这|哇哦)/", }, ], },