集成 bangumi-data 项目,开启后提升动画源的检索与匹配速度并解锁隐藏/区域番剧 (#283)

* 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:双节点同时竞速下载数据,提升下载可用性与速度
This commit is contained in:
Elegy233 authored and GitHub committed 2026-04-26 15:26:02 +08:00
1 parent b2afb2dbdc
commit 01fd9e15aa
13 files changed
+755 -103

No files matched your search

+7
View File
@@ -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) 提供的平台动画元数据
### 贡献者
<a href="https://github.com/huangxd-/danmu_api/graphs/contributors">
<img src="https://contrib.rocks/image?repo=huangxd-/danmu_api" alt="contributors" />
+13
View File
@@ -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(用于缓存)
+4
View File
@@ -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), // 部署平台项目名称配置(默认空)
+18
View File
@@ -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);
});
}
+27 -18
View File
@@ -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<Array>} 转换后的搜索结果列表
*/
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,
/(?<!\d)\d+$/, // 末尾数字
/\S+篇/i, // 篇章标识 (如: 柱训练篇)
/\S+章/i,
/Act\s*\d+/i,
/Phase\s*\d+/i
];
return patterns.some(p => 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|(?<!\d)\d+$|\S+[篇章]/i
return pattern.test(title);
}
/**
+44 -13
View File
@@ -8,6 +8,7 @@ import { simplized, traditionalized } from "../utils/zh-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';
import { searchBangumiData } from '../utils/bangumi-data-util.js';
// =====================
// 获取巴哈姆特弹幕
@@ -17,6 +18,13 @@ import { SegmentListResponse } from '../models/dandan-model.js';
export default class BahamutSource extends BaseSource {
async search(keyword) {
try {
let localMatches = [];
// 提前获取本地匹配结果
if (globals.useBangumiData) {
localMatches = searchBangumiData(keyword, ['gamer', 'gamer_hk']);
log("info", `[Bahamut] Bangumi-Data 本地命中 ${localMatches.length} 条数据`);
}
// 在函数内部进行简转繁
const traditionalizedKeyword = traditionalized(keyword);
const tmdbSearchKeyword = keyword;
@@ -137,18 +145,42 @@ export default class BahamutSource extends BaseSource {
tmdbSearchPromise
]);
// 优先返回原始搜索结果
let finalResults = [];
if (originalResult.success) {
return originalResult.data;
finalResults = originalResult.data;
} else if (tmdbResult.success) {
finalResults = tmdbResult.data;
} else {
log("info", "[Bahamut] 原始搜索和基于TMDB的搜索均未返回任何结果");
}
// 原始搜索无结果,返回TMDB搜索结果
if (tmdbResult.success) {
return tmdbResult.data;
// 对齐 Bangumi Data 进行信息强化
if (finalResults.length > 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("[電影]")) {
+165 -31
View File
@@ -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 = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&#39;': "'" }; 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) => {
+22
View File
@@ -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}`);
+368
View File
@@ -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<void>}
*/
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<void>}
*/
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<string>} siteKeys - 需要匹配的源站点标识数组
* @returns {Array<Object>} 匹配的动漫条目数组,包含解析后的展示标题、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; // 重置警告标记
}
}
+5 -1
View File
@@ -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);
+12 -12
View File
@@ -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 + ' ');
}
}
+60 -26
View File
@@ -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}"`);
}
}
}
+10 -2
View File
@@ -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);
},
};