feat: 多平台搜索源升级与严格匹配模式支持 (#72)
* 修复 环境变量模板
* 修复芒果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之只手遮天")
* 调整默认超时时间 更新版本号
This commit is contained in:
1 parent
039d9a2d90
commit
1e80b60632
18 files changed
+1750
-59
No files matched your search
+36
-9
@@ -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(用于缓存)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,宽松模糊匹配)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+460
-4
@@ -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<Array>} 搜索结果数组
|
||||
*/
|
||||
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<Array>} 分集列表
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+464
-20
@@ -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 = [];
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
+324
-5
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,9 @@ export function convertToDanmakuJson(contents, platform) {
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
throw new Error("无效输入,需为 XML 字符串或弹幕数组");
|
||||
// 如果是空数组,直接返回空数组,不抛出异常
|
||||
// 这样可以让兜底逻辑有机会执行
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user