增加Animeko源,增加源合并功能 (#152)
* 新增源合并处理工具 非常多的优化,使不同源的合并进入高可用状态(主要还是动画这块,译名不同和集不一样太多了,动画怎会如此折腾) * 新增Animeko源 感谢188的鼎力相助感谢Animeko,开源伟大! * 新增MERGE_SOURCE_PAIRS环境变量并增加相关解析,新增允许animeko源 * 兼容新的源合并相关请求,修复b23.tv相关处理缺少httpGet引用 * 更新文档 * 更新env * 将源合并从匹配配置挪到源配置 * 更新版本号为1.12.0 * 更新获取弹幕函数,使其更加符合整体规范 * 增加Animeko测试例
This commit is contained in:
1 parent
6b12112022
commit
937532dfa9
8 files changed
+1488
-52
No files matched your search
@@ -370,8 +370,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
| VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 |
|
||||
| BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 |
|
||||
| YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` |
|
||||
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,custom` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, custom` |
|
||||
| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,animeko,custom` |
|
||||
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko, custom` |
|
||||
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段`,示例:`imgo&iqiyi,dandan&animeko,dandan&bahamut`, 允许多组、允许同时存在、一组配置一对,目前允许合并的源字段有`qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko` |
|
||||
| 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` |
|
||||
@@ -447,6 +448,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
| hanjutv | hanjutv |
|
||||
| bahamut | bahamut |
|
||||
| dandan | dandan |
|
||||
| animeko | [animeko](https://github.com/open-ani/animeko) |
|
||||
| custom | custom |
|
||||
|
||||
## 项目结构
|
||||
@@ -486,6 +488,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
│ ├── models/
|
||||
│ │ └── dandan-model.js # 弹弹play数据模型
|
||||
│ ├── sources/
|
||||
│ │ ├── animeko.js # Animeko源
|
||||
│ │ ├── bahamut.js # 巴哈姆特源
|
||||
│ │ ├── base.js # 弹幕源获取基类
|
||||
│ │ ├── bilibili.js # b站源
|
||||
@@ -522,11 +525,13 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
│ ├── cache-util.js # 缓存数据处理工具
|
||||
│ ├── codec-util.js # 编解码工具
|
||||
│ ├── common-util.js # 通用工具
|
||||
│ ├── cookie-util.js # b站 cookie获取工具
|
||||
│ ├── danmu-util.js # 弹幕处理工具
|
||||
│ ├── douban-util.js # 豆瓣API请求工具
|
||||
│ ├── http-util.js # 请求工具
|
||||
│ ├── imdb-util.js # IMDB API请求工具
|
||||
│ ├── log-util.js # 日志工具
|
||||
│ ├── merge-util.js # 源合并处理工具
|
||||
│ ├── redis-util.js # redis工具
|
||||
│ ├── time-util.js # 时间日期工具
|
||||
│ ├── tmdb-util.js # TMDB API请求处理工具
|
||||
|
||||
+10
-2
@@ -68,7 +68,7 @@ YOUKU_CONCURRENCY=8
|
||||
|
||||
# ==================== 源排序配置 ====================
|
||||
|
||||
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, custom)
|
||||
# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, animeko, custom)
|
||||
# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili 和 sohu)
|
||||
# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源或搜狐源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,ren,hanjutv,sohu
|
||||
# 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut
|
||||
@@ -81,9 +81,17 @@ SOURCE_ORDER=360,vod,renren,hanjutv
|
||||
|
||||
# 平台优先级排序(用于自动匹配)
|
||||
# 默认值:空(自动匹配)
|
||||
# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, custom
|
||||
# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko, custom
|
||||
# PLATFORM_ORDER=
|
||||
|
||||
# ==================== 源合并配置 ====================
|
||||
# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, animeko)
|
||||
# 默认值:空
|
||||
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、一组配置一对
|
||||
# 格式:源字段&源字段
|
||||
# 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&animeko,dandan&bahamut
|
||||
# MERGE_SOURCE_PAIRS=
|
||||
|
||||
# ==================== 剧集标题过滤 ====================
|
||||
|
||||
# 剧集标题过滤正则表达式(用于过滤花絮、预告等非正片内容)
|
||||
|
||||
+180
-45
@@ -1,5 +1,5 @@
|
||||
import { globals } from '../configs/globals.js';
|
||||
import { getPageTitle, jsonResponse } from '../utils/http-util.js';
|
||||
import { getPageTitle, jsonResponse, httpGet } from '../utils/http-util.js';
|
||||
import { log } from '../utils/log-util.js'
|
||||
import { setRedisKey, updateRedisCaches } from "../utils/redis-util.js";
|
||||
import {
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile,
|
||||
updateLocalCaches
|
||||
} from "../utils/cache-util.js";
|
||||
import { formatDanmuResponse } from "../utils/danmu-util.js";
|
||||
import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js";
|
||||
import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, extractYear } from "../utils/common-util.js";
|
||||
import { getTMDBChineseTitle } from "../utils/tmdb-util.js";
|
||||
import { applyMergeLogic, mergeDanmakuList, MERGE_DELIMITER } from "../utils/merge-util.js";
|
||||
import Kan360Source from "../sources/kan360.js";
|
||||
import VodSource from "../sources/vod.js";
|
||||
import TmdbSource from "../sources/tmdb.js";
|
||||
@@ -25,6 +26,7 @@ import MangoSource from "../sources/mango.js";
|
||||
import BilibiliSource from "../sources/bilibili.js";
|
||||
import YoukuSource from "../sources/youku.js";
|
||||
import SohuSource from "../sources/sohu.js";
|
||||
import AnimekoSource from "../sources/animeko.js";
|
||||
import OtherSource from "../sources/other.js";
|
||||
import { Anime, AnimeMatch, Episodes, Bangumi } from "../models/dandan-model.js";
|
||||
|
||||
@@ -45,10 +47,14 @@ const iqiyiSource = new IqiyiSource();
|
||||
const mangoSource = new MangoSource();
|
||||
const bilibiliSource = new BilibiliSource();
|
||||
const sohuSource = new SohuSource();
|
||||
const animekoSource = new AnimekoSource();
|
||||
const otherSource = new OtherSource();
|
||||
const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource);
|
||||
const tmdbSource = new TmdbSource(doubanSource);
|
||||
|
||||
// 用于聚合请求的去重Map
|
||||
const PENDING_DANMAKU_REQUESTS = new Map();
|
||||
|
||||
// 匹配年份函数,优先于季匹配
|
||||
function matchYear(anime, queryYear) {
|
||||
if (!queryYear) {
|
||||
@@ -195,6 +201,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
|
||||
if (source === "imgo") return mangoSource.search(queryTitle);
|
||||
if (source === "bilibili") return bilibiliSource.search(queryTitle);
|
||||
if (source === "sohu") return sohuSource.search(queryTitle);
|
||||
if (source === "animeko") return animekoSource.search(queryTitle);
|
||||
});
|
||||
|
||||
// 执行所有请求并等待结果
|
||||
@@ -213,7 +220,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
|
||||
vod: animesVodResults, 360: animes360, tmdb: animesTmdb, douban: animesDouban, renren: animesRenren,
|
||||
hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom,
|
||||
tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili,
|
||||
sohu: animesSohu
|
||||
sohu: animesSohu, animeko: animesAnimeko
|
||||
} = resultData;
|
||||
|
||||
// 按顺序处理每个来源的结果
|
||||
@@ -269,12 +276,20 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
|
||||
} else if (key === 'sohu') {
|
||||
// 等待处理Sohu来源
|
||||
await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes);
|
||||
} else if (key === 'animeko') {
|
||||
// 等待处理Animeko来源
|
||||
await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("error", "发生错误:", error);
|
||||
}
|
||||
|
||||
// 执行源合并逻辑
|
||||
if (globals.mergeSourcePairs.length > 0) {
|
||||
await applyMergeLogic(curAnimes);
|
||||
}
|
||||
|
||||
storeAnimeIdsToMap(curAnimes, queryTitle);
|
||||
|
||||
// 如果启用了集标题过滤,则为每个动漫添加过滤后的 episodes
|
||||
@@ -915,6 +930,111 @@ export async function getBangumi(path) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理聚合源弹幕获取
|
||||
* @param {string} url 聚合URL
|
||||
* @returns {Promise<Array>} 合并后的弹幕列表
|
||||
*/
|
||||
async function fetchMergedComments(url) {
|
||||
const parts = url.split(MERGE_DELIMITER);
|
||||
const sourceNames = parts.map(part => part.split(':')[0]).filter(Boolean);
|
||||
const sourceTag = sourceNames.join('&');
|
||||
|
||||
log("info", `[Merge] 开始获取 [${sourceTag}] 聚合弹幕...`);
|
||||
|
||||
// 1. 检查聚合缓存
|
||||
const cached = getCommentCache(url);
|
||||
if (cached) {
|
||||
log("info", `[Merge] 命中缓存 [${sourceTag}],返回 ${cached.length} 条`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stats = {};
|
||||
|
||||
// 2. 并行获取所有源的弹幕
|
||||
const tasks = parts.map(async (part) => {
|
||||
const firstColonIndex = part.indexOf(':');
|
||||
if (firstColonIndex === -1) return [];
|
||||
|
||||
const sourceName = part.substring(0, firstColonIndex);
|
||||
const realId = part.substring(firstColonIndex + 1);
|
||||
|
||||
if (!sourceName || !realId) return [];
|
||||
|
||||
// 构建去重Key
|
||||
const pendingKey = `${sourceName}:${realId}`;
|
||||
|
||||
// 检查是否有正在进行的相同请求(请求合并)
|
||||
if (PENDING_DANMAKU_REQUESTS.has(pendingKey)) {
|
||||
log("info", `[Merge] 复用正在进行的请求: ${pendingKey}`);
|
||||
try {
|
||||
const list = await PENDING_DANMAKU_REQUESTS.get(pendingKey);
|
||||
return list || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 定义请求任务
|
||||
const fetchTask = (async () => {
|
||||
let sourceInstance = null;
|
||||
|
||||
if (sourceName === 'renren') sourceInstance = renrenSource;
|
||||
else if (sourceName === 'hanjutv') sourceInstance = hanjutvSource;
|
||||
else if (sourceName === 'bahamut') sourceInstance = bahamutSource;
|
||||
else if (sourceName === 'dandan') sourceInstance = dandanSource;
|
||||
else if (sourceName === 'tencent') sourceInstance = tencentSource;
|
||||
else if (sourceName === 'youku') sourceInstance = youkuSource;
|
||||
else if (sourceName === 'iqiyi') sourceInstance = iqiyiSource;
|
||||
else if (sourceName === 'imgo') sourceInstance = mangoSource;
|
||||
else if (sourceName === 'bilibili') sourceInstance = bilibiliSource;
|
||||
else if (sourceName === 'sohu') sourceInstance = sohuSource;
|
||||
else if (sourceName === 'animeko') sourceInstance = animekoSource;
|
||||
// 如有新增允许的源合并,在此处添加
|
||||
|
||||
if (sourceInstance) {
|
||||
try {
|
||||
// 获取原始数据 -> 格式化
|
||||
const raw = await sourceInstance.getEpisodeDanmu(realId);
|
||||
const formatted = sourceInstance.formatComments(raw);
|
||||
stats[sourceName] = formatted.length;
|
||||
return formatted;
|
||||
} catch (e) {
|
||||
log("error", `[Merge] 获取 ${sourceName} 失败: ${e.message}`);
|
||||
stats[sourceName] = 0;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
// 将任务加入队列
|
||||
PENDING_DANMAKU_REQUESTS.set(pendingKey, fetchTask);
|
||||
|
||||
try {
|
||||
return await fetchTask;
|
||||
} finally {
|
||||
// 任务完成后移除队列
|
||||
PENDING_DANMAKU_REQUESTS.delete(pendingKey);
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有源请求完成
|
||||
const results = await Promise.all(tasks);
|
||||
|
||||
// 3. 合并数据
|
||||
let mergedList = [];
|
||||
results.forEach(list => {
|
||||
mergedList = mergeDanmakuList(mergedList, list);
|
||||
});
|
||||
|
||||
const statDetails = Object.entries(stats).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
log("info", `[Merge] 聚合原始数据完成: 总计 ${mergedList.length} 条 (${statDetails})`);
|
||||
|
||||
// 4. 统一处理(去重、过滤、转JSON)
|
||||
return convertToDanmakuJson(mergedList, sourceTag);
|
||||
}
|
||||
|
||||
// Extracted function for GET /api/v2/comment/:commentId
|
||||
export async function getComment(path, queryFormat, segmentFlag) {
|
||||
const commentId = parseInt(path.split("/").pop());
|
||||
@@ -939,57 +1059,70 @@ export async function getComment(path, queryFormat, segmentFlag) {
|
||||
|
||||
log("info", "开始从本地请求弹幕...", url);
|
||||
let danmus = [];
|
||||
if (url.includes('.qq.com')) {
|
||||
danmus = await tencentSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.iqiyi.com')) {
|
||||
danmus = await iqiyiSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.mgtv.com')) {
|
||||
danmus = await mangoSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.bilibili.com') || url.includes('b23.tv')) {
|
||||
// 如果是 b23.tv 短链接,先解析为完整 URL
|
||||
if (url.includes('b23.tv')) {
|
||||
url = await bilibiliSource.resolveB23Link(url);
|
||||
}
|
||||
danmus = await bilibiliSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.youku.com')) {
|
||||
danmus = await youkuSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.sohu.com')) {
|
||||
danmus = await sohuSource.getComments(url, plat, segmentFlag);
|
||||
}
|
||||
|
||||
// 请求其他平台弹幕
|
||||
const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i;
|
||||
if (!urlPattern.test(url)) {
|
||||
if (plat === "renren") {
|
||||
danmus = await renrenSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "hanjutv") {
|
||||
danmus = await hanjutvSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "bahamut") {
|
||||
danmus = await bahamutSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "dandan") {
|
||||
danmus = await dandanSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "custom") {
|
||||
danmus = await customSource.getComments(url, plat, segmentFlag);
|
||||
if (url && url.includes(MERGE_DELIMITER)) {
|
||||
danmus = await fetchMergedComments(url);
|
||||
} else {
|
||||
if (url.includes('.qq.com')) {
|
||||
danmus = await tencentSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.iqiyi.com')) {
|
||||
danmus = await iqiyiSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.mgtv.com')) {
|
||||
danmus = await mangoSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.bilibili.com') || url.includes('b23.tv')) {
|
||||
// 如果是 b23.tv 短链接,先解析为完整 URL
|
||||
if (url.includes('b23.tv')) {
|
||||
url = await bilibiliSource.resolveB23Link(url);
|
||||
}
|
||||
danmus = await bilibiliSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.youku.com')) {
|
||||
danmus = await youkuSource.getComments(url, plat, segmentFlag);
|
||||
} else if (url.includes('.sohu.com')) {
|
||||
danmus = await sohuSource.getComments(url, plat, segmentFlag);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果弹幕为空,则请求第三方弹幕服务器作为兜底
|
||||
if (danmus.length === 0 && urlPattern.test(url)) {
|
||||
danmus = await otherSource.getComments(url, "other_server", segmentFlag);
|
||||
// 请求其他平台弹幕
|
||||
const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i;
|
||||
if (!urlPattern.test(url)) {
|
||||
if (plat === "renren") {
|
||||
danmus = await renrenSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "hanjutv") {
|
||||
danmus = await hanjutvSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "bahamut") {
|
||||
danmus = await bahamutSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "dandan") {
|
||||
danmus = await dandanSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "custom") {
|
||||
danmus = await customSource.getComments(url, plat, segmentFlag);
|
||||
} else if (plat === "animeko") {
|
||||
danmus = await animekoSource.getComments(url, plat, segmentFlag);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果弹幕为空,则请求第三方弹幕服务器作为兜底
|
||||
if ((!danmus || danmus.length === 0) && urlPattern.test(url)) {
|
||||
danmus = await otherSource.getComments(url, "other_server", segmentFlag);
|
||||
}
|
||||
}
|
||||
|
||||
const [animeId, source] = findAnimeIdByCommentId(commentId);
|
||||
setPreferByAnimeId(animeId, source);
|
||||
if (globals.localCacheValid && animeId) {
|
||||
writeCacheToFile('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap)));
|
||||
}
|
||||
if (globals.redisValid && animeId) {
|
||||
await setRedisKey('lastSelectMap', globals.lastSelectMap);
|
||||
if (animeId && source) {
|
||||
setPreferByAnimeId(animeId, source);
|
||||
if (globals.localCacheValid && animeId) {
|
||||
writeCacheToFile('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap)));
|
||||
}
|
||||
if (globals.redisValid && animeId) {
|
||||
setRedisKey('lastSelectMap', globals.lastSelectMap).catch(e => log("error", "Redis set error", e));
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存弹幕结果
|
||||
if (danmus.length > 0) {
|
||||
setCommentCache(url, danmus);
|
||||
if (!segmentFlag) {
|
||||
if (danmus && danmus.comments) danmus = danmus.comments;
|
||||
if (!Array.isArray(danmus)) danmus = [];
|
||||
if (danmus.length > 0) {
|
||||
setCommentCache(url, danmus);
|
||||
}
|
||||
}
|
||||
|
||||
const responseData = { count: danmus.length, comments: danmus };
|
||||
@@ -1144,6 +1277,8 @@ export async function getSegmentComment(segment, queryFormat) {
|
||||
danmus = await renrenSource.getSegmentComments(segment);
|
||||
} else if (platform === "dandan") {
|
||||
danmus = await dandanSource.getSegmentComments(segment);
|
||||
} else if (platform === "animeko") {
|
||||
danmus = await animekoSource.getSegmentComments(segment);
|
||||
} else if (platform === "custom") {
|
||||
danmus = await customSource.getSegmentComments(segment);
|
||||
} else if (platform === "other_server") {
|
||||
|
||||
@@ -10,8 +10,9 @@ export class Envs {
|
||||
static accessedEnvVars = new Map();
|
||||
|
||||
static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu']; // vod允许的播放平台
|
||||
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'custom']; // 全部源允许的播放平台
|
||||
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'custom']; // 允许的源
|
||||
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko', 'custom']; // 全部源允许的播放平台
|
||||
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko', 'custom']; // 允许的源
|
||||
static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko']; // 允许的源合并
|
||||
|
||||
/**
|
||||
* 获取环境变量
|
||||
@@ -135,6 +136,36 @@ export class Envs {
|
||||
return orderArr.length > 0 ? [...orderArr, null] : [null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析源合并配置
|
||||
* 从环境变量 MERGE_SOURCE_PAIRS 获取配置
|
||||
* 支持使用分号或逗号分隔多组配置
|
||||
* 格式示例: bilibili&animeko,animeko&dandan
|
||||
* @returns {Array} 合并配置数组 [{primary: 'animeko', secondary: 'bilibili'}, ...]
|
||||
*/
|
||||
static resolveMergeSourcePairs() {
|
||||
const config = this.get('MERGE_SOURCE_PAIRS', '', 'string');
|
||||
if (!config) return [];
|
||||
|
||||
// 使用正则同时支持分号(;)和逗号(,)作为分隔符
|
||||
return config.split(/[,;]/)
|
||||
.map(pair => {
|
||||
// 过滤空字符串
|
||||
if (!pair || !pair.includes('&')) return null;
|
||||
|
||||
const [primary, secondary] = pair.split('&').map(s => s.trim());
|
||||
|
||||
// 仅当两个源都在 MERGE_ALLOWED_SOURCES 允许列表中时才生效
|
||||
if (primary && secondary &&
|
||||
this.MERGE_ALLOWED_SOURCES.includes(primary) &&
|
||||
this.MERGE_ALLOWED_SOURCES.includes(secondary)) {
|
||||
return { primary, secondary };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析剧集标题过滤正则
|
||||
* @returns {RegExp} 过滤正则表达式
|
||||
@@ -238,6 +269,7 @@ export class Envs {
|
||||
'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 },
|
||||
'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' },
|
||||
'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 },
|
||||
'MERGE_SOURCE_PAIRS': { category: 'source', type: 'text', description: '源合并配置,格式:主源&副源,主源2&副源2,例如:dandan&animeko' },
|
||||
|
||||
// 匹配配置
|
||||
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置' },
|
||||
@@ -288,6 +320,7 @@ export class Envs {
|
||||
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), // 优酷并发配置
|
||||
mergeSourcePairs: this.resolveMergeSourcePairs(), // 源合并配置,用于将源合并获取
|
||||
platformOrderArr: this.resolvePlatformOrder(), // 自动匹配优选平台
|
||||
episodeTitleFilter: this.resolveEpisodeTitleFilter(), // 剧集标题正则过滤
|
||||
blockedWords: this.get('BLOCKED_WORDS', '', 'string'), // 屏蔽词列表
|
||||
|
||||
@@ -13,7 +13,7 @@ export const Globals = {
|
||||
accessedEnvVars: {},
|
||||
|
||||
// 静态常量
|
||||
VERSION: '1.11.5',
|
||||
VERSION: '1.12.0',
|
||||
MAX_LOGS: 500, // 日志存储,最多保存 500 行
|
||||
MAX_ANIMES: 100,
|
||||
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import BaseSource from './base.js';
|
||||
import { globals } from '../configs/globals.js';
|
||||
import { log } from "../utils/log-util.js";
|
||||
import { httpGet, httpPost } from "../utils/http-util.js";
|
||||
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
|
||||
import { simplized } from "../utils/zh-util.js";
|
||||
import { SegmentListResponse } from '../models/dandan-model.js';
|
||||
|
||||
// =====================
|
||||
// 获取Animeko弹幕(https://github.com/open-ani/animeko)
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* Animeko 源适配器 (基于 Bangumi API V0)
|
||||
* 提供深度元数据搜索、结果过滤及条目关系检测功能
|
||||
*/
|
||||
export default class AnimekoSource extends BaseSource {
|
||||
|
||||
/**
|
||||
* 获取标准 HTTP 请求头
|
||||
* @returns {Object} 请求头对象
|
||||
*/
|
||||
get headers() {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `huangxd-/danmu_api/${globals.version}(https://github.com/huangxd-/danmu_api)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索动画条目
|
||||
* 使用 Bangumi V0 POST 接口进行搜索,并进行后置过滤和关系检测
|
||||
* @param {string} keyword 搜索关键词
|
||||
* @returns {Promise<Array>} 转换后的搜索结果列表
|
||||
*/
|
||||
async search(keyword) {
|
||||
try {
|
||||
log("info", `[Animeko] 开始搜索 (V0): ${keyword}`);
|
||||
|
||||
const searchUrl = `https://api.bgm.tv/v0/search/subjects?limit=5`;
|
||||
|
||||
const payload = {
|
||||
keyword: keyword,
|
||||
filter: {
|
||||
type: [2] // 2 代表动画类型
|
||||
}
|
||||
};
|
||||
|
||||
const resp = await httpPost(searchUrl, JSON.stringify(payload), {
|
||||
headers: this.headers
|
||||
});
|
||||
|
||||
if (!resp || !resp.data) {
|
||||
log("info", "[Animeko] 搜索请求失败或无数据返回");
|
||||
return [];
|
||||
}
|
||||
|
||||
let resultsList = resp.data.data || [];
|
||||
|
||||
if (resultsList.length === 0) {
|
||||
log("info", "[Animeko] 未找到相关条目");
|
||||
return [];
|
||||
}
|
||||
|
||||
// 执行结果相关度过滤
|
||||
resultsList = this.filterSearchResults(resultsList, keyword);
|
||||
|
||||
if (resultsList.length === 0) {
|
||||
log("info", "[Animeko] 过滤后无匹配结果");
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检测条目间关系 (如处理续篇、剧场版等层级关系)
|
||||
if (resultsList.length > 1) {
|
||||
resultsList = await this.checkRelationsAndModifyTitles(resultsList);
|
||||
}
|
||||
|
||||
log("info", `[Animeko] 搜索完成,找到 ${resultsList.length} 个有效结果`);
|
||||
return this.transformResults(resultsList);
|
||||
} catch (error) {
|
||||
log("error", "[Animeko] Search error:", {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤搜索结果
|
||||
* 计算关键词与标题(含中文名、原名及别名)的相似度,剔除低相关度结果
|
||||
* @param {Array} list 原始 API 返回结果列表
|
||||
* @param {string} keyword 用户搜索关键词
|
||||
* @returns {Array} 过滤后的结果列表
|
||||
*/
|
||||
filterSearchResults(list, keyword) {
|
||||
const threshold = 0.4; // 相似度阈值
|
||||
const normalizedKeyword = simplized(keyword).toLowerCase().trim();
|
||||
|
||||
return list.filter(item => {
|
||||
// 收集所有可能的标题用于比对
|
||||
const titles = new Set();
|
||||
if (item.name) titles.add(item.name);
|
||||
if (item.name_cn) titles.add(item.name_cn);
|
||||
|
||||
// 解析 infobox 获取更多别名信息
|
||||
if (item.infobox && Array.isArray(item.infobox)) {
|
||||
item.infobox.forEach(info => {
|
||||
if (info.key === '别名' && Array.isArray(info.value)) {
|
||||
info.value.forEach(v => { if(v.v) titles.add(v.v); });
|
||||
}
|
||||
if (info.key === '中文名' && typeof info.value === 'string') {
|
||||
titles.add(info.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 计算最高相似度得分
|
||||
let maxScore = 0;
|
||||
for (const t of titles) {
|
||||
const normalizedTitle = simplized(t).toLowerCase().trim();
|
||||
const score = this.calculateSimilarity(normalizedKeyword, normalizedTitle);
|
||||
if (score > maxScore) maxScore = score;
|
||||
}
|
||||
|
||||
return maxScore >= threshold;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串相似度
|
||||
* 结合包含关系与编辑距离算法
|
||||
* @param {string} s1 字符串1
|
||||
* @param {string} s2 字符串2
|
||||
* @returns {number} 相似度得分 (0.0 - 1.0)
|
||||
*/
|
||||
calculateSimilarity(s1, s2) {
|
||||
if (!s1 || !s2) return 0;
|
||||
if (s1 === s2) return 1.0;
|
||||
if (s1.includes(s2) || s2.includes(s1)) {
|
||||
const lenRatio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length);
|
||||
return 0.8 + (lenRatio * 0.2);
|
||||
}
|
||||
|
||||
// Levenshtein 距离计算
|
||||
const len1 = s1.length;
|
||||
const len2 = s2.length;
|
||||
const matrix = [];
|
||||
for (let i = 0; i <= len1; i++) matrix[i] = [i];
|
||||
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
|
||||
for (let i = 1; i <= len1; i++) {
|
||||
for (let j = 1; j <= len2; j++) {
|
||||
const cost = s1.charAt(i - 1) === s2.charAt(j - 1) ? 0 : 1;
|
||||
matrix[i][j] = Math.min(matrix[i-1][j]+1, matrix[i][j-1]+1, matrix[i-1][j-1]+cost);
|
||||
}
|
||||
}
|
||||
const distance = matrix[len1][len2];
|
||||
const maxLength = Math.max(len1, len2);
|
||||
return maxLength === 0 ? 1.0 : 1.0 - (distance / maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查标题是否包含明确的季度或类型标识
|
||||
* @param {string} title 标题文本
|
||||
* @returns {boolean} 是否包含明确标识
|
||||
*/
|
||||
hasExplicitSeasonInfo(title) {
|
||||
if (!title) return false;
|
||||
|
||||
const patterns = [
|
||||
/第\s*[0-9一二三四五六七八九十]+\s*[季期部]/i, // 第2季
|
||||
/Season\s*\d+/i, // Season 2
|
||||
/S\d+/i, // S2
|
||||
/Part\s*\d+/i, // Part 2
|
||||
/OVA/i, /OAD/i,
|
||||
/剧场版|Movie|Film/i,
|
||||
/续篇|续集/i,
|
||||
/SP/i,
|
||||
/(?<!\d)\d+$/, // 末尾数字
|
||||
/\S+篇/i, // 篇章标识 (如: 柱训练篇)
|
||||
/\S+章/i,
|
||||
/Act\s*\d+/i,
|
||||
/Phase\s*\d+/i
|
||||
];
|
||||
|
||||
return patterns.some(p => p.test(title));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查条目关系并修正标题
|
||||
* 对于检测到的续作或衍生关系,在标题后追加标识
|
||||
* @param {Array} list 条目列表
|
||||
* @returns {Promise<Array>} 修正后的列表
|
||||
*/
|
||||
async checkRelationsAndModifyTitles(list) {
|
||||
const checkLimit = Math.min(list.length, 3);
|
||||
|
||||
for (let i = 0; i < checkLimit; i++) {
|
||||
for (let j = 0; j < checkLimit; j++) {
|
||||
if (i === j) continue;
|
||||
|
||||
const subjectA = list[i];
|
||||
const subjectB = list[j];
|
||||
const nameA = subjectA.name_cn || subjectA.name;
|
||||
const nameB = subjectB.name_cn || subjectB.name;
|
||||
|
||||
// 简单的包含关系预检
|
||||
if (nameB.includes(nameA) && nameB.length > nameA.length) {
|
||||
|
||||
// 如果标题已有明确区分,跳过耗时的 API 检查
|
||||
if (this.hasExplicitSeasonInfo(nameB)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 查询 API 确认具体关系
|
||||
const relations = await this.getSubjectRelations(subjectA.id);
|
||||
const relationInfo = relations.find(r => r.id === subjectB.id);
|
||||
|
||||
if (relationInfo) {
|
||||
log("info", `[Animeko] 检测到关系: [${nameA}] -> ${relationInfo.relation} -> [${nameB}]`);
|
||||
|
||||
const targetRelations = ["续集", "番外篇", "主线故事", "前传", "不同演绎", "衍生"];
|
||||
|
||||
if (targetRelations.includes(relationInfo.relation)) {
|
||||
let mark = relationInfo.relation;
|
||||
if (mark === '续集') mark = '续篇'; // 归一化处理
|
||||
|
||||
subjectB._relation_mark = `(${mark})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定条目的关联条目列表
|
||||
* @param {number} subjectId 条目 ID
|
||||
* @returns {Promise<Array>} 关联条目数组
|
||||
*/
|
||||
async getSubjectRelations(subjectId) {
|
||||
try {
|
||||
const url = `https://api.bgm.tv/v0/subjects/${subjectId}/subjects`;
|
||||
const resp = await httpGet(url, { headers: this.headers });
|
||||
|
||||
if (!resp || !resp.data || !Array.isArray(resp.data)) return [];
|
||||
|
||||
return resp.data.filter(item => item.type === 2).map(item => ({
|
||||
id: item.id,
|
||||
name: item.name_cn || item.name,
|
||||
relation: item.relation
|
||||
}));
|
||||
} catch (e) {
|
||||
log("warn", `[Animeko] 获取关系失败 ID:${subjectId}: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 API 结果转换为统一的数据格式
|
||||
* @param {Array} results API 原始结果
|
||||
* @returns {Array} 转换后的数据
|
||||
*/
|
||||
transformResults(results) {
|
||||
return results.map(item => {
|
||||
let typeDesc = "动漫";
|
||||
if (item.platform) {
|
||||
switch (item.platform) {
|
||||
case "TV": typeDesc = "TV动画"; break;
|
||||
case "Web": typeDesc = "Web动画"; break;
|
||||
case "OVA": typeDesc = "OVA"; break;
|
||||
case "Movie": typeDesc = "剧场版"; break;
|
||||
default: typeDesc = item.platform;
|
||||
}
|
||||
}
|
||||
|
||||
const titleSuffix = item._relation_mark ? ` ${item._relation_mark}` : "";
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
name_cn: (item.name_cn || item.name) + titleSuffix,
|
||||
images: item.images,
|
||||
air_date: item.date,
|
||||
score: item.score,
|
||||
typeDescription: typeDesc
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剧集列表
|
||||
* @param {number} subjectId 条目 ID
|
||||
* @returns {Promise<Array>} 剧集数组
|
||||
*/
|
||||
async getEpisodes(subjectId) {
|
||||
try {
|
||||
const resp = await httpGet(`https://api.bgm.tv/v0/episodes?subject_id=${subjectId}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!resp || !resp.data) {
|
||||
log("info", `[Animeko] Subject ${subjectId} 无剧集数据`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const body = resp.data;
|
||||
if (Array.isArray(body.data)) return body.data;
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
log("error", "[Animeko] GetEpisodes error:", {
|
||||
message: error.message,
|
||||
id: subjectId
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理并存储番剧及剧集信息
|
||||
* @param {Array} sourceAnimes 搜索到的番剧列表
|
||||
* @param {string} queryTitle 原始查询标题
|
||||
* @param {Array} curAnimes 当前缓存的番剧列表
|
||||
*/
|
||||
async handleAnimes(sourceAnimes, queryTitle, curAnimes) {
|
||||
const tmpAnimes = [];
|
||||
|
||||
if (!sourceAnimes || !Array.isArray(sourceAnimes)) {
|
||||
if (sourceAnimes) log("error", "[Animeko] sourceAnimes is not a valid array");
|
||||
return [];
|
||||
}
|
||||
|
||||
const processAnimekoAnimes = await Promise.all(sourceAnimes.map(async (anime) => {
|
||||
try {
|
||||
const eps = await this.getEpisodes(anime.id);
|
||||
let links = [];
|
||||
|
||||
let effectiveStartDate = anime.air_date || "";
|
||||
|
||||
if (Array.isArray(eps)) {
|
||||
eps.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
for (const ep of eps) {
|
||||
if (ep.type !== 0) continue; // 仅保留本篇
|
||||
|
||||
if (!effectiveStartDate && ep.airdate) {
|
||||
effectiveStartDate = ep.airdate;
|
||||
}
|
||||
|
||||
const epNum = ep.sort || ep.ep;
|
||||
const epName = ep.name_cn || ep.name || "";
|
||||
const fullTitle = `EP${epNum} ${epName}`.trim();
|
||||
|
||||
links.push({
|
||||
"name": `${epNum}`,
|
||||
"url": ep.id.toString(),
|
||||
"title": `【animeko】 ${fullTitle}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
const yearStr = effectiveStartDate ? new Date(effectiveStartDate).getFullYear() : "";
|
||||
|
||||
let transformedAnime = {
|
||||
animeId: anime.id,
|
||||
bangumiId: String(anime.id),
|
||||
animeTitle: `${anime.name_cn || anime.name}(${yearStr})【${anime.typeDescription || '动漫'}】from animeko`,
|
||||
type: "动漫",
|
||||
typeDescription: anime.typeDescription || "动漫",
|
||||
imageUrl: anime.images ? (anime.images.common || anime.images.large) : "",
|
||||
startDate: effectiveStartDate,
|
||||
episodeCount: links.length,
|
||||
rating: anime.score || 0,
|
||||
isFavorited: true,
|
||||
source: "animeko",
|
||||
};
|
||||
|
||||
tmpAnimes.push(transformedAnime);
|
||||
addAnime({...transformedAnime, links: links});
|
||||
|
||||
if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime();
|
||||
}
|
||||
} catch (error) {
|
||||
log("error", `[Animeko] Error processing anime ${anime.id}: ${error.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.sortAndPushAnimesByYear(tmpAnimes, curAnimes);
|
||||
return processAnimekoAnimes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整弹幕列表
|
||||
* 支持传入纯数字ID或完整URL
|
||||
* @param {string} episodeId 剧集 ID 或 完整 API URL
|
||||
* @returns {Promise<Array>} 弹幕数组
|
||||
*/
|
||||
async getEpisodeDanmu(episodeId) {
|
||||
try {
|
||||
// 兼容分片请求传递过来的完整 URL
|
||||
const url = episodeId.startsWith('http')
|
||||
? episodeId
|
||||
: `https://danmaku-global.myani.org/v1/danmaku/${episodeId}`;
|
||||
// 目前使用的服务器是全球区域的,备用大陆区域:https://danmaku-cn.myani.org
|
||||
|
||||
const resp = await httpGet(url, { headers: this.headers });
|
||||
|
||||
if (!resp || !resp.data) return [];
|
||||
const body = resp.data;
|
||||
if (body.danmakuList) return body.danmakuList;
|
||||
return [];
|
||||
} catch (error) {
|
||||
log("error", "[Animeko] GetDanmu error:", { message: error.message, url: episodeId });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分段弹幕列表定义
|
||||
* 使用完整的 API URL 填充 url 字段,以通过 format 校验
|
||||
*/
|
||||
async getEpisodeDanmuSegments(id) {
|
||||
return new SegmentListResponse({
|
||||
"type": "animeko",
|
||||
"segmentList": [{
|
||||
"type": "animeko",
|
||||
"segment_start": 0,
|
||||
"segment_end": 30000,
|
||||
"url": `https://danmaku-global.myani.org/v1/danmaku/${id}` // 使用完整 URL
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取具体分片的弹幕数据
|
||||
* 标准实现:返回原始数据,格式化交由父类统一处理
|
||||
*/
|
||||
async getEpisodeSegmentDanmu(segment) {
|
||||
// 增加 trim 防止 URL 意外空格
|
||||
const url = (segment.url || '').trim();
|
||||
if (!url) return [];
|
||||
|
||||
// 返回原始数据
|
||||
return this.getEpisodeDanmu(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化弹幕为标准格式
|
||||
* @param {Array} comments 原始弹幕数据
|
||||
* @returns {Array} 格式化后的弹幕
|
||||
*/
|
||||
formatComments(comments) {
|
||||
if (!Array.isArray(comments)) return [];
|
||||
const locationMap = { "NORMAL": 1, "TOP": 5, "BOTTOM": 4 };
|
||||
|
||||
return comments
|
||||
.filter(item => item && item.danmakuInfo)
|
||||
.map(item => {
|
||||
const info = item.danmakuInfo;
|
||||
const time = (Number(info.playTime) / 1000).toFixed(2);
|
||||
const mode = locationMap[info.location] || 1;
|
||||
const color = info.color === -1 ? 16777215 : info.color;
|
||||
const text = globals.danmuSimplified ? simplized(info.text) : info.text;
|
||||
|
||||
return {
|
||||
cid: item.id,
|
||||
p: `${time},${mode},${color},[animeko]`,
|
||||
m: text
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import { globals } from '../configs/globals.js';
|
||||
import { log } from './log-util.js';
|
||||
import { normalizeSpaces } from './common-util.js';
|
||||
import { addAnime } from './cache-util.js';
|
||||
import { simplized } from '../utils/zh-util.js';
|
||||
|
||||
// =====================
|
||||
// 源合并处理工具
|
||||
// =====================
|
||||
|
||||
// 定义组合ID的分隔符 (URL Safe)
|
||||
export const MERGE_DELIMITER = '$$$';
|
||||
// 定义前端显示的源连接符
|
||||
export const DISPLAY_CONNECTOR = '&';
|
||||
|
||||
/**
|
||||
* 文本清洗工具函数
|
||||
* 将文本转为简体,并移除特定源标识、地区限制及标点符号,用于提高匹配精度
|
||||
* @param {string} text 原始文本
|
||||
* @returns {string} 清洗后的文本
|
||||
*/
|
||||
function cleanText(text) {
|
||||
if (!text) return '';
|
||||
// 1. 繁体转简体
|
||||
let clean = simplized(text);
|
||||
// 2. 移除源标识如 【dandan】
|
||||
clean = clean.replace(/【.*?】/g, '');
|
||||
// 3. 移除地区限制标识如 (仅限台湾地区)
|
||||
clean = clean.replace(/(\(|()仅限.*?地区(\)|))/g, '');
|
||||
// 4. 移除常见标点符号 (避免 "不行!" 和 "不行。" 被判为不同)
|
||||
clean = clean.replace(/[!!??,,.。、~~]/g, ' ');
|
||||
|
||||
return normalizeSpaces(clean).toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除标题中的所有括号内容
|
||||
* 用于提取主标题进行比对,规避副标题翻译差异(如:(※不是不可能!?) vs (※似乎可行?))
|
||||
* @param {string} text 清洗后的文本
|
||||
* @returns {string} 移除括号后的文本
|
||||
*/
|
||||
function removeParentheses(text) {
|
||||
if (!text) return '';
|
||||
// 移除 () 和 () 及其内部的所有内容
|
||||
return text.replace(/(\(|().*?(\)|))/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗并提取真实的 ID/URL
|
||||
* 用于从组合或带前缀的字符串中还原出原始的请求 ID
|
||||
* @param {string} urlStr 原始 URL 字符串
|
||||
* @returns {string} 清洗后的 ID 或 完整 URL
|
||||
*/
|
||||
function sanitizeUrl(urlStr) {
|
||||
if (!urlStr) return '';
|
||||
|
||||
// 去除可能存在的组合后缀,只取当前部分
|
||||
let clean = String(urlStr).split(MERGE_DELIMITER)[0].trim();
|
||||
|
||||
// 自动修复被错误截断协议头的 URL
|
||||
if (clean.startsWith('//')) {
|
||||
return 'https:' + clean;
|
||||
}
|
||||
|
||||
// 尝试解析 "source:id" 格式
|
||||
const match = clean.match(/^([^:]+):(.+)$/);
|
||||
if (match) {
|
||||
const prefix = match[1].toLowerCase();
|
||||
const body = match[2];
|
||||
|
||||
// 如果前缀是 http/https,说明是原始 URL,保留
|
||||
if (prefix === 'http' || prefix === 'https') {
|
||||
return clean;
|
||||
}
|
||||
|
||||
// 如果 body 是 http 开头,直接返回
|
||||
if (/^https?:\/\//i.test(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// 如果 body 是 // 开头,自动补全协议
|
||||
if (body.startsWith('//')) {
|
||||
return 'https:' + body;
|
||||
}
|
||||
|
||||
// 普通 ID
|
||||
return body;
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期字符串为对象
|
||||
* @param {string} dateStr 日期字符串
|
||||
* @returns {Object} { year: number|null, month: number|null }
|
||||
*/
|
||||
function parseDate(dateStr) {
|
||||
if (!dateStr) return { year: null, month: null };
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return { year: null, month: null };
|
||||
return {
|
||||
year: d.getFullYear(),
|
||||
month: d.getMonth() + 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算编辑距离 (Levenshtein Distance)
|
||||
* 用于衡量两个字符串的差异程度(对顺序敏感)
|
||||
* @param {string} s1 字符串1
|
||||
* @param {string} s2 字符串2
|
||||
* @returns {number} 编辑距离
|
||||
*/
|
||||
function editDistance(s1, s2) {
|
||||
const len1 = s1.length;
|
||||
const len2 = s2.length;
|
||||
const matrix = [];
|
||||
|
||||
for (let i = 0; i <= len1; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= len2; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= len1; i++) {
|
||||
for (let j = 1; j <= len2; j++) {
|
||||
const cost = s1.charAt(i - 1) === s2.charAt(j - 1) ? 0 : 1;
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost
|
||||
);
|
||||
}
|
||||
}
|
||||
return matrix[len1][len2];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Dice 相似度系数 (基于字符集合)
|
||||
* 用于解决长标题意译差异(如 "我怎么可能" vs "我们不可能"),对语序不敏感
|
||||
* @param {string} s1 字符串1
|
||||
* @param {string} s2 字符串2
|
||||
* @returns {number} Dice 系数 (0.0 - 1.0)
|
||||
*/
|
||||
function calculateDiceSimilarity(s1, s2) {
|
||||
if (!s1 || !s2) return 0;
|
||||
|
||||
// 将字符串转换为去重的字符集合 (移除空格)
|
||||
const set1 = new Set(s1.replace(/\s/g, ''));
|
||||
const set2 = new Set(s2.replace(/\s/g, ''));
|
||||
|
||||
if (set1.size === 0 && set2.size === 0) return 1.0;
|
||||
if (set1.size === 0 || set2.size === 0) return 0.0;
|
||||
|
||||
// 计算交集大小
|
||||
let intersection = 0;
|
||||
for (const char of set1) {
|
||||
if (set2.has(char)) {
|
||||
intersection++;
|
||||
}
|
||||
}
|
||||
|
||||
// Dice 公式: 2 * |A∩B| / (|A| + |B|)
|
||||
return (2.0 * intersection) / (set1.size + set2.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个字符串的综合相似度 (0.0 - 1.0)
|
||||
* 结合了 编辑距离(顺序敏感)和 Dice系数(字符重合度),并预先进行清洗
|
||||
* @param {string} str1 字符串1
|
||||
* @param {string} str2 字符串2
|
||||
* @returns {number} 相似度得分 (取多种算法的最大值)
|
||||
*/
|
||||
function calculateSimilarity(str1, str2) {
|
||||
if (!str1 || !str2) return 0;
|
||||
|
||||
// 使用增强的 cleanText 进行预处理
|
||||
const s1 = cleanText(str1);
|
||||
const s2 = cleanText(str2);
|
||||
|
||||
// 1. 精确匹配
|
||||
if (s1 === s2) return 1.0;
|
||||
|
||||
// 2. 包含关系 (给予较高基础分)
|
||||
if (s1.includes(s2) || s2.includes(s1)) {
|
||||
const lenRatio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length);
|
||||
return 0.8 + (lenRatio * 0.2);
|
||||
}
|
||||
|
||||
// 3. 编辑距离得分
|
||||
const distance = editDistance(s1, s2);
|
||||
const maxLength = Math.max(s1.length, s2.length);
|
||||
const editScore = maxLength === 0 ? 1.0 : 1.0 - (distance / maxLength);
|
||||
|
||||
// 4. Dice 系数得分 (处理长标题意译)
|
||||
const diceScore = calculateDiceSimilarity(s1, s2);
|
||||
|
||||
// 返回两种算法中较高的分数
|
||||
return Math.max(editScore, diceScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取标题和类型中的季度/类型标识
|
||||
* 支持提取:第N季, Season N, Part N, OVA, OAD, 剧场版, 续篇, 以及末尾数字
|
||||
* 同时从 typeDesc (类型描述) 中提取特征,解决标题未写明但类型明确的情况
|
||||
* @param {string} title 标题文本
|
||||
* @param {string} typeDesc 类型描述 (可选)
|
||||
* @returns {Set<string>} 标识集合
|
||||
*/
|
||||
function extractSeasonMarkers(title, typeDesc = '') {
|
||||
const markers = new Set();
|
||||
// 使用 cleanText 确保繁简统一
|
||||
const t = cleanText(title);
|
||||
const type = cleanText(typeDesc || '');
|
||||
|
||||
const patterns = [
|
||||
{ regex: /第(\d+)[季期]/, prefix: 'S' },
|
||||
{ regex: /season\s*(\d+)/, prefix: 'S' },
|
||||
{ regex: /s(\d+)/, prefix: 'S' },
|
||||
{ regex: /part\s*(\d+)/, prefix: 'P' },
|
||||
{ regex: /(ova|oad)/, val: 'OVA' },
|
||||
{ regex: /(剧场版|movie|film)/, val: 'MOVIE' },
|
||||
{ regex: /(续篇|续集)/, val: 'SEQUEL' },
|
||||
{ regex: /sp/, val: 'SP' },
|
||||
{ regex: /[^0-9](\d)$/, prefix: 'S' }
|
||||
];
|
||||
|
||||
patterns.forEach(p => {
|
||||
const match = t.match(p.regex);
|
||||
if (match) {
|
||||
if (p.prefix) {
|
||||
markers.add(`${p.prefix}${parseInt(match[1])}`);
|
||||
} else {
|
||||
markers.add(p.val);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 从 Type 字段中补全标记
|
||||
if (type.includes('剧场版') || type.includes('movie') || type.includes('film')) markers.add('MOVIE');
|
||||
if (type.includes('ova') || type.includes('oad')) markers.add('OVA');
|
||||
if (type.includes('sp') || type.includes('special')) markers.add('SP');
|
||||
|
||||
const cnNums = {'一':1, '二':2, '三':3, '四':4, '五':5, 'final': 99};
|
||||
for (const [cn, num] of Object.entries(cnNums)) {
|
||||
if (t.includes(`第${cn}季`)) markers.add(`S${num}`);
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验季度/续作标记是否冲突
|
||||
* @param {string} titleA 标题A
|
||||
* @param {string} titleB 标题B
|
||||
* @param {string} typeA 类型A
|
||||
* @param {string} typeB 类型B
|
||||
* @returns {boolean} true 表示冲突(禁止合并),false 表示无冲突
|
||||
*/
|
||||
function checkSeasonMismatch(titleA, titleB, typeA, typeB) {
|
||||
const markersA = extractSeasonMarkers(titleA, typeA);
|
||||
const markersB = extractSeasonMarkers(titleB, typeB);
|
||||
|
||||
// 两者都无标记 -> 无冲突
|
||||
if (markersA.size === 0 && markersB.size === 0) return false;
|
||||
|
||||
// 1. 如果两者都有标记,必须有交集,不能互斥
|
||||
if (markersA.size > 0 && markersB.size > 0) {
|
||||
for (const m of markersA) {
|
||||
// 如果 A 的标记在 B 中不存在,且 B 也有同类标记(如都是S开头的季数),则视为冲突
|
||||
if (m.startsWith('S') && !markersB.has(m) && Array.from(markersB).some(b => b.startsWith('S'))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 一方有标记,一方无标记 -> 冲突
|
||||
if (markersA.size !== markersB.size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查两个标题是否包含相同的季度/季数标记
|
||||
* 用于在年份不匹配时进行“豁免”判断
|
||||
* @param {string} titleA 标题A
|
||||
* @param {string} titleB 标题B
|
||||
* @param {string} typeA 类型A
|
||||
* @param {string} typeB 类型B
|
||||
* @returns {boolean} 是否包含相同的明确季度标记(如都包含 S1)
|
||||
*/
|
||||
function hasSameSeasonMarker(titleA, titleB, typeA, typeB) {
|
||||
const markersA = extractSeasonMarkers(titleA, typeA);
|
||||
const markersB = extractSeasonMarkers(titleB, typeB);
|
||||
|
||||
const seasonsA = Array.from(markersA).filter(m => m.startsWith('S'));
|
||||
const seasonsB = Array.from(markersB).filter(m => m.startsWith('S'));
|
||||
|
||||
if (seasonsA.length > 0 && seasonsB.length > 0) {
|
||||
return seasonsA.some(sa => seasonsB.includes(sa));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验日期匹配度
|
||||
* @param {Object} dateA 日期对象A
|
||||
* @param {Object} dateB 日期对象B
|
||||
* @returns {number} 匹配得分 (-1 表示硬性不匹配)
|
||||
*/
|
||||
function checkDateMatch(dateA, dateB) {
|
||||
if (!dateA.year || !dateB.year) return 0;
|
||||
const yearDiff = Math.abs(dateA.year - dateB.year);
|
||||
|
||||
// 年份相差 > 1,硬性抛弃
|
||||
if (yearDiff > 1) return -1;
|
||||
|
||||
// 年份相同
|
||||
if (yearDiff === 0) {
|
||||
if (dateA.month && dateB.month) {
|
||||
const monthDiff = Math.abs(dateA.month - dateB.month);
|
||||
// 月份差异大也不扣分 (可能是占位符 01-01)
|
||||
if (monthDiff > 2) return 0;
|
||||
return monthDiff === 0 ? 0.2 : 0.1;
|
||||
}
|
||||
return 0.1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在副源列表中寻找最佳匹配的动画对象
|
||||
* 采用“双重对比策略”:同时计算“完整标题相似度”和“去括号主标题相似度”,取最大值。
|
||||
* 并结合类型信息进行更精准的冲突检测(如剧场版vsTV版)。
|
||||
* @param {Object} primaryAnime 主源动画对象
|
||||
* @param {Array} secondaryList 副源动画列表
|
||||
* @returns {Object|null} 匹配的动画对象或 null
|
||||
*/
|
||||
export function findSecondaryMatch(primaryAnime, secondaryList) {
|
||||
if (!secondaryList || secondaryList.length === 0) return null;
|
||||
|
||||
const primaryTitle = primaryAnime.animeTitle.replace(/\(\d{4}\).*$/, '').trim();
|
||||
const primaryDate = parseDate(primaryAnime.startDate);
|
||||
|
||||
let bestMatch = null;
|
||||
let maxScore = 0;
|
||||
|
||||
for (const secAnime of secondaryList) {
|
||||
const secDate = parseDate(secAnime.startDate);
|
||||
const secTitle = secAnime.animeTitle.replace(/\(\d{4}\).*$/, '').trim();
|
||||
|
||||
// 检查是否具有完全一致的显式季度标记 (豁免日期校验)
|
||||
// 传入 typeDescription 以便提取隐含的类型标记
|
||||
const isSeasonExactMatch = hasSameSeasonMarker(primaryTitle, secTitle, primaryAnime.typeDescription, secAnime.typeDescription);
|
||||
|
||||
// 1. 日期校验
|
||||
const dateScore = checkDateMatch(primaryDate, secDate);
|
||||
if (!isSeasonExactMatch && dateScore === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 季度/续作标记冲突硬性校验
|
||||
// 传入 typeDescription,利用元数据补全标题中缺失的 "剧场版/OVA" 等信息
|
||||
if (checkSeasonMismatch(primaryTitle, secTitle, primaryAnime.typeDescription, secAnime.typeDescription)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 综合文本相似度计算
|
||||
// 策略 A: 完整标题对比 (保留括号内容,适合精准匹配)
|
||||
let scoreFull = calculateSimilarity(primaryTitle, secTitle);
|
||||
|
||||
// 策略 B: 主标题对比 (移除括号内的副标题/注译,解决如 (※不是不可能) vs (※似乎可行) 的翻译差异)
|
||||
const baseA = removeParentheses(primaryTitle);
|
||||
const baseB = removeParentheses(secTitle);
|
||||
let scoreBase = calculateSimilarity(baseA, baseB);
|
||||
|
||||
// 取两者的最大值作为最终得分
|
||||
let score = Math.max(scoreFull, scoreBase);
|
||||
|
||||
if (dateScore !== -1) {
|
||||
score += dateScore;
|
||||
}
|
||||
|
||||
if (score > maxScore) {
|
||||
maxScore = score;
|
||||
bestMatch = secAnime;
|
||||
}
|
||||
}
|
||||
|
||||
return maxScore >= 0.6 ? bestMatch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取集数信息
|
||||
* 增强正则以支持紧凑格式,并预先进行去噪清洗
|
||||
* @param {string} title 分集标题
|
||||
* @returns {Object} { isMovie: boolean, num: number|null }
|
||||
*/
|
||||
function extractEpisodeInfo(title) {
|
||||
// 使用 cleanText 移除干扰前缀和地区文字
|
||||
const t = cleanText(title || "");
|
||||
|
||||
// 1. 判断是否是剧场版
|
||||
const isMovie = /剧场版|movie|film/i.test(t);
|
||||
|
||||
let num = null;
|
||||
|
||||
// 2. 提取数字
|
||||
|
||||
// 策略 A: 强前缀 (EP, O, S, Part, 第)
|
||||
const strongPrefixMatch = t.match(/(?:ep|o|s|part|第)\s*(\d+(\.\d+)?)/i);
|
||||
if (strongPrefixMatch) {
|
||||
num = parseFloat(strongPrefixMatch[1]);
|
||||
} else {
|
||||
// 策略 B: 弱前缀 (行首或空格)
|
||||
// 必须有后缀分隔符 (话/集/空格/行尾) 或者数字是独立的
|
||||
const weakPrefixMatch = t.match(/(?:^|\s)(\d+(\.\d+)?)(?:话|集|\s|$)/);
|
||||
if (weakPrefixMatch) {
|
||||
num = parseFloat(weakPrefixMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { isMovie, num };
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤无效剧集 (预告/花絮等)
|
||||
* 完全依赖传入的正则,不再内置硬编码规则
|
||||
* @param {Array} links 剧集链接列表
|
||||
* @param {RegExp} filterRegex 过滤正则
|
||||
* @returns {Array} 包含原始索引的有效剧集列表
|
||||
*/
|
||||
function filterEpisodes(links, filterRegex) {
|
||||
if (!links) return [];
|
||||
|
||||
// 如果没有传入过滤正则(例如全局开关关闭),则不执行过滤,仅保留索引映射
|
||||
if (!filterRegex) {
|
||||
return links.map((link, index) => ({ link, originalIndex: index }));
|
||||
}
|
||||
|
||||
return links
|
||||
.map((link, index) => ({ link, originalIndex: index }))
|
||||
.filter(item => {
|
||||
const title = item.link.title || item.link.name || "";
|
||||
return !filterRegex.test(title);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 寻找最佳对齐偏移量
|
||||
* 解决策略:滑动窗口 + 匹配集数权重 + 类型惩罚 + 数字一致性
|
||||
* @param {Array} primaryLinks 主源链接列表
|
||||
* @param {Array} secondaryLinks 副源链接列表
|
||||
* @returns {number} 最佳偏移量
|
||||
*/
|
||||
function findBestAlignmentOffset(primaryLinks, secondaryLinks) {
|
||||
if (primaryLinks.length === 0 || secondaryLinks.length === 0) return 0;
|
||||
|
||||
let bestOffset = 0;
|
||||
let maxScore = -999;
|
||||
let maxMatchCount = 0; // 记录最大匹配数量,用于 Tie-Breaker
|
||||
|
||||
// 限制滑动范围 (假设差异 +/- 10 集)
|
||||
const maxShift = Math.min(primaryLinks.length, 10);
|
||||
|
||||
for (let offset = -maxShift; offset <= maxShift; offset++) {
|
||||
let totalTextScore = 0;
|
||||
let matchCount = 0;
|
||||
let numericDiffs = new Map();
|
||||
|
||||
for (let i = 0; i < secondaryLinks.length; i++) {
|
||||
const pIndex = i + offset;
|
||||
|
||||
if (pIndex >= 0 && pIndex < primaryLinks.length) {
|
||||
const titleA = primaryLinks[pIndex].link.title || "";
|
||||
const titleB = secondaryLinks[i].link.title || "";
|
||||
const infoA = extractEpisodeInfo(titleA);
|
||||
const infoB = extractEpisodeInfo(titleB);
|
||||
|
||||
// 1. 类型惩罚 (关键:阻止剧场版与正片匹配)
|
||||
let pairScore = 0;
|
||||
if (infoA.isMovie !== infoB.isMovie) {
|
||||
pairScore -= 5.0; // 强惩罚
|
||||
}
|
||||
|
||||
// 2. 文本相似度 (使用 calculateSimilarity 自动清洗对比)
|
||||
pairScore += calculateSimilarity(titleA, titleB);
|
||||
|
||||
totalTextScore += pairScore;
|
||||
|
||||
// 3. 数字差值记录
|
||||
if (infoA.num !== null && infoB.num !== null) {
|
||||
const diff = infoB.num - infoA.num;
|
||||
const count = numericDiffs.get(diff) || 0;
|
||||
numericDiffs.set(diff, count + 1);
|
||||
}
|
||||
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
// 平均文本得分
|
||||
let finalAvgScore = totalTextScore / matchCount;
|
||||
|
||||
// 4. 计算数字一致性加分
|
||||
let maxFrequency = 0;
|
||||
for (const count of numericDiffs.values()) {
|
||||
if (count > maxFrequency) maxFrequency = count;
|
||||
}
|
||||
|
||||
// 如果大多数配对都遵循同一个数字差值规律,给予高额加分
|
||||
const consistencyRatio = maxFrequency / matchCount;
|
||||
if (consistencyRatio > 0.5) {
|
||||
finalAvgScore += (consistencyRatio * 2.0); // 一致性奖励
|
||||
}
|
||||
|
||||
// 选择逻辑 (Tie-Breaker)
|
||||
if (finalAvgScore > maxScore + 0.1) {
|
||||
maxScore = finalAvgScore;
|
||||
bestOffset = offset;
|
||||
maxMatchCount = matchCount;
|
||||
} else if (Math.abs(finalAvgScore - maxScore) <= 0.1) {
|
||||
if (matchCount > maxMatchCount) {
|
||||
maxScore = finalAvgScore;
|
||||
bestOffset = offset;
|
||||
maxMatchCount = matchCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当得分 > 0.3 时才采用偏移,否则默认对齐
|
||||
return maxScore > 0.3 ? bestOffset : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成符合 int32 范围的安全 ID
|
||||
* 通过哈希组合 ID 并映射到 10亿~21亿 区间,避免溢出并减少冲突
|
||||
* @param {string|number} id1 原始ID 1
|
||||
* @param {string|number} id2 原始ID 2
|
||||
* @returns {number} 安全的 Int32 ID
|
||||
*/
|
||||
function generateSafeMergedId(id1, id2) {
|
||||
const str = `${id1}_${id2}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
// 取绝对值并映射到 1,000,000,000 (10亿) ~ 2,147,483,647 (int32 max) 之间
|
||||
// 这样既保证了是正数,又避开了通常较小的原始 ID,同时确保不超过客户端 int32 限制
|
||||
return (Math.abs(hash) % 1000000000) + 1000000000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行源合并逻辑
|
||||
* 遍历配置的源对,合并符合条件的动画条目及其集数
|
||||
* @param {Array} curAnimes 当前所有的动画条目列表
|
||||
*/
|
||||
export async function applyMergeLogic(curAnimes) {
|
||||
const pairs = globals.mergeSourcePairs;
|
||||
if (!pairs || pairs.length === 0) return;
|
||||
|
||||
log("info", `[Merge] 启动源合并策略,配置: ${JSON.stringify(pairs)}`);
|
||||
|
||||
// 获取过滤正则 (直接从 globals 获取,支持 RegExp 或字符串)
|
||||
let epFilter = globals.episodeTitleFilter;
|
||||
if (epFilter && typeof epFilter === 'string') {
|
||||
try { epFilter = new RegExp(epFilter, 'i'); } catch (e) { epFilter = null; }
|
||||
}
|
||||
|
||||
const newMergedAnimes = [];
|
||||
const usedBaseAnimeIds = new Set();
|
||||
const mergedSecondaryAnimes = new Set();
|
||||
|
||||
for (const { primary, secondary } of pairs) {
|
||||
const primaryItems = curAnimes.filter(a => a.source === primary && !a._isMerged);
|
||||
const secondaryItems = curAnimes.filter(a => a.source === secondary && !a._isMerged);
|
||||
|
||||
if (primaryItems.length === 0 || secondaryItems.length === 0) continue;
|
||||
|
||||
for (const pAnime of primaryItems) {
|
||||
const match = findSecondaryMatch(pAnime, secondaryItems);
|
||||
|
||||
if (match) {
|
||||
const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId));
|
||||
const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId));
|
||||
|
||||
if (!cachedPAnime?.links || !cachedMatch?.links) {
|
||||
log("warn", `[Merge] 数据不完整,跳过合并: ${pAnime.animeTitle}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const derivedAnime = JSON.parse(JSON.stringify(cachedPAnime));
|
||||
|
||||
// 1. 预过滤 (保留 index 映射)
|
||||
const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter);
|
||||
derivedAnime.links = filteredPLinksWithIndex.map(item => item.link);
|
||||
|
||||
const filteredMLinksWithIndex = filterEpisodes(cachedMatch.links, epFilter);
|
||||
|
||||
// 2. 计算最佳对齐偏移量
|
||||
const offset = findBestAlignmentOffset(filteredPLinksWithIndex, filteredMLinksWithIndex);
|
||||
|
||||
if (offset !== 0) {
|
||||
log("info", `[Merge] 集数自动对齐: Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`);
|
||||
}
|
||||
|
||||
// [修复] 生成安全的 int32 ID,防止客户端溢出错误
|
||||
derivedAnime.animeId = generateSafeMergedId(pAnime.animeId, match.animeId);
|
||||
derivedAnime.bangumiId = String(derivedAnime.animeId);
|
||||
|
||||
let mergedCount = 0;
|
||||
const mappingEntries = []; // 用于存储所有的映射记录(包含匹配和未匹配)
|
||||
const matchedPIndices = new Set(); // 记录已被副源匹配的主源索引
|
||||
|
||||
// 3. 执行合并 (应用偏移量) - 以副源为驱动
|
||||
for (let i = 0; i < filteredMLinksWithIndex.length; i++) {
|
||||
const pIndex = i + offset;
|
||||
const sourceLink = filteredMLinksWithIndex[i].link;
|
||||
const sTitleShort = sourceLink.name || sourceLink.title || `Index ${i}`;
|
||||
|
||||
if (pIndex >= 0 && pIndex < derivedAnime.links.length) {
|
||||
// [匹配成功]
|
||||
const targetLink = derivedAnime.links[pIndex];
|
||||
const pTitleShort = targetLink.name || targetLink.title || `Index ${pIndex}`;
|
||||
|
||||
const idA = sanitizeUrl(targetLink.url);
|
||||
const idB = sanitizeUrl(sourceLink.url);
|
||||
|
||||
const newUrl = `${primary}:${idA}${MERGE_DELIMITER}${secondary}:${idB}`;
|
||||
targetLink.url = newUrl;
|
||||
|
||||
// 记录映射日志
|
||||
mappingEntries.push({
|
||||
idx: pIndex,
|
||||
text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}`
|
||||
});
|
||||
|
||||
matchedPIndices.add(pIndex);
|
||||
|
||||
// 修改分集标题
|
||||
if (targetLink.title) {
|
||||
const prefixRegex = new RegExp(`【${primary}(\\d*)】`);
|
||||
targetLink.title = targetLink.title.replace(
|
||||
prefixRegex,
|
||||
(match, num) => `【${primary}${num}${DISPLAY_CONNECTOR}${secondary}】`
|
||||
);
|
||||
}
|
||||
mergedCount++;
|
||||
} else {
|
||||
// [副源落单] (主源越界)
|
||||
mappingEntries.push({
|
||||
idx: pIndex, // 使用虚拟索引进行排序
|
||||
text: ` [落单] (主源越界) <-> ${sTitleShort}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查主源是否有落单集数
|
||||
for (let j = 0; j < derivedAnime.links.length; j++) {
|
||||
if (!matchedPIndices.has(j)) {
|
||||
const targetLink = derivedAnime.links[j];
|
||||
const pTitleShort = targetLink.name || targetLink.title || `Index ${j}`;
|
||||
|
||||
mappingEntries.push({
|
||||
idx: j,
|
||||
text: ` [落单] ${pTitleShort} <-> (副源缺失)`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按集数顺序排序日志
|
||||
mappingEntries.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
derivedAnime.animeTitle = derivedAnime.animeTitle.replace(`from ${primary}`, `from ${primary}${DISPLAY_CONNECTOR}${secondary}`);
|
||||
derivedAnime.source = primary;
|
||||
|
||||
addAnime(derivedAnime);
|
||||
|
||||
newMergedAnimes.push(derivedAnime);
|
||||
usedBaseAnimeIds.add(pAnime.animeId);
|
||||
mergedSecondaryAnimes.add(match);
|
||||
|
||||
const logTitleA = cachedPAnime.animeTitle.replace(/\s*from\s+.*$/i, '');
|
||||
const logTitleB = cachedMatch.animeTitle.replace(/\s*from\s+.*$/i, '');
|
||||
log("info", `[Merge] 关联成功: [${primary}] ${logTitleA} <-> [${secondary}] ${logTitleB} (共合并 ${mergedCount} 集) (MergedID: ${derivedAnime.animeId})`);
|
||||
|
||||
// 打印详细的集数映射日志
|
||||
if (mappingEntries.length > 0) {
|
||||
log("info", `[Merge] 集映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
curAnimes.push(...newMergedAnimes);
|
||||
|
||||
mergedSecondaryAnimes.forEach(item => {
|
||||
item._isMerged = true;
|
||||
});
|
||||
|
||||
for (let i = curAnimes.length - 1; i >= 0; i--) {
|
||||
const item = curAnimes[i];
|
||||
if (item._isMerged || usedBaseAnimeIds.has(item.animeId)) {
|
||||
curAnimes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并两个弹幕列表并按时间排序
|
||||
* @param {Array} listA 弹幕列表A
|
||||
* @param {Array} listB 弹幕列表B
|
||||
* @returns {Array} 合并后的弹幕列表
|
||||
*/
|
||||
export function mergeDanmakuList(listA, listB) {
|
||||
const final = [...(listA || []), ...(listB || [])];
|
||||
|
||||
const getTime = (item) => {
|
||||
if (!item) return 0;
|
||||
if (item.t !== undefined && item.t !== null) return Number(item.t);
|
||||
if (item.p && typeof item.p === 'string') {
|
||||
const pTime = parseFloat(item.p.split(',')[0]);
|
||||
return isNaN(pTime) ? 0 : pTime;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
final.sort((a, b) => {
|
||||
return getTime(a) - getTime(b);
|
||||
});
|
||||
|
||||
return final;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import MangoSource from "./sources/mango.js";
|
||||
import BilibiliSource from "./sources/bilibili.js";
|
||||
import YoukuSource from "./sources/youku.js";
|
||||
import SohuSource from "./sources/sohu.js";
|
||||
import AnimekoSource from "./sources/animeko.js";
|
||||
import OtherSource from "./sources/other.js";
|
||||
import { NodeHandler } from "./configs/handlers/node-handler.js";
|
||||
import { VercelHandler } from "./configs/handlers/vercel-handler.js";
|
||||
@@ -60,6 +61,7 @@ test('worker.js API endpoints', async (t) => {
|
||||
const bilibiliSource = new BilibiliSource();
|
||||
const youkuSource = new YoukuSource();
|
||||
const sohuSource = new SohuSource();
|
||||
const animekoSource = new AnimekoSource();
|
||||
const otherSource = new OtherSource();
|
||||
|
||||
await t.test('GET / should return welcome message', async () => {
|
||||
@@ -303,6 +305,43 @@ test('worker.js API endpoints', async (t) => {
|
||||
// assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`);
|
||||
// });
|
||||
|
||||
// // 测试Animeko源
|
||||
// await t.test('Animeko Source Search', async () => {
|
||||
// const source = new AnimekoSource();
|
||||
// const result = await source.search("我们不可能成为恋人!绝对不行。 (※似乎可行?)");
|
||||
// console.log(JSON.stringify(result, null, 2));
|
||||
// assert(result.length > 0);
|
||||
//
|
||||
// const curAnimes = [];
|
||||
// await source.handleAnimes(result, "我们不可能成为恋人!绝对不行。 (※似乎可行?)", curAnimes);
|
||||
// assert(curAnimes.length > 0);
|
||||
//
|
||||
// const animeId = result[0].id;
|
||||
// const episodes = await source.getEpisodes(animeId);
|
||||
//
|
||||
// if (episodes && episodes.length > 0) {
|
||||
// const firstEp = episodes.find(e => e.type === 0) || episodes[0];
|
||||
// const testId = firstEp.id;
|
||||
//
|
||||
// console.log(`Testing getSegmentComments with ID: ${testId}`);
|
||||
//
|
||||
// const segment = {
|
||||
// url: String(testId),
|
||||
// type: 'animeko'
|
||||
// };
|
||||
//
|
||||
// const danmu = await source.getSegmentComments(segment);
|
||||
//
|
||||
// console.log("Danmu count:", danmu ? danmu.length : 0);
|
||||
// assert(Array.isArray(danmu));
|
||||
//
|
||||
// if (danmu.length > 0) {
|
||||
// assert(danmu[0].p !== undefined);
|
||||
// assert(danmu[0].m !== undefined);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
// await t.test('GET realistic danmu', async () => {
|
||||
// // tencent
|
||||
// // const keyword = "子夜归";
|
||||
|
||||
Reference in new issue
Block a user