diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index 654941c..86ce657 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -21,7 +21,7 @@ export default class BahamutSource extends BaseSource { const traditionalizedKeyword = traditionalized(keyword); const tmdbSearchKeyword = keyword; const encodedKeyword = encodeURIComponent(traditionalizedKeyword); - + log("info", `[Bahamut] 原始搜索词: ${keyword}`); log("info", `[Bahamut] 巴哈使用搜索词: ${traditionalizedKeyword}`); @@ -33,7 +33,7 @@ export default class BahamutSource extends BaseSource { try { const targetUrl = `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedKeyword}`; const url = globals.makeProxyUrl(targetUrl); - + const originalResp = await httpGet(url, { headers: { "Content-Type": "application/json", @@ -60,7 +60,7 @@ export default class BahamutSource extends BaseSource { log("info", `[Bahamut] 返回 ${anime.length} 条结果 (source: original)`); return { success: true, data: anime, source: 'original' }; } - + log("info", `[Bahamut] 原始搜索成功,但未返回任何结果 (source: original)`); return { success: false, source: 'original' }; } catch (error) { @@ -79,7 +79,7 @@ export default class BahamutSource extends BaseSource { try { // 延迟100毫秒,避免与原始搜索争抢同一连接池 await new Promise(resolve => setTimeout(resolve, 100)); - + // 获取 TMDB 日语原名及中文别名 (解构返回值) const tmdbResult = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal, "Bahamut"); @@ -96,7 +96,7 @@ export default class BahamutSource extends BaseSource { const encodedTmdbTitle = encodeURIComponent(tmdbTitle); const targetUrl = `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}`; const tmdbSearchUrl = globals.makeProxyUrl(targetUrl); - + const tmdbResp = await httpGet(tmdbSearchUrl, { headers: { "Content-Type": "application/json", @@ -202,6 +202,9 @@ export default class BahamutSource extends BaseSource { async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { const tmpAnimes = []; + // 使用正则判断原始搜索词是否包含日文平假名或片假名 + const isJapaneseKeyword = /[\u3040-\u309F\u30A0-\u30FF]/.test(queryTitle); + queryTitle = traditionalized(queryTitle); // 巴哈姆特搜索辅助函数 @@ -272,9 +275,9 @@ export default class BahamutSource extends BaseSource { const itemTitle = item.title || ""; const usedSearchTitle = item._searchUsedTitle || item._originalQuery || ""; - // 如果有 _searchUsedTitle 字段(表示是TMDB搜索结果),则跳过标题匹配,直接保留 - if (item._searchUsedTitle && item._searchUsedTitle !== queryTitle) { - log("info", `[Bahamut] TMDB结果直接保留: ${itemTitle}`); + // 如果搜索词是日语,或者该结果是基于TMDB转换得来的,则直接跳过匹配规则放行 + if (isJapaneseKeyword || (item._searchUsedTitle && item._searchUsedTitle !== queryTitle)) { + log("info", `[Bahamut] 命中日语关键词或TMDB结果,绕过匹配规则直接保留: ${itemTitle}`); return true; } @@ -318,7 +321,7 @@ export default class BahamutSource extends BaseSource { if (links.length > 0) { let yearMatch = (anime.info || "").match(/(\d{4})/); - + // 优先使用tmdb智能标题替换的标题,否则简转繁处理原标题 const displayTitle = anime._displayTitle || simplized(anime.title); @@ -332,7 +335,7 @@ export default class BahamutSource extends BaseSource { let itemType = "动漫"; // 默认类型 // 从 epData 中获取完整标题 (优先使用 anime.title) const fullTitle = (epData.anime && epData.anime.title) || (detail && detail.title) || ""; - + if (fullTitle.includes("[電影]")) { itemType = "剧场版"; } else if (fullTitle.includes("[特別篇]")) { diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index 013abf9..444a83b 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -189,6 +189,7 @@ export default class DandanSource extends BaseSource { "Content-Type": "application/json", "User-Agent": DandanUserAgent, }, + retries: 1, }); // 判断 resp 和 resp.data 是否存在 diff --git a/danmu_api/sources/vod.js b/danmu_api/sources/vod.js index 50eac82..8e0ca3a 100644 --- a/danmu_api/sources/vod.js +++ b/danmu_api/sources/vod.js @@ -4,7 +4,7 @@ import { log } from "../utils/log-util.js"; import { httpGet } from "../utils/http-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; -import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { printFirst200Chars, titleMatches, sanitizeSearchKeyword } from "../utils/common-util.js"; // ===================== // 获取vod源播放链接 @@ -13,8 +13,12 @@ export default class VodSource extends BaseSource { // 查询vod站点影片信息 async getVodAnimes(title, server, serverName) { try { + // 调用common-util简单净化搜索关键词 + const safeTitle = sanitizeSearchKeyword(title); + const encodedTitle = encodeURIComponent(safeTitle); + const response = await httpGet( - `${server}/api.php/provide/vod/?ac=detail&wd=${title}&pg=1`, + `${server}/api.php/provide/vod/?ac=detail&wd=${encodedTitle}&pg=1`, { headers: { "Content-Type": "application/json", @@ -212,4 +216,4 @@ export default class VodSource extends BaseSource { async getEpisodeDanmu(id) {} formatComments(comments) {} -} \ No newline at end of file +} diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index c134926..f270c69 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -192,14 +192,25 @@ export function createDynamicPlatformOrder(preferredPlatform) { } /** - * 规范化标题(移除空格并清理修饰性符号) + * 净化搜索关键词(专门针对请求源阶段的温和版) + * @param {string} str - 原始搜索词 + * @returns {string} 净化后的搜索词 + */ +export function sanitizeSearchKeyword(str) { + if (!str) return ''; + // 仅移除零宽字符、BOM等肉眼不可见的“幽灵字符”,保留空格和合法标点,确保源站搜索的命中率。 + return String(str).replace(/[\u200B-\u200F\uFEFF]/g, '').trim(); +} + +/** + * 规范化结果标题(移除空格并清理修饰性符号) * @param {string} str - 输入字符串 * @returns {string} 规范化后的字符串 */ export function normalizeSpaces(str) { if (!str) return ''; - // 移除所有空格与修饰性符号(包括多个连续空格、制表符等) - return String(str).trim().replace(/[\s【】\[\]《》<>「」!?!?.,,。~~]/g, ''); + // 白名单模式:非白名单中的字符全部清理 + return String(str).replace(/[^\u4e00-\u9fa5\u3400-\u4DBF\u{20000}-\u{2EE5F}\u{30000}-\u{323AF}\u3040-\u30ff\uFF65-\uFF9F\uAC00-\uD7AFa-zA-Z0-9\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u2160-\u217F\u0400-\u04FF\u00C0-\u024F\u0370-\u03FF]/gu, ''); } /** diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 3c8872d..672084c 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -1389,8 +1389,8 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un let bestOffset = 0, maxScore = -9999; let minNormalA = null, minNormalB = null; - pInfos.forEach(({info}) => { if (info.num !== null && !info.isSpecial) minNormalA = minNormalA === null ? info.num : Math.min(minNormalA, info.num); }); - sInfos.forEach(({info}) => { if (info.num !== null && !info.isSpecial) minNormalB = minNormalB === null ? info.num : Math.min(minNormalB, info.num); }); + pInfos.forEach(({info}) => { if (info.num !== null && !info.isSpecial && info.num % 1 === 0) minNormalA = minNormalA === null ? info.num : Math.min(minNormalA, info.num); }); + sInfos.forEach(({info}) => { if (info.num !== null && !info.isSpecial && info.num % 1 === 0) minNormalB = minNormalB === null ? info.num : Math.min(minNormalB, info.num); }); const seasonShift = (minNormalA !== null && minNormalB !== null) ? (minNormalA - minNormalB) : null; const baseRange = 15; const targetShift = (seasonShift !== null) ? -seasonShift : 0; @@ -1527,8 +1527,7 @@ function stitchUnmatchedEpisodes(derivedAnime, orphans, sourceName) { const headList = [], tailList = [], specialList = []; const currentLen = derivedAnime.links.length; - // [逻辑关键点] 确定主源“正片”的有效边界。 - // 用于防止副源的后续正片被错误插入到主源尾部的番外(SP/OVA)中间。 + // [逻辑关键点] 确定主源“正片”的有效边界,用于防止副源的后续正片被错误插入到主源尾部的番外(SP/OVA)中间。 let lastPrimaryMainIndex = -1; for (let i = currentLen - 1; i >= 0; i--) { const link = derivedAnime.links[i]; @@ -1711,12 +1710,6 @@ function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) { modeCount = count; contributors = sources; } else if (freq === maxFreq) { - // 频率相同时的仲裁逻辑: - // 1. 优先取集数较大的 (假设少的是缺集) - // 但是,为了防止包含下一季首集的情况,之前是取小的。 - // 现在有了严格过滤,取较大值通常更安全(代表完整季度)。 - // 不过为了保守起见,如果两者相差不大(<=2),取大的;相差巨大,取小的。 - // 此处维持原逻辑:取较小值,避免合集切片越界。 if (count < modeCount) { modeCount = count; contributors = sources; @@ -1762,7 +1755,7 @@ function fastCloneAnime(anime) { /** * 执行单个主源的合并任务 - * 包含:寻找匹配、ID生成、链接映射、集数补全、跨源合集时序接管 + * 包含:寻找匹配、ID生成、链接映射、集数补全、跨源合集时序接管、共识差精准对齐、番外专项制导 * @param {Object} params - 任务参数对象 * @returns {Promise} 合并后的新对象或 null */ @@ -1973,65 +1966,86 @@ async function processMergeTask(params) { let mergedCount = 0; const redundantS = identifyRedundantTitle(derivedMatch.links, derivedMatch.animeTitle, secSource); - // 提取当前的共识集数差 (Consensus Shift) - let consensusShift = null; - const shiftCounts = new Map(); - for (let k = 0; k < filteredMLinksWithIndex.length; k++) { - const pIdx = k + offset; - if (pIdx >= 0 && pIdx < filteredPLinksWithIndex.length) { - const pLinkItem = filteredPLinksWithIndex[pIdx]; - const sLinkItem = filteredMLinksWithIndex[k]; - const infoP = extractEpisodeInfo(getTempTitle(pLinkItem.link.title || pLinkItem.link.name, redundantP), currentPrimarySource); - const infoS = extractEpisodeInfo(getTempTitle(sLinkItem.link.title || sLinkItem.link.name, redundantS), secSource); - if (infoP.num !== null && infoS.num !== null && !infoP.isSpecial && !infoS.isSpecial) { - const diff = infoP.num - infoS.num; - shiftCounts.set(diff, (shiftCounts.get(diff) || 0) + 1); - } - } - } - let maxShiftCount = 0; - for (const [diff, count] of shiftCounts.entries()) { - if (count > maxShiftCount) { - maxShiftCount = count; - consensusShift = diff; - } - } + // 智能对齐策略:共识差计算与番外制导 + const isBroadSpecial = (info) => info.isSpecial || info.isStrictSpecial || (info.num !== null && info.num % 1 !== 0); + // 1. 提取共识集数差 (Consensus Shift) + const shiftCounts = new Map(); + filteredMLinksWithIndex.forEach((sItem, k) => { + const pItem = filteredPLinksWithIndex[k + offset]; + if (!pItem) return; + + const infoP = extractEpisodeInfo(getTempTitle(pItem.link.title || pItem.link.name, redundantP), currentPrimarySource); + const infoS = extractEpisodeInfo(getTempTitle(sItem.link.title || sItem.link.name, redundantS), secSource); + + if (infoP.num !== null && infoS.num !== null && !infoP.isSpecial && !infoS.isSpecial) { + const diff = infoP.num - infoS.num; + shiftCounts.set(diff, (shiftCounts.get(diff) || 0) + 1); + } + }); + const consensusShift = shiftCounts.size > 0 + ? [...shiftCounts.entries()].reduce((max, curr) => curr[1] > max[1] ? curr : max)[0] + : null; + + // 2. 预处理主源的广义番外索引池 + const pSpecialIndices = filteredPLinksWithIndex.reduce((acc, pItem, i) => { + const info = extractEpisodeInfo(getTempTitle(pItem.link.title || pItem.link.name, redundantP), currentPrimarySource); + if (isBroadSpecial(info) && !info.isPV) acc.push(i); + return acc; + }, []); + let sSpecialCounter = 0; + + // 3. 执行智能映射 for (let k = 0; k < filteredMLinksWithIndex.length; k++) { let pIndex = k + offset; const sourceLinkItem = filteredMLinksWithIndex[k]; const sourceLink = sourceLinkItem.link; const sTitleShort = sourceLink.name || sourceLink.title || `Index ${k}`; - const orphanItem = { link: sourceLink, originalIndex: sourceLinkItem.originalIndex, relativeIndex: pIndex, info: null }; const cleanTitleS = getTempTitle(sourceLink.title || sourceLink.name, redundantS); - orphanItem.info = extractEpisodeInfo(cleanTitleS, secSource); + const infoS = extractEpisodeInfo(cleanTitleS, secSource); + const orphanItem = { link: sourceLink, originalIndex: sourceLinkItem.originalIndex, relativeIndex: pIndex, info: infoS }; + const broadSpecialS = isBroadSpecial(infoS); - // 智能数值对齐:破解源缺集导致的数组错位,并为落单集计算精确小数排序 - if (consensusShift !== null && orphanItem.info.num !== null && !orphanItem.info.isSpecial) { - const targetNum = orphanItem.info.num + consensusShift; - const exactMatchIndex = filteredPLinksWithIndex.findIndex(pItem => { - const pTitle = getTempTitle(pItem.link.title || pItem.link.name, redundantP); - const pInfo = extractEpisodeInfo(pTitle, currentPrimarySource); - return pInfo.num === targetNum && !pInfo.isSpecial; + if (consensusShift !== null && infoS.num !== null && !broadSpecialS) { + // [正片制导] 精确数值匹配 + const targetNum = infoS.num + consensusShift; + pIndex = filteredPLinksWithIndex.findIndex(pItem => { + const infoP = extractEpisodeInfo(getTempTitle(pItem.link.title || pItem.link.name, redundantP), currentPrimarySource); + return infoP.num === targetNum && !isBroadSpecial(infoP); }); - if (exactMatchIndex !== -1) { - pIndex = exactMatchIndex; + if (pIndex !== -1) { orphanItem.relativeIndex = pIndex; } else { - pIndex = -1; // 理论存在的集数在主源找不到,强制设为落单 let closestIdx = -0.5; - for (let i = 0; i < filteredPLinksWithIndex.length; i++) { - const pItem = filteredPLinksWithIndex[i]; - const pTitle = getTempTitle(pItem.link.title || pItem.link.name, redundantP); - const pInfo = extractEpisodeInfo(pTitle, currentPrimarySource); - if (pInfo.num !== null && !pInfo.isSpecial && pInfo.num < targetNum) { - closestIdx = i; + for (let i = filteredPLinksWithIndex.length - 1; i >= 0; i--) { + const infoP = extractEpisodeInfo(getTempTitle(filteredPLinksWithIndex[i].link.title || filteredPLinksWithIndex[i].link.name, redundantP), currentPrimarySource); + if (infoP.num !== null && !infoP.isSpecial && infoP.num < targetNum) { + closestIdx = i; break; } } orphanItem.relativeIndex = closestIdx + (k * 0.001) + 0.1; } + } else if (broadSpecialS) { + // [番外制导] 优先文本查重,其次顺序映射 + let bestPIdx = -1, bestSim = 0.65; + const cleanEpS = cleanEpisodeText(cleanTitleS); + + for (const pIdx of pSpecialIndices) { + const pTitle = getTempTitle(filteredPLinksWithIndex[pIdx].link.title || filteredPLinksWithIndex[pIdx].link.name, redundantP); + const infoP = extractEpisodeInfo(pTitle, currentPrimarySource); + if (infoS.isPV !== infoP.isPV) continue; // PV 与非 PV 不互通 + const sim = calculateSimilarity(cleanEpS, cleanEpisodeText(pTitle)); + if (sim > bestSim) { bestSim = sim; bestPIdx = pIdx; } + } + if (bestPIdx === -1 && !infoS.isPV && sSpecialCounter < pSpecialIndices.length) { + bestPIdx = pSpecialIndices[sSpecialCounter]; + } + if (!infoS.isPV) sSpecialCounter++; + + pIndex = bestPIdx; + orphanItem.relativeIndex = pIndex !== -1 ? pIndex : filteredPLinksWithIndex.length + (k * 0.001); } else { orphanItem.relativeIndex = pIndex !== -1 ? pIndex : (k + offset); } @@ -2045,20 +2059,27 @@ async function processMergeTask(params) { 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不匹配正片)` }); + mappingEntries.push({ idx: orphanItem.relativeIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (PV不匹配正片)` }); orphanedEpisodes.push(orphanItem); continue; } if (specialP !== specialS) { - mappingEntries.push({ idx: pIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (特殊集类型不匹配)` }); + mappingEntries.push({ idx: orphanItem.relativeIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (特殊集类型不匹配)` }); orphanedEpisodes.push(orphanItem); continue; } - if ((infoP.isStrictSpecial && !infoS.isSpecial) || (infoS.isStrictSpecial && !infoP.isSpecial)) { - mappingEntries.push({ idx: pIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (正片与番外阻断)` }); + + // 将严格特殊集与小数集视为“强番外属性” + const strictOrDecimalP = infoP.isStrictSpecial || (infoP.num !== null && infoP.num % 1 !== 0); + const strictOrDecimalS = infoS.isStrictSpecial || (infoS.num !== null && infoS.num % 1 !== 0); + // 纯正片必须既不带特殊标签,也不是小数集数 + const isRegularP = !infoP.isSpecial && (infoP.num === null || infoP.num % 1 === 0); + const isRegularS = !infoS.isSpecial && (infoS.num === null || infoS.num % 1 === 0); + + if ((strictOrDecimalP && isRegularS) || (strictOrDecimalS && isRegularP)) { + mappingEntries.push({ idx: orphanItem.relativeIndex, text: ` [略过] ${pTitleShort} =/= ${sTitleShort} (正片与番外阻断)` }); orphanedEpisodes.push(orphanItem); continue; } @@ -2081,7 +2102,7 @@ async function processMergeTask(params) { newMergedTitle = newMergedTitle.replace(/^【([^】]+)】/, (match, content) => `【${content}${DISPLAY_CONNECTOR}${sLabel}】`); } - mappingEntries.push({ idx: pIndex, text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}` }); + mappingEntries.push({ idx: orphanItem.relativeIndex, text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}` }); matchedPIndices.add(pIndex); mergedCount++; pendingMutations.push({ linkIndex: originalPIndex, newUrl: newMergedUrl, newTitle: newMergedTitle }); @@ -2365,10 +2386,6 @@ function detectCollectionCandidates(curAnimes) { /** * 应用番剧合并逻辑 (Main Entry Point) * 遍历所有合并组配置,执行多轮匹配与合并操作,直接修改传入的 curAnimes 数组 - * 采用分阶段执行策略,并在每阶段内部严格遵循: - * 优先级 1: 媒体类型 (TV正片 -> 电影/OVA/SP) - * 优先级 2: 季度顺序 (S1 -> S2) - * 优先级 3: 配置源优先级 * * Phase 1: CN Primary Isolation (主源CN优先隔离,仅匹配CN副源) * Phase 1.5: CN Secondary Self-Org (副源CN自组网) * Phase 2: Standard Fallback (标准回退匹配,匹配剩余所有资源) @@ -2448,7 +2465,6 @@ export async function applyMergeLogic(curAnimes, detailStore = null) { return sA - sB; }); - // 优化了调试日志:加入优先级展示,例如 [P0] [S2] [dandan] ... const debugOrder = list.map(a => { 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}`;