增加Animeko源,增加源合并功能 (#152)

* 新增源合并处理工具

非常多的优化,使不同源的合并进入高可用状态(主要还是动画这块,译名不同和集不一样太多了,动画怎会如此折腾)

* 新增Animeko源

感谢188的鼎力相助感谢Animeko,开源伟大!

* 新增MERGE_SOURCE_PAIRS环境变量并增加相关解析,新增允许animeko源

* 兼容新的源合并相关请求,修复b23.tv相关处理缺少httpGet引用

* 更新文档

* 更新env

* 将源合并从匹配配置挪到源配置

* 更新版本号为1.12.0

* 更新获取弹幕函数,使其更加符合整体规范

* 增加Animeko测试例
This commit is contained in:
Elegy233 authored and GitHub committed 2026-01-14 18:24:40 +08:00
1 parent 6b12112022
commit 937532dfa9
8 files changed
+1488 -52

No files matched your search

+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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") {
+35 -2
View File
@@ -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'), // 屏蔽词列表
+1 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.11.5',
VERSION: '1.12.0',
MAX_LOGS: 500, // 日志存储,最多保存 500 行
MAX_ANIMES: 100,
+477
View File
@@ -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
};
});
}
}
+739
View File
@@ -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;
}
+39
View File
@@ -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 = "子夜归";