From 1e80b606326c8f46f82faa248a2ca3792715138e Mon Sep 17 00:00:00 2001 From: woleigedouer <38418090+woleigedouer@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:42:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E5=B9=B3=E5=8F=B0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=BA=90=E5=8D=87=E7=BA=A7=E4=B8=8E=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复 环境变量模板 * 修复芒果TV弹幕获取失败问题并实现双API兜底机制 问题背景: 1. 芒果TV新API (getctlbarrage) 返回数据缺少 cdn_list 和 cdn_version 字段 2. convertToDanmakuJson() 在接收空数组时抛出异常,导致兜底逻辑无法执行 3. 用户无法获取芒果TV弹幕 修复内容: 1. danmu_api/utils/danmu-util.js - 修改 convertToDanmakuJson() 函数 - 空数组输入时返回空数组而不是抛出异常 - 允许兜底逻辑正常执行 2. danmu_api/sources/mango.js - 实现双API兜底机制 - 优先使用新API (getctlbarrage) 支持彩色弹幕 - 新API失败时自动切换到旧API (rdbarrage) - 旧API作为兜底保证弹幕获取成功率 * feat: 将 youku 升级为独立搜索源 ## 主要变更 ### 1. 核心功能集成 - 在 envs.js 中将 youku 添加到 ALLOWED_SOURCES - 在 dandan-api.js 中集成 youku 源的搜索和处理逻辑 - youku 现在既是独立搜索源,也是弹幕获取器(与 tencent 源一致) ### 2. Bug 修复 - 修复 http-util.js 中的超时配置问题 - 添加了 parseInt 的基数参数和默认值 - 解决了 vodRequestTimeout 为字符串导致的 NaN 超时问题 ### 3. 配置文件更新 - 更新 .env.example:在 SOURCE_ORDER 说明中添加 youku - 更新 README.md: - 在环境变量说明中添加 youku 源 - 在采集源列表中添加 youku 条目 ## 使用方式 在 .env 中配置 SOURCE_ORDER 即可启用 youku 源: * feat: 为 iQiyi 源添加独立搜索匹配功能 主要变更: 1. danmu_api/sources/iqiyi.js - 新增 search() 方法:使用爱奇艺桌面版 API 搜索内容 - 新增 getEpisodes() 方法:使用 v3 base_info API 获取分集列表 - 新增 handleAnimes() 方法:格式化搜索结果为 DanDanPlay 格式并缓存 - 新增工具方法: * _filterIqiyiSearchItem() - 过滤搜索结果 * _videoIdToEntityId() - video_id 到 entity_id 转换 * _xorOperation() - 异或运算 * _createSign() - API 签名生成 - 添加静态常量:XOR_KEY、SECRET_KEY、KEY_NAME 2. danmu_api/configs/envs.js - 将 'iqiyi' 添加到 ALLOWED_SOURCES 数组 3. danmu_api/apis/dandan-api.js - 导入 IqiyiSource 类 - 创建 iqiyiSource 实例 - 在搜索流程中集成 iqiyi 源 - 在结果处理循环中添加 iqiyi 处理逻辑 4. .env.example - 更新 SOURCE_ORDER 说明,添加 iqiyi 选项 5. README.md - 在 SOURCE_ORDER 环境变量说明中添加 iqiyi - 在采集源列表中添加 iqiyi 源(平台:qiyi) * feat: 将 mango (imgo) 升级为独立搜索源 ## 主要变更 ### 1. 核心功能集成 - 在 envs.js 中将 imgo 添加到 ALLOWED_SOURCES - 在 dandan-api.js 中集成 imgo 源的搜索和处理逻辑 - mango 现在既是独立搜索源,也是弹幕获取器 ### 2. 高级特性实现 - 分页获取分集列表(按月份分页) - 专属黑名单过滤(过滤预告、花絮、特辑等非正片内容) - 综艺节目智能处理("第N期"格式识别和排序) - 电影类型专门处理(智能正片识别) ### 3. 代码优化 - 添加 _getMovieEpisode() 方法处理电影类型 - 添加 _processVarietyEpisodes() 方法处理综艺节目 - 完善的日志输出和错误处理 ## 使用方式 在 .env 中配置 SOURCE_ORDER 即可启用 imgo 源: SOURCE_ORDER=360,vod,imgo,renren,hanjutv * feat: 将 bilibili 升级为独立搜索源 ## 主要变更 ### 1. 核心功能集成 - 在 envs.js 中将 bilibili 添加到 ALLOWED_SOURCES - 在 dandan-api.js 中集成 bilibili 源的搜索和处理逻辑 - bilibili 现在既是独立搜索源,也是弹幕获取器(与 youku 源一致) ### 2. bilibili.js 功能实现 - 添加 WBI 签名机制(_getWbiMixinKey, _getWbiSignedParams) - 实现搜索功能(search, _searchByType) - 支持 media_bangumi 和 media_ft 两种类型搜索 - 自动去重和结果合并 - 实现分集获取(getEpisodes, _getPgcEpisodes, _getUgcEpisodes) - 支持番剧(ss格式)和普通视频(bv格式) - 实现结果处理(handleAnimes) - 格式化为统一的 anime 对象 - 添加到缓存并按年份排序 ### 3. 配置文件更新 - 更新 .env.example:在 SOURCE_ORDER 说明中添加 bilibili - 更新 README.md: - 在环境变量说明中添加 bilibili 源 - 在采集源列表中添加 bilibili 条目 ## 使用方式 在 .env 中配置 SOURCE_ORDER 即可启用 bilibili 源: ```bash SOURCE_ORDER=360,vod,bilibili,renren,hanjutv * feat: 添加严格标题匹配模式环境变量 STRICT_TITLE_MATCH 新增 STRICT_TITLE_MATCH 环境变量,支持严格标题匹配模式,解决模糊搜索返回过多无关结果的问题。 ## 功能说明 - 默认值:false(宽松模糊匹配) - 启用后:只匹配标题开头或完全匹配的结果 - 示例:搜索"遮天"时 - false: 匹配"遮天"、"古惑仔3之只手遮天"(所有包含"遮天"的标题) - true: 只匹配"遮天"、"遮天 第一季"、"遮天(2024)"等 ## 修改内容 1. 环境变量配置 - 新增 STRICT_TITLE_MATCH 配置项到 .env 和 .env.example - 在 envs.js 中添加配置读取逻辑 2. 工具函数(common-util.js) - 新增 strictTitleMatch() 严格匹配函数 - 新增 titleMatches() 根据配置选择匹配模式 3. Source文件修改(10个) - bilibili.js, tencent.js, iqiyi.js, youku.js, mango.js - renren.js, hanjutv.js, vod.js, kan360.js - 统一使用 titleMatches() 替代 .includes() 模糊匹配 - bahamut.js: 扩展 bahamutTitleMatches() 支持严格模式(保留繁简转换) 4. 文档更新 - README.md 添加 STRICT_TITLE_MATCH 环境变量说明 ## 技术细节 - 严格匹配支持的分隔符:空格、括号、冒号、破折号、季集标识等 - Bahamut源特殊处理:严格模式下仍支持繁简体转换 - 所有修改向后兼容,默认保持原有模糊匹配行为 ## 相关 Issue 解决用户反馈的模糊搜索匹配过多无关内容的问题(如搜索"遮天"匹配到"古惑仔3之只手遮天") * 调整默认超时时间 更新版本号 --- .env.example | 45 ++- README.md | 9 +- danmu_api/apis/dandan-api.js | 22 +- danmu_api/configs/envs.js | 7 +- danmu_api/configs/globals.js | 2 +- danmu_api/sources/bahamut.js | 23 ++ danmu_api/sources/bilibili.js | 356 +++++++++++++++++++++++- danmu_api/sources/hanjutv.js | 3 +- danmu_api/sources/iqiyi.js | 464 ++++++++++++++++++++++++++++++- danmu_api/sources/kan360.js | 5 +- danmu_api/sources/mango.js | 484 +++++++++++++++++++++++++++++++-- danmu_api/sources/renren.js | 3 +- danmu_api/sources/tencent.js | 4 +- danmu_api/sources/vod.js | 6 +- danmu_api/sources/youku.js | 329 +++++++++++++++++++++- danmu_api/utils/common-util.js | 39 +++ danmu_api/utils/danmu-util.js | 4 +- danmu_api/utils/http-util.js | 4 +- 18 files changed, 1750 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 88a74e7..c1b0caa 100644 --- a/.env.example +++ b/.env.example @@ -34,10 +34,11 @@ VOD_SERVERS=金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风 VOD_RETURN_MODE=fastest # VOD 服务器请求超时时间(毫秒) -# 默认值:5000(5秒) -# 建议值:3000-10000 +# 默认值:10000(10秒) +# 建议值:5000-15000 # 说明:防止慢速或失效的采集站阻塞整个搜索过程(单个请求超时) -VOD_REQUEST_TIMEOUT=5000 +# 由于 fastest 模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 +VOD_REQUEST_TIMEOUT=10000 # ==================== Bilibili 配置 ==================== @@ -53,9 +54,9 @@ YOUKU_CONCURRENCY=8 # ==================== 源排序配置 ==================== -# 数据源查询顺序(可选值:360, vod, tencent, renren, hanjutv, bahamut) -# 默认值:360,vod,renren,hanjutv(不包含 tencent) -# 如需使用腾讯源,请手动添加:SOURCE_ORDER=360,vod,tencent,renren,hanjutv +# 数据源查询顺序(可选值:360, vod, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut) +# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo 和 bilibili) +# 如需使用腾讯源、优酷源、爱奇艺源、芒果源或B站源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv # 注意:bahamut 仅在 Cloudflare 和 Vercel 平台自动启用 SOURCE_ORDER=360,vod,renren,hanjutv @@ -63,7 +64,7 @@ SOURCE_ORDER=360,vod,renren,hanjutv # 平台优先级排序(用于自动匹配) # 默认值:空(自动匹配) -# 可选值:youku,bilibili,iqiyi,qq,mgtv +# 可选值:qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut # PLATFORM_ORDER= # ==================== 剧集标题过滤 ==================== @@ -127,22 +128,42 @@ RATE_LIMIT_MAX_REQUESTS=3 # 可选值:true, false ENABLE_EPISODE_FILTER=false +# ==================== 搜索匹配模式 ==================== + +# 是否启用严格标题匹配模式 +# 默认值:false(宽松模糊匹配) +# 说明: +# - false(默认): 模糊匹配,只要标题包含搜索词即可(例如搜索"遮天"会匹配"古惑仔3之只手遮天") +# - true: 严格匹配,只匹配标题开头或完全匹配(例如搜索"遮天"只匹配"遮天"、"遮天 第一季"等) +# 可选值:true, false +STRICT_TITLE_MATCH=false + # ==================== 代理配置 ==================== # 代理地址(用于访问受限资源) # 默认值:空(不使用代理) # 格式:http://proxy-server:port 或 https://proxy-server:port +# 说明:目前只对巴哈姆特生效,如果使用 Docker 部署并且访问不了 bahamut 源,请配置代理地址 # PROXY_URL= +# ==================== TMDB 配置 ==================== + +# TMDB API Key(用于巴哈姆特源的译名转换) +# 默认值:空(不使用 TMDB) +# 说明:配置后当巴哈第一次搜索无结果时自动从 TMDB 获取日语原名搜索 +# 可以解决巴哈译名不同导致的搜索无结果问题(例如:间谍过家家 vs 間諜家家酒) +# 获取方法参考:https://www.ugnas.com/tutorial-detail/id-226.html +# TMDB_API_KEY= + # ==================== 日志配置 ==================== # 日志级别(可选值:error, warn, info) -# 默认值:warn +# 默认值:info # 说明: # - error: 仅显示错误日志 # - warn: 显示错误和警告日志 # - info: 显示所有日志(包括详细的调试信息) -# 建议:生产环境使用 warn,开发/调试时使用 info(默认) +# 建议:生产环境使用 warn,开发/调试时使用 info LOG_LEVEL=info # ==================== 缓存配置 ==================== @@ -160,6 +181,12 @@ SEARCH_CACHE_MINUTES=1 # 可根据需要调整 COMMENT_CACHE_MINUTES=1 +# 用户偏好记忆缓存大小限制 +# 默认值:100 +# 说明:lastSelectMap 最多保存的条目数,超过限制时删除最早的条目(FIFO) +# 用于存储查询关键字上次选择的 animeId,下次 match 自动匹配时优先选择该 anime +# MAX_LAST_SELECT_MAP=100 + # ==================== Redis 配置(Upstash)==================== # Upstash Redis REST URL(用于缓存) diff --git a/README.md b/README.md index dbb57b4..fef9a12 100644 --- a/README.md +++ b/README.md @@ -308,13 +308,14 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | OTHER_SERVER | 【可选】兜底第三方弹幕服务器,不填默认为`https://api.danmu.icu` | | VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`,不填默认为`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top` | | VOD_RETURN_MODE | 【可选】VOD返回模式,可选值:`all`(返回所有站点结果)、`fastest`(只返回最快的站点结果),默认为`fastest`。当配置多个VOD站点时,`all`模式会返回所有站点的结果(结果较多),`fastest`模式只返回首先响应成功的站点结果(结果较少,避免重复) | -| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`5000`(5秒),建议值:`3000-10000` | +| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,实际最少只需取 `SESSDATA=xxxx` 字段 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | -| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tencent,renren,hanjutv,bahamut` | +| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tencent,youku,iqiyi,imgo,bilibili,renren,hanjutv,bahamut` | | PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, renren, hanjutv, bahamut` | | EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 | | ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 | +| STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` | | BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 | | GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 | | CONVERT_TOP_BOTTOM_TO_SCROLL | 【可选】是否将顶部和底部弹幕转换为浮动弹幕,默认为`false`(不转换),启用后顶部弹幕(ct=5)和底部弹幕(ct=4)会被转换为浮动弹幕(ct=1),可选值:`true`、`false` | @@ -367,6 +368,10 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | 360 | qiyi, bilibili1, imgo, youku, qq | | vod | qiyi, bilibili1, imgo, youku, qq | | tencent | qq | +| youku | youku | +| iqiyi | qiyi | +| imgo | imgo | +| bilibili | bilibili1 | | renren | renren | | hanjutv | hanjutv | | bahamut | bahamut | diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index ee66035..cff3816 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -57,6 +57,10 @@ export async function searchAnime(url) { const hanjutvSource = new HanjutvSource(); const bahamutSource = new BahamutSource(); const tencentSource = new TencentSource(); + const youkuSource = new YoukuSource(); + const iqiyiSource = new IqiyiSource(); + const mangoSource = new MangoSource(); + const bilibiliSource = new BilibiliSource(); const queryTitle = url.searchParams.get("keyword"); log("info", `Search anime with keyword: ${queryTitle}`); @@ -137,6 +141,10 @@ export async function searchAnime(url) { if (source === "hanjutv") return hanjutvSource.search(queryTitle); if (source === "bahamut") return bahamutSource.search(queryTitle); if (source === "tencent") return tencentSource.search(queryTitle); + if (source === "youku") return youkuSource.search(queryTitle); + if (source === "iqiyi") return iqiyiSource.search(queryTitle); + if (source === "imgo") return mangoSource.search(queryTitle); + if (source === "bilibili") return bilibiliSource.search(queryTitle); }); // 执行所有请求并等待结果 @@ -151,7 +159,7 @@ export async function searchAnime(url) { }); // 解构出返回的结果 - const { vod: animesVodResults, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut, tencent: animesTencent } = resultData; + const { vod: animesVodResults, 360: animes360, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut, tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili } = resultData; // 按顺序处理每个来源的结果 for (const key of globals.sourceOrderArr) { @@ -179,6 +187,18 @@ export async function searchAnime(url) { } else if (key === 'tencent') { // 等待处理Tencent来源 await tencentSource.handleAnimes(animesTencent, queryTitle, curAnimes); + } else if (key === 'youku') { + // 等待处理Youku来源 + await youkuSource.handleAnimes(animesYouku, queryTitle, curAnimes); + } else if (key === 'iqiyi') { + // 等待处理iQiyi来源 + await iqiyiSource.handleAnimes(animesIqiyi, queryTitle, curAnimes); + } else if (key === 'imgo') { + // 等待处理Mango来源 + await mangoSource.handleAnimes(animesImgo, queryTitle, curAnimes); + } else if (key === 'bilibili') { + // 等待处理Bilibili来源 + await bilibiliSource.handleAnimes(animesBilibili, queryTitle, curAnimes); } } } catch (error) { diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 6a1bca6..d930faf 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -8,7 +8,7 @@ export class Envs { static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq']; // vod允许的播放平台 static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut']; // 全部源允许的播放平台 - static ALLOWED_SOURCES = ['360', 'vod', 'tencent', 'renren', 'hanjutv', 'bahamut']; // 允许的源 + static ALLOWED_SOURCES = ['360', 'vod', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut']; // 允许的源 /** * 获取环境变量 @@ -188,7 +188,7 @@ export class Envs { otherServer: this.get('OTHER_SERVER', 'https://api.danmu.icu', 'string'), // 第三方弹幕服务器 vodServers: this.resolveVodServers(env), // vod站点配置,格式:名称@URL,名称@URL vodReturnMode: this.get('VOD_RETURN_MODE', 'fastest', 'string').toLowerCase(), // vod返回模式:all(所有站点)或 fastest(最快的站点) - vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '5000', 'string'), // vod超时时间 + vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '10000', 'string'), // vod超时时间(默认10秒) bilibliCookie: this.get('BILIBILI_COOKIE', '', 'string', true), // b站cookie youkuConcurrency: Math.min(this.get('YOUKU_CONCURRENCY', 8, 'number'), 16), // 优酷并发配置 sourceOrderArr: this.resolveSourceOrder(env, deployPlatform), // 源排序 @@ -207,7 +207,8 @@ export class Envs { commentCacheMinutes: this.get('COMMENT_CACHE_MINUTES', 1, 'number'), // 弹幕缓存时间配置(分钟,默认 1) convertTopBottomToScroll: this.get('CONVERT_TOP_BOTTOM_TO_SCROLL', false, 'boolean'), // 顶部/底部弹幕转换为浮动弹幕配置(默认 false,禁用转换) convertColorToWhite: this.get('CONVERT_COLOR_TO_WHITE', false, 'boolean'), // 彩色弹幕转换为纯白弹幕配置(默认 false,禁用转换) - danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string') // 弹幕输出格式配置(默认 json,可选值:json, xml) + danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string'), // 弹幕输出格式配置(默认 json,可选值:json, xml) + strictTitleMatch: this.get('STRICT_TITLE_MATCH', false, 'boolean') // 严格标题匹配模式配置(默认 false,宽松模糊匹配) }; } } \ No newline at end of file diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index d654b70..abf693b 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -11,7 +11,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.5.0', + VERSION: '1.6.0', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, MAX_LAST_SELECT_MAP: 100, diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index 14f7b45..db05f57 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -6,6 +6,7 @@ import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized, traditionalized } from "../utils/zh-util.js"; import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js"; +import { strictTitleMatch } from "../utils/common-util.js"; // ===================== // 获取巴哈姆特弹幕 @@ -156,6 +157,28 @@ export default class BahamutSource extends BaseSource { const q = String(queryTitle || ""); const used = String(searchUsedTitle || ""); + // 如果启用严格匹配模式 + if (globals.strictTitleMatch) { + // 检查原始查询词 + if (strictTitleMatch(tItem, q)) return true; + if (used && strictTitleMatch(tItem, used)) return true; + + // 尝试繁体/简体互转后的严格匹配 + try { + if (strictTitleMatch(tItem, traditionalized(q))) return true; + if (strictTitleMatch(tItem, simplized(q))) return true; + if (used) { + if (strictTitleMatch(tItem, traditionalized(used))) return true; + if (strictTitleMatch(tItem, simplized(used))) return true; + } + } catch (e) { + // 转换过程中可能会因为异常输入而抛错;忽略继续 + } + + return false; + } + + // 宽松模糊匹配模式(默认) // 直接包含检查 if (tItem.includes(q)) return true; if (used && tItem.includes(used)) return true; diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index e2e798c..e60b079 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -2,12 +2,25 @@ import BaseSource from './base.js'; import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { httpGet} from "../utils/http-util.js"; -import { parseDanmakuBase64 } from "../utils/codec-util.js"; +import { parseDanmakuBase64, md5, convertToAsciiSum } from "../utils/codec-util.js"; +import { generateValidStartDate } from "../utils/time-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { titleMatches } from "../utils/common-util.js"; // ===================== // 获取b站弹幕 // ===================== export default class BilibiliSource extends BaseSource { + // WBI 签名相关常量 + static WBI_MIXIN_KEY_CACHE = { key: null, timestamp: 0 }; + static WBI_MIXIN_KEY_CACHE_TTL = 3600; // 缓存1小时 + static WBI_MIXIN_KEY_TABLE = [ + 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, + 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, + 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, + 36, 20, 34, 44, 52 + ]; + // 解析 b23.tv 短链接 async resolveB23Link(shortUrl) { try { @@ -46,11 +59,346 @@ export default class BilibiliSource extends BaseSource { } } - async search(keyword) {} + /** + * 获取 WBI mixin key(带缓存) + */ + async _getWbiMixinKey() { + const now = Math.floor(Date.now() / 1000); + const cache = BilibiliSource.WBI_MIXIN_KEY_CACHE; - async getEpisodes(id) {} + if (cache.key && (now - cache.timestamp < BilibiliSource.WBI_MIXIN_KEY_CACHE_TTL)) { + return cache.key; + } - async handleAnimes(sourceAnimes, queryTitle, curAnimes) {} + log("info", "[Bilibili] WBI mixin key 已过期或不存在,正在获取新的..."); + + try { + const navResp = await httpGet("https://api.bilibili.com/x/web-interface/nav", { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.bilibili.com/", + "Cookie": globals.bilibliCookie || "" + } + }); + + const data = typeof navResp.data === "string" ? JSON.parse(navResp.data) : navResp.data; + + if (data.code !== 0) { + log("error", "[Bilibili] 获取 WBI 密钥失败:", data.message); + return "dba4a5925b345b4598b7452c75070bca"; // Fallback + } + + const wbiImg = data.data?.wbi_img || {}; + const imgUrl = wbiImg.img_url || ""; + const subUrl = wbiImg.sub_url || ""; + + const imgKey = imgUrl.split('/').pop()?.split('.')[0] || ""; + const subKey = subUrl.split('/').pop()?.split('.')[0] || ""; + + const mixinKey = BilibiliSource.WBI_MIXIN_KEY_TABLE + .map(i => (imgKey + subKey)[i]) + .join('') + .substring(0, 32); + + cache.key = mixinKey; + cache.timestamp = now; + + log("info", "[Bilibili] 成功获取新的 WBI mixin key"); + return mixinKey; + } catch (error) { + log("error", "[Bilibili] 获取 WBI 密钥失败:", error.message); + return "dba4a5925b345b4598b7452c75070bca"; // Fallback + } + } + + /** + * 对参数进行 WBI 签名 + */ + _getWbiSignedParams(params, mixinKey) { + const signedParams = { ...params }; + signedParams.wts = Math.floor(Date.now() / 1000); + + // 按键名排序 + const sortedKeys = Object.keys(signedParams).sort(); + const queryParts = sortedKeys.map(key => { + const value = signedParams[key] ?? ""; + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + }); + + const query = queryParts.join('&'); + const signedQuery = query + mixinKey; + const wRid = md5(signedQuery); + + signedParams.w_rid = wRid; + return signedParams; + } + + /** + * 按类型搜索 + */ + async _searchByType(keyword, searchType, mixinKey) { + try { + log("info", `[Bilibili] 搜索类型 '${searchType}',关键词 '${keyword}'`); + + const searchParams = { keyword, search_type: searchType }; + const signedParams = this._getWbiSignedParams(searchParams, mixinKey); + + const queryString = Object.keys(signedParams) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedParams[key])}`) + .join('&'); + + const url = `https://api.bilibili.com/x/web-interface/wbi/search/type?${queryString}`; + + const response = await httpGet(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.bilibili.com/", + "Cookie": globals.bilibliCookie || "" + } + }); + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (data.code !== 0 || !data.data?.result) { + log("info", `[Bilibili] 类型 '${searchType}' 无结果 (Code: ${data.code})`); + return []; + } + + const results = []; + for (const item of data.data.result) { + const mediaId = item.season_id ? `ss${item.season_id}` : item.bvid ? `bv${item.bvid}` : ""; + if (!mediaId) continue; + + const mediaType = item.season_type_name === "电影" ? "电影" : "电视剧"; + const episodeCount = mediaType === "电影" ? 1 : (item.ep_size || 0); + + // 提取年份 + let year = null; + try { + if (item.pubdate) { + if (typeof item.pubdate === 'number') { + year = new Date(item.pubdate * 1000).getFullYear(); + } else if (typeof item.pubdate === 'string' && item.pubdate.length >= 4) { + year = parseInt(item.pubdate.substring(0, 4)); + } + } else if (item.pubtime) { + year = new Date(item.pubtime * 1000).getFullYear(); + } + } catch (e) { + // 忽略年份解析错误 + } + + // 清理标题 + const cleanedTitle = (item.title || "") + .replace(/<[^>]+>/g, '') // 移除 HTML 标签 + .replace(/&[^;]+;/g, match => { // 解码 HTML 实体 + const entities = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" }; + return entities[match] || match; + }) + .replace(/:/g, ':') + .trim(); + + results.push({ + provider: "bilibili", + mediaId, + title: cleanedTitle, + type: mediaType, + year, + imageUrl: item.cover || null, + episodeCount + }); + } + + log("info", `[Bilibili] 类型 '${searchType}' 找到 ${results.length} 个结果`); + return results; + } catch (error) { + log("error", `[Bilibili] 搜索类型 '${searchType}' 失败:`, error.message); + return []; + } + } + + async search(keyword) { + try { + log("info", `[Bilibili] 开始搜索: ${keyword}`); + + const mixinKey = await this._getWbiMixinKey(); + const searchTypes = ["media_bangumi", "media_ft"]; + + const searchPromises = searchTypes.map(type => this._searchByType(keyword, type, mixinKey)); + const results = await Promise.all(searchPromises); + + // 合并结果并去重 + const allResults = results.flat(); + const uniqueResults = []; + const seenIds = new Set(); + + for (const item of allResults) { + if (!seenIds.has(item.mediaId)) { + seenIds.add(item.mediaId); + uniqueResults.push(item); + } + } + + log("info", `[Bilibili] 搜索完成,找到 ${uniqueResults.length} 个有效结果`); + return uniqueResults; + } catch (error) { + log("error", "[Bilibili] 搜索出错:", error.message); + return []; + } + } + + /** + * 获取番剧分集列表 + */ + async _getPgcEpisodes(seasonId) { + try { + const url = `https://api.bilibili.com/pgc/view/web/season?season_id=${seasonId}`; + + const response = await httpGet(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.bilibili.com/", + "Cookie": globals.bilibliCookie || "" + } + }); + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (data.code !== 0 || !data.result) { + log("error", `[Bilibili] 获取番剧分集失败 (season_id=${seasonId}):`, data.message); + return []; + } + + // 优先从 main_section 获取分集 + const rawEpisodes = data.result.main_section?.episodes || data.result.episodes || []; + + if (rawEpisodes.length === 0) { + log("info", `[Bilibili] 番剧 season_id=${seasonId} 无分集数据`); + return []; + } + + const episodes = rawEpisodes.map((ep, index) => ({ + vid: `${ep.aid},${ep.cid}`, + id: ep.id, + title: (ep.show_title || ep.long_title || ep.title || `第${index + 1}集`).trim(), + link: `https://www.bilibili.com/bangumi/play/ep${ep.id}` + })); + + log("info", `[Bilibili] 获取到 ${episodes.length} 个番剧分集`); + return episodes; + } catch (error) { + log("error", `[Bilibili] 获取番剧分集出错 (season_id=${seasonId}):`, error.message); + return []; + } + } + + /** + * 获取普通视频分集列表 + */ + async _getUgcEpisodes(bvid) { + try { + const url = `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`; + + const response = await httpGet(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://www.bilibili.com/", + "Cookie": globals.bilibliCookie || "" + } + }); + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (data.code !== 0 || !data.data) { + log("error", `[Bilibili] 获取视频分集失败 (bvid=${bvid}):`, data.message); + return []; + } + + const pages = data.data.pages || []; + + if (pages.length === 0) { + log("info", `[Bilibili] 视频 bvid=${bvid} 无分集数据`); + return []; + } + + const episodes = pages.map((page, index) => ({ + vid: `${data.data.aid},${page.cid}`, + id: page.cid, + title: (page.part || `P${page.page}`).trim(), + link: `https://www.bilibili.com/video/${bvid}?p=${page.page}` + })); + + log("info", `[Bilibili] 获取到 ${episodes.length} 个视频分集`); + return episodes; + } catch (error) { + log("error", `[Bilibili] 获取视频分集出错 (bvid=${bvid}):`, error.message); + return []; + } + } + + async getEpisodes(id) { + if (id.startsWith('ss')) { + const seasonId = id.substring(2); + return await this._getPgcEpisodes(seasonId); + } else if (id.startsWith('bv')) { + const bvid = id.substring(2); + return await this._getUgcEpisodes(bvid); + } + + log("error", `[Bilibili] 不支持的 ID 格式: ${id}`); + return []; + } + + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + const processPromises = sourceAnimes + .filter(anime => titleMatches(anime.title, queryTitle)) + .map(async (anime) => { + try { + const eps = await this.getEpisodes(anime.mediaId); + + if (eps.length === 0) { + log("info", `[Bilibili] ${anime.title} 无分集,跳过`); + return; + } + + const links = eps.map((ep, index) => ({ + name: `${index + 1}`, + url: ep.link, + title: `【bilibili】 ${ep.title}` + })); + + const numericAnimeId = convertToAsciiSum(anime.mediaId); + const transformedAnime = { + animeId: numericAnimeId, + bangumiId: anime.mediaId, + animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from bilibili`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year), + episodeCount: links.length, + rating: 0, + isFavorited: true, + }; + + tmpAnimes.push(transformedAnime); + addAnime({ ...transformedAnime, links }); + + if (globals.animes.length > globals.MAX_ANIMES) { + removeEarliestAnime(); + } + } catch (error) { + log("error", `[Bilibili] 处理 ${anime.title} 失败:`, error.message); + } + }); + + await Promise.all(processPromises); + + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); + return tmpAnimes; + } async getEpisodeDanmu(id) { log("info", "开始从本地请求B站弹幕...", id); diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index adf74ac..6ad2551 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -5,6 +5,7 @@ import { httpGet } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { titleMatches } from "../utils/common-util.js"; // ===================== // 获取韩剧TV弹幕 @@ -136,7 +137,7 @@ export default class HanjutvSource extends BaseSource { // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 const processHanjutvAnimes = await Promise.all(sourceAnimes - .filter(s => s.name.includes(queryTitle)) + .filter(s => titleMatches(s.name, queryTitle)) .map(async (anime) => { const detail = await this.getDetail(anime.sid); const eps = await this.getEpisodes(anime.sid); diff --git a/danmu_api/sources/iqiyi.js b/danmu_api/sources/iqiyi.js index 5bc2863..863535b 100644 --- a/danmu_api/sources/iqiyi.js +++ b/danmu_api/sources/iqiyi.js @@ -1,17 +1,473 @@ import BaseSource from './base.js'; import { log } from "../utils/log-util.js"; import { buildQueryString, httpGet} from "../utils/http-util.js"; -import { printFirst200Chars } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { md5, convertToAsciiSum } from "../utils/codec-util.js"; +import { generateValidStartDate } from "../utils/time-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { globals } from '../configs/globals.js'; // ===================== // 获取爱奇艺弹幕 // ===================== export default class IqiyiSource extends BaseSource { - async search(keyword) {} + // 爱奇艺 API 签名相关常量 + static XOR_KEY = 0x75706971676c; + static SECRET_KEY = "howcuteitis"; + static KEY_NAME = "secret_key"; - async getEpisodes(id) {} + /** + * 搜索爱奇艺内容 + * @param {string} keyword - 搜索关键词 + * @returns {Promise} 搜索结果数组 + */ + async search(keyword) { + try { + log("info", `[iQiyi] 开始搜索: ${keyword}`); - async handleAnimes(sourceAnimes, queryTitle, curAnimes) {} + // 使用桌面版 API 搜索 + const params = { + key: keyword, + current_page: '1', + mode: '1', + source: 'input', + suggest: '', + pcv: '13.074.22699', + version: '13.074.22699', + pageNum: '1', + pageSize: '25', + pu: '', + u: 'f6440fc5d919dca1aea12b6aff56e1c7', + scale: '200', + token: '', + userVip: '0', + conduit: '', + vipType: '-1', + os: '', + osShortName: 'win10', + dataType: '', + appMode: '', + ad: JSON.stringify({"lm":3,"azd":1000000000951,"azt":733,"position":"feed"}), + adExt: JSON.stringify({"r":"2.1.5-ares6-pure"}) + }; + + // 手动构建 URL(httpGet 不支持 params 选项) + const queryString = buildQueryString(params); + const url = `https://mesh.if.iqiyi.com/portal/lw/search/homePageV3?${queryString}`; + + const response = await httpGet(url, { + headers: { + 'accept': '*/*', + 'origin': 'https://www.iqiyi.com', + 'referer': 'https://www.iqiyi.com/', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }); + + if (!response || !response.data) { + log("info", "[iQiyi] 搜索响应为空"); + return []; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (!data.data || !data.data.templates) { + log("info", "[iQiyi] 搜索无结果"); + return []; + } + + // 处理搜索结果 + const results = []; + const templates = data.data.templates; + + for (const template of templates) { + let albumsToProcess = []; + + // 优先处理意图卡片 (template 112) + if (template.template === 112 && template.intentAlbumInfos) { + log("debug", `[iQiyi] 找到意图卡片 (template 112),处理 ${template.intentAlbumInfos.length} 个结果`); + albumsToProcess = template.intentAlbumInfos; + } + // 然后处理普通结果卡片 + else if ([101, 102, 103].includes(template.template) && template.albumInfo) { + log("debug", `[iQiyi] 找到普通结果卡片 (template ${template.template})`); + albumsToProcess = [template.albumInfo]; + } + + for (const album of albumsToProcess) { + const filtered = this._filterIqiyiSearchItem(album, keyword); + if (filtered) { + results.push(filtered); + } + } + } + + log("info", `[iQiyi] 搜索找到 ${results.length} 个有效结果`); + return results; + + } catch (error) { + log("error", "[iQiyi] 搜索出错:", error.message); + return []; + } + } + + /** + * 过滤爱奇艺搜索项 + * @param {Object} album - 搜索结果专辑信息 + * @param {string} keyword - 搜索关键词 + * @returns {Object|null} 过滤后的结果 + */ + _filterIqiyiSearchItem(album, keyword) { + if (!album.title) { + return null; + } + + // 提取 link_id (从 pageUrl 或 playUrl 中提取) + const url = album.pageUrl || album.playUrl; + if (!url) { + return null; + } + + const linkIdMatch = url.match(/v_(\w+?)\.html/); + if (!linkIdMatch) { + return null; + } + const linkId = linkIdMatch[1]; + + // 过滤外站付费播放 + if (album.btnText === '外站付费播放') { + log("debug", `[iQiyi] 过滤掉外站付费播放内容: ${album.title}`); + return null; + } + + // 过滤不相关频道 + const channel = album.channel || ""; + let mediaType = "电视剧"; + if (channel.includes("电影")) { + mediaType = "电影"; + } else if (channel.includes("电视剧") || channel.includes("动漫")) { + mediaType = "电视剧"; + } else { + // 只保留电影、电视剧、动漫 + return null; + } + + // 提取年份 + let year = null; + if (album.year) { + const yearStr = album.year.value || album.year.name; + if (yearStr && typeof yearStr === 'string' && yearStr.length === 4 && /^\d{4}$/.test(yearStr)) { + year = parseInt(yearStr); + } + } + + // 提取分集数 + let episodeCount = null; + if (album.videos && album.videos.length > 0) { + episodeCount = album.videos.length; + } else if (album.subscriptContent) { + // 从 subscriptContent 中提取集数 + const countMatch = album.subscriptContent.match(/(?:更新至|全|共)\s*(\d+)\s*(?:集|话|期)/); + if (countMatch) { + episodeCount = parseInt(countMatch[1]); + } else { + const simpleMatch = album.subscriptContent.trim().match(/^(\d+)$/); + if (simpleMatch) { + episodeCount = parseInt(simpleMatch[1]); + } + } + } + + // 清理标题 + const cleanedTitle = album.title.replace(/<[^>]+>/g, '').replace(/:/g, ':'); + + return { + provider: "iqiyi", + mediaId: linkId, + title: cleanedTitle, + type: mediaType, + year: year, + imageUrl: album.img || album.imgH, + episodeCount: episodeCount + }; + } + + /** + * 获取分集列表 + * @param {string} id - 视频 ID (link_id) + * @returns {Promise} 分集列表 + */ + async getEpisodes(id) { + try { + log("info", `[iQiyi] 获取分集列表: media_id=${id}`); + + // 第一步:将 video_id 转换为 entity_id + const entityId = this._videoIdToEntityId(id); + if (!entityId) { + log("error", `[iQiyi] 无法将 media_id '${id}' 转换为 entity_id`); + return []; + } + + // 第二步:构建 API 请求参数 + const params = { + entity_id: entityId, + device_id: 'qd5fwuaj4hunxxdgzwkcqmefeb3ww5hx', + auth_cookie: '', + user_id: '0', + vip_type: '-1', + vip_status: '0', + conduit_id: '', + pcv: '13.082.22866', + app_version: '13.082.22866', + ext: '', + app_mode: 'standard', + scale: '100', + timestamp: String(Date.now()), + src: 'pca_tvg', + os: '', + ad_ext: '{"r":"2.2.0-ares6-pure"}' + }; + + // 生成签名 + params.sign = this._createSign(params); + + // 第三步:请求 API + const queryString = buildQueryString(params); + const url = `https://www.iqiyi.com/prelw/tvg/v2/lw/base_info?${queryString}`; + + const response = await httpGet(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://www.iqiyi.com/' + } + }); + + if (!response || !response.data) { + log("error", "[iQiyi] 获取分集响应为空"); + return []; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (data.status_code !== 0 || !data.data || !data.data.template) { + log("error", `[iQiyi] API 返回错误,status_code: ${data.status_code}`); + return []; + } + + // 第四步:解析分集数据 + const allEpisodes = []; + const tabs = data.data.template.tabs || []; + + if (tabs.length === 0) { + log("info", "[iQiyi] 未找到分集标签页"); + return []; + } + + const blocks = tabs[0].blocks || []; + const episodeGroups = []; + + for (const block of blocks) { + if (block.bk_type === "album_episodes") { + const blockData = block.data?.data || []; + episodeGroups.push(...blockData); + } + } + + if (episodeGroups.length === 0) { + log("info", "[iQiyi] 未找到分集数据块"); + return []; + } + + // 第五步:处理分集数据 + for (const group of episodeGroups) { + let videosData = group.videos; + + // 如果 videos 是 URL,需要额外请求 + if (typeof videosData === 'string') { + log("info", `[iQiyi] 发现分季URL,正在获取: ${videosData}`); + try { + const seasonResponse = await httpGet(videosData); + videosData = typeof seasonResponse.data === "string" ? JSON.parse(seasonResponse.data) : seasonResponse.data; + } catch (error) { + log("error", `[iQiyi] 获取分季数据失败: ${error.message}`); + continue; + } + } + + // 处理分页数据 + if (videosData && typeof videosData === 'object' && videosData.feature_paged) { + for (const pageKey in videosData.feature_paged) { + const pagedList = videosData.feature_paged[pageKey]; + for (const epData of pagedList) { + if (epData.content_type !== 1) continue; + + const playUrl = epData.play_url || ""; + const tvidMatch = playUrl.match(/tvid=(\d+)/); + if (!tvidMatch) continue; + + const tvid = tvidMatch[1]; + let title = epData.short_display_name || epData.title || "未知分集"; + const subtitle = epData.subtitle; + if (subtitle && !title.includes(subtitle)) { + title = `${title} ${subtitle}`; + } + + const order = epData.album_order; + const pageUrl = epData.page_url; + + if (tvid && title && order && pageUrl) { + allEpisodes.push({ + id: tvid, + title: title, + order: order, + link: pageUrl + }); + } + } + } + } + } + + // 去重并排序 + const uniqueEpisodes = Array.from( + new Map(allEpisodes.map(ep => [ep.id, ep])).values() + ); + uniqueEpisodes.sort((a, b) => a.order - b.order); + + log("info", `[iQiyi] 成功获取 ${uniqueEpisodes.length} 个分集`); + return uniqueEpisodes; + + } catch (error) { + log("error", "[iQiyi] 获取分集出错:", error.message); + return []; + } + } + + /** + * 将 video_id 转换为 entity_id + * @param {string} videoId - 视频 ID + * @returns {string|null} entity_id + */ + _videoIdToEntityId(videoId) { + try { + const base36Decoded = parseInt(videoId, 36); + const xorResult = this._xorOperation(base36Decoded); + const finalResult = xorResult < 900000 ? 100 * (xorResult + 900000) : xorResult; + return String(finalResult); + } catch (error) { + log("error", `[iQiyi] 将 video_id '${videoId}' 转换为 entity_id 时出错: ${error.message}`); + return null; + } + } + + /** + * 异或运算 + * @param {number} num - 输入数字 + * @returns {number} 异或结果 + */ + _xorOperation(num) { + const numBinary = num.toString(2); + const keyBinary = IqiyiSource.XOR_KEY.toString(2); + const numBits = numBinary.split('').reverse(); + const keyBits = keyBinary.split('').reverse(); + const resultBits = []; + const maxLen = Math.max(numBits.length, keyBits.length); + + for (let i = 0; i < maxLen; i++) { + const numBit = i < numBits.length ? numBits[i] : '0'; + const keyBit = i < keyBits.length ? keyBits[i] : '0'; + if (numBit === '1' && keyBit === '1') { + resultBits.push('0'); + } else if (numBit === '1' || keyBit === '1') { + resultBits.push('1'); + } else { + resultBits.push('0'); + } + } + + const resultBinary = resultBits.reverse().join(''); + return resultBinary ? parseInt(resultBinary, 2) : 0; + } + + /** + * 为 API 生成签名 + * @param {Object} params - 请求参数 + * @returns {string} MD5 签名 + */ + _createSign(params) { + const cleanParams = {}; + for (const key in params) { + if (key !== 'sign') { + cleanParams[key] = params[key]; + } + } + + const sortedKeys = Object.keys(cleanParams).sort(); + const paramParts = []; + for (const key of sortedKeys) { + const value = cleanParams[key] === null || cleanParams[key] === undefined ? "" : cleanParams[key]; + paramParts.push(`${key}=${value}`); + } + + const paramString = paramParts.join("&"); + const signString = `${paramString}&${IqiyiSource.KEY_NAME}=${IqiyiSource.SECRET_KEY}`; + return md5(signString).toUpperCase(); + } + + /** + * 处理搜索结果并格式化为 DanDanPlay 格式 + * @param {Array} sourceAnimes - 搜索结果数组 + * @param {string} queryTitle - 搜索关键词 + * @param {Array} curAnimes - 当前动漫列表 + * @returns {Promise} + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + const processIqiyiAnimes = await Promise.all(sourceAnimes + .filter(s => titleMatches(s.title, queryTitle)) + .map(async (anime) => { + const eps = await this.getEpisodes(anime.mediaId); + + // 格式化分集列表 + const links = []; + for (const ep of eps) { + const fullUrl = ep.link || `https://www.iqiyi.com/v_${anime.mediaId}.html`; + links.push({ + "name": ep.order, + "url": fullUrl, + "title": `【iqiyi】 ${ep.title}` + }); + } + + if (links.length > 0) { + const numericAnimeId = convertToAsciiSum(anime.mediaId); + const transformedAnime = { + animeId: numericAnimeId, + bangumiId: anime.mediaId, + animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from iqiyi`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year), + episodeCount: links.length, + rating: 0, + isFavorited: true, + }; + + tmpAnimes.push(transformedAnime); + addAnime({...transformedAnime, links: links}); + + if (globals.animes.length > globals.MAX_ANIMES) { + removeEarliestAnime(); + } + } + }) + ); + + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); + return processIqiyiAnimes; + } async getEpisodeDanmu(id) { log("info", "开始从本地请求爱奇艺弹幕...", id); diff --git a/danmu_api/sources/kan360.js b/danmu_api/sources/kan360.js index 573afa5..6d596fe 100644 --- a/danmu_api/sources/kan360.js +++ b/danmu_api/sources/kan360.js @@ -4,6 +4,7 @@ import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { titleMatches } from "../utils/common-util.js"; // ===================== // 获取360看源播放链接 @@ -107,7 +108,9 @@ export default class Kan360Source extends BaseSource { async handleAnimes(sourceAnimes, queryTitle, curAnimes) { const tmpAnimes = []; - const process360Animes = await Promise.all(sourceAnimes.map(async (anime) => { + const process360Animes = await Promise.all(sourceAnimes + .filter(anime => titleMatches(anime.titleTxt, queryTitle)) + .map(async (anime) => { let links = []; if (anime.cat_name === "电影") { for (const key of Object.keys(anime.playlinks)) { diff --git a/danmu_api/sources/mango.js b/danmu_api/sources/mango.js index c948199..18bfd3c 100644 --- a/danmu_api/sources/mango.js +++ b/danmu_api/sources/mango.js @@ -1,9 +1,12 @@ import BaseSource from './base.js'; +import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { httpGet} from "../utils/http-util.js"; -import { printFirst200Chars } from "../utils/common-util.js"; -import { time_to_second } from "../utils/time-util.js"; +import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { time_to_second, generateValidStartDate } from "../utils/time-util.js"; import { rgbToInt } from "../utils/danmu-util.js"; +import { convertToAsciiSum } from "../utils/codec-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; // ===================== // 获取芒果TV弹幕 @@ -37,11 +40,420 @@ export default class MangoSource extends BaseSource { return Math.floor((leftColor + rightColor) / 2); } - async search(keyword) {} + async search(keyword) { + try { + log("info", `[Mango] 开始搜索: ${keyword}`); - async getEpisodes(id) {} + const encodedKeyword = encodeURIComponent(keyword); + const searchUrl = `https://mobileso.bz.mgtv.com/msite/search/v2?q=${encodedKeyword}&pc=30&pn=1&sort=-99&ty=0&du=0&pt=0&corr=1&abroad=0&_support=10000000000000000`; - async handleAnimes(sourceAnimes, queryTitle, curAnimes) {} + const response = await httpGet(searchUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'application/json', + 'Referer': 'https://www.mgtv.com/' + } + }); + + if (!response || !response.data) { + log("info", "[Mango] 搜索响应为空"); + return []; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (!data.data || !data.data.contents) { + log("info", "[Mango] 搜索无结果"); + return []; + } + + // 处理搜索结果 + const results = []; + for (const content of data.data.contents) { + if (content.type !== "media") { + continue; + } + + for (const item of content.data) { + // 只处理芒果TV自有内容 (source为'imgo') + if (item.source !== "imgo") { + continue; + } + + // 从URL中提取 collection_id + const urlMatch = item.url ? item.url.match(/\/b\/(\d+)/) : null; + if (!urlMatch) { + continue; + } + + const mediaId = urlMatch[1]; + + // 清理标题 (移除HTML标签) + const cleanedTitle = item.title ? item.title.replace(/<[^>]+>/g, '').replace(/:/g, ':') : ''; + + // 提取年份 + const yearMatch = item.desc && item.desc[0] ? item.desc[0].match(/[12][890][0-9][0-9]/) : null; + const year = yearMatch ? parseInt(yearMatch[0]) : null; + + // 提取类型 + const typeMatch = item.desc && item.desc[0] ? item.desc[0].split('/')[0].replace("类型:", "").trim() : ''; + const mediaType = typeMatch === "电影" ? "电影" : "电视剧"; + + results.push({ + provider: "imgo", + mediaId: mediaId, + title: cleanedTitle, + type: mediaType, + year: year, + imageUrl: item.img || null, + episodeCount: item.videoCount || null + }); + } + } + + log("info", `[Mango] 搜索找到 ${results.length} 个有效结果`); + return results; + + } catch (error) { + log("error", "[Mango] 搜索出错:", error.message); + return []; + } + } + + async getEpisodes(id) { + try { + log("info", `[Mango] 获取分集列表: collection_id=${id}`); + + let allEpisodes = []; + let month = ""; + let pageIndex = 0; + let totalPages = 1; + + // 分页获取所有分集(按月份分页) + while (pageIndex < totalPages) { + const url = `https://pcweb.api.mgtv.com/variety/showlist?allowedRC=1&collection_id=${id}&month=${month}&page=1&_support=10000000`; + + const response = await httpGet(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://www.mgtv.com/' + } + }); + + if (!response || !response.data) { + log("info", "[Mango] 未找到分集信息"); + break; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (!data.data || !data.data.list) { + log("info", "[Mango] 分集列表为空"); + break; + } + + // 添加当前页的分集 + if (data.data.list && data.data.list.length > 0) { + allEpisodes.push(...data.data.list.filter(ep => ep.src_clip_id === id)); + } + + // 第一次请求时获取总页数 + if (pageIndex === 0) { + totalPages = data.data.tab_m && data.data.tab_m.length > 0 ? data.data.tab_m.length : 1; + log("info", `[Mango] 检测到 ${totalPages} 个月份分页`); + } + + // 准备下一页 + pageIndex++; + if (pageIndex < totalPages && data.data.tab_m && data.data.tab_m[pageIndex]) { + month = data.data.tab_m[pageIndex].m; + } + } + + // 芒果TV专属黑名单正则 + const mangoBlacklist = /^(.*?)(抢先(看|版)|加更(版)?|花絮|预告|特辑|(特别|惊喜|纳凉)?企划|彩蛋|专访|幕后(花絮)?|直播|纯享|未播|衍生|番外|合伙人手记|会员(专享|加长)|片花|精华|看点|速看|解读|reaction|超前营业|超前(vlog)?|陪看(记)?|.{3,}篇|影评)(.*?)$/i; + + // 过滤掉预告片等非正片内容 + const episodes = allEpisodes.filter(ep => { + const fullTitle = `${ep.t2 || ''} ${ep.t1 || ''}`.trim(); + + // 过滤掉预告片 (isnew === "2") + if (ep.isnew === "2") { + log("debug", `[Mango] 过滤预告片: ${fullTitle}`); + return false; + } + + // 使用专属黑名单过滤 + if (mangoBlacklist.test(fullTitle)) { + log("debug", `[Mango] 黑名单过滤: ${fullTitle}`); + return false; + } + + // 优先保留正片 (isIntact === "1"),或者没有标记的内容 + return true; + }); + + // 综艺节目智能处理 + const processedEpisodes = this._processVarietyEpisodes(episodes); + + log("info", `[Mango] 共获取 ${processedEpisodes.length} 集`); + return processedEpisodes; + + } catch (error) { + log("error", "[Mango] 获取分集出错:", error.message); + return []; + } + } + + /** + * 获取电影正片 + * @param {string} mediaId - 媒体ID + * @returns {Object|null} 电影正片信息 + */ + async _getMovieEpisode(mediaId) { + try { + log("info", `[Mango] 获取电影正片: collection_id=${mediaId}`); + + const url = `https://pcweb.api.mgtv.com/variety/showlist?allowedRC=1&collection_id=${mediaId}&month=&page=1&_support=10000000`; + + const response = await httpGet(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://www.mgtv.com/' + } + }); + + if (!response || !response.data) { + log("info", "[Mango] 未找到电影信息"); + return null; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (!data.data || !data.data.list || data.data.list.length === 0) { + log("info", "[Mango] 电影列表为空"); + return null; + } + + // 智能寻找正片: + // 1. 优先寻找 isIntact === "1" 的条目 + let mainFeature = data.data.list.find(ep => ep.isIntact === "1"); + + // 2. 如果找不到,寻找第一个不是预告片的条目 + if (!mainFeature) { + mainFeature = data.data.list.find(ep => ep.isnew !== "2"); + } + + // 3. 最后备用方案,使用第一个条目 + if (!mainFeature) { + mainFeature = data.data.list[0]; + } + + log("info", `[Mango] 找到电影正片: ${mainFeature.t3 || mainFeature.t1 || '正片'}`); + return mainFeature; + + } catch (error) { + log("error", "[Mango] 获取电影正片出错:", error.message); + return null; + } + } + + /** + * 处理综艺分集,智能过滤和排序 + * @param {Array} rawEpisodes - 原始分集数据 + * @returns {Array} 处理后的分集列表 + */ + _processVarietyEpisodes(rawEpisodes) { + if (!rawEpisodes || rawEpisodes.length === 0) { + return []; + } + + log("debug", `[Mango] 综艺处理开始,原始分集数: ${rawEpisodes.length}`); + + // 检查是否有"第N期"格式 + const hasQiFormat = rawEpisodes.some(ep => { + const fullTitle = `${ep.t2 || ''} ${ep.t1 || ''}`.trim(); + return /第\d+期/.test(fullTitle); + }); + + log("debug", `[Mango] 综艺格式分析: 有期数格式=${hasQiFormat}`); + + const episodeInfos = []; + const qiInfoMap = new Map(); // 存储期数信息的映射 + + for (const ep of rawEpisodes) { + const fullTitle = `${ep.t2 || ''} ${ep.t1 || ''}`.trim(); + + if (hasQiFormat) { + // 有"第N期"格式时:只保留纯粹的"第N期"和"第N期上/中/下" + const qiUpMidDownMatch = fullTitle.match(/第(\d+)期([上中下])/); + const qiPureMatch = fullTitle.match(/第(\d+)期/); + const hasUpMidDown = /第\d+期[上中下]/.test(fullTitle); + + if (qiUpMidDownMatch) { + // 检查是否包含无效后缀 + const qiNum = qiUpMidDownMatch[1]; + const upMidDown = qiUpMidDownMatch[2]; + const qiUpMidDownText = `第${qiNum}期${upMidDown}`; + const afterUpMidDown = fullTitle.substring(fullTitle.indexOf(qiUpMidDownText) + qiUpMidDownText.length); + const hasInvalidSuffix = /^(加更|会员版|纯享版|特别版|独家版|Plus|\+|花絮|预告|彩蛋|抢先|精选|未播|回顾|特辑|幕后)/.test(afterUpMidDown); + + if (!hasInvalidSuffix) { + qiInfoMap.set(ep, [parseInt(qiNum), upMidDown]); + episodeInfos.push(ep); + log("debug", `[Mango] 综艺保留上中下格式: ${fullTitle}`); + } else { + log("debug", `[Mango] 综艺过滤上中下格式+后缀: ${fullTitle}`); + } + } else if (qiPureMatch && !hasUpMidDown && !/会员版|纯享版|特别版|独家版|加更|Plus|\+|花絮|预告|彩蛋|抢先|精选|未播|回顾|特辑|幕后|访谈|采访|混剪|合集|盘点|总结|删减|未播放|NG|番外|片段|看点|精彩|制作|导演|演员|拍摄|片尾曲|插曲|主题曲|背景音乐|OST|音乐|歌曲/.test(fullTitle)) { + // 匹配纯粹的"第N期"格式 + const qiNum = qiPureMatch[1]; + qiInfoMap.set(ep, [parseInt(qiNum), '']); + episodeInfos.push(ep); + log("debug", `[Mango] 综艺保留标准期数: ${fullTitle}`); + } else { + log("debug", `[Mango] 综艺过滤非标准期数格式: ${fullTitle}`); + } + } else { + // 没有任何"第N期"格式时:全部保留(除了明显的广告) + if (fullTitle.includes('广告') || fullTitle.includes('推广')) { + log("debug", `[Mango] 跳过广告内容: ${fullTitle}`); + continue; + } + + episodeInfos.push(ep); + log("debug", `[Mango] 综艺保留原始标题: ${fullTitle}`); + } + } + + // 排序逻辑 + if (hasQiFormat) { + // 有期数格式时,按期数和上中下排序 + episodeInfos.sort((a, b) => { + const infoA = qiInfoMap.get(a) || [0, '']; + const infoB = qiInfoMap.get(b) || [0, '']; + + // 先按期数排序 + if (infoA[0] !== infoB[0]) { + return infoA[0] - infoB[0]; + } + + // 期数相同时,按上中下排序 + const orderMap = {'': 0, '上': 1, '中': 2, '下': 3}; + return (orderMap[infoA[1]] || 0) - (orderMap[infoB[1]] || 0); + }); + } else { + // 没有期数格式时,按集数排序 + episodeInfos.sort((a, b) => { + const getEpisodeNumber = (ep) => { + const fullTitle = `${ep.t2 || ''} ${ep.t1 || ''}`.trim(); + const match = fullTitle.match(/第(\d+)集/); + return match ? parseInt(match[1]) : 999999; + }; + + const numA = getEpisodeNumber(a); + const numB = getEpisodeNumber(b); + + // 如果都没有集数,按时间戳排序 + if (numA === 999999 && numB === 999999) { + const timeA = a.ts || "0"; + const timeB = b.ts || "0"; + return timeA.localeCompare(timeB); + } + + return numA - numB; + }); + } + + log("debug", `[Mango] 综艺处理完成,过滤后分集数: ${episodeInfos.length}`); + return episodeInfos; + } + + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + const processMangoAnimes = await Promise.all(sourceAnimes + .filter(s => titleMatches(s.title, queryTitle)) + .map(async (anime) => { + // 电影类型专门处理 + if (anime.type === "电影") { + const movieEpisode = await this._getMovieEpisode(anime.mediaId); + if (!movieEpisode) { + return; + } + + const fullUrl = `https://www.mgtv.com/b/${anime.mediaId}/${movieEpisode.video_id}.html`; + const episodeTitle = movieEpisode.t3 || movieEpisode.t1 || "正片"; + + const links = [{ + "name": "1", + "url": fullUrl, + "title": `【imgo】 ${episodeTitle}` + }]; + + const numericAnimeId = convertToAsciiSum(anime.mediaId); + let transformedAnime = { + animeId: numericAnimeId, + bangumiId: anime.mediaId, + animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from imgo`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year), + episodeCount: 1, + rating: 0, + isFavorited: true, + }; + + tmpAnimes.push(transformedAnime); + addAnime({...transformedAnime, links: links}); + + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + return; + } + + // 电视剧/综艺类型处理 + const eps = await this.getEpisodes(anime.mediaId); + + let links = []; + for (let i = 0; i < eps.length; i++) { + const ep = eps[i]; + const fullUrl = `https://www.mgtv.com/b/${anime.mediaId}/${ep.video_id}.html`; + const episodeTitle = `${ep.t2 || ''} ${ep.t1 || ''}`.trim(); + + links.push({ + "name": String(i + 1), + "url": fullUrl, + "title": `【imgo】 ${episodeTitle}` + }); + } + + if (links.length > 0) { + const numericAnimeId = convertToAsciiSum(anime.mediaId); + let transformedAnime = { + animeId: numericAnimeId, + bangumiId: anime.mediaId, + animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from imgo`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year), + episodeCount: links.length, + rating: 0, + isFavorited: true, + }; + + tmpAnimes.push(transformedAnime); + addAnime({...transformedAnime, links: links}); + + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + } + }) + ); + + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); + return processMangoAnimes; + } async getEpisodeDanmu(id) { log("info", "开始从本地请求芒果TV弹幕...", id); @@ -49,6 +461,7 @@ export default class MangoSource extends BaseSource { // 弹幕和视频信息 API 基础地址 const api_video_info = "https://pcweb.api.mgtv.com/video/info"; const api_ctl_barrage = "https://galaxy.bz.mgtv.com/getctlbarrage"; + const api_rd_barrage = "https://galaxy.bz.mgtv.com/rdbarrage"; // 解析 URL 获取 cid 和 vid // 手动解析 URL(没有 URL 对象的情况下) @@ -89,7 +502,10 @@ export default class MangoSource extends BaseSource { log("info", `标题: ${title}`); // 计算弹幕分段请求 - const promises = []; + let promises = []; + let useNewApi = true; + + // 尝试使用新API(支持彩色弹幕) try { const ctlBarrageUrl = `${api_ctl_barrage}?version=8.1.39&abroad=0&uuid=&os=10.15.7&platform=0&mac=&vid=${vid}&pid=&cid=${cid}&ticket=`; const res = await httpGet(ctlBarrageUrl, { @@ -100,24 +516,52 @@ export default class MangoSource extends BaseSource { }); const ctlBarrage = typeof res.data === "string" ? JSON.parse(res.data) : res.data; - // 每1分钟一个分段 - for (let i = 0; i < Math.ceil(time_to_second(time) / 60); i += 1) { - const danmakuUrl = `https://${ctlBarrage.data?.cdn_list.split(',')[0]}/${ctlBarrage.data?.cdn_version}/${i}.json`; - promises.push( - httpGet(danmakuUrl, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, - }) - ); + // 检查数据结构 + if (!ctlBarrage.data || !ctlBarrage.data.cdn_list || !ctlBarrage.data.cdn_version) { + log("warn", `新API缺少必要字段,切换到旧API`); + useNewApi = false; + } else { + // 每1分钟一个分段 + for (let i = 0; i < Math.ceil(time_to_second(time) / 60); i += 1) { + const danmakuUrl = `https://${ctlBarrage.data.cdn_list.split(',')[0]}/${ctlBarrage.data.cdn_version}/${i}.json`; + promises.push( + httpGet(danmakuUrl, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + }) + ); + } + log("info", `使用新API,弹幕分段数量: ${promises.length}`); } } catch (error) { - log("error", "请求弹幕分片失败:", error); - return []; + log("warn", "新API请求失败,切换到旧API:", error.message); + useNewApi = false; } - log("info", `弹幕分段数量: ${promises.length}`); + // 如果新API失败,使用旧API作为兜底 + if (!useNewApi) { + try { + const step = 60 * 1000; // 每60秒一个分段 + const end_time = time_to_second(time) * 1000; + for (let i = 0; i < end_time; i += step) { + const danmakuUrl = `${api_rd_barrage}?vid=${vid}&cid=${cid}&time=${i}`; + promises.push( + httpGet(danmakuUrl, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + }) + ); + } + log("info", `使用旧API,弹幕分段数量: ${promises.length}`); + } catch (error) { + log("error", "旧API请求失败:", error); + return []; + } + } // 解析弹幕数据 let contents = []; diff --git a/danmu_api/sources/renren.js b/danmu_api/sources/renren.js index ea647a4..aeeb082 100644 --- a/danmu_api/sources/renren.js +++ b/danmu_api/sources/renren.js @@ -5,6 +5,7 @@ import { getPathname, httpGet, sortedQueryString, updateQueryString } from "../u import { autoDecode, createHmacSha256 } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { titleMatches } from "../utils/common-util.js"; // ===================== // 获取人人视频弹幕 @@ -186,7 +187,7 @@ export default class RenrenSource extends BaseSource { // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 const processRenrenAnimes = await Promise.all(sourceAnimes - .filter(s => s.title.includes(queryTitle)) + .filter(s => titleMatches(s.title, queryTitle)) .map(async (anime) => { const eps = await this.getEpisodes(anime.mediaId); let links = []; diff --git a/danmu_api/sources/tencent.js b/danmu_api/sources/tencent.js index a8d25b8..5a0aa1b 100644 --- a/danmu_api/sources/tencent.js +++ b/danmu_api/sources/tencent.js @@ -5,7 +5,7 @@ import { httpGet, httpPost } from "../utils/http-util.js"; import { convertToAsciiSum } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; // ===================== // 获取腾讯视频弹幕 @@ -342,7 +342,7 @@ export default class TencentSource extends BaseSource { // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 const processTencentAnimes = await Promise.all(sourceAnimes - .filter(s => s.title.includes(queryTitle)) + .filter(s => titleMatches(s.title, queryTitle)) .map(async (anime) => { const eps = await this.getEpisodes(anime.mediaId); let links = []; diff --git a/danmu_api/sources/vod.js b/danmu_api/sources/vod.js index b86dfe6..c7165ac 100644 --- a/danmu_api/sources/vod.js +++ b/danmu_api/sources/vod.js @@ -4,7 +4,7 @@ import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; // ===================== // 获取vod源播放链接 @@ -111,7 +111,9 @@ export default class VodSource extends BaseSource { async handleAnimes(sourceAnimes, queryTitle, curAnimes, vodName) { const tmpAnimes = []; - const processVodAnimes = await Promise.all(sourceAnimes.map(async (anime) => { + const processVodAnimes = await Promise.all(sourceAnimes + .filter(anime => titleMatches(anime.vod_name, queryTitle)) + .map(async (anime) => { let vodPlayFromList = anime.vod_play_from.split("$$$"); vodPlayFromList = vodPlayFromList.map(item => { if (item === "mgtv") return "imgo"; diff --git a/danmu_api/sources/youku.js b/danmu_api/sources/youku.js index e92bda0..92581c7 100644 --- a/danmu_api/sources/youku.js +++ b/danmu_api/sources/youku.js @@ -2,8 +2,10 @@ import BaseSource from './base.js'; import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { buildQueryString, httpGet, httpPost } from "../utils/http-util.js"; -import { printFirst200Chars } from "../utils/common-util.js"; -import { md5 } from "../utils/codec-util.js"; +import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { md5, convertToAsciiSum } from "../utils/codec-util.js"; +import { generateValidStartDate } from "../utils/time-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; // ===================== // 获取优酷弹幕 @@ -21,11 +23,328 @@ export default class YoukuSource extends BaseSource { return `https://v.youku.com/v_show/id_${vid}.html`; } - async search(keyword) {} + /** + * 过滤优酷搜索项 + * @param {Object} component - 搜索组件 + * @param {string} keyword - 搜索关键词 + * @returns {Object|null} 过滤后的结果 + */ + filterYoukuSearchItem(component, keyword) { + const commonData = component.commonData; + if (!commonData || !commonData.titleDTO) { + return null; + } - async getEpisodes(id) {} + // 过滤非优酷内容 + if (commonData.isYouku !== 1 && commonData.hasYouku !== 1) { + return null; + } - async handleAnimes(sourceAnimes, queryTitle, curAnimes) {} + const title = commonData.titleDTO.displayName; + + // 过滤不相关内容 + const skipKeywords = ["中配版", "抢先看", "非正片", "解读", "揭秘", "赏析", "《"]; + if (skipKeywords.some(kw => title.includes(kw))) { + return null; + } + + // 提取年份 + const yearMatch = commonData.feature.match(/[12][890][0-9][0-9]/); + const year = yearMatch ? parseInt(yearMatch[0]) : null; + + // 清理标题 (移除HTML标签和特殊符号) + let cleanedTitle = title.replace(/<[^>]+>/g, '').replace(/【.+?】/g, '').trim().replace(/:/g, ':'); + + // 提取媒体类型 + const mediaType = commonData.feature.includes("电影") ? "电影" : "电视剧"; + + return { + provider: "youku", + mediaId: commonData.showId, + title: cleanedTitle, + type: mediaType, + year: year, + imageUrl: commonData.posterDTO ? commonData.posterDTO.vThumbUrl : null, + episodeCount: commonData.episodeTotal, + cats: commonData.cats // 保存分类信息用于后续判断 + }; + } + + async search(keyword) { + try { + log("info", `[Youku] 开始搜索: ${keyword}`); + + const encodedKeyword = encodeURIComponent(keyword); + const encodedUA = encodeURIComponent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); + const searchUrl = `https://search.youku.com/api/search?keyword=${encodedKeyword}&userAgent=${encodedUA}&site=1&categories=0&ftype=0&ob=0&pg=1`; + + const response = await httpGet(searchUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'application/json', + 'Referer': 'https://www.youku.com/' + } + }); + + if (!response || !response.data) { + log("info", "[Youku] 搜索响应为空"); + return []; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + + if (!data.pageComponentList) { + log("info", "[Youku] 搜索无结果"); + return []; + } + + // 过滤和处理搜索结果 + const results = []; + for (const component of data.pageComponentList) { + const filtered = this.filterYoukuSearchItem(component, keyword); + if (filtered) { + results.push(filtered); + } + } + + log("info", `[Youku] 搜索找到 ${results.length} 个有效结果`); + return results; + + } catch (error) { + log("error", "[Youku] 搜索出错:", error.message); + return []; + } + } + + async getEpisodes(id) { + try { + log("info", `[Youku] 获取分集列表: show_id=${id}`); + + // 第一步:获取第一页以确定总数 + const pageSize = 100; + const firstPage = await this._getEpisodesPage(id, 1, pageSize); + + if (!firstPage || !firstPage.videos || firstPage.videos.length === 0) { + log("info", "[Youku] 未找到分集信息"); + return []; + } + + let allEpisodes = [...firstPage.videos]; + const totalCount = firstPage.total; + + // 第二步:如果有多页,并发获取剩余页面 + if (totalCount > pageSize) { + const totalPages = Math.ceil(totalCount / pageSize); + log("info", `[Youku] 检测到 ${totalCount} 个分集,将并发请求 ${totalPages} 页`); + + const pagePromises = []; + for (let page = 2; page <= totalPages; page++) { + pagePromises.push(this._getEpisodesPage(id, page, pageSize)); + } + + const results = await Promise.allSettled(pagePromises); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'fulfilled' && result.value && result.value.videos) { + allEpisodes.push(...result.value.videos); + } else if (result.status === 'rejected') { + log("error", `[Youku] 获取分集页面 ${i + 2} 失败:`, result.reason); + } + } + + log("info", `[Youku] 并发获取完成,共获取 ${allEpisodes.length} 个分集`); + } + + log("info", `[Youku] 共获取 ${allEpisodes.length} 集`); + return allEpisodes; + + } catch (error) { + log("error", "[Youku] 获取分集出错:", error.message); + return []; + } + } + + async _getEpisodesPage(showId, page, pageSize) { + const url = `https://openapi.youku.com/v2/shows/videos.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&ext=show&show_id=${showId}&page=${page}&count=${pageSize}`; + + const response = await httpGet(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + }); + + if (!response || !response.data) { + return null; + } + + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; + return data; + } + + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + const processYoukuAnimes = await Promise.all(sourceAnimes + .filter(s => titleMatches(s.title, queryTitle)) + .map(async (anime) => { + const eps = await this.getEpisodes(anime.mediaId); + + // 提取媒体类型 + const mediaType = this._extractMediaType(anime.cats, anime.type); + + // 处理和格式化分集 + const formattedEps = this._processAndFormatEpisodes(eps, mediaType); + + let links = []; + for (const ep of formattedEps) { + const fullUrl = ep.link || `https://v.youku.com/v_show/id_${ep.vid}.html`; + links.push({ + "name": ep.episodeIndex, + "url": fullUrl, + "title": `【youku】 ${ep.title}` + }); + } + + if (links.length > 0) { + const numericAnimeId = convertToAsciiSum(anime.mediaId); + let transformedAnime = { + animeId: numericAnimeId, + bangumiId: anime.mediaId, + animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from youku`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year), + episodeCount: links.length, + rating: 0, + isFavorited: true, + }; + + tmpAnimes.push(transformedAnime); + addAnime({...transformedAnime, links: links}); + + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + } + }) + ); + + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); + return processYoukuAnimes; + } + + /** + * 处理和格式化分集列表 + * @param {Array} rawEpisodes - 原始分集数据 + * @param {string} mediaType - 媒体类型 (variety/movie/drama/anime) + * @returns {Array} 格式化后的分集列表 + */ + _processAndFormatEpisodes(rawEpisodes, mediaType = 'variety') { + let filteredEpisodes = [...rawEpisodes]; + + // 综艺节目需要倒序处理 + if (mediaType === 'variety') { + log("info", `[Youku] 检测到综艺节目,进行倒序处理`); + filteredEpisodes = filteredEpisodes.reverse(); + + // 检测特殊格式 "第N期:上/中/下" + const specialFormatPattern = /第\d+期\s*[::]\s*[上中下]\s*[::]/; + const hasSpecialFormat = filteredEpisodes.some(ep => { + const displayName = ep.displayName || ep.title; + return specialFormatPattern.test(displayName); + }); + + if (hasSpecialFormat) { + log("info", `[Youku] 检测到综艺特殊格式 '第N期:上/中/下',进行智能过滤`); + // 只保留"上"或没有标记的分集 + const finalFiltered = []; + for (const ep of filteredEpisodes) { + const displayName = ep.displayName || ep.title; + const match = displayName.match(/第(\d+)期\s*[::]\s*([上中下])/); + if (!match || match[2] === '上') { + finalFiltered.push(ep); + } else { + log("debug", `[Youku] 过滤掉综艺分段: ${displayName}`); + } + } + filteredEpisodes = finalFiltered; + } + } + + // 格式化分集标题 + const formattedEpisodes = filteredEpisodes.map((ep, index) => { + const episodeIndex = index + 1; + const title = this._formatEpisodeTitle(ep, episodeIndex, mediaType); + + return { + vid: ep.id, + title: title, + episodeIndex: episodeIndex, + link: ep.link + }; + }); + + return formattedEpisodes; + } + + /** + * 根据媒体类型格式化分集标题 + * @param {Object} ep - 分集对象 + * @param {number} episodeIndex - 分集索引 + * @param {string} mediaType - 媒体类型 + * @returns {string} 格式化后的标题 + */ + _formatEpisodeTitle(ep, episodeIndex, mediaType) { + // 清理 displayName (移除日期前缀) + let cleanDisplayName = ep.displayName || ep.title; + const datePattern = /^(?:\d{2,4}-\d{2}-\d{2}|\d{2}-\d{2})\s*(?=(?:第\d+期))|^(?:\d{2,4}-\d{2}-\d{2}|\d{2}-\d{2})\s*:\s*/; + cleanDisplayName = cleanDisplayName.replace(datePattern, '').trim(); + + // 电影:直接使用原始标题 + if (mediaType === 'movie') { + return cleanDisplayName; + } + + // 综艺:使用 "第N期" 格式 + if (mediaType === 'variety') { + const periodMatch = cleanDisplayName.match(/第(\d+)期/); + if (periodMatch) { + return `第${periodMatch[1]}期`; + } else { + return `第${episodeIndex}期`; + } + } + + // 电视剧/动漫:使用 "第N集" 格式 + 原始标题 + if (/^第\d+集/.test(cleanDisplayName)) { + return cleanDisplayName; + } else { + return `第${episodeIndex}集 ${cleanDisplayName}`; + } + } + + /** + * 从分类信息中提取媒体类型 + * @param {string} cats - 分类字符串 + * @param {string} feature - 特征字符串 + * @returns {string} 媒体类型 + */ + _extractMediaType(cats, feature) { + const catsLower = (cats || '').toLowerCase(); + const featureLower = (feature || '').toLowerCase(); + + if (catsLower.includes('综艺') || catsLower.includes('variety') || featureLower.includes('综艺')) { + return 'variety'; + } else if (catsLower.includes('电影') || catsLower.includes('movie') || featureLower.includes('电影')) { + return 'movie'; + } else if (catsLower.includes('动漫') || catsLower.includes('anime') || featureLower.includes('动漫')) { + return 'anime'; + } else if (catsLower.includes('电视剧') || catsLower.includes('drama') || featureLower.includes('电视剧')) { + return 'drama'; + } + + // 默认返回综艺 + return 'variety'; + } async getEpisodeDanmu(id) { log("info", "开始从本地请求优酷弹幕...", id); diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index a08ce61..e24eb41 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -186,4 +186,43 @@ export function createDynamicPlatformOrder(preferredPlatform) { dynamicOrder.push(null); return dynamicOrder; +} + +/** + * 严格标题匹配函数 + * @param {string} title - 动漫标题 + * @param {string} query - 搜索关键词 + * @returns {boolean} 是否匹配 + */ +export function strictTitleMatch(title, query) { + if (!title || !query) return false; + + const t = String(title).trim(); + const q = String(query).trim(); + + // 完全匹配 + if (t === q) return true; + + // 标题以搜索词开头,且后面跟着空格、括号等分隔符 + const separators = [' ', '(', '(', ':', ':', '-', '—', '·', '第', 'S', 's']; + for (const sep of separators) { + if (t.startsWith(q + sep)) return true; + } + + return false; +} + +/** + * 根据配置选择匹配模式 + * @param {string} title - 动漫标题 + * @param {string} query - 搜索关键词 + * @returns {boolean} 是否匹配 + */ +export function titleMatches(title, query) { + if (globals.strictTitleMatch) { + return strictTitleMatch(title, query); + } else { + // 宽松模糊匹配 + return String(title).includes(String(query)); + } } \ No newline at end of file diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index e029098..9be4a19 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -97,7 +97,9 @@ export function convertToDanmakuJson(contents, platform) { } if (!items.length) { - throw new Error("无效输入,需为 XML 字符串或弹幕数组"); + // 如果是空数组,直接返回空数组,不抛出异常 + // 这样可以让兜底逻辑有机会执行 + return []; } for (const item of items) { diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index 3bad2c3..a551e1c 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -5,12 +5,12 @@ import { log } from './log-util.js' // 请求工具方法 // ===================== -export async function httpGet(url, options) { +export async function httpGet(url, options = {}) { log("info", `[请求模拟] HTTP GET: ${url}`); // 设置超时时间(默认5秒) - const timeout = parseInt(globals.vodRequestTimeout); + const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout);