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:
1 parent
d622a5be8d
commit
d894ccdd3d
6 files changed
+3368
-2277
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="->">-></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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user