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:
woleigedouer authored and GitHub committed 2025-10-26 17:42:44 +08:00
1 parent 039d9a2d90
commit 1e80b60632
18 files changed
+1750 -59

No files matched your search

+36 -9
View File
@@ -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(用于缓存)
+7 -2
View File
@@ -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 |
+21 -1
View File
@@ -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) {
+4 -3
View File
@@ -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,宽松模糊匹配)
};
}
}
+1 -1
View File
@@ -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,
+23
View File
@@ -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;
+352 -4
View File
@@ -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 = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&#39;': "'" };
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);
+2 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 = [];
+2 -1
View File
@@ -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 = [];
+2 -2
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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);
+39
View File
@@ -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));
}
}
+3 -1
View File
@@ -97,7 +97,9 @@ export function convertToDanmakuJson(contents, platform) {
}
if (!items.length) {
throw new Error("无效输入,需为 XML 字符串或弹幕数组");
// 如果是空数组,直接返回空数组,不抛出异常
// 这样可以让兜底逻辑有机会执行
return [];
}
for (const item of items) {
+2 -2
View File
@@ -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);