From 01fd9e15aa9f7f75dd431b179767c5ba09bd124a Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:26:02 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9B=86=E6=88=90=20bangumi-data=20=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=EF=BC=8C=E5=BC=80=E5=90=AF=E5=90=8E=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E6=BA=90=E7=9A=84=E6=A3=80=E7=B4=A2=E4=B8=8E?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E9=80=9F=E5=BA=A6=E5=B9=B6=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E9=9A=90=E8=97=8F/=E5=8C=BA=E5=9F=9F=E7=95=AA=E5=89=A7=20(#283?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * envs.js:新增 Bangumi Data 加速匹配开关 与 Bangumi Data 缓存有效期 配置 * 新增 Bangumi Data 管理工具,用于下载与管理Bangumi Data数据 感谢 https://github.com/bangumi-data/bangumi-data * worker.js:参加Bangumi Data 辅助函数用于判断数据更新 * server.js:配合清理 Bangumi Data 关闭时占用的内存 * tmdb-util.js:Bangumi Data 开启时调用它大幅提升获取日语原名速度 实现获取动画日语原名零请求 * animeko.js:Bangumi Data 开启时调用它,节省一次搜索请求 已知问题:缺失imageUrl,问题不大 * bahamut.js:Bangumi Data 开启时调用它,补全别名与类型 Bangumi Data 给的是acg_sn,预期需要是video_sn,因此无法直接应用 https://github.com/bangumi-data/bangumi-data/issues/336 * bilibili.js:Bangumi Data 开启时调用它,补全缺失的隐藏结果与区域结果 实现港澳台番剧与隐藏番剧自由,例如《笨拙之极的上野》 * dandan.js:Bangumi Data 开启时调用它,节省一次搜索请求 弹弹其实当务之急是怎么节省详情接口的请求… 多结果风控错误概率大 * common-util.js:为标题匹配的相似度策略加入一定阻断 防止纯英文搜索时因相似度打乱字符匹配大量结果被放行 * .env.example:更新 Bangumi Data 相关变量 * README.md:更新 Bangumi Data 相关说明 * Enhance comments for USE_BANGUMI_DATA in .env.example Updated comments to clarify the functionality of USE_BANGUMI_DATA. * Enhance USE_BANGUMI_DATA description Updated the description for USE_BANGUMI_DATA to include unlocking hidden/region-specific anime. * README.md:补全目录 * bangumi-data-util.js:纯内存加载时使用原生 fetch ,并声明压缩头 * bangumi-data-util.js:增加云部署纯内存运行提示 * bangumi-data-util.js:更新配音版本正则 * merge-util.js:标准化正则 * tmdb-util.js:标准化正则 * animeko.js:标准化正则 * README.md:补充文件目录 * bangumi-data-util.js:修复CF部署下载请求被切断导致堵塞 Added download lock mechanism to prevent deadlocks in serverless environments and implemented a timeout for download operations. * worker.js:透传ctx * bangumi-data-util.js:调用 ctx.waitUntil 延长 Serverless 生命周期 * bilibili.js:补全md号解析链路 Add handling for media IDs starting with 'md' to fetch episodes. * server.js:异步初始化 Bangumi Data 缓存,消除首个真实请求遭遇冷启动的等待延迟 * bangumi-data-util.js:双节点同时竞速下载数据,提升下载可用性与速度 --- README.md | 7 + config/.env.example | 13 + danmu_api/configs/envs.js | 4 + danmu_api/server.js | 18 ++ danmu_api/sources/animeko.js | 45 ++-- danmu_api/sources/bahamut.js | 57 ++++- danmu_api/sources/bilibili.js | 196 +++++++++++--- danmu_api/sources/dandan.js | 22 ++ danmu_api/utils/bangumi-data-util.js | 368 +++++++++++++++++++++++++++ danmu_api/utils/common-util.js | 6 +- danmu_api/utils/merge-util.js | 24 +- danmu_api/utils/tmdb-util.js | 86 +++++-- danmu_api/worker.js | 12 +- 13 files changed, 755 insertions(+), 103 deletions(-) create mode 100644 danmu_api/utils/bangumi-data-util.js diff --git a/README.md b/README.md index 962baad..e23ffd5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ LogVar 弹幕 API 服务器 - [采集源及对应平台列表](#采集源及对应平台列表) - [项目结构](#项目结构) - [注意事项](#注意事项) +- [关联项目](#关联项目) +- [特别感谢](#特别感谢) - [贡献者](#贡献者) ## 功能 @@ -414,6 +416,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | REMEMBER_LAST_SELECT | 【可选】是否记住手动选择结果,用于match自动匹配时优选上次的选择,默认为`true`,表示记住,请注意,该功能为实验性功能,会记住某个剧上次选择的结果作为下次自动匹配的优选,如不需要,请关闭 | | MAX_LAST_SELECT_MAP | 【可选】最后选择映射缓存大小限制,默认为`100`,lastSelectMap最多保存的条目数,超过限制时删除最早的条目(FIFO),用于存储查询关键字上次选择的animeId,最小值100,最大值1000 | | MAX_ANIMES | 【可选】动漫标题缓存最大数量,默认为`100`,缓存最多保存的anime条目数,超过限制时删除最早的条目(FIFO),最小值100,最大值1000 | +| BANGUMI_DATA_CACHE_DAYS | 【可选】指定 Bangumi Data 数据有效期(天),默认为:`7`,超过有效期后会下载更新,设置0则每次请求时强制异步更新(需开启`USE_BANGUMI_DATA`)' | | UPSTASH_REDIS_REST_URL | 【可选】Upstash redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` | | UPSTASH_REDIS_REST_TOKEN | 【可选】Upstash redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` | | LOCAL_REDIS_URL | 【可选】本地Redis连接URL,用于本地缓存存储,适用于docker和本地部署环境,格式:`redis://:password@127.0.0.1:6379/0`,默认为空(不使用本地Redis) | @@ -425,6 +428,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | AI_MODEL | 【可选】AI模型名称,指定使用的AI模型,不填默认为gpt-4o | | AI_API_KEY | 【可选】AI服务的API密钥,用于身份验证,默认为空,需手动填写 | | AI_MATCH_PROMPT | 【可选】AI匹配提示词,用于自定义AI匹配行为,不填提供默认提示词,提示词如下 | +| USE_BANGUMI_DATA | 【可选】[Bangumi Data](https://github.com/bangumi-data/bangumi-data) 加速匹配开关,默认值:`false`(关闭),开启后将动画元数据缓存至本地或内存中给源调用,提升动画源的检索与匹配速度并解锁隐藏/区域番剧(本地和Docker部署使用时请先挂载.cache目录获得最佳体验,云部署使用时会将数据缓存至临时内存中如果体验不佳请关闭) | ```regex # EPISODE_TITLE_FILTER 默认值 @@ -602,6 +606,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 │ └── utils/ │ ├── ai-util.js # AI相关处理工具 │ ├── aiyifan-util.js # 爱壹帆签名工具 +│ ├── bangumi-data-util.js # Bangumi Data管理工具 │ ├── cache-util.js # 缓存数据处理工具 │ ├── codec-util.js # 编解码工具 │ ├── common-util.js # 通用工具 @@ -685,6 +690,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 - 开源项目 [animeko](https://github.com/open-ani/animeko) 提供的弹幕API +- 开源项目 [bangumi-data](https://github.com/bangumi-data/bangumi-data) 提供的平台动画元数据 + ### 贡献者 contributors diff --git a/config/.env.example b/config/.env.example index a61fe92..ed81a84 100644 --- a/config/.env.example +++ b/config/.env.example @@ -230,6 +230,14 @@ STRICT_TITLE_MATCH=false # 可选值:true, false # TITLE_TO_CHINESE=false +# 是否启用 Bangumi Data 加速匹配 +# 默认值:false(关闭) +# 说明: +# - 开启后将动画元数据缓存至本地或内存中给源调用,提升动画源的检索与匹配速度并解锁隐藏/区域番剧。 +# - 本地和Docker部署使用时请先挂载.cache目录获得最佳体验,云部署使用时会将数据缓存至临时内存中如果体验不佳请关闭。 +# 可选值:true, false +USE_BANGUMI_DATA=false + # ==================== 代理配置 ==================== # 代理/反代地址(用于访问受限资源) @@ -301,6 +309,11 @@ MAX_LAST_SELECT_MAP=100 # 最小值100,最大值1000 MAX_ANIMES=100 +# Bangumi Data 缓存有效期(天) +# 默认值:7 +# 说明:指定 Bangumi Data 数据有效期,超过有效期后会下载更新,设置0则每次请求时强制异步更新 +BANGUMI_DATA_CACHE_DAYS=7 + # ==================== Redis 配置(Upstash)==================== # Upstash Redis REST URL(用于缓存) diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 1300c88..0a2013e 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -520,6 +520,7 @@ export class Envs { 'AI_MODEL': { category: 'match', type: 'text', description: 'AI模型名称,不填默认为gpt-4o' }, 'AI_API_KEY': { category: 'match', type: 'text', description: 'AI服务API密钥,默认为空,需手动填写' }, 'AI_MATCH_PROMPT': { category: 'match', type: 'text', description: 'AI自动匹配提示词模板,不填提供默认提示词,默认提示词请查看README' }, + 'USE_BANGUMI_DATA': { category: 'match', type: 'boolean', description: 'Bangumi Data 加速匹配开关,开启后将动画元数据缓存至本地或内存中给源调用,提升动画源的检索与匹配速度并解锁隐藏/区域番剧。\n本地和Docker部署使用时请先挂载.cache目录获得最佳体验,云部署使用时会将数据缓存至临时内存中如果体验不佳请关闭。' }, // 弹幕配置 'BLOCKED_WORDS': { category: 'danmu', type: 'text', description: '屏蔽词列表' }, @@ -543,6 +544,7 @@ export class Envs { 'UPSTASH_REDIS_REST_URL': { category: 'cache', type: 'text', description: 'Upstash Redis请求链接' }, 'UPSTASH_REDIS_REST_TOKEN': { category: 'cache', type: 'text', description: 'Upstash Redis访问令牌' }, 'LOCAL_REDIS_URL': { category: 'cache', type: 'text', description: '本地 Redis 连接URL,示例:redis://:password@127.0.0.1:6379/0,只支持本地部署和docker部署' }, + 'BANGUMI_DATA_CACHE_DAYS': { category: 'cache', type: 'number', description: 'Bangumi Data 缓存有效期(天),设置0则每次请求时强制异步更新,默认7天', min: 0, max: 30 }, // 系统配置 'PROXY_URL': { category: 'system', type: 'text', description: '代理/反代地址' }, @@ -604,9 +606,11 @@ export class Envs { aiModel: this.get('AI_MODEL', 'gpt-4o', 'string'), // AI模型名称 aiApiKey: this.get('AI_API_KEY', '', 'string', true), // AI服务API密钥 aiMatchPrompt: this.get('AI_MATCH_PROMPT', this.DEFAULT_AI_MATCH_PROMPT, 'string'), // AI自动匹配提示词模板 + useBangumiData: this.get('USE_BANGUMI_DATA', false, 'boolean'), // Bangumi Data 加速匹配开关 rememberLastSelect: this.get('REMEMBER_LAST_SELECT', true, 'boolean'), // 是否记住手动选择结果,用于match自动匹配时优选上次的选择(默认 true,记住) MAX_LAST_SELECT_MAP: this.get('MAX_LAST_SELECT_MAP', 100, 'number'), // 记住上次选择映射缓存大小限制(默认 100) MAX_ANIMES: this.get('MAX_ANIMES', 100, 'number'), // 动漫标题缓存最大数量(默认 100) + bangumiDataCacheDays: this.get('BANGUMI_DATA_CACHE_DAYS', 7, 'number'), // Bangumi Data 缓存有效期(天),默认7天 deployPlatformAccount: this.get('DEPLOY_PLATFROM_ACCOUNT', '', 'string', true), // 部署平台账号ID配置(默认空) deployPlatformProject: this.get('DEPLOY_PLATFROM_PROJECT', '', 'string', true), // 部署平台项目名称配置(默认空) deployPlatformToken: this.get('DEPLOY_PLATFROM_TOKEN', '', 'string', true), // 部署平台项目名称配置(默认空) diff --git a/danmu_api/server.js b/danmu_api/server.js index 2e844de..a8e377b 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -9,6 +9,8 @@ import { HttpsProxyAgent } from 'https-proxy-agent'; import dotenv from 'dotenv'; import { Request as NodeFetchRequest } from 'node-fetch'; import { handleRequest } from './worker.js'; +import { Globals } from './configs/globals.js'; +import { clearBangumiDataCache, initBangumiData } from './utils/bangumi-data-util.js'; // ===================== // server.js - 本地node智能启动脚本:根据 Node.js 环境自动选择最优启动模式 @@ -186,6 +188,12 @@ async function setupEnvWatcher() { console.log('[server] Environment variables reloaded successfully'); console.log('[server] Updated keys:', Array.from(newEnvKeys).join(', ')); + + // 如果检测到关闭了 Bangumi Data 功能,主动释放 V8 内存 + if (process.env.USE_BANGUMI_DATA === 'false' || process.env.USE_BANGUMI_DATA === false) { + clearBangumiDataCache(); + } + } catch (error) { console.log('[server] Error reloading configuration files:', error.message); } @@ -455,6 +463,13 @@ async function startServer() { // 设置 .env 文件监听 await setupEnvWatcher(); + // 初始化全局变量环境 + try { + Globals.init(process.env); + } catch (e) { + console.error('[server] Globals init failed:', e); + } + // 启动主业务服务器(默认 9321,可通过 DANMU_API_PORT 覆盖) const configuredMainPort = Number.parseInt(process.env.DANMU_API_PORT ?? '', 10); const mainPort = Number.isNaN(configuredMainPort) ? 9321 : configuredMainPort; @@ -467,6 +482,9 @@ async function startServer() { proxyServer = createProxyServer(); proxyServer.listen(5321, '0.0.0.0', () => { console.log('Proxy server running on http://0.0.0.0:5321'); + + // 异步初始化 Bangumi Data 缓存 + setTimeout(() => initBangumiData('node', true).catch(console.error), 1000); }); } diff --git a/danmu_api/sources/animeko.js b/danmu_api/sources/animeko.js index 8ea449f..1f2b789 100644 --- a/danmu_api/sources/animeko.js +++ b/danmu_api/sources/animeko.js @@ -6,9 +6,10 @@ import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { simplized } from "../utils/zh-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; import { titleMatches } from "../utils/common-util.js"; +import { searchBangumiData } from '../utils/bangumi-data-util.js'; // ===================== -// 获取Animeko弹幕(https://github.com/open-ani/animeko) +// 获取Animeko弹幕(https://github.com/open-ani/animeko) // ===================== /** @@ -35,6 +36,28 @@ export default class AnimekoSource extends BaseSource { * @returns {Promise} 转换后的搜索结果列表 */ async search(keyword) { + if (globals.useBangumiData) { + const localMatches = searchBangumiData(keyword, ['bangumi']); + if (localMatches.length > 0) { + log("info", `[Animeko] Bangumi-Data 命中 ${localMatches.length} 条数据`); + return this.transformResults(localMatches.map(m => { + const displayTitle = m.titles.find(t => t && t.includes(keyword)) || m.titles[1] || m.title; + const finalTitle = displayTitle + (m.titleSuffix || ''); + + return { + id: parseInt(m.siteId), + name: m.title, + name_cn: finalTitle, + imageUrl: "", + date: m.begin, + score: 0, + platform: m.typeStr, + aliases: m.titles + }; + })); + } + } + try { // 标准化函数 const searchKeyword = keyword.replace(/[._]/g, ' ').replace(/\s+/g, ' ').trim(); @@ -151,24 +174,10 @@ export default class AnimekoSource extends BaseSource { */ hasExplicitSeasonInfo(title) { if (!title) return false; - - const patterns = [ - /第\s*[0-9一二三四五六七八九十]+\s*[季期部]/i, // 第2季 - /Season\s*\d+/i, // Season 2 - /S\d+/i, // S2 - /Part\s*\d+/i, // Part 2 - /OVA/i, /OAD/i, - /剧场版|Movie|Film/i, - /续篇|续集/i, - /SP/i, - /(? p.test(title)); + const pattern = /第?\s*(?:\d+|[一二三四五六七八九十]+)\s*[季期部]|Season\s*\d+|S\d+|Part\s*\d+|Act\s*\d+|Phase\s*\d+|The\s+Final\s+Season|OVA|OAD|剧场版|劇場版|Movie|Film|续[篇集]|外传|SP|(? 0 && localMatches.length > 0) { + for (const item of finalResults) { + if (!item || !item.acg_sn) continue; + + // 依据 acg_sn 匹配本地数据 + const matchedLocal = localMatches.find(m => + String(item.acg_sn) === String(m.siteId) || + (item.title && m.title === item.title) + ); + + if (matchedLocal) { + const displayTitle = matchedLocal.titles.find(t => t && t.includes(keyword)) || matchedLocal.titles[1] || matchedLocal.title; + const finalTitle = displayTitle + (matchedLocal.titleSuffix || ''); + + // 注入本地别名和优选标题,同时挂载精准类型 + item.title = finalTitle; + item._displayTitle = finalTitle; + item.aliases = [...matchedLocal.titles]; + item._typeStr = matchedLocal.typeStr; + + log("info", `[Bahamut] 网络结果 [${item.title}] 成功对齐本地 Bangumi-Data 数据`); + } + } } - log("info", "[Bahamut] 原始搜索和基于TMDB的搜索均未返回任何结果"); - return []; + return finalResults; } catch (error) { // 捕获请求中的错误 log("error", "getBahamutAnimes error:", { @@ -325,15 +357,14 @@ export default class BahamutSource extends BaseSource { // 优先使用tmdb智能标题替换的标题,否则简转繁处理原标题 const displayTitle = anime._displayTitle || simplized(anime.title); - // 提取原始标题作为别名 - const aliases = []; - if (anime._originalTitleAlias && anime._originalTitleAlias !== displayTitle) { + // 提取网络结果原标题以及在 search 阶段注入的本地多国别名 + const aliases = Array.isArray(anime.aliases) ? [...anime.aliases] : []; + if (anime._originalTitleAlias && anime._originalTitleAlias !== displayTitle && !aliases.includes(anime._originalTitleAlias)) { aliases.push(anime._originalTitleAlias); } - // 解析剧集类型 - let itemType = "动漫"; // 默认类型 - // 从 epData 中获取完整标题 (优先使用 anime.title) + // 优先使用本地数据标注的精准类型,如果不存在则使用原版默认类型兜底 + let itemType = anime._typeStr || "动漫"; const fullTitle = (epData.anime && epData.anime.title) || (detail && detail.title) || ""; if (fullTitle.includes("[電影]")) { diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index d50e565..e21e4e0 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -9,6 +9,7 @@ import { titleMatches } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; import { simplized } from "../utils/zh-util.js"; import { getTmdbJaOriginalTitle, smartTitleReplace } from "../utils/tmdb-util.js"; +import { searchBangumiData } from '../utils/bangumi-data-util.js'; // ===================== // 获取b站弹幕 @@ -217,6 +218,7 @@ export default class BilibiliSource extends BaseSource { const resultItem = { provider: "bilibili", mediaId, + mdId: item.media_id ? `md${item.media_id}` : null, title: cleanedTitle, org_title: cleanedOrgTitle, type: mediaType, @@ -282,43 +284,143 @@ export default class BilibiliSource extends BaseSource { } async search(keyword) { + let localMatches = []; + if (globals.useBangumiData) { + // 获取本地匹配条目 + localMatches = searchBangumiData(keyword, [ + 'bilibili', 'bilibili_hk_mo_tw', 'bilibili_hk_mo', 'bilibili_tw' + ]); + log("info", `[Bilibili] Bangumi-Data 本地命中 ${localMatches.length} 条数据`); + } + + // 筛选出港澳台相关的本地匹配项 + const localOverseas = localMatches.filter(m => + ['bilibili_hk_mo_tw', 'bilibili_hk_mo', 'bilibili_tw'].includes(m.matchedSiteKey) + ); + try { log("info", `[Bilibili] 开始搜索: ${keyword}`); - const mixinKey = await this._getWbiMixinKey(); - const searchTypes = ["media_bangumi", "media_ft"]; - const searchPromises = searchTypes.map(type => this._searchByType(keyword, type, mixinKey)); - const tasks = [...searchPromises]; + // 执行并行网络搜索任务 + const t1 = this._searchByType(keyword, "media_bangumi", mixinKey); + const t2 = this._searchByType(keyword, "media_ft", mixinKey); - // 检测到代理配置时,启用港澳台并行搜索 + let t3 = Promise.resolve([]); if (this._hasBilibiliProxy()) { log("info", `[Bilibili] 检测到代理配置,启用港澳台并行搜索`); - tasks.push(this._searchOversea(keyword)); + // 如果本地存在港澳台数据,则完全代替海外番剧搜索请求(Type 7) + t3 = this._searchOversea(keyword, localOverseas.length > 0); } - const results = await Promise.all(tasks); + // 等待所有网络请求完成 + let networkResults = (await Promise.all([t1, t2, t3])).flat(); - // 合并结果并去重 - const allResults = results.flat(); - const uniqueResults = []; + const finalResults = []; const seenIds = new Set(); + const consumedLocalMdIds = new Set(); - for (const item of allResults) { - if (!seenIds.has(item.mediaId)) { - seenIds.add(item.mediaId); - uniqueResults.push(item); + // 处理网络结果:对齐本地数据进行信息增强 + for (const item of networkResults) { + if (!item || (!item.mediaId && !item.season_id)) continue; + + // 对齐逻辑:优先匹配 mdId,其次匹配原名 + const matchedLocal = localMatches.find(m => + (item.mdId && item.mdId === `md${m.siteId}`) || + (item.org_title && m.title === item.org_title) + ); + + if (matchedLocal) { + const displayTitle = matchedLocal.titles.find(t => t && t.includes(keyword)) || matchedLocal.titles[1] || matchedLocal.title; + const finalTitle = displayTitle + (matchedLocal.titleSuffix || ''); + + // 使用本地数据完全替换标题、展示标题与别名池 + item.title = finalTitle; + item._displayTitle = finalTitle; + item.aliases = [...matchedLocal.titles]; + item.type = matchedLocal.typeStr || item.type; + item.isLocalPriority = true; + + consumedLocalMdIds.add(`md${matchedLocal.siteId}`); + log("info", `[Bilibili] 网络结果 [${item.title}] 成功对齐本地 Bangumi-Data 数据`); } + + const idKey = item.mediaId || (item.season_id ? `ss${item.season_id}` : null); + if (idKey && seenIds.has(idKey)) continue; + if (idKey) seenIds.add(idKey); + + finalResults.push(item); } - log("info", `[Bilibili] 搜索完成,找到 ${uniqueResults.length} 个有效结果`); - return uniqueResults; + // 处理本地遗珠:补全网络搜索未覆盖的本地条目 + const missingLocalMatches = localMatches.filter(m => !consumedLocalMdIds.has(`md${m.siteId}`)); + if (missingLocalMatches.length > 0) { + log("info", `[Bilibili] 从本地 Bangumi-Data 补充 ${missingLocalMatches.length} 条缺漏记录并请求详情...`); + + const missingPromises = missingLocalMatches.map(async (m) => { + const mediaInfo = await this._resolveMediaInfo(m.siteId); + const displayTitle = m.titles.find(t => t && t.includes(keyword)) || m.titles[1] || m.title; + const finalTitle = displayTitle + (m.titleSuffix || ''); + + return { + provider: "bilibili", + mediaId: mediaInfo.seasonId || `md${m.siteId}`, + mdId: `md${m.siteId}`, + title: finalTitle, + org_title: m.title, + aliases: [...m.titles], + _displayTitle: finalTitle, + type: m.typeStr, + year: m.begin ? parseInt(m.begin.substring(0, 4)) : null, + imageUrl: mediaInfo.cover, + episodeCount: 0, + isOversea: ['bilibili_hk_mo_tw', 'bilibili_hk_mo', 'bilibili_tw'].includes(m.matchedSiteKey), + isLocalPriority: true + }; + }); + + const missingResults = await Promise.all(missingPromises); + for (const item of missingResults) { + const idKey = item.mediaId; + if (idKey && seenIds.has(idKey)) continue; + if (idKey) { + seenIds.add(idKey); + finalResults.unshift(item); + } + } + } + + log("info", `[Bilibili] 搜索完成,找到 ${finalResults.length} 个有效结果`); + return finalResults; + } catch (error) { log("error", "[Bilibili] 搜索出错:", error.message); return []; } } + /** + * 将 media_id 转换为 season_id 并提取封面 + * @param {string|number} mediaId - B站 md 号 + * @returns {Promise<{seasonId: string|null, cover: string}>} + */ + async _resolveMediaInfo(mediaId) { + try { + const res = await httpGet(`https://api.bilibili.com/pgc/review/user?media_id=${mediaId}`); + const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + if (data.code === 0 && data.result && data.result.media) { + const media = data.result.media; + return { + seasonId: `ss${media.season_id}`, + cover: media.cover || media.horizontal_picture || "" + }; + } + } catch (e) { + log("error", `[Bilibili] 获取媒体信息失败 (md${mediaId}):`, e.message); + } + return { seasonId: null, cover: "" }; + } + /** * 获取番剧分集列表 */ @@ -357,12 +459,27 @@ export default class BilibiliSource extends BaseSource { return []; } - const episodes = rawEpisodes.map((ep, index) => ({ - vid: `${ep.aid},${ep.cid}`, - id: ep.id, - title: (ep.show_title || ep.long_title || ep.title || `第${index + 1}集`).trim(), - link: `https://www.bilibili.com/bangumi/play/ep${ep.id}` - })); + const episodes = rawEpisodes.map((ep, index) => { + let displayTitle = ""; + + if (ep.show_title) { + displayTitle = ep.show_title; + } else { + const epIndex = ep.title || String(index + 1); + const longTitle = ep.long_title || ""; + displayTitle = /^\d+(\.\d+)?$/.test(epIndex) ? `第${epIndex}话` : epIndex; + if (longTitle && longTitle !== epIndex) { + displayTitle += ` ${longTitle}`; + } + } + + return { + vid: `${ep.aid},${ep.cid}`, + id: ep.id, + title: displayTitle.trim(), + link: `https://www.bilibili.com/bangumi/play/ep${ep.id}` + }; + }); log("info", `[Bilibili] 获取到 ${episodes.length} 个番剧分集`); return episodes; @@ -413,6 +530,17 @@ export default class BilibiliSource extends BaseSource { } async getEpisodes(id) { + if (id.startsWith('md')) { + const mediaId = id.substring(2); + const mediaInfo = await this._resolveMediaInfo(mediaId); + if (mediaInfo.seasonId) { + const episodes = await this._getPgcEpisodes(mediaInfo.seasonId.substring(2)); + episodes._cover = mediaInfo.cover; + return episodes; + } + return []; + } + if (id.startsWith('ss')) { const seasonId = id.substring(2); return await this._getPgcEpisodes(seasonId); @@ -1038,10 +1166,17 @@ export default class BilibiliSource extends BaseSource { if(data.data?.result) { // 在 Web Fallback 提取并清洗 org_title 字段 return data.data.result.filter(i => i.url?.includes("bilibili.com") && (!i.areas?.includes("漫游"))).map(i => ({ - provider: "bilibili", mediaId: i.season_id?`ss${i.season_id}`:"", title: (i.title||"").replace(/<[^>]+>/g,'').trim(), + provider: "bilibili", + mediaId: i.season_id ? `ss${i.season_id}` : "", + mdId: i.media_id ? `md${i.media_id}` : null, + title: (i.title||"").replace(/<[^>]+>/g,'').trim(), org_title: (i.org_title || "").replace(/<[^>]+>/g,'').replace(/&[^;]+;/g, match => { const entities = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" }; return entities[match] || match; }).trim(), - type: this._extractMediaType(i.season_type_name), year: i.pubtime?new Date(i.pubtime*1000).getFullYear():null, imageUrl: i.cover||null, - episodeCount: i.ep_size||0, _eps: i.eps, isOversea: true + type: this._extractMediaType(i.season_type_name), + year: i.pubtime?new Date(i.pubtime*1000).getFullYear():null, + imageUrl: i.cover||null, + episodeCount: i.ep_size||0, + _eps: i.eps, + isOversea: true })).filter(i => i.mediaId); } } catch(e) { @@ -1052,14 +1187,13 @@ export default class BilibiliSource extends BaseSource { } // 综合港澳台搜索入口 - async _searchOversea(keyword) { + async _searchOversea(keyword, skipAnime = false) { const tmdbAbortController = new AbortController(); - // 定义搜索配置:同时搜索番剧(App:7, Web:media_bangumi)和影视(App:8, Web:media_ft) - const searchConfigs = [ - { appType: 7, webType: 'media_bangumi' }, - { appType: 8, webType: 'media_ft' } - ]; + // 根据本地数据命中情况动态配置类型搜索,若本地有数据则由本地补全,不再请求 B 站番剧搜索接口 + const searchConfigs = skipAnime + ? [{ appType: 8, webType: 'media_ft' }] + : [{ appType: 7, webType: 'media_bangumi' }, { appType: 8, webType: 'media_ft' }]; // 1. 原始关键词搜索 (并发执行所有类型,增加间隔延迟) const t1 = Promise.all(searchConfigs.map(async (conf, index) => { diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index 444a83b..b84c166 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -13,6 +13,7 @@ import BilibiliSource from "./bilibili.js"; import YoukuSource from "./youku.js"; import BahamutSource from "./bahamut.js"; import { titleMatches, getExplicitSeasonNumber } from "../utils/common-util.js"; +import { searchBangumiData } from '../utils/bangumi-data-util.js'; const tencentSource = new TencentSource(); const iqiyiSource = new IqiyiSource(); @@ -35,6 +36,27 @@ export default class DandanSource extends BaseSource { * @param {boolean} isFallback 标记当前是否处于降级搜索状态,防止无限递归 */ async search(keyword, isFallback = false) { + if (globals.useBangumiData && !isFallback) { + const localMatches = searchBangumiData(keyword, ['anidb']); + if (localMatches.length > 0) { + log("info", `[Dandan] Bangumi-Data 本地命中 ${localMatches.length} 条数据`); + return localMatches.map(m => { + const displayTitle = m.titles.find(t => t && t.includes(keyword)) || m.titles[1] || m.title; + const finalTitle = displayTitle + (m.titleSuffix || ''); + + return { + animeId: parseInt(m.siteId), + animeTitle: finalTitle, + type: m.typeId, + typeDescription: m.typeStr, + imageUrl: "", + startDate: m.begin, + rating: 0 + }; + }); + } + } + try { log("info", `[Dandan] 原始搜索词: ${keyword}`); diff --git a/danmu_api/utils/bangumi-data-util.js b/danmu_api/utils/bangumi-data-util.js new file mode 100644 index 0000000..a88784e --- /dev/null +++ b/danmu_api/utils/bangumi-data-util.js @@ -0,0 +1,368 @@ +import fs from 'fs'; +import path from 'path'; +import { pipeline } from 'stream/promises'; +import fetch from 'node-fetch'; +import { globals } from '../configs/globals.js'; +import { log } from './log-util.js'; +import { titleMatches } from './common-util.js'; + +// ===================== +// Bangumi Data 管理工具(https://github.com/bangumi-data/bangumi-data) +// ===================== + +let memoryCache = null; +let memoryCacheTime = 0; +let isDownloading = false; +let downloadLockTime = 0; +let memoryFootprintMB = '0.00'; +let hasLoggedCacheWarning = false; + +// 定义缓存目录和文件名 +const CACHE_DIR = path.join(process.cwd(), '.cache'); +const CACHE_FILENAME = 'bangumi-data-cache.json'; +const DOWNLOAD_TIMEOUT_MS = 20000; + +/** + * 初始化 Bangumi Data 数据源 + * 包含内存与磁盘双端缓存的生命周期校验,并在环境允许时持久化缓存数据 + * 若目录未挂载,则自动退化为纯内存模式 + * @param {string} deployPlatform - 部署平台类型 (如 'node', 'vercel', 'netlify') + * @param {boolean} isDataDependentRequest - 当前是否为强依赖数据的核心接口请求 + * @returns {Promise} + */ +export async function initBangumiData(deployPlatform, isDataDependentRequest = false, ctx = null) { + if (!globals.useBangumiData) return; + + let cachePath = null; + const cacheDays = globals.bangumiDataCacheDays !== undefined ? globals.bangumiDataCacheDays : 7; + const expireMs = cacheDays * 24 * 60 * 60 * 1000; + + if (deployPlatform === 'node') { + if (fs.existsSync(CACHE_DIR)) { + cachePath = path.join(CACHE_DIR, CACHE_FILENAME); + } else if (!hasLoggedCacheWarning) { + log("warn", "[Bangumi-Data] 未检测到根目录的 .cache 文件夹!"); + log("warn", "[Bangumi-Data] 按照项目规范,请手动挂载或创建 .cache 目录以启用持久化。"); + log("warn", "[Bangumi-Data] 本次运行已退化为纯内存模式 (重启后需重新下载)。"); + hasLoggedCacheWarning = true; + } + } else if (!hasLoggedCacheWarning) { + log("info", `[Bangumi-Data] 检测到 ${deployPlatform} 云环境,当前运行于纯内存加速模式。`); + hasLoggedCacheWarning = true; + } + + // 幽灵死锁自愈机制 (针对 Serverless 强杀) + if (isDownloading && (Date.now() - downloadLockTime > DOWNLOAD_TIMEOUT_MS)) { + log("warn", "[Bangumi-Data] 检测到下载状态锁死 (由于 Serverless 进程休眠/强杀导致),强制释放锁..."); + isDownloading = false; + } + + // 内存数据生命周期校验 + if (memoryCache) { + if (cacheDays > 0 && (Date.now() - memoryCacheTime < expireMs)) { + return; + } + + // 当内存过期或配置强制更新时,仅在核心请求触发后台静默更新 + if (isDataDependentRequest && !isDownloading) { + log("info", `[Bangumi-Data] 内存数据${cacheDays === 0 ? '强制更新' : '已过期'},保留老数据服务本次请求,启动后台静默更新...`); + isDownloading = true; + downloadLockTime = Date.now(); + downloadAndCache(cachePath).finally(() => { isDownloading = false; }); + } + return; + } + + // 磁盘数据生命周期校验 + if (cachePath && fs.existsSync(cachePath)) { + try { + const stats = fs.statSync(cachePath); + const memBefore = process.memoryUsage().heapUsed; + const startTime = Date.now(); + + memoryCache = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + + const memAfter = process.memoryUsage().heapUsed; + const loadTimeMs = Date.now() - startTime; + memoryFootprintMB = Math.max(0, (memAfter - memBefore) / 1024 / 1024).toFixed(2); + const totalMemMB = (process.memoryUsage().rss / 1024 / 1024).toFixed(2); + memoryCacheTime = Date.now(); + + log("info", `[Bangumi-Data] 成功从本地磁盘加载基础数据,条目数: ${memoryCache.items.length},解析耗时: ${loadTimeMs} ms,约占内存: ${memoryFootprintMB} MB`); + + if (cacheDays === 0 || (Date.now() - stats.mtimeMs >= expireMs)) { + if (isDataDependentRequest && !isDownloading) { + log("info", `[Bangumi-Data] 磁盘数据${cacheDays === 0 ? '强制更新' : '已过期'},保留老数据服务本次请求,启动后台静默更新...`); + isDownloading = true; + downloadLockTime = Date.now(); + downloadAndCache(cachePath).finally(() => { isDownloading = false; }); + } + } + return; + } catch (e) { + log("error", "[Bangumi-Data] 磁盘缓存解析失败,准备重新下载", e.message); + memoryCache = null; + } + } + + // 内存与磁盘均无有效数据时的获取逻辑 + if (!isDownloading) { + log("info", `[Bangumi-Data] 未命中任何有效缓存,正在获取基础数据...`); + isDownloading = true; + downloadLockTime = Date.now(); + + const downloadPromise = downloadAndCache(cachePath).finally(() => { isDownloading = false; }); + + if (isDataDependentRequest) { + await downloadPromise; + } else { + log("info", `[Bangumi-Data] 当前非核心请求,数据获取转入后台异步执行`); + if (ctx && typeof ctx.waitUntil === 'function') { + log("info", `[Bangumi-Data] 调用 ctx.waitUntil 延长 Serverless 生命周期`); + ctx.waitUntil(downloadPromise); + } + } + } else if (isDataDependentRequest) { + log("info", `[Bangumi-Data] 正在等待基础数据下载完成...`); + let waitCount = 0; + while (isDownloading) { + if (waitCount > 150) { // 最多等待 15 秒 (150 * 100ms) + log("warn", "[Bangumi-Data] 等待排队超时,放弃等待"); + isDownloading = false; + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + waitCount++; + } + } +} + +/** + * 下载并缓存最新版本的 Bangumi Data + * 支持多节点并发竞速流式下载写入磁盘或直接解析到内存,并记录细粒度性能指标 + * @param {string|null} cachePath - 缓存文件写入路径,为 null 时仅加载到内存 + * @returns {Promise} + */ +async function downloadAndCache(cachePath) { + log("info", "[Bangumi-Data] 开始优选节点下载最新数据 (约 7MB)..."); + try { + const memBefore = process.memoryUsage().heapUsed; + const startTime = Date.now(); + + let parseStartTime = 0; + let parseTimeMs = 0; + + // 备选 CDN 节点列表 + const CDNS = [ + "https://cdn.jsdelivr.net/npm/bangumi-data@0.3/dist/data.json", + "https://unpkg.com/bangumi-data@0.3/dist/data.json" + ]; + + // 声明压缩头,提升传输效率 + const fetchOptions = { + headers: { + 'Accept-Encoding': 'br, gzip, deflate' + } + }; + + // 为每个请求分配独立的终止控制器 + const controllers = CDNS.map(() => new AbortController()); + + CDNS.forEach(url => { + log("info", `[请求模拟] HTTP GET: ${url}`); + }); + + // 构建完整下载任务的 Promise 数组 + const racePromises = CDNS.map(async (url, index) => { + const controller = controllers[index]; + + const response = await fetch(url, { ...fetchOptions, signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP error ${response.status} from ${url}`); + + let resultData = null; + let tempFilePath = null; + + if (cachePath) { + // 物理磁盘模式:为每个并发流生成独立的临时文件 + tempFilePath = `${cachePath}.tmp${index}`; + await pipeline(response.body, fs.createWriteStream(tempFilePath)); + } else { + // 纯内存模式:直接解析完整的 JSON + resultData = await response.json(); + } + + // 返回胜出者所需的所有上下文信息 + return { index, url, tempFilePath, resultData }; + }); + + // Promise.any 会等待第一个完全执行完毕的 Promise + const winner = await Promise.any(racePromises); + + // 首个下载完成后,立刻中断所有落后者的网络请求 + controllers.forEach((ctrl, i) => { + if (i !== winner.index) { + ctrl.abort(); + } + }); + + // 清理落后者的临时文件,避免占用磁盘空间 + if (cachePath) { + CDNS.forEach((_, i) => { + if (i !== winner.index) { + const loserTempPath = `${cachePath}.tmp${i}`; + if (fs.existsSync(loserTempPath)) { + try { fs.unlinkSync(loserTempPath); } catch (e) {} + } + } + }); + } + + const downloadTime = ((Date.now() - startTime) / 1000).toFixed(2); + log("info", `[Bangumi-Data] 流式下载并写入磁盘成功(优选节点: ${new URL(winner.url).hostname} ,网络耗时: ${downloadTime} 秒)`); + + // 数据处理流 + if (cachePath) { + // 将胜出者的临时文件正式重命名为缓存文件 + fs.renameSync(winner.tempFilePath, cachePath); + + parseStartTime = Date.now(); + memoryCache = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + parseTimeMs = Date.now() - parseStartTime; + } else { + parseStartTime = Date.now(); + memoryCache = winner.resultData; + parseTimeMs = Date.now() - parseStartTime; + } + + const memAfter = process.memoryUsage().heapUsed; + const totalTime = ((Date.now() - startTime) / 1000).toFixed(2); + + memoryFootprintMB = Math.max(0, (memAfter - memBefore) / 1024 / 1024).toFixed(2); + const totalMemMB = (process.memoryUsage().rss / 1024 / 1024).toFixed(2); + memoryCacheTime = Date.now(); + + log("info", `[Bangumi-Data] 加载到内存成功,条目数: ${memoryCache.items.length},解析耗时: ${parseTimeMs} ms,全链路总耗时: ${totalTime} 秒,约占内存: ${memoryFootprintMB} MB (当前项目总占用: ${totalMemMB} MB)`); + } catch (e) { + // 提取所有请求均失败时的具体错误信息 + const errorMessage = e instanceof AggregateError ? e.errors.map(err => err.message).join(' | ') : e.message; + log("error", "[Bangumi-Data] 下载失败 (所有 CDN 均未响应或报错):", errorMessage); + } +} + +/** + * 在 Bangumi Data 中搜索匹配的动漫条目 + * 支持多语言标题检索、特定站点过滤以及配音/区域版本解析 + * @param {string} keyword - 搜索关键词 + * @param {Array} siteKeys - 需要匹配的源站点标识数组 + * @returns {Array} 匹配的动漫条目数组,包含解析后的展示标题、ID及类型等元数据 + */ +export function searchBangumiData(keyword, siteKeys) { + if (!memoryCache || !memoryCache.items) return []; + + const validDubRegex = /(?:普通[话話]|[国國][语語]|中文配音|中配|中文|[粤粵][语語]配音|[粤粵]配|[粤粵][语語]|[台臺]配|[台臺][语語]|港配|港[语語]|字幕|助[听聽]|日[语語]|日配|原版|原[声聲])(?:版)?/; + const results = []; + for (const item of memoryCache.items) { + // 构建完整的搜索标题池 + const titles = [item.title]; + if (item.titleTranslate) { + for (const lang in item.titleTranslate) { + titles.push(...item.titleTranslate[lang]); + } + } + + const isMatch = titles.some(t => t && titleMatches(t, keyword)); + + if (isMatch && item.sites) { + // 捕获所有符合条件的站点记录,支持同源多区域版本(如大陆版和港澳台版共存) + const matchedSites = item.sites.filter(s => siteKeys.includes(s.site)); + + for (const matchedSite of matchedSites) { + // 标准化媒体类型映射 + let typeStr = "TV动画"; + let typeId = "tvseries"; + if (item.type === 'movie') { typeStr = "剧场版"; typeId = "movie"; } + else if (item.type === 'tv') { typeStr = "TV动画"; typeId = "tvseries"; } + else if (item.type === 'ova') { typeStr = "OVA"; typeId = "ova"; } + else if (item.type === 'web') { typeStr = "WEB动画"; typeId = "web"; } + + // 提取区域版本基础后缀 + let baseSuffix = ""; + if (['bilibili_hk_mo_tw', 'bilibili_hk_mo', 'bilibili_tw'].includes(matchedSite.site)) { + baseSuffix = "(港澳台)"; + } + + // 解析衍生配音版本与当前主条目的附加描述 + let additionalDubs = []; + let mainItemDubs = []; + + if (matchedSite.comment) { + const dubs = matchedSite.comment.split(/[,,]/); + for (const dub of dubs) { + const parts = dub.split(/[::]/); + if (parts.length >= 2) { + const dubName = parts[0].trim(); + const dubId = parts[1].trim(); + if (dubId && validDubRegex.test(dubName)) { + additionalDubs.push({ + title: item.title, + titles: [...titles], + begin: item.begin, + siteId: dubId, + matchedSiteKey: matchedSite.site, + type: item.type, + typeStr: typeStr, + typeId: typeId, + titleSuffix: ` ${dubName}${baseSuffix}` + }); + } + } else if (parts.length === 1 && parts[0].trim()) { + const dubName = parts[0].trim(); + if (validDubRegex.test(dubName)) { + mainItemDubs.push(dubName); + } + } + } + } + + // 组装当前主条目的最终后缀标签 + let currentItemSuffix = ""; + if (mainItemDubs.length > 0) { + currentItemSuffix += ` ${mainItemDubs.join(' ')}`; + } + currentItemSuffix += baseSuffix; + + results.push({ + title: item.title, + titles: [...titles], + begin: item.begin, + siteId: matchedSite.id, + matchedSiteKey: matchedSite.site, + type: item.type, + typeStr: typeStr, + typeId: typeId, + titleSuffix: currentItemSuffix + }); + + // 推送提取出的衍生条目 + results.push(...additionalDubs); + } + } + } + return results; +} + +/** + * 释放本地数据内存缓存 + * 当动态关闭本地数据开关时调用,协助 V8 引擎进行垃圾回收 + */ +export function clearBangumiDataCache() { + if (memoryCache !== null) { + const itemCount = memoryCache.items ? memoryCache.items.length : 0; + memoryCache = null; // 切断引用,等待 GC 回收 + memoryCacheTime = 0; // 重置寿命时钟 + const totalMemMB = (process.memoryUsage().rss / 1024 / 1024).toFixed(2); + log("info", `[Bangumi-Data] 内存缓存已主动释放 (原条目数: ${itemCount},释放: ${memoryFootprintMB} MB,当前项目总占用: ${totalMemMB} MB)`); + memoryFootprintMB = '0.00'; // 重置探针 + hasLoggedCacheWarning = false; // 重置警告标记 + } +} diff --git a/danmu_api/utils/common-util.js b/danmu_api/utils/common-util.js index c63c0d4..443377a 100644 --- a/danmu_api/utils/common-util.js +++ b/danmu_api/utils/common-util.js @@ -287,7 +287,11 @@ export function titleMatches(title, query) { } // 策略3:相似度匹配 (阈值0.8) - // 解决"和/与"等翻译差异,只要搜索词中 大于 80% 的字符出现在标题里,即视为匹配 + // 长度差异过大,或纯英文/数字时,禁止使用字符打散策略阻断 + if (Math.abs(t.length - q.length) > Math.max(t.length, q.length) * 0.7 || /^[a-zA-Z0-9]+$/.test(q)) { + return false; + } + // 核心相似度计算:解决"和/与"等翻译差异 const qSet = new Set(q); const tSet = new Set(t); const matchCount = [...qSet].reduce((acc, char) => acc + (tSet.has(char) ? 1 : 0), 0); diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 672084c..55c905c 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -125,13 +125,13 @@ function log(level, ...args) { const RegexStore = { Lang: { - CN: /(普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听)(?:版)?/, - JP: /(日语|日配|原版|原声)(?:版)?/, - CN_DUB_VER: /(\(|(|\[)?(普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听)版?(\)|)|\])?/g, - JP_DUB_VER: /(\(|(|\[)?(日语|日配|原版|原声)版?(\)|)|\])?/g, - KEYWORDS_STRONG: /(?:普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听|日语|日配|原版|原声)(?:版)?/g, - CN_STD: /普通话|国语|中文配音|中配|中文|粤配|粤语|台配|台语|港配|港语|字幕|助听/g, - JP_STD: /日语|日配|原版|原声/g + CN: /(普通[话話]|[国國][语語]|中文配音|中配|中文|[粤粵][语語]配音|[粤粵]配|[粤粵][语語]|[台臺]配|[台臺][语語]|港配|港[语語]|字幕|助[听聽])(?:版)?/, + JP: /(日[语語]|日配|原版|原[声聲])(?:版)?/, + CN_DUB_VER: /(\(|(|\[)?(普通[话話]|[国國][语語]|中文配音|中配|中文|[粤粵][语語]配音|[粤粵]配|[粤粵][语語]|[台臺]配|[台臺][语語]|港配|港[语語]|字幕|助[听聽])版?(\)|)|\])?/g, + JP_DUB_VER: /(\(|(|\[)?(日[语語]|日配|原版|原[声聲])版?(\)|)|\])?/g, + KEYWORDS_STRONG: /(?:普通[话話]|[国國][语語]|中文配音|中配|中文|[粤粵][语語]配音|[粤粵]配|[粤粵][语語]|[台臺]配|[台臺][语語]|港配|港[语語]|字幕|助[听聽]|日[语語]|日配|原版|原[声聲])(?:版)?/g, + CN_STD: /普通[话話]|[国國][语語]|中文配音|中配|中文|[粤粵][语語]配音|[粤粵]配|[粤粵][语語]|[台臺]配|[台臺][语語]|港配|港[语語]|字幕|助[听聽]/g, + JP_STD: /日[语語]|日配|原版|原[声聲]/g }, Season: { PURE_PART: /^(?:(?:第|S(?:eason)?)\s*\d+(?:季|期|部)?|(?:Part|P|第)\s*\d+(?:部分)?)$/i, @@ -156,7 +156,7 @@ const RegexStore = { WHITESPACE: /\s+/g, FROM_SUFFIX: /\s*from\s+.*$/i, PARENTHESES_CONTENT: /(\(|(|\[).*?(\)|)|\])/g, - MOVIE_KEYWORDS: /剧场版|the\s*movie|theatrical|movie|film|电影/gi, + MOVIE_KEYWORDS: /剧场版|劇場版|the\s*movie|theatrical|movie|film|电影/gi, LONE_VER_CHAR: /(\s|^)版(\s|$)/g, NON_ALPHANUM_CN: /[^\u4e00-\u9fa5a-zA-Z0-9]/g, META_SUFFIX: /(\(|(|\[)(续篇|TV版|无修|未删减|完整版)(\)|)|\])/gi, @@ -176,7 +176,7 @@ const RegexStore = { PUNCTUATION: /[!!??,,.。、~~::\-–—]/g, DANDAN_TAG: /^【(dandan|animeko)】/i, SPECIAL_START: /^S\d+/i, - MOVIE_CHECK: /剧场版|movie|film/i, + MOVIE_CHECK: /剧场版|劇場版|movie|film/i, PV_CHECK: /(pv|trailer|预告)/i, SPECIAL_CHECK: /^(s|o|sp|special)\d/i, SEASON_MATCH: /(?:^|\s)(?:第|S)(\d+)[季S]/i, @@ -210,7 +210,7 @@ const SEASON_PATTERNS = [ { regex: /\bs\s*(\d+)\b/i, prefix: 'S' }, { regex: /\bpart\s*(\d+)/i, prefix: 'P' }, { regex: /\b(ova|oad)\d*\b/i, val: 'OVA' }, - { regex: /(剧场版|the\s*movie|theatrical|movie|film|电影)/i, val: 'MOVIE' }, + { regex: /(剧场版|劇場版|the\s*movie|theatrical|movie|film|电影)/i, val: 'MOVIE' }, { regex: /(续篇|续集)/, val: 'SEQUEL' }, { regex: /\b(sp|special)\d*\b/i, val: 'SP' }, { regex: /[^0-9](\d)$/, prefix: 'S', useCleaned: true } @@ -360,7 +360,7 @@ function cleanTitleForSimilarity(text) { const startBracketMatch = clean.match(/^(?:【|\[)(.+?)(?:】|\])/); if (startBracketMatch) { const content = startBracketMatch[1]; - if (!/^(TV|剧场版|movie|film|anime|动漫|动画|AVC|HEVC|MP4|MKV)$/i.test(content)) { + if (!/^(TV|剧场版|劇場版|movie|film|anime|动漫|动画|AVC|HEVC|MP4|MKV)$/i.test(content)) { clean = clean.replace(startBracketMatch[0], content + ' '); } } @@ -2247,7 +2247,7 @@ function detectCollectionCandidates(curAnimes) { const startBracketMatch = protectedTitle.match(/^(?:【|\[)(.+?)(?:】|\])/); if (startBracketMatch) { const content = startBracketMatch[1]; - if (!/^(TV|剧场版|movie|film|anime|动漫|动画|AVC|HEVC|MP4|MKV)$/i.test(content)) { + if (!/^(TV|剧场版|劇場版|movie|film|anime|动漫|动画|AVC|HEVC|MP4|MKV)$/i.test(content)) { protectedTitle = protectedTitle.replace(startBracketMatch[0], content + ' '); } } diff --git a/danmu_api/utils/tmdb-util.js b/danmu_api/utils/tmdb-util.js index 15768d4..4056a81 100644 --- a/danmu_api/utils/tmdb-util.js +++ b/danmu_api/utils/tmdb-util.js @@ -2,6 +2,7 @@ import { globals } from '../configs/globals.js'; import { log } from './log-util.js' import { httpGet } from "./http-util.js"; import { isNonChinese } from "./zh-util.js"; +import { searchBangumiData } from './bangumi-data-util.js'; // --------------------- // TMDB API 工具方法 @@ -205,17 +206,30 @@ async function getChineseTitleForResult(result, signal, queryTitle = "") { // 使用TMDB API 查询日语原名,支持请求合并与引用计数控制 export async function getTmdbJaOriginalTitle(title, signal = null, sourceLabel = 'Unknown') { - if (!globals.tmdbApiKey) { - log("info", "[TMDB] 未配置API密钥,跳过TMDB搜索"); - return null; - } - // 优化搜索关键词: 剥离 "Season 2", "第二季" 等后缀 const cleanTitle = cleanSearchQuery(title); if (cleanTitle !== title) { log("info", `[TMDB] 优化搜索关键词: "${title}" -> "${cleanTitle}"`); } + // 优先尝试使用本地 Bangumi Data 获取原名与翻译,零延迟且无需 API Key + if (globals.useBangumiData) { + const localMatches = searchBangumiData(cleanTitle, ['tmdb', 'bangumi', 'anidb']); + if (localMatches && localMatches.length > 0) { + const m = localMatches[0]; // 取第一个最佳匹配 + const displayTitle = m.titles.find(t => t && t.includes(cleanTitle)) || m.titles[1] || m.title; + const jaOriginalTitle = m.title; // Bangumi Data 的主标题就是原名 + + log("info", `[TMDB] 命中本地 Bangumi Data,提取原名成功: 原名=${jaOriginalTitle}, 别名=${displayTitle}`); + return { title: jaOriginalTitle, cnAlias: displayTitle }; + } + } + + if (!globals.tmdbApiKey) { + log("info", "[TMDB] 未配置API密钥,跳过TMDB网络搜索"); + return null; + } + // 检查是否已有相同关键词的搜索任务正在进行 let task = TMDB_PENDING.get(cleanTitle); @@ -574,6 +588,21 @@ export async function getTMDBChineseTitle(title, season = null, episode = null) return title; } +// 优先尝试本地 Bangumi Data 转换 + if (globals.useBangumiData) { + const cleanTitle = cleanSearchQuery(title); + const localMatches = searchBangumiData(cleanTitle, ['tmdb', 'bangumi', 'anidb']); + if (localMatches && localMatches.length > 0) { + const m = localMatches[0]; + // 找一个不全是外文的翻译作为中文名 + const displayTitle = m.titles.find(t => t && !isNonChinese(t)) || m.titles[1]; + if (displayTitle && !isNonChinese(displayTitle)) { + log("info", `[TMDB] 命中本地 Bangumi Data: ${title} -> ${displayTitle}`); + return displayTitle; + } + } + } + // 判断是电影还是电视剧 const isTV = season !== null && season !== undefined; const mediaType = isTV ? 'tv' : 'movie'; @@ -620,7 +649,7 @@ export async function getTMDBChineseTitle(title, season = null, episode = null) // ===================== // 识别季度、剧场版、外传、副标题等后缀信息的正则白名单 -const SUFFIX_PATTERN = /(?:\s+|^)(?:第?(?:\d+|[一二三四五六七八九十]+)[季期部]|season\s*\d+|s\d+|part\s*\d+|the\s+final\s+season|(?:movie|film|ova|oad|sp)(?![a-z]))|[::~~]|\s+.*?篇|外传/i; +const SUFFIX_PATTERN = /(?:\s+|^)(?:第?\s*(?:\d+|[一二三四五六七八九十]+)\s*[季期部]|season\s*\d+|s\d+|part\s*\d+|act\s*\d+|phase\s*\d+|the\s+final\s+season|(?:movie|film|ova|oad|sp|剧场版|劇場版|续[篇集]|外传)(?![a-z]))|[::~~]|\s+.*?篇|(?<=\s|^)\d+$/i const SEPARATOR_REGEX = /[ ::~~]/; @@ -660,15 +689,13 @@ export function smartTitleReplace(animes, cnAlias) { // 计算所有标题主体部分的 LCP (最长公共前缀) const baseTitles = animes.map(a => { const t = a.org_title || a.title || ""; - const limit = detectSuffixStart(t); - return t.substring(0, limit); + return t.substring(0, detectSuffixStart(t)); }); let lcp = ""; if (baseTitles.length > 0) { const sorted = baseTitles.concat().sort(); - const a1 = sorted[0]; - const a2 = sorted[sorted.length - 1]; + const a1 = sorted[0], a2 = sorted[sorted.length - 1]; let i = 0; while (i < a1.length && a1.charAt(i) === a2.charAt(i)) i++; lcp = a1.substring(0, i); @@ -678,36 +705,43 @@ export function smartTitleReplace(animes, cnAlias) { log("info", `[TMDB] 计算出最长公共前缀 (LCP): "${lcp}"`); } - for (let i = 0; i < animes.length; i++) { - const anime = animes[i]; + for (const anime of animes) { const originalTitle = anime.title || ""; + // 防止本地 Bangumi Data 标题被替换与防止重复标题 + if (anime.isLocalPriority || originalTitle.includes(cnAlias)) { + anime._displayTitle = anime._displayTitle || originalTitle; + continue; + } + // 策略 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}"`); + anime._displayTitle = suffix ? `${cnAlias}${suffix.match(/^[~~::]/) ? '' : ' '}${suffix}` : cnAlias; + log("info", `[TMDB] [LCP模式] "${originalTitle}" -> "${anime._displayTitle}"`); } // 策略 B: 分隔符模式 else { const match = originalTitle.match(SEPARATOR_REGEX); if (match) { + const prefix = originalTitle.substring(0, match.index).trim(); const suffix = originalTitle.substring(match.index); - const newTitle = cnAlias + suffix; - anime._displayTitle = newTitle; - log("info", `[TMDB] [分隔符模式] "${originalTitle}" -> "${newTitle}"`); + + // 防止替换必要前缀 + if (prefix && SUFFIX_PATTERN.test(prefix)) { + const subMatch = suffix.trim().match(SEPARATOR_REGEX); + const subSuffix = subMatch ? suffix.trim().substring(subMatch.index) : ''; + anime._displayTitle = `${prefix} ${cnAlias}${subSuffix}`; + log("info", `[TMDB] [前缀保护模式] "${originalTitle}" -> "${anime._displayTitle}"`); + } else { + // 常规分隔符替换 + anime._displayTitle = cnAlias + suffix; + log("info", `[TMDB] [分隔符模式] "${originalTitle}" -> "${anime._displayTitle}"`); + } } else { // 策略 C: 全替模式 anime._displayTitle = cnAlias; - log("info", `[TMDB] [全替模式] "${originalTitle}" -> "${cnAlias}"`); + log("info", `[TMDB] [全替模式] "${originalTitle}" -> "${anime._displayTitle}"`); } } } diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 24f5a47..15e75b7 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -5,6 +5,7 @@ import { getRedisCaches, judgeRedisValid } from "./utils/redis-util.js"; import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js"; import { formatDanmuResponse } from "./utils/danmu-util.js"; import AIClient from './utils/ai-util.js'; +import { initBangumiData } from "./utils/bangumi-data-util.js"; import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js"; import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords } from "./apis/system-api.js"; import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js"; @@ -19,7 +20,7 @@ import { let globals; -async function handleRequest(req, env, deployPlatform, clientIp) { +async function handleRequest(req, env, deployPlatform, clientIp, ctx) { // 加载全局变量和环境变量配置 globals = Globals.init(env); @@ -27,6 +28,13 @@ async function handleRequest(req, env, deployPlatform, clientIp) { let path = url.pathname; const method = req.method; + // Bangumi Data 辅助函数,用于判断数据更新 + const isDataDependentRequest = path.includes('/search') || path.includes('/match'); + + if (globals.useBangumiData) { + await initBangumiData(deployPlatform, isDataDependentRequest, ctx); + } + globals.deployPlatform = deployPlatform; if (deployPlatform === "node") { await judgeLocalCacheValid(path, deployPlatform); @@ -643,7 +651,7 @@ export default { // 获取客户端的真实 IP const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown'; - return handleRequest(request, env, isRunningOnVercel() ? "vercel" : "cloudflare", clientIp); + return handleRequest(request, env, isRunningOnVercel() ? "vercel" : "cloudflare", clientIp, ctx); }, };