perf(match): 为 match 接口支持跨季集数顺延匹配,提高匹配成功率;新增 CUSTOM_MERGE_RULES 环境变量,用于自定义源合并行为 (#310)

* perf(api): 将数据源处理逻辑提取为公共函数,加入'跨季集数顺延映射'功能提高 match 接口匹配成功率

* feat(utils): 等价重组合并工具,使用12层清晰分层并补全所有函数必要注释,降低维护难度

我是超级重构大师()这一次真的好起来了结构清晰多了()

* perf(envs): 新增合并映射表('CUSTOM_MERGE_RULES')变量与解析函数

* feat(utils): 使合并工具支持合并映射表

* feat(ui): 新增CUSTOM_MERGE_RULES 合并映射表 专用编辑界面

* feat(env): 新增合并映射表配置说明

* feat(md): 新增合并映射表配置说明
This commit is contained in:
Elegy233 authored and GitHub committed 2026-05-18 23:02:08 +08:00
1 parent d622a5be8d
commit d894ccdd3d
6 files changed
+3368 -2277

No files matched your search

+2 -1
View File
@@ -405,7 +405,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| 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,migu,sohu,leshi,xigua,maiduidui,aiyifan,renren,hanjutv,bahamut,dandan,animeko,custom` |
| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台,当配置合并平台的时候为指定期望的合并源;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, migu, sohu, leshi, xigua, maiduidui, aiyifan, renren, hanjutv, bahamut, dandan, animeko, custom` |
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`imgo&iqiyi,dandan&bahamut&animeko,imgo`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,aiyifan,renren,hanjutv,bahamut,dandan,animeko` |
| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段&源字段`,示例:`dandan&bahamut&animeko,renren&hanjutv,renren`, 允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环,目前允许合并的源字段有`tencent,youku,iqiyi,imgo,bilibili,migu,sohu,leshi,xigua,maiduidui,aiyifan,renren,hanjutv,bahamut,dandan,animeko` |
| CUSTOM_MERGE_RULES | 【可选】合并映射表,用于自定义源合并行为,默认为空。<br>格式 1 (合并):`副源剧名/S季数@来源 -> 主源剧名/S季数@来源 \| E副源集数>E主源集数`<br>格式 2 (阻断):`副源剧名/S季数@来源 × 主源剧名/S季数@来源`<br>说明:`[/S季数]` 与 `[\|路由规则]` 为可选项,留空则交由程序判断。多个规则用分号隔开,多段路由用逗号分隔。<br>示例:<br>1. 常规合并:`天气之子@bilibili -> 天气之子@dandan`<br>2. 多集路由:`我推的孩子/S01@bahamut -> 我推的孩子/S03@dandan \| E25~E35>E25~E35`<br>3. 阻断合并:`辉夜大小姐想让我告白?~天才们的恋爱头脑战~(2020)@bilibili × 辉夜大小姐想让我告白~天才们的恋爱头脑战~ OVA(2021)【OVA】@dandan` |
| ANIME_TITLE_FILTER | 【可选】剧名过滤规则,用于按正则表达式对剧名进行过滤,适用于过滤一些不需要的剧集,需开启ENABLE_ANIME_EPISODE_FILTER,默认值:空(不过滤),格式:使用 \| 分隔多个关键词,例如:广告\|预告\|无关剧名 |
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
| ENABLE_ANIME_EPISODE_FILTER | 【可选】控制手动搜索的时候是否根据ANIME_TITLE_FILTER进行剧名过滤以及根据EPISODE_TITLE_FILTER进行集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 |
+12 -1
View File
@@ -99,9 +99,20 @@ SOURCE_ORDER=360,vod,renren,hanjutv
# 默认值:空
# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许同时存在、允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源循环
# 格式:源字段&源字段&源字段,源字段
# 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&bahamut&animeko,imgo
# 示例:MERGE_SOURCE_PAIRS=dandan&bahamut&animeko,renren&hanjutv,renren
# MERGE_SOURCE_PAIRS=
# 合并映射表
# 默认值:空
# 说明:用于自定义源合并行为,实现强制合并源结果或阻断合并源结果。[/S季数] 与 [|路由规则] 为可选项,留空则交由程序判断。多个规则用分号(;)隔开,多段路由用逗号(,)分隔。
# 格式1(合并):副源剧名/S季数@来源 -> 主源剧名/S季数@来源 | E副源集数>E主源集数
# 格式2(阻断):副源剧名@来源 × 主源剧名@来源
# 示例:
# - 常规合并:天气之子@bilibili -> 天气之子@dandan
# - 多集路由:我推的孩子/S01@bahamut -> 我推的孩子/S03@dandan | E25~E35>E25~E35
# - 阻断合并:辉夜大小姐想让我告白?~天才们的恋爱头脑战~(2020)@bilibili × 辉夜大小姐想让我告白~天才们的恋爱头脑战~ OVA(2021)【OVA】@dandan
# CUSTOM_MERGE_RULES=
# ==================== 剧集标题过滤 ====================
# 剧集标题过滤正则表达式(用于过滤花絮、预告等非正片内容)
+410 -94
View File
@@ -13,7 +13,7 @@ import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.j
import { resolveOffset, resolveOffsetRule, applyOffset } from "../utils/offset-util.js";
import {
extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces,
extractYear, titleMatches, extractAnimeInfo, extractEpisodeNumberFromTitle
extractYear, titleMatches, extractAnimeInfo, extractEpisodeNumberFromTitle, extractSeasonNumberFromAnimeTitle, extractAnimeTitle
} from "../utils/common-util.js";
import { getTMDBChineseTitle } from "../utils/tmdb-util.js";
import { applyMergeLogic, mergeDanmakuList, MERGE_DELIMITER, alignSourceTimelines } from "../utils/merge-util.js";
@@ -216,12 +216,194 @@ export function matchSeason(anime, queryTitle, season) {
}
}
/**
* 验证指定结果集中是否满足目标集数的需求
* 依据目标平台偏好,推断核心数据源容量,决定是否需要触发跨季全量检索
* @param {Array} animesList 动漫列表
* @param {number|null} querySeason 目标季数
* @param {number|null} queryEpisode 目标集数
* @param {Map} requestAnimeDetailsMap 详情缓存字典
* @param {string|null} targetPlatform 期望优先验证的目标平台
* @returns {boolean} 是否满足需求
*/
function checkEpisodeSatisfied(animesList, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform) {
if (queryEpisode === null || querySeason === null) return true;
let targetPlatforms = [];
if (targetPlatform) {
targetPlatforms = targetPlatform.split('&').map(s => s.trim().toLowerCase()).filter(s => s);
}
if (targetPlatforms.length === 0) {
targetPlatforms = ['_any_'];
}
let allSatisfied = true;
for (const tPlat of targetPlatforms) {
let isEpisodeSatisfied = false;
const seasonCapacities = new Map();
let platformHasData = false;
for (const anime of animesList) {
const actualPlatform = extractPlatformFromTitle(anime.animeTitle) || anime.source;
if (tPlat !== '_any_' && getPlatformMatchScore(actualPlatform, tPlat) === 0) {
continue;
}
platformHasData = true;
const bData = getBangumiDataForMatch(anime, requestAnimeDetailsMap);
if (bData?.success && bData.bangumi?.episodes) {
const validEps = bData.bangumi.episodes.filter(ep => !globals.episodeTitleFilter.test(ep.episodeTitle));
const filtered = filterSameEpisodeTitle(validEps);
if (filtered.some(ep => extractEpisodeNumberFromTitle(ep.episodeTitle) === queryEpisode)) {
isEpisodeSatisfied = true;
break;
}
const candidateTitles = [anime.animeTitle];
if (anime.aliases && Array.isArray(anime.aliases)) candidateTitles.push(...anime.aliases);
let sNum = null;
for (const candTitle of candidateTitles) {
if (!candTitle) continue;
const s = extractSeasonNumberFromAnimeTitle(candTitle).season;
if (s !== null) { sNum = s; break; }
}
if (sNum === null) sNum = 1;
const currentMax = seasonCapacities.get(sNum) || 0;
if (filtered.length > currentMax) {
seasonCapacities.set(sNum, filtered.length);
}
}
}
if (!platformHasData) {
continue;
}
if (!isEpisodeSatisfied) {
let totalValidEpisodes = 0;
for (const capacity of seasonCapacities.values()) {
totalValidEpisodes += capacity;
}
if (totalValidEpisodes < queryEpisode) {
allSatisfied = false;
break;
}
}
}
return allSatisfied;
}
/**
* 执行配置数据源的并发请求与解析逻辑
* 负责将获取的源站数据映射、过滤,并存入目标季度的动漫结果集合中
* @param {Object} resultData 并发请求的原始返回数据集
* @param {string} queryTitle 搜索关键词
* @param {Array} targetAnimesList 目标存储列表
* @param {Map} requestAnimeDetailsMap 详情缓存字典
* @param {number|null} targetSeason 目标季数
* @param {string|null} preferAnimeId 优选ID
* @param {string|null} preferSource 优选源
*/
async function executeSourceHandlers(resultData, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason, preferAnimeId = null, preferSource = null) {
const {
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,
migu: animesMigu, sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, maiduidui: animesMaiduidui,
aiyifan: animesAiyifan, animeko: animesAnimeko
} = resultData;
// 按顺序处理每个来源的结果
for (const key of globals.sourceOrderArr) {
if (key === '360') {
// 等待处理360来源
await kan360Source.handleAnimes(animes360, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'vod') {
// 等待处理Vod来源(遍历所有VOD服务器的结果)
if (animesVodResults && Array.isArray(animesVodResults)) {
for (const vodResult of animesVodResults) {
if (vodResult && vodResult.list && vodResult.list.length > 0) {
await vodSource.handleAnimes(vodResult.list, queryTitle, targetAnimesList, vodResult.serverName, requestAnimeDetailsMap, targetSeason);
}
}
}
} else if (key === 'tmdb') {
// 等待处理TMDB来源
await tmdbSource.handleAnimes(animesTmdb, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'douban') {
// 等待处理Douban来源
await doubanSource.handleAnimes(animesDouban, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'renren') {
// 等待处理Renren来源
await renrenSource.handleAnimes(animesRenren, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'hanjutv') {
// 等待处理Hanjutv来源
await hanjutvSource.handleAnimes(animesHanjutv, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'bahamut') {
// 等待处理Bahamut来源
await bahamutSource.handleAnimes(animesBahamut, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'dandan') {
// 等待处理弹弹play来源
await dandanSource.handleAnimes(animesDandan, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'custom') {
// 等待处理自定义弹幕源来源
await customSource.handleAnimes(animesCustom, queryTitle, targetAnimesList);
} else if (key === 'tencent') {
// 等待处理Tencent来源
await tencentSource.handleAnimes(animesTencent, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'youku') {
// 等待处理Youku来源
await youkuSource.handleAnimes(animesYouku, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'iqiyi') {
// 等待处理iQiyi来源
await iqiyiSource.handleAnimes(animesIqiyi, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'imgo') {
// 等待处理Mango来源
await mangoSource.handleAnimes(animesImgo, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'bilibili') {
// 等待处理Bilibili来源
await bilibiliSource.handleAnimes(animesBilibili, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'migu') {
// 等待处理Migu来源
await miguSource.handleAnimes(animesMigu, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'sohu') {
// 等待处理Sohu来源
await sohuSource.handleAnimes(animesSohu, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'leshi') {
// 等待处理Leshi来源
await leshiSource.handleAnimes(animesLeshi, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'xigua') {
// 等待处理Xigua来源
await xiguaSource.handleAnimes(animesXigua, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'maiduidui') {
// 等待处理Maiduidui来源
await maiduiduiSource.handleAnimes(animesMaiduidui, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'aiyifan') {
// 等待处理Aiyifan来源
await aiyifanSource.handleAnimes(animesAiyifan, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
} else if (key === 'animeko') {
// 等待处理Animeko来源
await animekoSource.handleAnimes(animesAnimeko, queryTitle, targetAnimesList, requestAnimeDetailsMap, targetSeason);
}
}
}
// Extracted function for GET /api/v2/search/anime
export async function searchAnime(url, preferAnimeId = null, preferSource = null, detailStore = null) {
export async function searchAnime(url, preferAnimeId = null, preferSource = null, detailStore = null, targetPlatform = null) {
let queryTitle = url.searchParams.get("keyword");
let querySeason = url.searchParams.get("season");
querySeason = querySeason ? parseInt(querySeason, 10) : null;
log("info", `Search anime with keyword: ${queryTitle}, target season: ${querySeason}`);
let queryEpisode = url.searchParams.get("episode");
queryEpisode = queryEpisode ? parseInt(queryEpisode, 10) : null;
log("info", `Search anime with keyword: ${queryTitle}, target season: ${querySeason}, target episode: ${queryEpisode}`);
// 关键字为空直接返回,不用多余查询
if (queryTitle === "") {
@@ -246,12 +428,43 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
// 检查搜索缓存
const cachedResults = getSearchCache(cacheKey, requestAnimeDetailsMap);
if (cachedResults !== null) {
return jsonResponse({
errorCode: 0,
success: true,
errorMessage: "",
animes: cachedResults,
});
let satisfied = checkEpisodeSatisfied(cachedResults, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform);
if (satisfied) {
return jsonResponse({
errorCode: 0,
success: true,
errorMessage: "",
animes: cachedResults,
});
} else {
// 当前季度缓存未能满足目标集数,尝试顺延加载后续季度的缓存拼接
let currentS = querySeason + 1;
let combinedCachedResults = [...cachedResults];
let cacheMissed = false;
while (!satisfied && !cacheMissed) {
const nextCacheKey = `${queryTitle}_S${currentS}`;
const nextCache = getSearchCache(nextCacheKey, requestAnimeDetailsMap);
if (nextCache !== null && nextCache.length > 0) {
combinedCachedResults.push(...nextCache);
satisfied = checkEpisodeSatisfied(combinedCachedResults, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform);
currentS++;
} else {
cacheMissed = true;
}
}
if (satisfied) {
log("info", `Episode ${queryEpisode} satisfied by combining cached seasons S${querySeason} to S${currentS - 1}`);
return jsonResponse({
errorCode: 0,
success: true,
errorMessage: "",
animes: combinedCachedResults,
});
}
log("info", `Episode ${queryEpisode} not satisfied in cache. Proceeding to network search.`);
}
}
const curAnimes = [];
@@ -369,94 +582,62 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
resultData[source] = results[index]; // 根据顺序赋值
});
// 解构出返回的结果
const {
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,
migu: animesMigu, sohu: animesSohu, leshi: animesLeshi, xigua: animesXigua, maiduidui: animesMaiduidui,
aiyifan: animesAiyifan, animeko: animesAnimeko
} = resultData;
// 优先处理指定的季度(或全量)
await executeSourceHandlers(resultData, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason, preferAnimeId, preferSource);
// 按顺序处理每个来源的结果
for (const key of globals.sourceOrderArr) {
if (key === '360') {
// 等待处理360来源
await kan360Source.handleAnimes(animes360, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'vod') {
// 等待处理Vod来源(遍历所有VOD服务器的结果)
if (animesVodResults && Array.isArray(animesVodResults)) {
for (const vodResult of animesVodResults) {
if (vodResult && vodResult.list && vodResult.list.length > 0) {
await vodSource.handleAnimes(vodResult.list, queryTitle, curAnimes, vodResult.serverName, requestAnimeDetailsMap, querySeason);
// 缓存首季/默认请求结果,剥离附加链接
if (curAnimes.length > 0) {
setSearchCache(cacheKey, curAnimes.map(({ links, ...pureAnime }) => pureAnime), requestAnimeDetailsMap);
}
// 判断当前获取的季度是否已包含用户指定的集数
const isEpisodeSatisfied = checkEpisodeSatisfied(curAnimes, querySeason, queryEpisode, requestAnimeDetailsMap, targetPlatform);
// 若未包含且用户指定了季度,推导最大季并并行映射 S2、S3 等结果,合并同一次搜索下的后续季以辅助跨季匹配
if (!isEpisodeSatisfied && querySeason !== null) {
let maxSeason = querySeason;
for (const source of globals.sourceOrderArr) {
const rawAnimes = resultData[source];
if (Array.isArray(rawAnimes)) {
for (const item of rawAnimes) {
const list = (item && Array.isArray(item.list)) ? item.list : [item];
for (const a of list) {
if (!a) continue;
const s = extractSeasonNumberFromAnimeTitle(a.animeTitle || a.title || "").season;
if (s !== null && s > maxSeason) maxSeason = s;
}
}
}
} else if (key === 'tmdb') {
// 等待处理TMDB来源
await tmdbSource.handleAnimes(animesTmdb, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'douban') {
// 等待处理Douban来源
await doubanSource.handleAnimes(animesDouban, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'renren') {
// 等待处理Renren来源
await renrenSource.handleAnimes(animesRenren, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'hanjutv') {
// 等待处理Hanjutv来源
await hanjutvSource.handleAnimes(animesHanjutv, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'bahamut') {
// 等待处理Bahamut来源
await bahamutSource.handleAnimes(animesBahamut, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'dandan') {
// 等待处理弹弹play来源
await dandanSource.handleAnimes(animesDandan, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'custom') {
// 等待处理自定义弹幕源来源
await customSource.handleAnimes(animesCustom, queryTitle, curAnimes, requestAnimeDetailsMap);
} else if (key === 'tencent') {
// 等待处理Tencent来源
await tencentSource.handleAnimes(animesTencent, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'youku') {
// 等待处理Youku来源
await youkuSource.handleAnimes(animesYouku, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'iqiyi') {
// 等待处理iQiyi来源
await iqiyiSource.handleAnimes(animesIqiyi, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'imgo') {
// 等待处理Mango来源
await mangoSource.handleAnimes(animesImgo, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'bilibili') {
// 等待处理Bilibili来源
await bilibiliSource.handleAnimes(animesBilibili, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'migu') {
// 等待处理Migu来源
await miguSource.handleAnimes(animesMigu, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'sohu') {
// 等待处理Sohu来源
await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'leshi') {
// 等待处理Leshi来源
await leshiSource.handleAnimes(animesLeshi, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'xigua') {
// 等待处理Xigua来源
await xiguaSource.handleAnimes(animesXigua, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'maiduidui') {
// 等待处理Maiduidui来源
await maiduiduiSource.handleAnimes(animesMaiduidui, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'aiyifan') {
// 等待处理Aiyifan来源
await aiyifanSource.handleAnimes(animesAiyifan, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
} else if (key === 'animeko') {
// 等待处理Animeko来源
await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes, requestAnimeDetailsMap, querySeason);
}
if (maxSeason > querySeason) {
log("info", `Episode ${queryEpisode} not satisfied in Season ${querySeason}. Parallel mapping to S${querySeason + 1}~S${maxSeason}...`);
const expandPromises = [];
for (let s = querySeason + 1; s <= maxSeason; s++) {
expandPromises.push((async () => {
const seasonAnimes = [];
await executeSourceHandlers(resultData, queryTitle, seasonAnimes, requestAnimeDetailsMap, s, preferAnimeId, preferSource);
// 缓存额外季度的结果
if (seasonAnimes.length > 0) {
setSearchCache(`${queryTitle}_S${s}`, seasonAnimes.map(({ links, ...pureAnime }) => pureAnime), requestAnimeDetailsMap);
}
return seasonAnimes;
})());
}
const expandedResults = await Promise.all(expandPromises);
for (const res of expandedResults) {
curAnimes.push(...res);
}
}
}
} catch (error) {
log("error", "发生错误:", error);
}
// 执行源合并逻辑
if (globals.mergeSourcePairs.length > 0) {
// 执行源合并逻辑(支持常规配对组和自定义规则表触发)
const hasMergePairs = globals.mergeSourcePairs && globals.mergeSourcePairs.length > 0;
const hasCustomRules = globals.customMergeRules && globals.customMergeRules.length > 0;
if (hasMergePairs || hasCustomRules) {
await applyMergeLogic(curAnimes, requestAnimeDetailsMap);
}
@@ -766,6 +947,117 @@ function computeTargetEpisode(offsets, season, episode, filteredEpisodes, target
return targetEpisode;
}
/**
* 跨季集数顺延映射逻辑
* @param {Object} searchData 搜索结果数据
* @param {string} title 搜索标题
* @param {number|null} year 年份
* @param {number} season 当前季数
* @param {number} episode 目标集数
* @param {string|null} platform 平台偏好
* @param {Map|null} detailStore 详情缓存
* @returns {Object} 匹配结果 { resEpisode, resAnime }
*/
function findCrossSeasonEpisodeMap(searchData, title, year, season, episode, platform, detailStore) {
// 仅在当前季集三种匹配策略均未命中时才启动相对顺延溢出机制
if (!season || !episode) return { resEpisode: null, resAnime: null };
log("info", `[Spillover] 当前季集匹配策略失败 (S${season}E${episode}),正在进行跨季集数映射匹配...`);
const normalizedTitle = normalizeSpaces(title);
const seasonMap = new Map();
for (const anime of searchData.animes) {
const candidateTitles = [anime.animeTitle];
if (anime.aliases && Array.isArray(anime.aliases)) candidateTitles.push(...anime.aliases);
let isBaseMatch = false;
for (const candTitle of candidateTitles) {
if (!candTitle) continue;
if (normalizeSpaces(candTitle).includes(normalizedTitle)) {
if (!matchYear(anime, year)) continue;
isBaseMatch = true;
break;
}
}
if (!isBaseMatch) continue;
let sNum = null;
for (const candTitle of candidateTitles) {
if (!candTitle) continue;
const s = extractSeasonNumberFromAnimeTitle(candTitle).season;
if (s !== null) { sNum = s; break; }
}
if (sNum === null) sNum = 1;
if (!seasonMap.has(sNum)) {
const bangumiData = getBangumiDataForMatch(anime, detailStore);
if (bangumiData?.success && bangumiData?.bangumi?.episodes) {
const filteredTmpEpisodes = bangumiData.bangumi.episodes.filter(ep => !globals.episodeTitleFilter.test(ep.episodeTitle));
const filteredEpisodes = filterSameEpisodeTitle(filteredTmpEpisodes);
if (filteredEpisodes.length > 0) {
seasonMap.set(sNum, {
anime: anime,
episodes: filteredEpisodes,
actualPlatform: extractPlatformFromTitle(anime.animeTitle) || anime.source
});
}
}
}
}
let currentTargetEpisode = episode;
let currentSeason = season;
let bestRes = { anime: null, episode: null, score: 0 };
while (seasonMap.has(currentSeason)) {
const seasonData = seasonMap.get(currentSeason);
const allEps = seasonData.episodes;
let absoluteMatch = null;
for (const ep of allEps) {
const extNum = extractEpisodeNumberFromTitle(ep.episodeTitle);
if (extNum === episode) {
absoluteMatch = ep;
break;
}
}
if (absoluteMatch) {
if (platform && getPlatformMatchScore(extractEpisodeTitle(absoluteMatch.episodeTitle), platform) === 0) {
break;
}
log("info", `[Spillover] 跨季溢出查找命中 (按绝对标题数字) -> 所在季:S${currentSeason} 集标题:${absoluteMatch.episodeTitle}`);
bestRes = {
anime: seasonData.anime,
episode: absoluteMatch,
score: platform ? getPlatformMatchScore(seasonData.actualPlatform, platform) : 1
};
break;
}
if (currentTargetEpisode <= allEps.length) {
const targetEp = allEps[currentTargetEpisode - 1];
if (platform && getPlatformMatchScore(extractEpisodeTitle(targetEp.episodeTitle), platform) === 0) {
break;
}
log("info", `[Spillover] 跨季溢出查找命中 (按相对排位计算) -> 所在季:S${currentSeason} 集标题:${targetEp.episodeTitle}`);
bestRes = {
anime: seasonData.anime,
episode: targetEp,
score: platform ? getPlatformMatchScore(seasonData.actualPlatform, platform) : 1
};
break;
}
log("info", `[Spillover] S${currentSeason} 共有 ${allEps.length} 集(已过滤番外),剩余目标集数为 ${currentTargetEpisode},映射至 S${currentSeason + 1} 继续查找`);
currentTargetEpisode -= allEps.length;
currentSeason++;
}
return { resEpisode: bestRes.episode, resAnime: bestRes.anime };
}
async function matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets, detailStore = null) {
// 定义最佳匹配结果容器
let bestRes = {
@@ -920,10 +1212,22 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
}
}
// 跨季集数顺延映射匹配逻辑
if (!bestRes.episode && season && episode) {
const spilloverRes = findCrossSeasonEpisodeMap(searchData, title, year, season, episode, platform, detailStore);
if (spilloverRes.resEpisode) {
bestRes = {
episode: spilloverRes.resEpisode,
anime: spilloverRes.resAnime,
score: platform ? getPlatformMatchScore(extractPlatformFromTitle(spilloverRes.resAnime.animeTitle) || spilloverRes.resAnime.source, platform) : 1
};
}
}
return { resEpisode: bestRes.episode, resAnime: bestRes.anime };
}
async function fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets, detailStore = null) {
async function fallbackMatchAniAndEp(searchData, req, season, episode, year, title, resEpisode, resAnime, offsets, detailStore = null) {
for (const anime of searchData.animes) {
// 年份匹配优先(如果提供了年份)
if (year && !matchYear(anime, year)) {
@@ -967,6 +1271,16 @@ async function fallbackMatchAniAndEp(searchData, req, season, episode, year, res
}
}
}
// 跨季兜底溢出查找逻辑
if (!resEpisode && season && episode) {
const spilloverRes = findCrossSeasonEpisodeMap(searchData, title, year, season, episode, null, detailStore);
if (spilloverRes.resEpisode) {
resEpisode = spilloverRes.resEpisode;
resAnime = spilloverRes.resAnime;
}
}
return {resEpisode, resAnime};
}
@@ -1099,9 +1413,13 @@ export async function matchAnime(url, req, clientIp) {
const [preferAnimeId, preferSource, offsets] = getPreferAnimeId(title, season);
log("info", `prefer animeId: ${preferAnimeId} from ${preferSource}`);
// 根据指定平台创建动态平台顺序
const dynamicPlatformOrder = createDynamicPlatformOrder(preferredPlatform);
const targetPlatform = dynamicPlatformOrder.length > 0 ? dynamicPlatformOrder[0] : null;
const requestAnimeDetailsMap = new Map();
let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}&season=${season || ''}`));
const searchRes = await searchAnime(originSearchUrl, preferAnimeId, preferSource, requestAnimeDetailsMap);
let originSearchUrl = new URL(req.url.replace("/match", `/search/anime?keyword=${title}&season=${season || ''}&episode=${episode || ''}`));
const searchRes = await searchAnime(originSearchUrl, preferAnimeId, preferSource, requestAnimeDetailsMap, targetPlatform);
const searchData = await searchRes.json();
log("info", `searchData: ${searchData.animes}`);
@@ -1116,15 +1434,13 @@ export async function matchAnime(url, req, clientIp) {
"matches": []
};
// 根据指定平台创建动态平台顺序
const dynamicPlatformOrder = createDynamicPlatformOrder(preferredPlatform);
log("info", `Original platformOrderArr: ${globals.platformOrderArr}`);
log("info", `Dynamic platformOrder: ${dynamicPlatformOrder}`);
log("info", `Preferred platform: ${preferredPlatform || 'none'}`);
// 尝试使用AI进行匹配
const aiMatchResult = await matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId, requestAnimeDetailsMap);
if (aiMatchResult.resAnime) {
if (aiMatchResult.resAnime && aiMatchResult.resEpisode) {
resAnime = aiMatchResult.resAnime;
resEpisode = aiMatchResult.resEpisode;
resData["isMatched"] = true;
@@ -1145,7 +1461,7 @@ export async function matchAnime(url, req, clientIp) {
// 如果都没有找到则返回第一个满足剧集数的剧集
if (!resAnime) {
const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, resEpisode, resAnime, offsets, requestAnimeDetailsMap);
const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, title, resEpisode, resAnime, offsets, requestAnimeDetailsMap);
resEpisode = __ret.resEpisode;
resAnime = __ret.resAnime;
}
+98
View File
@@ -228,6 +228,102 @@ export class Envs {
.filter(Boolean);
}
/**
* 解析合并映射表
* 用于解析用户自定义的剧集/季度级别合并与乱序重排规则,以及阻断特定合并。
* 支持从全局强制合并到精确单集的路由干预,解决跨源集数错位问题。
* 格式:
* 合并: 副源实体 -> 主源实体 | 路由规则
* 阻断: 副源实体 × 主源实体
* @returns {Array} 解析后的规则对象列表
*/
static resolveCustomMergeRules() {
const raw = this.get('CUSTOM_MERGE_RULES', '', 'string').trim();
if (!raw) return [];
const rules = [];
const ruleStrs = raw.split(';');
for (const rStr of ruleStrs) {
if (!rStr.trim()) continue;
try {
const parts = rStr.split('|');
const entities = parts[0];
const routesStr = parts[1] || '';
let secStr, primStr;
let action = 'merge';
if (entities.includes('->')) {
[secStr, primStr] = entities.split('->').map(s => s.trim());
} else if (entities.includes('×')) {
[secStr, primStr] = entities.split('×').map(s => s.trim());
action = 'block';
} else {
continue;
}
if (!secStr || !primStr) continue;
// 解析实体信息:提取标题、季数、来源平台
const parseEntity = (str) => {
const match = str.match(/^(.+?)(?:\/S(\d+))?@([a-zA-Z0-9_&]+)$/i);
if (match) {
return {
title: match[1].trim(),
season: match[2] ? parseInt(match[2], 10) : null,
source: match[3].toLowerCase()
};
}
return null;
};
const secEntity = parseEntity(secStr);
const primEntity = parseEntity(primStr);
if (!secEntity || !primEntity) continue;
// 解析路由规则:提取对应的单集或区间映射
const routes = [];
let hasRoutes = false;
if (action === 'merge' && routesStr.trim()) {
hasRoutes = true;
const routeParts = routesStr.split(',');
for (const rp of routeParts) {
if (!rp.trim()) continue;
const [sPart, pPart] = rp.split('>').map(s => s.trim());
if (!sPart || !pPart) continue;
const parseRange = (r) => {
const match = r.match(/^E(\d+)(?:~E(\d+))?$/i);
if (match) {
return {
start: parseInt(match[1], 10),
end: match[2] ? parseInt(match[2], 10) : parseInt(match[1], 10)
};
}
return null;
};
const sRange = parseRange(sPart);
const pRange = parseRange(pPart);
if (sRange && pRange) {
routes.push({ sec: sRange, prim: pRange });
}
}
}
rules.push({ action, secondary: secEntity, primary: primEntity, routes, hasRoutes });
} catch (e) {
console.warn(`[Envs] 解析合并映射表规则失败: ${rStr}`, e);
}
}
this.accessedEnvVars.set('CUSTOM_MERGE_RULES', raw);
return rules;
}
/**
* 解析剧集标题过滤正则
* @description 过滤非正片内容,同时内置白名单防止误杀正片
@@ -498,6 +594,7 @@ export class Envs {
// 源配置
'SOURCE_ORDER': { category: 'source', type: 'multi-select', options: this.ALLOWED_SOURCES, description: '源排序配置,默认360,vod,renren,hanjutv' },
'MERGE_SOURCE_PAIRS': { category: 'source', type: 'multi-select', options: this.MERGE_ALLOWED_SOURCES, description: '源合并配置,配置后将对应源合并同时一起获取弹幕返回,允许多组,允许多源,允许填单源表示保留原结果,一组中第一个为主源其余为副源,副源往主源合并,主源如果没有结果会轮替下一个作为主源。\n格式:源1&源2&源3 ,多组用逗号分隔。\n示例:dandan&animeko&bahamut,bilibili&animeko,dandan' },
'CUSTOM_MERGE_RULES': { category: 'source', type: 'text', sources: this.MERGE_ALLOWED_SOURCES, description: '合并映射表,用于自定义源合并行为。\n格式1(合并):副源剧名/S季数@来源 -> 主源剧名/S季数@来源 | E副源集数>E主源集数\n格式2(阻断):副源剧名/S季数@来源 × 主源剧名/S季数@来源\n说明:[/S季数] 与 [|路由规则] 为可选项,留空则交由程序判断。多个规则用分号隔开,多段路由用逗号分隔。\n示例:\n1. 常规合并:天气之子@bilibili -> 天气之子@dandan\n2. 多集路由:我推的孩子/S01@bahamut -> 我推的孩子/S03@dandan | E25~E35>E25~E35\n3. 阻断合并:辉夜大小姐想让我告白?~天才们的恋爱头脑战~(2020)@bilibili × 辉夜大小姐想让我告白~天才们的恋爱头脑战~ OVA(2021)【OVA】@dandan' },
'OTHER_SERVER': { category: 'source', type: 'text', description: '第三方弹幕服务器,默认https://api.danmu.icu' },
'CUSTOM_SOURCE_API_URL': { category: 'source', type: 'text', description: '自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源' },
'VOD_SERVERS': { category: 'source', type: 'text', description: 'VOD站点配置,格式:名称@URL,名称@URL,默认金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top' },
@@ -564,6 +661,7 @@ export class Envs {
adminToken: this.get('ADMIN_TOKEN', '', 'string', true), // admin token,用于系统管理访问控制
sourceOrderArr: this.resolveSourceOrder(), // 源排序
mergeSourcePairs: this.resolveMergeSourcePairs(), // 源合并配置,用于将源合并获取
customMergeRules: this.resolveCustomMergeRules(), // 合并映射表,用于自定义源合并行为。
otherServer: this.get('OTHER_SERVER', 'https://api.danmu.icu', 'string'), // 第三方弹幕服务器
customSourceApiUrl: this.get('CUSTOM_SOURCE_API_URL', '', 'string', true), // 自定义弹幕源API地址,默认为空,配置后还需在SOURCE_ORDER添加custom源
vodServers: this.resolveVodServers(), // vod站点配置,格式:名称@URL,名称@URL
+134
View File
@@ -568,6 +568,7 @@ function renderValueInput(item) {
const isAiApiKey = currentKey === 'AI_API_KEY';
const isColorPool = currentKey === 'COLOR_POOL';
const isDanmuOffset = currentKey === 'DANMU_OFFSET';
const isCustomMergeRules = currentKey === 'CUSTOM_MERGE_RULES';
const offsetSources = item && item.sources ? item.sources : [];
if (isColorPool) {
@@ -718,6 +719,56 @@ function renderValueInput(item) {
});
}
}, 120);
} else if (isCustomMergeRules) {
// CUSTOM_MERGE_RULES 专用编辑界面
const rows = value && value.length > 50 ? Math.min(Math.max(Math.ceil(value.length / 50), 3), 10) : 3;
container.innerHTML = \`
<label>变量值</label>
<textarea id="text-value" placeholder="格式:副源 -> 主源 | 路由规则 或 副源 × 主源" rows="\${rows}" class="text-monospace">\${value || ''}</textarea>
<div style="margin-top: 8px;">
<button type="button" class="btn btn-primary btn-sm" id="merge-rule-toggle" onclick="toggleMergeRulePanel()">
添加规则
</button>
</div>
<div id="merge-rule-panel" class="offset-rule-panel">
<div class="offset-form-row">
<div style="flex: 1; min-width: 120px;">
<label class="offset-label">副源实体(副源剧名@源)</label>
<input type="text" id="merge-sec-entity" class="offset-input" placeholder="例: 我推的孩子/S01@bahamut" onfocus="setMergeFocus('sec')">
</div>
<div style="width: 60px;">
<label class="offset-label" style="text-align: center; display: block;">关系</label>
<select id="merge-action" class="offset-input" onchange="onMergeActionChange()" style="cursor: pointer; padding: 6px; text-align: center; font-weight: bold; font-size: 14px;">
<option value="->">-&gt;</option>
<option value="×">×</option>
</select>
<div id="merge-action-hint" style="font-size: 11px; color: #666; text-align: center; margin-top: 4px;">合并</div>
</div>
<div style="flex: 1; min-width: 120px;">
<label class="offset-label">主源实体(主源剧名@源)</label>
<input type="text" id="merge-prim-entity" class="offset-input" placeholder="例: 我推的孩子/S03@dandan" onfocus="setMergeFocus('prim')">
</div>
</div>
<div class="offset-form-row" id="merge-route-row">
<div style="flex: 1;">
<label class="offset-label">集数路由规则 (选填,可多组。例如: E01>E01,E25~E35>E25~E35)</label>
<input type="text" id="merge-route-rule" class="offset-input" placeholder="留空则交由系统自动计算偏移">
</div>
</div>
<div style="margin-bottom: 10px;">
<label class="offset-label">快速追加来源至当前聚焦的输入框 (没有 @ 则追加 @xxx,已存在 @ 则追加 &xxx 合并写法)</label>
<div class="offset-sources">
\${offsetSources.map(src => \`
<div class="offset-source-tag" onclick="appendSourceToMerge('\${src}')">\${src}</div>
\`).join('')}
</div>
</div>
<div class="offset-actions">
<button type="button" class="btn btn-sm" onclick="toggleMergeRulePanel()">取消</button>
<button type="button" class="btn btn-primary btn-sm" onclick="appendMergeRule()">确认添加</button>
</div>
</div>
\`;
} else if (value && value.length > 50) {
const rows = Math.min(Math.max(Math.ceil(value.length / 50), 3), 10);
container.innerHTML = \`
@@ -1064,6 +1115,89 @@ function appendOffsetRule() {
toggleOffsetRulePanel();
}
// CUSTOM_MERGE_RULES 快速配置 - 焦点状态记录
let currentMergeFocus = 'sec';
function setMergeFocus(type) {
currentMergeFocus = type;
}
// CUSTOM_MERGE_RULES 快速配置 - 快捷追加来源
function appendSourceToMerge(source) {
const inputId = currentMergeFocus === 'sec' ? 'merge-sec-entity' : 'merge-prim-entity';
const inputEl = document.getElementById(inputId);
if (!inputEl) return;
let val = inputEl.value.trim();
if (val.includes('@')) {
val += '&' + source;
} else {
val += '@' + source;
}
inputEl.value = val;
inputEl.focus(); // 保持焦点,方便用户连续点击多个来源
}
// CUSTOM_MERGE_RULES 快速配置 - 切换规则面板
function toggleMergeRulePanel() {
const panel = document.getElementById('merge-rule-panel');
if (panel) {
const isHidden = getComputedStyle(panel).display === 'none';
panel.style.display = isHidden ? 'block' : 'none';
const btn = document.getElementById('merge-rule-toggle');
if (btn) btn.textContent = isHidden ? '收起' : '添加规则';
}
}
// CUSTOM_MERGE_RULES 快速配置 - 切换操作符联动
function onMergeActionChange() {
const action = document.getElementById('merge-action').value;
const routeRow = document.getElementById('merge-route-row');
const hint = document.getElementById('merge-action-hint');
if (action === '×') {
if (routeRow) routeRow.style.display = 'none';
if (hint) hint.textContent = '阻断';
} else {
if (routeRow) routeRow.style.display = 'flex';
if (hint) hint.textContent = '合并';
}
}
// CUSTOM_MERGE_RULES 快速配置 - 确认添加规则
function appendMergeRule() {
const secEntity = document.getElementById('merge-sec-entity').value.trim();
const action = document.getElementById('merge-action').value;
const primEntity = document.getElementById('merge-prim-entity').value.trim();
const routeRule = document.getElementById('merge-route-rule').value.trim();
if (!secEntity || !primEntity) {
customAlert('副源实体和主源实体不能为空');
return;
}
let rule = secEntity + ' ' + action + ' ' + primEntity;
if (action === '->' && routeRule) {
rule += ' | ' + routeRule;
}
const textarea = document.getElementById('text-value');
const current = textarea.value.trim();
if (current && !current.endsWith(';')) {
textarea.value = current + ';' + rule;
} else {
textarea.value = current ? current + rule : rule;
}
// 清空表单
document.getElementById('merge-sec-entity').value = '';
document.getElementById('merge-prim-entity').value = '';
document.getElementById('merge-route-rule').value = '';
toggleMergeRulePanel();
}
// 调整数字
function adjustNumber(delta) {
const display = document.getElementById('num-value');
+2712 -2181
View File
File diff suppressed because it is too large. Load diff