集成 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:
1 parent
b2afb2dbdc
commit
01fd9e15aa
13 files changed
+755
-103
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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(用于缓存)
|
||||
|
||||
@@ -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), // 部署平台项目名称配置(默认空)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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; // 重置警告标记
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 + ' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user