diff --git a/README.md b/README.md index 08b7690..df96ed7 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 ```regex # EPISODE_TITLE_FILTER 默认值 -(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘 +(特别|惊喜|纳凉)?企划(?!(书|案|部))|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|(? { + if (!str) return ""; + return simplized(str).toLowerCase().replace(/[\p{P}\p{S}\s]/gu, ""); + }; + + const normalizedKeyword = normalize(keyword); // 1. 基础相似度过滤 (获取所有潜在相关结果) const candidates = list.filter(item => { @@ -145,7 +155,8 @@ export default class AnimekoSource extends BaseSource { // 计算最高相似度得分 let maxScore = 0; for (const t of titles) { - const normalizedTitle = simplized(t).toLowerCase().trim(); + // 使用去符号后的文本进行比对 + const normalizedTitle = normalize(t); const score = this.calculateSimilarity(normalizedKeyword, normalizedTitle); if (score > maxScore) maxScore = score; } @@ -353,28 +364,72 @@ export default class AnimekoSource extends BaseSource { /** * 获取剧集列表 + * Bangumi API 限制单次 limit=200,需循环获取完整列表 * @param {number} subjectId 条目 ID * @returns {Promise} 剧集数组 */ async getEpisodes(subjectId) { - try { - const resp = await httpGet(`https://api.bgm.tv/v0/episodes?subject_id=${subjectId}`, { - headers: this.headers, - }); + let allEpisodes = []; + let offset = 0; + const limit = 200; - if (!resp || !resp.data) { - log("info", `[Animeko] Subject ${subjectId} 无剧集数据`); - return []; + try { + while (true) { + // 构造分页 URL + const url = `https://api.bgm.tv/v0/episodes?subject_id=${subjectId}&limit=${limit}&offset=${offset}`; + + const resp = await httpGet(url, { + headers: this.headers, + }); + + // 1. 结构校验:确保 resp.data.data 存在且为数组 + // 对应您的 JSON: resp.data 存在,resp.data.data 是 [] (数组),校验通过 + if (!resp || !resp.data || !Array.isArray(resp.data.data)) { + if (offset === 0) { + log("info", `[Animeko] Subject ${subjectId} 无剧集数据或响应异常`); + } + break; + } + + const currentBatch = resp.data.data; + + // 2. 空数据校验:如果没有数据,停止 + // 对应您的 JSON: data 为 [],length 为 0,在此处 break 退出 + if (currentBatch.length === 0) { + break; + } + + // 3. 合并数据 + allEpisodes = allEpisodes.concat(currentBatch); + + // 打印进度日志 + if (currentBatch.length === limit) { + log("info", `[Animeko] ID:${subjectId} 正加载更多剧集 (当前已获: ${allEpisodes.length})`); + } + + // 4. 判断是否还有下一页 + // 如果当前获取的数量少于限制数量 (例如获取了 2 个,而 limit 是 200),说明是最后一页 + if (currentBatch.length < limit) { + break; + } + + // 5. 准备下一页 + offset += limit; + + // 6. 安全熔断:防止API异常导致死循环 + if (offset > 1600) { + log("warn", `[Animeko] ID:${subjectId} 剧集数量超过安全限制(1600),停止翻页`); + break; + } } - const body = resp.data; - if (Array.isArray(body.data)) return body.data; - - return []; + return allEpisodes; + } catch (error) { log("error", "[Animeko] GetEpisodes error:", { message: error.message, - id: subjectId + id: subjectId, + offset: offset }); return []; } @@ -457,28 +512,68 @@ export default class AnimekoSource extends BaseSource { /** * 获取完整弹幕列表 - * 支持传入纯数字ID或完整URL + * 支持自动降级:Global -> CN * @param {string} episodeId 剧集 ID 或 完整 API URL * @returns {Promise} 弹幕数组 */ async getEpisodeDanmu(episodeId) { - try { - // 兼容分片请求传递过来的完整 URL - const url = episodeId.startsWith('http') - ? episodeId - : `https://danmaku-global.myani.org/v1/danmaku/${episodeId}`; - // 目前使用的服务器是全球区域的,备用大陆区域:https://danmaku-cn.myani.org - - const resp = await httpGet(url, { headers: this.headers }); - - if (!resp || !resp.data) return []; - const body = resp.data; - if (body.danmakuList) return body.danmakuList; - return []; - } catch (error) { - log("error", "[Animeko] GetDanmu error:", { message: error.message, url: episodeId }); + // 1. 提取真实 ID + // 兼容分片请求传递过来的完整 URL 或 纯 ID + let realId = String(episodeId).trim(); + + // 如果是完整 URL (包含 /),尝试提取最后一部分 + if (realId.includes('/')) { + const parts = realId.split('/'); + realId = parts[parts.length - 1]; + } + + // 去除可能存在的 URL 参数干扰 (例如 ?v=1) + if (realId.includes('?')) { + realId = realId.split('?')[0]; + } + + if (!realId) { + log("error", "[Animeko] 无效的 episodeId"); return []; } + + const HOST_GLOBAL = "https://danmaku-global.myani.org"; + const HOST_CN = "https://danmaku-cn.myani.org"; + + // 定义内部通用请求函数 + const fetchDanmu = async (hostUrl) => { + const targetUrl = `${hostUrl}/v1/danmaku/${realId}`; + try { + const resp = await httpGet(targetUrl, { headers: this.headers }); + + if (!resp || !resp.data) return null; + + const body = resp.data; + if (body.danmakuList) return body.danmakuList; + return null; + } catch (error) { + log("warn", `[Animeko] 请求节点失败: ${hostUrl} - ${error.message}`); + return null; + } + }; + + // 2. 优先尝试 Global 节点 + let danmuList = await fetchDanmu(HOST_GLOBAL); + + // 3. 如果失败,降级尝试 CN 节点 + if (!danmuList) { + log("info", `[Animeko] Global 节点获取失败/无数据,降级尝试 CN 节点... ID:${realId}`); + danmuList = await fetchDanmu(HOST_CN); + } + + // 4. 返回结果或空数组 + if (danmuList) { + log("info", `[Animeko] 成功获取弹幕,共 ${danmuList.length} 条`); + return danmuList; + } + + log("error", "[Animeko] 所有节点尝试均失败,无法获取弹幕"); + return []; } /** @@ -492,7 +587,7 @@ export default class AnimekoSource extends BaseSource { "type": "animeko", "segment_start": 0, "segment_end": 30000, - "url": `https://danmaku-global.myani.org/v1/danmaku/${id}` // 使用完整 URL + "url": String(id) }] }); } diff --git a/danmu_api/sources/bahamut.js b/danmu_api/sources/bahamut.js index 0eaa0bc..ffdd3aa 100644 --- a/danmu_api/sources/bahamut.js +++ b/danmu_api/sources/bahamut.js @@ -5,7 +5,7 @@ import { httpGet } from "../utils/http-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized, traditionalized } from "../utils/zh-util.js"; -import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js"; +import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js"; import { strictTitleMatch, normalizeSpaces } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; @@ -80,14 +80,18 @@ export default class BahamutSource extends BaseSource { // 延迟100毫秒,避免与原始搜索争抢同一连接池 await new Promise(resolve => setTimeout(resolve, 100)); - // 如果类型不符合会返回null,自然跳过后续搜索 - const tmdbTitle = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal, "Bahamut"); + // 获取 TMDB 日语原名及中文别名 (解构返回值) + const tmdbResult = await getTmdbJaOriginalTitle(tmdbSearchKeyword, tmdbAbortController.signal, "Bahamut"); - if (!tmdbTitle) { + // 如果没有结果或者没有标题,则停止 + if (!tmdbResult || !tmdbResult.title) { log("info", "[Bahamut] TMDB转换未返回结果,取消日语原名搜索"); return { success: false, source: 'tmdb' }; } + // 解构出日语原名和中文别名 + const { title: tmdbTitle, cnAlias } = tmdbResult; + log("info", `[Bahamut] 使用日语原名进行搜索: ${tmdbTitle}`); const encodedTmdbTitle = encodeURIComponent(tmdbTitle); const targetUrl = `https://api.gamer.com.tw/mobile_app/anime/v1/search.php?kw=${encodedTmdbTitle}`; @@ -107,6 +111,7 @@ export default class BahamutSource extends BaseSource { try { a._originalQuery = keyword; a._searchUsedTitle = tmdbTitle; + a._tmdbCnAlias = cnAlias; } catch (e) {} } log("info", `bahamutSearchresp (TMDB): ${JSON.stringify(anime)}`); @@ -276,6 +281,10 @@ export default class BahamutSource extends BaseSource { return bahamutTitleMatches(itemTitle, queryTitle, usedSearchTitle); }); + // 应用tmdb智能标题替换 + const cnAlias = filtered.length > 0 ? filtered[0]._tmdbCnAlias : null; + smartTitleReplace(filtered, cnAlias); + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 const processBahamutAnimes = await Promise.all(filtered.map(async (anime) => { try { @@ -304,10 +313,14 @@ 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); + let transformedAnime = { animeId: anime.video_sn, bangumiId: String(anime.video_sn), - animeTitle: `${simplized(anime.title)}(${(anime.info.match(/(\d{4})/) || [null])[0]})【动漫】from bahamut`, + animeTitle: `${displayTitle}(${(anime.info.match(/(\d{4})/) || [null])[0]})【动漫】from bahamut`, type: "动漫", typeDescription: "动漫", imageUrl: anime.cover, diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 160102e..0270b9f 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -8,7 +8,7 @@ import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; import { simplized } from "../utils/zh-util.js"; -import { getTmdbJaOriginalTitle } from "../utils/tmdb-util.js"; +import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js"; // ===================== // 获取b站弹幕 @@ -203,12 +203,22 @@ export default class BilibiliSource extends BaseSource { return entities[match] || match; }) .replace(/:/g, ':') + .trim(); + + // 清洗原标题 + const cleanedOrgTitle = (item.org_title || "") + .replace(/<[^>]+>/g, '') + .replace(/&[^;]+;/g, match => { + const entities = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" }; + return entities[match] || match; + }) .trim(); const resultItem = { provider: "bilibili", mediaId, title: cleanedTitle, + org_title: cleanedOrgTitle, type: mediaType, year, imageUrl: item.cover || null, @@ -424,9 +434,13 @@ export default class BilibiliSource extends BaseSource { return []; } + // 应用tmdb智能标题替换 + const cnAlias = sourceAnimes.length > 0 ? sourceAnimes[0]._tmdbCnAlias : null; + smartTitleReplace(sourceAnimes, cnAlias); + const processPromises = sourceAnimes // 港澳台资源不做严格标题匹配,因为可能搜到的是日语/繁体标题 - .filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle)) + .filter(anime => anime.isOversea || titleMatches(anime.title, queryTitle)||(anime.org_title && titleMatches(anime.org_title, queryTitle))) .map(async (anime) => { try { let links = []; @@ -493,10 +507,14 @@ export default class BilibiliSource extends BaseSource { if (links.length === 0) return; const numericAnimeId = convertToAsciiSum(anime.mediaId); + + // 优先使用tmdb智能标题替换的标题,否则对原标题进行繁转简处理 + const displayTitle = anime._displayTitle || simplized(anime.title); + const transformedAnime = { animeId: numericAnimeId, bangumiId: anime.mediaId, - animeTitle: `${anime.title}(${anime.year || 'N/A'})【${anime.type}】from bilibili`, + animeTitle: `${displayTitle}(${anime.year || 'N/A'})【${anime.type}】from bilibili`, type: anime.type, typeDescription: anime.type, imageUrl: anime.imageUrl, @@ -936,8 +954,37 @@ export default class BilibiliSource extends BaseSource { // 综合港澳台搜索入口 async _searchOversea(keyword) { const tmdbAbortController = new AbortController(); - const t1 = this._searchOverseaRequest(keyword, "Original").then(r => { if(r.length) tmdbAbortController.abort(); return r; }).catch(()=>[]); - const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(()=>getTmdbJaOriginalTitle(keyword, tmdbAbortController.signal, "Bilibili")).then(t => (t&&t!==keyword)?this._searchOverseaRequest(t,"TMDB",tmdbAbortController.signal):[]).catch(()=>[])) : Promise.resolve([]); + + // 1. 原始关键词搜索 + const t1 = this._searchOverseaRequest(keyword, "Original").then(r => { + if(r.length) tmdbAbortController.abort(); + // 挂载原始 keyword + r.forEach(i => i._originalQuery = keyword); + return r; + }).catch(()=>[]); + + // 2. TMDB 辅助搜索 + const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(async ()=>{ + // 获取 TMDB 原名及别名 + const tmdbResult = await getTmdbJaOriginalTitle(keyword, tmdbAbortController.signal, "Bilibili"); + + if (tmdbResult && tmdbResult.title && tmdbResult.title !== keyword) { + const { title: tmdbTitle, cnAlias } = tmdbResult; + + // 使用日语原名进行搜索 + const results = await this._searchOverseaRequest(tmdbTitle, "TMDB", tmdbAbortController.signal); + + // 注入上下文信息,包括别名 + results.forEach(r => { + r._originalQuery = keyword; + r._searchUsedTitle = tmdbTitle; + r._tmdbCnAlias = cnAlias; + }); + return results; + } + return []; + }).catch(()=>[])) : Promise.resolve([]); + return (await Promise.all([t1, t2])).flat(); } diff --git a/danmu_api/utils/tmdb-util.js b/danmu_api/utils/tmdb-util.js index 8cd3398..0f9ad07 100644 --- a/danmu_api/utils/tmdb-util.js +++ b/danmu_api/utils/tmdb-util.js @@ -15,7 +15,7 @@ const TMDB_PENDING = new Map(); async function tmdbApiGet(url, options = {}) { const tmdbApi = "https://api.tmdb.org/3/"; const tartgetUrl = `${tmdbApi}${url}`; - // 修改:使用统一的代理 URL 构建方法 + // 使用统一的代理 URL 构建方法 const nextUrl = globals.makeProxyUrl(tartgetUrl); try { @@ -213,8 +213,14 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = return null; } + // 优化搜索关键词: 剥离 "Season 2", "第二季" 等后缀 + const cleanTitle = cleanSearchQuery(title); + if (cleanTitle !== title) { + log("info", `[TMDB] 优化搜索关键词: "${title}" -> "${cleanTitle}"`); + } + // 检查是否已有相同关键词的搜索任务正在进行 - let task = TMDB_PENDING.get(title); + let task = TMDB_PENDING.get(cleanTitle); if (!task) { // 创建一个新的控制器,用于控制真正的后台网络请求 @@ -340,12 +346,12 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = }; // 第一步:TMDB搜索 - log("info", `[TMDB] 正在搜索 (Shared Task): ${title}`); + log("info", `[TMDB] 正在搜索 (Shared Task): ${cleanTitle}`); // 检查 masterController 是否已被中断 if (backgroundSignal.aborted) throw new DOMException('Aborted', 'AbortError'); - const respZh = await searchTmdbTitles(title, "multi", { signal: backgroundSignal }); + const respZh = await searchTmdbTitles(cleanTitle, "multi", { signal: backgroundSignal }); if (!respZh || !respZh.data) { log("info", "[TMDB] TMDB搜索结果为空"); @@ -382,9 +388,9 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = if (!resultTitle) continue; // 先计算原标题的相似度 - const directScore = similarity(title, resultTitle); + const directScore = similarity(cleanTitle, resultTitle); const originalTitle = result.original_name || result.original_title || ""; - const originalScore = originalTitle ? similarity(title, originalTitle) : 0; + const originalScore = originalTitle ? similarity(cleanTitle, originalTitle) : 0; const initialScore = Math.max(directScore, originalScore); // 如果原标题已经100%匹配,标记跳过后续所有别名搜索 @@ -433,7 +439,7 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = log("info", `[TMDB] 已达到别名获取上限(${MAX_ALTERNATIVE_FETCHES}),使用原标题: ${resultTitle}`); } - const finalDirectScore = similarity(title, chineseTitle); + const finalDirectScore = similarity(cleanTitle, chineseTitle); finalScore = Math.max(finalDirectScore, originalScore); const displayInfo = chineseTitle !== resultTitle @@ -467,22 +473,22 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = const detailResp = await getTmdbJpDetail(mediaType, bestMatch.id, { signal: backgroundSignal }); + let jaOriginalTitle; if (!detailResp || !detailResp.data) { - const fallbackTitle = bestMatch.name || bestMatch.title; - log("info", `[TMDB] 使用中文搜索结果标题: ${fallbackTitle}`); - return fallbackTitle; + jaOriginalTitle = bestMatch.name || bestMatch.title; + log("info", `[TMDB] 使用中文搜索结果标题: ${jaOriginalTitle}`); + } else { + const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data; + jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title; + log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle}`); } - const detail = typeof detailResp.data === "string" ? JSON.parse(detailResp.data) : detailResp.data; - - const jaOriginalTitle = detail.original_name || detail.original_title || detail.name || detail.title; - log("info", `[TMDB] 找到日语原名: ${jaOriginalTitle}`); - - return jaOriginalTitle; + // 返回对象,包含原名和别名 + return { title: jaOriginalTitle, cnAlias: bestMatchChineseTitle }; } catch (error) { if (error.name === 'AbortError') { - log("info", `[TMDB] 后台搜索任务已完全终止 (${title})`); + log("info", `[TMDB] 后台搜索任务已完全终止 (${cleanTitle})`); throw error; } log("error", "[TMDB] Background Search error:", { @@ -500,14 +506,14 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = refCount: 0, promise: executeSearch().finally(() => { // 无论成功失败,移除 Map 记录 - TMDB_PENDING.delete(title); + TMDB_PENDING.delete(cleanTitle); }) }; - TMDB_PENDING.set(title, task); - log("info", `[TMDB] 启动新搜索任务: ${title}`); + TMDB_PENDING.set(cleanTitle, task); + log("info", `[TMDB] 启动新搜索任务: ${cleanTitle}`); } else { - log("info", `[TMDB] 加入正在进行的搜索: ${title} (${sourceLabel})`); + log("info", `[TMDB] 加入正在进行的搜索: ${cleanTitle} (${sourceLabel})`); } // 增加引用计数 @@ -516,11 +522,11 @@ export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = // 定义退出任务的逻辑 const leaveTask = () => { // 再次获取任务确认其仍存在 - const currentTask = TMDB_PENDING.get(title); + const currentTask = TMDB_PENDING.get(cleanTitle); if (currentTask === task) { task.refCount--; if (task.refCount <= 0) { - log("info", `[TMDB] 所有调用者已取消,终止后台请求: ${title}`); + log("info", `[TMDB] 所有调用者已取消,终止后台请求: ${cleanTitle}`); task.controller.abort(); } } @@ -609,3 +615,124 @@ export async function getTMDBChineseTitle(title, season = null, episode = null) return title; } } + +// ===================== +// 智能标题替换相关函数 +// ===================== + +// 识别季度、剧场版、外传、副标题等后缀信息的正则白名单 +const SUFFIX_PATTERNS = [ + /(\s+|^)(?:第)?(\d+|[一二三四五六七八九十]+)[季期部]/, + /(\s+|^)season\s*\d+/i, + /(\s+|^)s\d+/i, + /(\s+|^)part\s*\d+/i, + /(\s+|^)the\s+final\s+season/i, + /(\s+|^)movie(?![a-z])/i, // 负向预查,防止误匹配单词内部 + /(\s+|^)film(?![a-z])/i, + /(\s+|^)ova(?![a-z])/i, + /(\s+|^)oad(?![a-z])/i, + /(\s+|^)sp(?![a-z])/i, + /[::]\s*.+/, + /[~~].+/, + /\s+.*篇/, + /.*外传/ +]; + +const SEPARATOR_REGEX = /[ ::~~]/; + +/** + * 寻找标题中属于后缀或季度信息的起始位置 + * @param {string} title 原标题 + * @returns {number} 后缀起始索引 + */ +function detectSuffixStart(title) { + let minIndex = title.length; + for (const pattern of SUFFIX_PATTERNS) { + const match = title.match(pattern); + if (match && match.index !== undefined) { + if (match.index < minIndex) { + minIndex = match.index; + } + } + } + return minIndex; +} + +/** + * 利用后缀正则清洗搜索关键词,移除季度等信息以提高 TMDB 搜索命中率 + * @param {string} title 原始标题 + * @returns {string} 清洗后的标题主体 + */ +function cleanSearchQuery(title) { + const limit = detectSuffixStart(title); + if (limit < title.length) { + return title.substring(0, limit).trim(); + } + return title; +} + +/** + * 根据 TMDB 中文别名对番剧列表进行智能标题替换 + * @param {Array} animes 待处理的 anime 对象列表 + * @param {string} cnAlias TMDB 中文别名 + */ +export function smartTitleReplace(animes, cnAlias) { + if (!animes || animes.length === 0 || !cnAlias) return; + + log("info", `[TMDB] 启动智能替换,目标别名: "${cnAlias}",待处理条目: ${animes.length}`); + + // 计算所有标题主体部分的 LCP (最长公共前缀) + const baseTitles = animes.map(a => { + const t = a.org_title || a.title || ""; + const limit = detectSuffixStart(t); + return t.substring(0, limit); + }); + + let lcp = ""; + if (baseTitles.length > 0) { + const sorted = baseTitles.concat().sort(); + const a1 = sorted[0]; + const a2 = sorted[sorted.length - 1]; + let i = 0; + while (i < a1.length && a1.charAt(i) === a2.charAt(i)) i++; + lcp = a1.substring(0, i); + } + + if (lcp && lcp.length > 1) { + log("info", `[TMDB] 计算出最长公共前缀 (LCP): "${lcp}"`); + } + + for (let i = 0; i < animes.length; i++) { + const anime = animes[i]; + const originalTitle = anime.title || ""; + + // 策略 A: LCP 模式 + if (lcp && lcp.length > 1 && originalTitle.startsWith(lcp)) { + const suffix = originalTitle.substring(lcp.length).trim(); + let newTitle; + if (!suffix) { + newTitle = cnAlias; + } else if (suffix.match(/^[~~::]/)) { + newTitle = cnAlias + suffix; + } else { + newTitle = cnAlias + " " + suffix; + } + anime._displayTitle = newTitle; + log("info", `[TMDB] [LCP模式] "${originalTitle}" -> "${newTitle}"`); + } + // 策略 B: 分隔符模式 + else { + const match = originalTitle.match(SEPARATOR_REGEX); + if (match) { + const suffix = originalTitle.substring(match.index); + const newTitle = cnAlias + suffix; + anime._displayTitle = newTitle; + log("info", `[TMDB] [分隔符模式] "${originalTitle}" -> "${newTitle}"`); + } else { + // 策略 C: 全替模式 + anime._displayTitle = cnAlias; + log("info", `[TMDB] [全替模式] "${originalTitle}" -> "${cnAlias}"`); + } + } + } +}