为合并支持判断3D2D,修复带有不同配音的结果合集判断失误,增加简易的内部特殊条目映射规则 (#252)

* animeko.js:从tags中提取3D/2D追加到类型

* iqiyi.js:从metaTags中提取3D/2D追加到类型

* tencent.js:从richTags中提取3D/2D追加到类型

* merge-util.js:支持判断3D2D,修复带有不同配音的结果合集判断失误,增加简易的内部特殊条目映射规则
This commit is contained in:
Elegy233 authored and GitHub committed 2026-03-22 08:04:32 +08:00
1 parent 42ae6b8a61
commit 832d604aed
4 files changed
+317 -111

No files matched your search

+14 -2
View File
@@ -260,6 +260,18 @@ export default class AnimekoSource extends BaseSource {
}
}
// 识别 3D 与 2D 标签并追加至类型描述
let is3D = false;
let is2D = false;
if (item.tags && Array.isArray(item.tags)) {
item.tags.forEach(tag => {
if (tag.name === '3D') is3D = true;
if (tag.name === '2D') is2D = true;
});
}
if (is3D) typeDesc = "3D" + typeDesc;
else if (is2D) typeDesc = "2D" + typeDesc;
const titleSuffix = item._relation_mark ? ` ${item._relation_mark}` : "";
// 提取别名列表 (用于合并工具进行模糊匹配)
@@ -370,7 +382,7 @@ export default class AnimekoSource extends BaseSource {
* @param {string} queryTitle 原始查询标题
* @param {Array} curAnimes 当前缓存的番剧列表
*/
async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) {
async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) {
const tmpAnimes = [];
if (!sourceAnimes || !Array.isArray(sourceAnimes)) {
@@ -426,7 +438,7 @@ export default class AnimekoSource extends BaseSource {
};
tmpAnimes.push(transformedAnime);
addAnime({...transformedAnime, links: links}, detailStore);
addAnime({...transformedAnime, links: links}, detailStore);
if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime();
}
+22 -1
View File
@@ -148,8 +148,29 @@ export default class IqiyiSource extends BaseSource {
return null;
}
// 提取 3D 与 2D 属性标签并追加至媒体类型
let is3D = false;
let is2D = false;
if (album.metaTags && Array.isArray(album.metaTags)) {
album.metaTags.forEach(tag => {
if (tag.name === '3D') is3D = true;
if (tag.name === '2D') is2D = true;
});
}
if (album.baseTags && Array.isArray(album.baseTags)) {
album.baseTags.forEach(tag => {
if (tag.value === '3D') is3D = true;
if (tag.value === '2D') is2D = true;
});
}
if (is3D) {
mediaType = "3D" + mediaType;
} else if (is2D) {
mediaType = "2D" + mediaType;
}
// 电影类型:使用 qipuId 作为 mediaId
if (mediaType === "电影") {
if (mediaType.includes("电影")) {
const qipuId = album.qipuId || album.playQipuId;
if (!qipuId) {
log("debug", `[iQiyi] 电影缺少 qipuId: ${album.title}`);
+27 -1
View File
@@ -55,6 +55,32 @@ export default class TencentSource extends BaseSource {
return null;
}
let mediaType = contentType;
// 提取 3D 与 2D 属性标签并追加至媒体类型
let is3D = false;
let is2D = false;
if (videoInfo.coverDoc) {
if (videoInfo.coverDoc.richTags && Array.isArray(videoInfo.coverDoc.richTags)) {
videoInfo.coverDoc.richTags.forEach(tag => {
if (tag.text && tag.text.includes('3D')) is3D = true;
if (tag.text && tag.text.includes('2D')) is2D = true;
});
}
if (videoInfo.coverDoc.tags && Array.isArray(videoInfo.coverDoc.tags)) {
videoInfo.coverDoc.tags.forEach(tag => {
if (tag && tag.includes('3D')) is3D = true;
if (tag && tag.includes('2D')) is2D = true;
});
}
}
if (is3D) {
mediaType = "3D" + mediaType;
} else if (is2D) {
mediaType = "2D" + mediaType;
}
// 过滤非腾讯视频内容
const allSites = (videoInfo.playSites || []).concat(videoInfo.episodeSites || []);
if (allSites.length > 0 && !allSites.some(site => site.enName === 'qq')) {
@@ -75,7 +101,7 @@ export default class TencentSource extends BaseSource {
provider: "tencent",
mediaId: mediaId,
title: title,
type: contentType, // 使用中文类型,与360/vod保持一致
type: mediaType,
year: videoInfo.year,
imageUrl: videoInfo.imgUrl,
episodeCount: episodeCount
+254 -107
View File
@@ -34,10 +34,10 @@ const MergeWeights = Object.freeze({
TITLE_STRUCTURE_CONFLICT: -0.30, // 标题结构冲突(如父子集关系)
LANG_MATCH_CN: 0.15, // 双端均为中文时的奖励
LANG_MISMATCH: -0.20, // 语言不一致时的惩罚
// 日期
DATE_MATCH: 0.0, // 基础日期匹配(动态计算,此处为占位)
// 集数对齐 (Alignment)
EP_ALIGN: {
MOVIE_TYPE_MISMATCH: -5.0, // 电影/TV 类型不符
@@ -216,6 +216,84 @@ const SEASON_PATTERNS = [
{ regex: /[^0-9](\d)$/, prefix: 'S', useCleaned: true }
];
// ==========================================
// 扩展配置: 特殊番剧特征规则库 (Special Series Registry)
// ==========================================
const SpecialSeriesRegistry = [
{
// 案例 1:经典的带副标题映射(美少女战士)
seriesKeywords: ["美少女战士"],
mappings: [
// 忽略大小写,填入 "R" 即可自动匹配 "r"
{ markers: ["R"], targetStandard: "第二季" },
{ markers: ["S"], targetStandard: "第三季" },
{ markers: ["SuperS", "Super S"], targetStandard: "第四季" },
{ markers: ["Sailor Stars", "最后的星光"], targetStandard: "第五季" }
]
},
{
seriesKeywords: ["小林家的龙女仆"],
mappings: [
{ markers: ["S"], targetStandard: "第二季" },
]
},
{
// 案例 2:可能携带副标题的(我们不可能成为恋人)
seriesKeywords: ["可能成为恋人", "不行"],
mappings: [
{ markers: ["NEXT SHINE", "再次闪耀"], targetStandard: "续篇" }
]
},
{
// 案例 3:没有任何副标题,主标题本身就是特殊类型
seriesKeywords: ["红猪", "千与千寻", "龙猫"],
// 当没有匹配到任何 mappings(或根本没写 mappings)时,直接给这个条目打上默认标签
defaultStandard: "剧场版"
}
];
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 引擎级标题语义转换器 (Semantic Normalizer)
*/
function normalizeTitleForEngine(title) {
if (!title) return '';
let normTitle = title;
const upperTitle = normTitle.toUpperCase();
for (const rule of SpecialSeriesRegistry) {
// 判断是否命中了设定的系列
const isTargetSeries = rule.seriesKeywords.every(kw => upperTitle.includes(kw.toUpperCase()));
if (isTargetSeries) {
let hasMapped = false;
// 1. 尝试匹配并替换副标题
if (rule.mappings) {
for (const mapping of rule.mappings) {
mapping.markers.forEach(marker => {
// 'gi' 保证了全局且忽略大小写的替换
const reg = new RegExp(escapeRegExp(marker), 'gi');
if (reg.test(normTitle)) {
normTitle = normTitle.replace(reg, ` ${mapping.targetStandard} `);
hasMapped = true;
}
});
}
}
// 如果没有任何副标题被替换,且配置了 defaultStandard,则强行追加标准类型
if (!hasMapped && rule.defaultStandard) {
normTitle += ` ${rule.defaultStandard} `;
}
}
}
return normTitle;
}
// ==========================================
// 3. 基础文本处理工具 (Utilities)
// ==========================================
@@ -243,13 +321,13 @@ function getLanguageType(text) {
function cleanText(text) {
if (!text) return '';
let clean = simplized(text);
clean = clean.replace(RegexStore.Clean.NA_TAG, '');
clean = clean.replace(RegexStore.Season.PART_NORM, 'part $1');
clean = clean.replace(RegexStore.Season.PART_NORM_2, 'part $1');
clean = clean.replace(RegexStore.Season.FINAL, '最终季');
clean = clean.replace(RegexStore.Season.NORM, '第$1季');
const cnNums = {'一':'1', '二':'2', '三':'3', '四':'4', '五':'5', '六':'6', '七':'7', '八':'8', '九':'9', '十':'10'};
clean = clean.replace(RegexStore.Season.CN, (m, num) => `第${cnNums[num]}季`);
clean = clean.replace(RegexStore.Season.ROMAN, (match, p1, roman, p2) => {
@@ -264,19 +342,20 @@ function cleanText(text) {
clean = clean.replace(/(\d+)\.(\d+)/g, '$1{{DOT}}$2');
clean = clean.replace(RegexStore.Clean.PUNCTUATION, ' ');
clean = clean.replace(/{{DOT}}/g, '.');
return clean.replace(RegexStore.Clean.WHITESPACE, ' ').toLowerCase().trim();
}
/**
* 相似度计算专用极简清洗
* 强力移除所有季数、Part、括号内容、语言标识,只保留核心标题
* 强力移除所有季数、Part、括号内容、语言标识、维数标识等,只保留核心标题
* @param {string} text - 原始标题
* @returns {string} - 用于相似度计算的极简标题
*/
function cleanTitleForSimilarity(text) {
if (!text) return '';
let clean = simplized(text);
let clean = normalizeTitleForEngine(text);
clean = simplized(clean);
// 标题保护逻辑:如果标题被包裹在【】或[]中且位于开头,尝试提取内容
const startBracketMatch = clean.match(/^(?:【|\[)(.+?)(?:】|\])/);
if (startBracketMatch) {
@@ -432,12 +511,12 @@ function calculateSimilarity(str1, str2) {
if (s1 === s2) return 1.0;
const len1 = s1.length, len2 = s2.length;
const maxLen = Math.max(len1, len2), minLen = Math.min(len1, len2);
if (s1.includes(s2) || s2.includes(s1)) {
const lenRatio = minLen / maxLen;
if (lenRatio > 0.5) return 0.8 + (lenRatio * 0.2);
}
const distance = editDistance(s1, s2);
const editScore = maxLen === 0 ? 1.0 : 1.0 - (distance / maxLen);
const set1 = new Set(s1.replace(RegexStore.Clean.WHITESPACE, ''));
@@ -521,6 +600,7 @@ function checkTitleSubtitleConflict(titleA, titleB, isDateValid = true) {
* @returns {Set<string>}
*/
function extractSeasonMarkers(title, typeDesc = '') {
title = normalizeTitleForEngine(title);
const markers = new Set();
const t = cleanText(title);
const type = cleanText(typeDesc || '');
@@ -583,6 +663,7 @@ function extractSeasonMarkers(title, typeDesc = '') {
* @returns {number|null}
*/
function getSeasonNumber(title, typeDesc = '') {
title = normalizeTitleForEngine(title);
const markers = extractSeasonMarkers(title, typeDesc);
let maxSeason = null;
for (const m of markers) {
@@ -657,13 +738,38 @@ function checkTheatricalExemption(titleA, titleB, typeDescA, typeDescB) {
}
/**
* 校验媒体类型是否冲突 (真人 vs 动漫, TV vs Movie)
* 校验媒体类型是否冲突 (真人 vs 动漫, TV vs Movie, 3D vs 2D)
* @returns {boolean} true=冲突, false=兼容
*/
function checkMediaTypeMismatch(titleA, titleB, typeDescA, typeDescB, countA, countB, sourceA = '', sourceB = '') {
const catA = getContentCategory(titleA, typeDescA, sourceA);
const catB = getContentCategory(titleB, typeDescB, sourceB);
if ((catA === 'REAL' && catB === 'ANIME') || (catA === 'ANIME' && catB === 'REAL')) return true;
// 提取明确的维数属性
let is3DA = (typeDescA || '').includes('3D');
let is3DB = (typeDescB || '').includes('3D');
let is2DA = (typeDescA || '').includes('2D');
let is2DB = (typeDescB || '').includes('2D');
// 维数动态探测逻辑 (Dynamic Dimension Detection):
// 如果有一方在类型中明确是 3D,则被授权从另一方的标题中主动探测 3D 关键词
if (is3DA && !is3DB) {
if (/3[dD]/.test(titleB)) is3DB = true;
} else if (is3DB && !is3DA) {
if (/3[dD]/.test(titleA)) is3DA = true;
}
// 同样适用 2D 的动态探测:明确有 2D 标签时,才去对方标题里找 2D
if (is2DA && !is2DB) {
if (/2[dD]/.test(titleB)) is2DB = true;
} else if (is2DB && !is2DA) {
if (/2[dD]/.test(titleA)) is2DA = true;
}
// 检测 3D 与 2D 的维数属性冲突:任意一方带 3D 且另一方不带 3D(即视为 2D)则判定为冲突
if (is3DA !== is3DB) return true;
const mediaA = getStrictMediaType(titleA, typeDescA);
const mediaB = getStrictMediaType(titleB, typeDescB);
if (!mediaA || !mediaB || mediaA === mediaB) return false;
@@ -912,7 +1018,7 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
const isSecDub = !!(secTitleForSim.match(RegexStore.Lang.CN_DUB_VER)) || RegexStore.Lang.CN.test(secTitleForSim);
const isDubRelation = isPrimaryDub || isSecDub;
const secCount = secAnime.episodeCount || (secAnime.links ? secAnime.links.length : 0);
// 之字结构强阻断
if (secTitleForSim.includes('之')) {
const parts = secTitleForSim.split('之');
@@ -926,7 +1032,7 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
if (checkMediaTypeMismatch(rawPrimaryTitle, rawSecTitle, primaryAnime.typeDescription, secAnime.typeDescription, primaryCount, secCount, primaryAnime.source, secAnime.source)) {
const pType = getContentCategory(rawPrimaryTitle, primaryAnime.typeDescription, primaryAnime.source);
const sType = getContentCategory(rawSecTitle, secAnime.typeDescription, secAnime.source);
logReason(rawSecTitle, `媒体类型不匹配 (P:${pType}/${getStrictMediaType(rawPrimaryTitle, primaryAnime.typeDescription)} vs S:${sType}/${getStrictMediaType(rawSecTitle, secAnime.typeDescription)})`);
logReason(rawSecTitle, `媒体类型或维数不匹配 (P:${pType}/${getStrictMediaType(rawPrimaryTitle, primaryAnime.typeDescription)} [${primaryAnime.typeDescription}] vs S:${sType}/${getStrictMediaType(rawSecTitle, secAnime.typeDescription)} [${secAnime.typeDescription}])`);
continue;
}
@@ -945,7 +1051,7 @@ export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnim
}
}
}
if (isPrimaryContextSequel) {
if (cleanTitleForSimilarity(secTitleForSim) === cleanTitleForSimilarity(primaryBaseTitleFromContext)) {
logReason(rawSecTitle, `上下文阻断: 主源(S2/Sequel) vs 副源(Base/S1) (Base: "${primaryBaseTitleFromContext}")`);
@@ -1141,7 +1247,7 @@ function extractEpisodeInfo(title, sourceName = '') {
*/
function filterEpisodes(links, filterRegex, sourceName = '') {
if (!links) return [];
// 特定源白名单:这些源的集标题通常比较规范,免除正则过滤拦截
const skipFilterSources = ['animeko', 'bilibili', 'bilibili1', 'bahamut', 'dandan'];
const shouldSkipFilter = skipFilterSources.includes(sourceName);
@@ -1149,7 +1255,7 @@ function filterEpisodes(links, filterRegex, sourceName = '') {
if (!filterRegex || shouldSkipFilter) {
return links.map((link, index) => ({ link, originalIndex: index }));
}
const validLinks = [];
const droppedTitles = [];
@@ -1330,7 +1436,7 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un
else sim = MergeWeights.EP_ALIGN.CN_STRICT_MISMATCH;
} else sim = calculateSimilarity(dataA.cleanEpText, dataB.cleanEpText);
} else sim = calculateSimilarity(dataA.cleanEpText, dataB.cleanEpText);
pairScore += sim;
rawTextScoreSum += sim;
if (infoA.num !== null && infoB.num !== null && infoA.num === infoB.num) pairScore += MergeWeights.EP_ALIGN.NUMERIC_MATCH;
@@ -1348,7 +1454,7 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un
let finalScore = totalTextScore / matchCount;
let maxFrequency = 0;
for (const count of numericDiffs.values()) maxFrequency = Math.max(maxFrequency, count);
const consistencyRatio = maxFrequency / matchCount;
const avgRawTextScore = rawTextScoreSum / matchCount;
@@ -1431,7 +1537,7 @@ function stitchUnmatchedEpisodes(derivedAnime, orphans, sourceName) {
break;
}
}
for (const item of orphans) {
const relativeIdx = item.relativeIndex;
const isStrictSpecial = item.info && item.info.isStrictSpecial;
@@ -1540,7 +1646,7 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
}
const realAnime = globals.animes.find(a => String(a.animeId) === String(anime.animeId)) || anime;
// 2. 类型过滤:严格剔除电影和真人剧
// 地图构建是为了给 TV 动画切片,电影(1集)和真人剧(集数不同)会严重干扰统计
const category = getContentCategory(realAnime.animeTitle, realAnime.typeDescription, realAnime.source);
@@ -1548,7 +1654,7 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
debugLogs.push(` [剔除] [${realAnime.source}] ${realAnime.animeTitle} (类型: 真人剧/REAL)`);
continue;
}
const mediaType = getStrictMediaType(realAnime.animeTitle, realAnime.typeDescription);
if (mediaType === 'MOVIE') {
debugLogs.push(` [剔除] [${realAnime.source}] ${realAnime.animeTitle} (类型: 电影/MOVIE)`);
@@ -1561,7 +1667,7 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
const title = item.link.title || item.link.name || "";
const cleanT = cleanText(title);
const rawTemp = cleanT.replace(RegexStore.Clean.SOURCE_TAG, '').replace(RegexStore.Clean.FROM_SUFFIX, '').trim();
// 特定源的番外过滤逻辑
if (/^(dandan|animeko)$/i.test(realAnime.source)) {
if (RegexStore.Episode.SPECIAL_CHECK.test(rawTemp) || RegexStore.Episode.DANDAN_IGNORE.test(rawTemp)) return false;
@@ -1574,10 +1680,10 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
if (count > 0) {
if (!seasonStats.has(seasonNum)) seasonStats.set(seasonNum, new Map());
const freqMap = seasonStats.get(seasonNum);
if (!freqMap.has(count)) freqMap.set(count, []);
freqMap.get(count).push(realAnime.source);
debugLogs.push(` [采纳] [${realAnime.source}] S${seasonNum} = ${count}集 ("${realAnime.animeTitle}")`);
} else {
debugLogs.push(` [忽略] [${realAnime.source}] S${seasonNum} (有效集数为 0)`);
@@ -1615,9 +1721,9 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) {
}
}
}
seasonMap.set(sNum, modeCount);
// 构建详细的统计日志字符串
const statDetails = Array.from(freqMap.entries())
.map(([cnt, srcs]) => `${cnt}集(x${srcs.length})[${srcs.join(',')}]`)
@@ -1654,7 +1760,7 @@ function fastCloneAnime(anime) {
/**
* 执行单个主源的合并任务
* 包含:寻找匹配、ID生成、链接映射、集数补全
* 包含:寻找匹配、ID生成、链接映射、集数补全、跨源合集时序接管
* @param {Object} params - 任务参数对象
* @returns {Promise<Anime|null>} 合并后的新对象或 null
*/
@@ -1679,14 +1785,14 @@ async function processMergeTask(params) {
const logTitleA = pAnime.animeTitle.replace(RegexStore.Clean.FROM_SUFFIX, '');
let derivedAnime = fastCloneAnime(cachedPAnime);
const actualMergedSources = [];
const contentSignatureParts = [pAnime.animeId];
let hasMergedAny = false;
const seriesLangA = getLanguageType(pAnime.animeTitle);
const redundantP = identifyRedundantTitle(derivedAnime.links, pAnime.animeTitle, currentPrimarySource);
const getTempTitle = (rawTitle, redundantStr) => {
if (!rawTitle) return "";
if (redundantStr && rawTitle.includes(redundantStr)) return rawTitle.replace(redundantStr, '');
@@ -1697,7 +1803,7 @@ async function processMergeTask(params) {
const pCleanTitle = cleanTitleForSimilarity(pAnime.animeTitle);
const peerAnimes = curAnimes.filter(a => cleanTitleForSimilarity(a.animeTitle) === pCleanTitle);
const seasonLengthMap = buildSeasonLengthMap(peerAnimes, epFilter, collectionAnimeIds);
if (seasonLengthMap.size > 0) {
const mapDesc = Array.from(seasonLengthMap.entries()).map(([k,v]) => `S${k}=${v}`).join(', ');
if (isPrimaryCollection || availableSecondaries.some(s => curAnimes.some(a => a.source === s && collectionAnimeIds.has(a.animeId)))) {
@@ -1705,10 +1811,13 @@ async function processMergeTask(params) {
}
}
// 隔离检索,汇总排序池:避免不同源之间因微小相似度差异在 findSecondaryMatches 中发生内卷排挤
let allMatches = [];
for (const secSource of availableSecondaries) {
let secondaryItems = curAnimes.filter(a => {
if (a.source !== secSource) return false;
// 合集主源特权:当主源为合集时,允许无视当前组的消耗状态,复用已被消耗的副源以拼凑完整季度
if (isPrimaryCollection) return true;
@@ -1717,7 +1826,7 @@ async function processMergeTask(params) {
if (isConsumed && !isAllowedReuse) return false;
return true;
});
if (limitSecondaryLang) secondaryItems = secondaryItems.filter(a => getLanguageType(a.animeTitle) === limitSecondaryLang);
if (secondaryItems.length > 1) {
@@ -1730,9 +1839,31 @@ async function processMergeTask(params) {
}
if (secondaryItems.length === 0) continue;
const matches = findSecondaryMatches(pAnime, secondaryItems, collectionAnimeIds);
for (const match of matches) {
// 逐源查找匹配,确保每个源的候选者在自身赛道内出线
const matchesForSource = findSecondaryMatches(pAnime, secondaryItems, collectionAnimeIds);
allMatches.push(...matchesForSource);
}
if (allMatches.length > 0) {
// 跨源合集时序接管:汇集所有有效匹配后,强制按季数升序排列全局匹配队列
if (allMatches.length > 1 && (isPrimaryCollection || allMatches.some(m => collectionAnimeIds.has(m.animeId)))) {
allMatches.sort((a, b) => {
const sA = getSeasonNumber(a.animeTitle, a.typeDescription) || 1;
const sB = getSeasonNumber(b.animeTitle, b.typeDescription) || 1;
if (sA !== sB) return sA - sB; // 季数优先
// 季数相同时,遵循用户配置的源优先级
const idxA = availableSecondaries.indexOf(a.source);
const idxB = availableSecondaries.indexOf(b.source);
return idxA - idxB;
});
const seqLogs = allMatches.map(m => `[${m.source}]S${getSeasonNumber(m.animeTitle, m.typeDescription) || 1}`);
log("info", `${logPrefix} [合集时序] 已将跨源匹配队列按季数升序排列,保障切片推断严格自举: ${seqLogs.join(' -> ')}`);
}
for (const match of allMatches) {
const secSource = match.source;
// 合集主源特权:如果是合集主源,直接跳过此处的二次消耗校验
if (!isPrimaryCollection) {
const isReuse = allowReuseIds && allowReuseIds.has(match.animeId);
@@ -1750,9 +1881,9 @@ async function processMergeTask(params) {
const decimalsS = getDecimalEpisodes(derivedMatch.links, secSource);
const toSinkS = new Set([...decimalsS].filter(x => !decimalsP.has(x)));
const toSinkP = new Set([...decimalsP].filter(x => !decimalsS.has(x)));
if (toSinkP.size > 0) sinkDecimalEpisodes(derivedAnime.links, toSinkP, currentPrimarySource, `主源:${currentPrimarySource}`);
if (toSinkS.size > 0) {
sinkDecimalEpisodes(derivedMatch.links, toSinkS, secSource, `副源:${secSource}`);
}
@@ -1765,14 +1896,15 @@ async function processMergeTask(params) {
let activePLinks = filteredPLinksWithIndex, activeMLinks = filteredMLinksWithIndex;
let sliceStartP = 0, sliceStartS = 0;
const isSecondaryCollection = collectionAnimeIds.has(match.animeId);
const performSlicing = (isPrimarySide, collectionLinks, seasonNum) => {
let sliceStart = 0, slicedList = collectionLinks;
if (seasonNum && seasonNum > 1) {
// 1. 优先尝试历史推断
let historyFound = false;
if (!isPrimarySide && collectionProgress && collectionProgress.has(match.animeId)) {
const progress = collectionProgress.get(match.animeId);
const collectionIdToCheck = isPrimarySide ? pAnime.animeId : match.animeId;
if (collectionProgress && collectionProgress.has(collectionIdToCheck)) {
const progress = collectionProgress.get(collectionIdToCheck);
const prevSeason = seasonNum - 1;
if (progress[`S${prevSeason}`] !== undefined) {
const inferredStart = progress[`S${prevSeason}`] + 1;
@@ -1789,7 +1921,7 @@ async function processMergeTask(params) {
if (!historyFound) {
let accumulatedCount = 0;
for (let s = 1; s < seasonNum; s++) accumulatedCount += (seasonLengthMap.get(s) || 0);
// 移除 -2 缓冲。既然使用 Mode 策略得到了较准确的长度,直接信任该长度。
// 这样可以避免 S2 的第 1 集错误地回溯匹配到 S1 的最后几集。
let safeAccumulated = accumulatedCount;
@@ -1850,18 +1982,18 @@ async function processMergeTask(params) {
const orphanItem = { link: sourceLink, originalIndex: sourceLinkItem.originalIndex, relativeIndex: pIndex, info: null };
const cleanTitleS = getTempTitle(sourceLink.title, redundantS);
orphanItem.info = extractEpisodeInfo(cleanTitleS, secSource);
if (pIndex >= 0 && pIndex < filteredPLinksWithIndex.length) {
const originalPIndex = filteredPLinksWithIndex[pIndex].originalIndex;
const targetLink = derivedAnime.links[originalPIndex];
const pTitleShort = targetLink.name || targetLink.title || `Index ${originalPIndex}`;
const cleanTitleP = getTempTitle(targetLink.title, redundantP);
const specialP = getSpecialEpisodeType(cleanTitleP);
const specialS = getSpecialEpisodeType(cleanTitleS);
const infoP = extractEpisodeInfo(cleanTitleP, currentPrimarySource);
const infoS = orphanItem.info;
if (infoS.isPV && !specialP) {
mappingEntries.push({ idx: pIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (PV不匹配正片)` });
orphanedEpisodes.push(orphanItem);
@@ -1877,7 +2009,7 @@ async function processMergeTask(params) {
orphanedEpisodes.push(orphanItem);
continue;
}
const idB = sanitizeUrl(sourceLink.url);
let currentUrl = targetLink.url;
const secPart = `${secSource}:${idB}`;
@@ -1885,7 +2017,7 @@ async function processMergeTask(params) {
if (!currentUrl.startsWith(currentPrimarySource + ':')) currentUrl = `${currentPrimarySource}:${currentUrl}`;
}
const newMergedUrl = `${currentUrl}${MERGE_DELIMITER}${secPart}`;
let newMergedTitle = targetLink.title;
if (newMergedTitle) {
let sLabel = secSource;
@@ -1905,7 +2037,7 @@ async function processMergeTask(params) {
orphanedEpisodes.push(orphanItem);
}
}
for (let j = 0; j < filteredPLinksWithIndex.length; j++) {
if (!matchedPIndices.has(j)) {
const originalPIndex = filteredPLinksWithIndex[j].originalIndex;
@@ -1928,33 +2060,52 @@ async function processMergeTask(params) {
mappingEntries.sort((a, b) => a.idx - b.idx);
log("info", `${logPrefix} [${secSource}] 映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`);
}
// [进度自举] 成功合并后,更新历史进度
if (collectionProgress && isSecondaryCollection) {
// 支持双向进度写入:主源为合集与副源为合集的情况都被覆盖,为链式关联铺路
if (collectionProgress && (isSecondaryCollection || isPrimaryCollection)) {
let maxUsedIndex = -1;
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
const pIndex = k + offset;
if (matchedPIndices.has(pIndex)) {
const item = filteredMLinksWithIndex[k];
if (item.originalIndex > maxUsedIndex) maxUsedIndex = item.originalIndex;
}
}
if (maxUsedIndex !== -1) {
const pSeason = getSeasonNumber(pAnime.animeTitle, pAnime.typeDescription) || 1;
if (!collectionProgress.has(match.animeId)) collectionProgress.set(match.animeId, {});
const progress = collectionProgress.get(match.animeId);
// 只要有合并成功,就记录下来,为后续季度的关联提供基准
if (mergedCount >= 3) {
progress[`S${pSeason}`] = maxUsedIndex;
// log("info", `[Merge-Check] 更新合集进度 [${match.animeId}]: S${pSeason} -> MaxIndex ${maxUsedIndex}`);
if (isPrimaryCollection && !isSecondaryCollection) {
// 主源是合集,在主源索引中找最大落点
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
const pIndex = k + offset;
if (matchedPIndices.has(pIndex)) {
const originalPIndex = filteredPLinksWithIndex[pIndex].originalIndex;
if (originalPIndex > maxUsedIndex) maxUsedIndex = originalPIndex;
}
}
if (maxUsedIndex !== -1) {
const sSeason = getSeasonNumber(derivedMatch.animeTitle, derivedMatch.typeDescription) || 1;
if (!collectionProgress.has(pAnime.animeId)) collectionProgress.set(pAnime.animeId, {});
const progress = collectionProgress.get(pAnime.animeId);
if (mergedCount >= 3) {
progress[`S${sSeason}`] = maxUsedIndex;
}
}
} else if (!isPrimaryCollection && isSecondaryCollection) {
// 副源是合集,在副源索引中找最大落点
for (let k = 0; k < filteredMLinksWithIndex.length; k++) {
const pIndex = k + offset;
if (matchedPIndices.has(pIndex)) {
const item = filteredMLinksWithIndex[k];
if (item.originalIndex > maxUsedIndex) maxUsedIndex = item.originalIndex;
}
}
if (maxUsedIndex !== -1) {
const pSeason = getSeasonNumber(pAnime.animeTitle, pAnime.typeDescription) || 1;
if (!collectionProgress.has(match.animeId)) collectionProgress.set(match.animeId, {});
const progress = collectionProgress.get(match.animeId);
if (mergedCount >= 3) {
progress[`S${pSeason}`] = maxUsedIndex;
log("info", `[Merge-Check] 更新合集进度 [${match.animeId}]: S${pSeason} -> MaxIndex ${maxUsedIndex}`);
}
}
}
}
if (collectionAnimeIds.has(match.animeId)) log("info", `${logPrefix} [智能补全] 跳过: 副源 [${secSource}] 为合集,为避免混入其他季度集数,不执行补全。`);
else stitchUnmatchedEpisodes(derivedAnime, orphanedEpisodes, secSource);
const normals = [], sinkers = [];
derivedAnime.links.forEach(link => {
const rawContent = link.title.replace(RegexStore.Clean.SOURCE_TAG, '').trim();
@@ -1980,7 +2131,7 @@ async function processMergeTask(params) {
return null;
}
generatedSignatures.add(signature);
for (let i = 1; i < contentSignatureParts.length; i++) {
const secId = contentSignatureParts[i];
if (!collectionAnimeIds.has(secId)) {
@@ -2035,7 +2186,7 @@ function detectCollectionCandidates(curAnimes) {
.replace(RegexStore.Clean.YEAR_TAG, '')
.replace(RegexStore.Clean.META_SUFFIX, '')
.replace(RegexStore.Lang.KEYWORDS_STRONG, '');
SUFFIX_SPECIFIC_MAP.forEach(m => clean = clean.replace(m.regex, ''));
clean = clean.replace(RegexStore.Season.SUFFIX_AMBIGUOUS, '')
@@ -2065,7 +2216,7 @@ function detectCollectionCandidates(curAnimes) {
list.forEach(anime => {
const realAnime = globals.animes.find(a => String(a.animeId) === String(anime.animeId)) || anime;
const markers = extractSeasonMarkers(realAnime.animeTitle, realAnime.typeDescription);
let seasonNum = 1;
for (const m of markers) {
if (m.startsWith('S')) {
@@ -2095,7 +2246,6 @@ function detectCollectionCandidates(curAnimes) {
const t = cleanText(rawTitle);
if (RegexStore.Episode.SPECIAL_CHECK.test(t) || RegexStore.Episode.DANDAN_IGNORE.test(t)) return false;
if (RegexStore.Episode.MAP_EXCLUDE_KEYWORDS.test(rawContent) || RegexStore.Episode.MAP_EXCLUDE_KEYWORDS.test(rawTitle)) return false;
log("info", `[Merge-Check] [Detail-Ep] [${realAnime.source}] 保留: "${rawTitle}"`);
return true;
}).length;
} else validCount = realAnime.links.length;
@@ -2103,9 +2253,12 @@ function detectCollectionCandidates(curAnimes) {
if (!sourceStats.has(realAnime.source)) sourceStats.set(realAnime.source, { seasonCounts: {}, maxSeason: 0, s1Candidates: [] });
const stat = sourceStats.get(realAnime.source);
if (!stat.seasonCounts[seasonNum]) stat.seasonCounts[seasonNum] = 0;
stat.seasonCounts[seasonNum] += validCount;
// 取单季度的最大集数,避免多语言版本或相同季度的重复条目累加导致集数虚高
stat.seasonCounts[seasonNum] = Math.max(stat.seasonCounts[seasonNum], validCount);
if (seasonNum > stat.maxSeason) stat.maxSeason = seasonNum;
if (seasonNum === 1) stat.s1Candidates.push({ anime: realAnime, originalCount: validCount });
});
@@ -2131,28 +2284,22 @@ function detectCollectionCandidates(curAnimes) {
if (!hasOtherSources) continue;
const currentS1Total = stat.seasonCounts[1];
const threshold = maxOtherS1 + Thresholds.COLLECTION_DIFF;
const ratio = maxOtherS1 > 0 ? (currentS1Total / maxOtherS1) : 0;
if (currentS1Total > threshold) {
if (ratio > Thresholds.COLLECTION_RATIO) {
log("info", `[Merge-Check] [合集探测] 拒绝: [${source}] ${baseTitle} (Ratio too high: ${ratio.toFixed(2)} > ${Thresholds.COLLECTION_RATIO})`);
continue;
}
const giants = stat.s1Candidates.filter(c => c.originalCount > threshold);
if (giants.length > 0) {
giants.forEach(cand => {
collectionIds.add(cand.anime.animeId);
log("info", `[Merge-Check] [合集探测] 发现疑似合集(单体): [${source}] ${cand.anime.animeTitle} (Count:${cand.originalCount} > Thr:${threshold}, Ratio:${ratio.toFixed(2)}) -> 标记为合集`);
});
// 独立评估每个候选条目,确认为大体积合集才进行标记,杜绝聚合误判
stat.s1Candidates.forEach(cand => {
const ratio = maxOtherS1 > 0 ? (cand.originalCount / maxOtherS1) : 0;
if (cand.originalCount > threshold) {
if (ratio > Thresholds.COLLECTION_RATIO) {
log("info", `[Merge-Check] [合集探测] 拒绝: [${source}] ${cand.anime.animeTitle} (Ratio too high: ${ratio.toFixed(2)} > ${Thresholds.COLLECTION_RATIO})`);
return;
}
collectionIds.add(cand.anime.animeId);
log("info", `[Merge-Check] [合集探测] 发现疑似合集(单体): [${source}] ${cand.anime.animeTitle} (Count:${cand.originalCount} > Thr:${threshold}, Ratio:${ratio.toFixed(2)}) -> 标记为合集`);
} else {
stat.s1Candidates.forEach(cand => {
collectionIds.add(cand.anime.animeId);
log("info", `[Merge-Check] [合集探测] 发现疑似合集(聚合): [${source}] ${cand.anime.animeTitle} (TotalS1:${currentS1Total} > Thr:${threshold}, Ratio:${ratio.toFixed(2)}) -> 标记为合集`);
});
log("info", `[Merge-Check] [合集探测] 未命中: [${source}] ${cand.anime.animeTitle} (Count:${cand.originalCount} <= Threshold:${threshold})`);
}
} else log("info", `[Merge-Check] [合集探测] 未命中: [${source}] ${baseTitle} (TotalS1:${currentS1Total} <= Threshold:${threshold})`);
});
}
}
return collectionIds;
@@ -2175,7 +2322,7 @@ function detectCollectionCandidates(curAnimes) {
* @param {Array<Anime>} curAnimes - 待处理的番剧列表(将被原地修改)
* @returns {Promise<void>}
*/
export async function applyMergeLogic(curAnimes, detailStore = null) {
export async function applyMergeLogic(curAnimes, detailStore = null) {
const groups = globals.mergeSourcePairs;
if (!groups || groups.length === 0) return;
@@ -2188,7 +2335,7 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
// 1. 合集探测 (前置计算)
const collectionAnimeIds = detectCollectionCandidates(curAnimes);
// 用于记录合集的使用进度,辅助切片推理 (Map<animeId, { S1: 10, S2: 24 }>)
// 此进度对象在所有 Phase 间共享,确保 Phase 1 产生的进度能被 Phase 2 利用
const collectionProgress = new Map();
@@ -2201,20 +2348,20 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
for (const group of groups) {
if (group.secondaries.length === 0) continue;
// 构建全局优先级地图,用于排序 (Primary=0, Sec1=1, Sec2=2...)
// 优先级数值越小,代表在配置文件中越靠前,优先级越高
const sourcePriorityMap = new Map();
const fullPriorityList = [group.primary, ...group.secondaries];
fullPriorityList.forEach((src, idx) => sourcePriorityMap.set(src, idx));
const groupFingerprint = fullPriorityList.join('&');
const groupConsumedIds = new Set();
// 通用排序函数:源优先级 ASC > 媒体类型 (TV>Movie) > 季度编号 ASC (S1->S2)
const sortCandidates = (list, phaseName) => {
if (!list || list.length < 2) return list;
log("info", `[Merge-Check] [Sort] ${phaseName} 排序前首个元素: ${list[0].animeTitle}`);
list.sort((a, b) => {
@@ -2230,11 +2377,11 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
const markers = extractSeasonMarkers(anime.animeTitle, anime.typeDescription);
if (markers.has('MOVIE')) return 2;
if (markers.has('OVA') || markers.has('SP')) return 2;
// 补充检查:防止 extractSeasonMarkers 漏网,使用严格类型判断
const strictType = getStrictMediaType(anime.animeTitle, anime.typeDescription);
if (strictType === 'MOVIE') return 2;
return 1; // 默认为 TV 正片季度
};
@@ -2253,7 +2400,7 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
const sNum = getSeasonNumber(a.animeTitle, a.typeDescription) || 1;
const typeLabel = (extractSeasonMarkers(a.animeTitle, a.typeDescription).has('MOVIE') || getStrictMediaType(a.animeTitle, a.typeDescription) === 'MOVIE') ? 'Movie' : `S${sNum}`;
const pLevel = sourcePriorityMap.get(a.source) ?? '?';
return `[P${pLevel}] [${typeLabel}] [${a.source}] ${a.animeTitle}`;
});
log("info", `[Merge-Check] [Sort] ${phaseName} 执行顺序:\n ${debugOrder.join('\n ')}`);
@@ -2322,7 +2469,7 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
const currentPriorityIdx = sourcePriorityMap.get(tAnime.source);
const availableSecondaries = fullPriorityList.slice(currentPriorityIdx + 1);
if (availableSecondaries.length === 0) continue;
const resultAnime = await processMergeTask({
@@ -2391,7 +2538,7 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
// 将所有合法衍生出来的合并对象推入主列表
if (newMergedAnimes.length > 0) {
for (const anime of newMergedAnimes) addAnime(anime, detailStore);
for (const anime of newMergedAnimes) addAnime(anime, detailStore);
curAnimes.unshift(...newMergedAnimes);
}
@@ -2401,13 +2548,13 @@ export async function applyMergeLogic(curAnimes, detailStore = null) {
if (globalConsumedIds.has(anime.animeId) && keepSources.has(anime.source)) globalConsumedIds.delete(anime.animeId);
}
}
// 最终清理:移除已被任意一组所消费的单源原始资源
for (let i = curAnimes.length - 1; i >= 0; i--) {
const item = curAnimes[i];
if (item._isMerged || globalConsumedIds.has(item.animeId)) curAnimes.splice(i, 1);
}
log("info", `[Merge] 合并执行完毕,最终列表数量: ${curAnimes.length}`);
}
@@ -2456,13 +2603,13 @@ export function alignSourceTimelines(results, sourceNames, realIds, dandanShifts
}
return targetStr.replace(/^https?:\/\/(www\.)?/, '').split('?')[0];
};
// 遍历其他来源进行对齐
for (let idx = 0; idx < results.length; idx++) {
const sourceName = sourceNames[idx];
const realId = realIds[idx];
const list = results[idx];
if (sourceName === 'dandan' || !Array.isArray(list) || list.length === 0) {
continue;
}
@@ -2486,13 +2633,13 @@ export function alignSourceTimelines(results, sourceNames, realIds, dandanShifts
// 仅根据 dandan related 接口提供的精确偏移量进行对齐,未命中则跳过
if (appliedShift !== undefined) {
log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 应用 dandan API 精确偏移量 ${appliedShift}s`);
for (let i = 0; i < list.length; i++) {
const danmu = list[i];
const time = getDanmuTime(danmu);
// 原本 time 是基准,shift表示加上该偏差到达 Dandan 时间轴
const targetTime = Math.max(0, time + appliedShift);
// 替换位移
if (danmu.p && typeof danmu.p === 'string') {
const firstComma = danmu.p.indexOf(',');
@@ -2511,7 +2658,7 @@ export function alignSourceTimelines(results, sourceNames, realIds, dandanShifts
log("info", `[Merge][AlignTimeline] ${sourceName}:${realId} 未找到相关 API 偏移量数据,跳过时间轴对齐`);
}
}
// 直接返回修改后的原始数组,不再产生深拷贝开销
return results;
}