feat(animeko): 新增 Bangumi 镜像站支持并引入详情接口多源降级适配 (#333)
- animeko.js: 1. 搜索节点扩容: 为 Bangumi V0 搜索接入高性能镜像站 (api.bangumi.one),并依据代理配置状态动态分配轮询优先级(代理状态:官方/代理 > 镜像;直连状态:镜像 > 官方)。 2. 详情灾备链路: 以聚合度最高、性能最优的 Animeko V2 API 作为首选详情节点,重新接入镜像 V0 与官方 V0 节点作为底层的降级备份。 3. 详情 V0 适配器: 构建降级适配器。在触发 V0 节点降级时,直接复用搜索链路已下发的基础元数据,仅并发请求剧集与关联端点,在内存中将数据无缝归一化为 V2 结构,确保上层业务逻辑零感知且网络开销最小化。
This commit is contained in:
1 parent
a94256c591
commit
6752d867cf
1 file changed
+253
-64
+253
-64
@@ -13,13 +13,12 @@ import { searchBangumiData } from '../utils/bangumi-data-util.js';
|
||||
// =====================
|
||||
|
||||
// 接口健康状态缓存 (全局共享,跨请求持久化,实现业务级智能路由)
|
||||
// 根据部署时区动态确定节点优先级,并记录当前健康的节点 URL
|
||||
const API_HEALTH = {
|
||||
danmu: null,
|
||||
subject: null
|
||||
};
|
||||
|
||||
// Animeko 详情数据短效缓存
|
||||
// Animeko 详情数据短效缓存 (用于跨阶段与跨搜索请求共享数据,避免短时间内重复网络请求)
|
||||
const SUBJECT_CACHE = new Map();
|
||||
const SUBJECT_CACHE_TTL = 3 * 60 * 1000; // 缓存有效期:3 分钟 (180000 毫秒)
|
||||
const SUBJECT_CACHE_MAX_SIZE = 100; // 最大缓存条目数
|
||||
@@ -49,36 +48,156 @@ export default class AnimekoSource extends BaseSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态获取基于部署时区的 Animeko 节点优先级列表 (完全反转官方逻辑)
|
||||
* 获取 Animeko 弹幕域专属节点优先级列表 (官方优先级及逻辑)
|
||||
* 弹幕域仅存在于 Animeko 自建节点,不混入官方镜像
|
||||
* @returns {Array<string>} 节点 URL 数组
|
||||
*/
|
||||
_getAnimekoServerPriority() {
|
||||
_getDanmuServerPriority() {
|
||||
// 获取当前运行环境的时区偏移 (分钟),UTC+8 为 -480
|
||||
const isUTC8 = new Date().getTimezoneOffset() === -480;
|
||||
|
||||
if (isUTC8) {
|
||||
// 原中国时区 (UTC+8) 优先级: api.animeko.org, danmaku-global.myani.org, danmaku-cn.myani.org, s1.animeko.openani.org
|
||||
// 完全反转后优先级:
|
||||
// 中国时区 (UTC+8) 优先级: api.animeko.org, danmaku-global.myani.org, danmaku-cn.myani.org, s1.animeko.openani.org
|
||||
return [
|
||||
"https://s1.animeko.openani.org",
|
||||
"https://danmaku-cn.myani.org",
|
||||
"https://api.animeko.org",
|
||||
"https://danmaku-global.myani.org",
|
||||
"https://api.animeko.org"
|
||||
"https://danmaku-cn.myani.org",
|
||||
"https://s1.animeko.openani.org"
|
||||
];
|
||||
} else {
|
||||
// 原其他时区优先级: danmaku-global.myani.org, api.animeko.org, s1.animeko.openani.org, danmaku-cn.myani.org
|
||||
// 完全反转后优先级:
|
||||
// 其他时区优先级: danmaku-global.myani.org, api.animeko.org, s1.animeko.openani.org, danmaku-cn.myani.org
|
||||
return [
|
||||
"https://danmaku-cn.myani.org",
|
||||
"https://s1.animeko.openani.org",
|
||||
"https://danmaku-global.myani.org",
|
||||
"https://api.animeko.org",
|
||||
"https://danmaku-global.myani.org"
|
||||
"https://s1.animeko.openani.org",
|
||||
"https://danmaku-cn.myani.org"
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Animeko API 获取条目详情(含剧集与关联数据,智能路由降级)
|
||||
* 获取多源详情节点优先级列表 (含动态降级及代理感知)
|
||||
* 支持官方(V0)、镜像(V0)及Animeko(V2)节点的融合队列调度
|
||||
* @returns {Array<Object>} 包含节点类型与 URL 的配置数组
|
||||
*/
|
||||
_getSubjectServerPriority() {
|
||||
const officialBase = "https://api.bgm.tv";
|
||||
const mirrorBase = "https://api.bangumi.one";
|
||||
// 获取可能被代理的官方 URL
|
||||
const proxyOfficialBase = this._applyProxyForSearch(officialBase);
|
||||
|
||||
// 获取当前运行环境的时区偏移 (分钟),UTC+8 为 -480
|
||||
const isUTC8 = new Date().getTimezoneOffset() === -480;
|
||||
|
||||
// 中国时区 (UTC+8) 优先级: api.animeko.org, danmaku-global.myani.org, danmaku-cn.myani.org, s1.animeko.openani.org
|
||||
// 其他时区优先级: danmaku-global.myani.org, api.animeko.org, s1.animeko.openani.org, danmaku-cn.myani.org
|
||||
const animekoNodes = isUTC8 ? [
|
||||
{ type: 'V2', url: "https://api.animeko.org" },
|
||||
{ type: 'V2', url: "https://danmaku-global.myani.org" },
|
||||
{ type: 'V2', url: "https://danmaku-cn.myani.org" },
|
||||
{ type: 'V2', url: "https://s1.animeko.openani.org" }
|
||||
] : [
|
||||
{ type: 'V2', url: "https://danmaku-global.myani.org" },
|
||||
{ type: 'V2', url: "https://api.animeko.org" },
|
||||
{ type: 'V2', url: "https://s1.animeko.openani.org" },
|
||||
{ type: 'V2', url: "https://danmaku-cn.myani.org" }
|
||||
];
|
||||
|
||||
// 依据代理配置状态构建查询队列
|
||||
if (proxyOfficialBase !== officialBase) {
|
||||
// 代理状态:Animeko(V2) > 官方/用户反代(V0) > 镜像(V0)
|
||||
return [
|
||||
...animekoNodes,
|
||||
{ type: 'V0', url: proxyOfficialBase },
|
||||
{ type: 'V0', url: mirrorBase }
|
||||
];
|
||||
} else {
|
||||
// 常规直连:Animeko(V2) > 镜像(V0) > 官方(V0)
|
||||
return [
|
||||
...animekoNodes,
|
||||
{ type: 'V0', url: mirrorBase },
|
||||
{ type: 'V0', url: officialBase }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 V0 剧集列表完整数据(并发适配器内部辅助方法)
|
||||
* Bangumi API 限制单次 limit=200,需循环获取完整列表以适配长篇番剧
|
||||
* @param {string} serverUrl 节点地址
|
||||
* @param {number} subjectId 条目 ID
|
||||
* @returns {Promise<Array>} 剧集原始数据数组
|
||||
*/
|
||||
async _fetchV0AllEpisodes(serverUrl, subjectId) {
|
||||
let allEpisodes = [];
|
||||
let offset = 0;
|
||||
const limit = 200;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// 构造分页 URL
|
||||
const url = `${serverUrl}/v0/episodes?subject_id=${subjectId}&limit=${limit}&offset=${offset}`;
|
||||
|
||||
const resp = await httpGet(url, {
|
||||
headers: this.headers,
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
// 1. 结构校验:确保 resp.data.data 存在且为数组
|
||||
if (!resp || !resp.data || !Array.isArray(resp.data.data)) {
|
||||
if (offset === 0) {
|
||||
log("info", `[Animeko] Subject ${subjectId} 无剧集数据或响应异常`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const currentBatch = resp.data.data;
|
||||
|
||||
// 2. 空数据校验:如果没有数据,停止
|
||||
if (currentBatch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 3. 合并数据
|
||||
allEpisodes = allEpisodes.concat(currentBatch);
|
||||
|
||||
// 打印进度日志
|
||||
if (currentBatch.length === limit) {
|
||||
log("info", `[Animeko] ID:${subjectId} 正加载更多剧集 (当前已获: ${allEpisodes.length})`);
|
||||
}
|
||||
|
||||
// 4. 判断是否还有下一页
|
||||
// 如果当前获取的数量少于限制数量 (例如获取了 2 个,而 limit 是 200),说明是最后一页
|
||||
if (currentBatch.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. 准备下一页
|
||||
offset += limit;
|
||||
|
||||
// 6. 安全熔断:防止API异常导致死循环
|
||||
if (offset > 1600) {
|
||||
log("warn", `[Animeko] ID:${subjectId} 剧集数量超过安全限制(1600),停止翻页`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allEpisodes;
|
||||
|
||||
} catch (error) {
|
||||
log("error", "[Animeko] V0剧集获取异常:", {
|
||||
message: error.message,
|
||||
id: subjectId,
|
||||
offset: offset
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过多源聚合获取条目详情(含剧集与关联数据,支持智能路由降级)
|
||||
* V2 节点: 单次请求聚合返回
|
||||
* V0 节点: 采用适配器模式,并发获取子端点数据并组装归一化为 V2 格式,实现底层调用抹平
|
||||
* 使用短效缓存策略兼顾请求去重与动态数据(剧集)的时效性
|
||||
* @param {number} subjectId 条目 ID
|
||||
* @returns {Promise<Object|null>}
|
||||
@@ -90,38 +209,76 @@ export default class AnimekoSource extends BaseSource {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const servers = this._getAnimekoServerPriority();
|
||||
const servers = this._getSubjectServerPriority();
|
||||
|
||||
let currentHost = API_HEALTH.subject;
|
||||
if (!currentHost || !servers.includes(currentHost)) {
|
||||
currentHost = servers[0];
|
||||
API_HEALTH.subject = servers[0];
|
||||
let currentUrl = API_HEALTH.subject;
|
||||
let startIndex = servers.findIndex(s => s.url === currentUrl);
|
||||
|
||||
// 初始状态与列表变更的安全校验,防止缓存失效节点导致路由异常
|
||||
if (!currentUrl || startIndex === -1) {
|
||||
startIndex = 0;
|
||||
currentUrl = servers[0].url;
|
||||
API_HEALTH.subject = currentUrl;
|
||||
}
|
||||
|
||||
let startIndex = servers.indexOf(currentHost);
|
||||
if (startIndex === -1) startIndex = 0;
|
||||
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const hostIndex = (startIndex + i) % servers.length;
|
||||
const hostUrl = servers[hostIndex];
|
||||
const targetUrl = `${hostUrl}/v2/subjects/${subjectId}`;
|
||||
|
||||
const server = servers[(startIndex + i) % servers.length];
|
||||
|
||||
try {
|
||||
log("info", `[Animeko] Animeko API 请求节点: ${hostUrl} (${subjectId})`);
|
||||
const resp = await httpGet(targetUrl, {
|
||||
headers: this.headers,
|
||||
timeout: 3000 // 限制节点请求超时时间为 3000 毫秒,加速故障节点跳过与轮询降级
|
||||
});
|
||||
let resultData = null;
|
||||
|
||||
if (resp && resp.data && resp.data.id) {
|
||||
if (API_HEALTH.subject !== hostUrl) {
|
||||
log("info", `[Animeko] Animeko API 详情节点健康状态更新: ${API_HEALTH.subject} -> ${hostUrl}`);
|
||||
API_HEALTH.subject = hostUrl;
|
||||
if (server.type === 'V2') {
|
||||
log("info", `[Animeko] 详情接口请求 V2 节点: ${server.url} (${subjectId})`);
|
||||
const resp = await httpGet(`${server.url}/v2/subjects/${subjectId}`, {
|
||||
headers: this.headers,
|
||||
timeout: 3000 // 限制节点请求超时时间,加速故障节点跳过与轮询降级
|
||||
});
|
||||
if (resp && resp.data && resp.data.id) {
|
||||
resultData = resp.data;
|
||||
}
|
||||
} else if (server.type === 'V0') {
|
||||
log("info", `[Animeko] 详情补全请求 V0 节点: ${server.url} (${subjectId})`);
|
||||
|
||||
// 基础搜索流程已提供充足详情元数据,此处直接并发获取 V0 独立的剧集与关联端点数据,组装归一化为 V2 格式
|
||||
const [episodesData, relationsResp] = await Promise.all([
|
||||
this._fetchV0AllEpisodes(server.url, subjectId),
|
||||
httpGet(`${server.url}/v0/subjects/${subjectId}/subjects`, { headers: this.headers, timeout: 3000 })
|
||||
.catch(e => { log("warn", `[Animeko] V0关联获取异常: ${e.message}`); return null; })
|
||||
]);
|
||||
|
||||
// 当至少有一个接口成功响应时,构建 V2 格式的基础数据对象
|
||||
if ((episodesData && episodesData.length > 0) || relationsResp) {
|
||||
resultData = { id: subjectId };
|
||||
|
||||
// 组装并映射剧集数据至 V2 预期格式
|
||||
if (episodesData && episodesData.length > 0) {
|
||||
resultData.episodes = episodesData.map(ep => ({
|
||||
episodeId: ep.id,
|
||||
sort: ep.sort,
|
||||
ep: ep.ep,
|
||||
type: ep.type === 0 ? 'MAIN' : (ep.type === 1 ? 'SP' : String(ep.type)),
|
||||
name: ep.name,
|
||||
nameCn: ep.name_cn,
|
||||
airdate: ep.airdate
|
||||
}));
|
||||
}
|
||||
|
||||
// 挂载关联数组数据 (后续 _extractRelations 已具备数组格式兼容能力)
|
||||
if (relationsResp && relationsResp.data && Array.isArray(relationsResp.data)) {
|
||||
resultData.relations = relationsResp.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resultData) {
|
||||
if (API_HEALTH.subject !== server.url) {
|
||||
log("info", `[Animeko] 详情节点健康状态更新: ${API_HEALTH.subject} -> ${server.url}`);
|
||||
API_HEALTH.subject = server.url;
|
||||
}
|
||||
|
||||
// 将成功获取的数据连同当前时间戳一并写入缓存
|
||||
SUBJECT_CACHE.set(subjectId, {
|
||||
data: resp.data,
|
||||
data: resultData,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
@@ -131,23 +288,22 @@ export default class AnimekoSource extends BaseSource {
|
||||
SUBJECT_CACHE.delete(firstKey);
|
||||
}
|
||||
|
||||
return resp.data;
|
||||
return resultData;
|
||||
}
|
||||
log("warn", `[Animeko] Animeko API 节点 ${hostUrl} 返回无效数据`);
|
||||
log("warn", `[Animeko] 节点 ${server.url} 返回无效数据`);
|
||||
} catch (error) {
|
||||
log("warn", `[Animeko] Animeko API 节点请求失败: ${hostUrl} - ${error.message}`);
|
||||
log("warn", `[Animeko] 节点请求失败: ${server.url} - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 所有节点均失败,重置至优先级最高的首选节点
|
||||
log("warn", `[Animeko] Animeko API 所有详情节点均失败,重置至首选节点`);
|
||||
API_HEALTH.subject = servers[0];
|
||||
log("warn", `[Animeko] 所有详情节点均失败,重置健康状态至优先级首位`);
|
||||
API_HEALTH.subject = servers[0].url;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索动画条目
|
||||
* 使用 Bangumi V0 POST 接口进行搜索,支持偏移翻页、结果过滤及关系检测
|
||||
* 使用 Bangumi V0 POST 接口进行搜索,支持偏移翻页、结果过滤、镜像回退及关系检测
|
||||
* @param {string} keyword 搜索关键词
|
||||
* @returns {Promise<Array>} 转换后的搜索结果列表
|
||||
*/
|
||||
@@ -193,8 +349,28 @@ export default class AnimekoSource extends BaseSource {
|
||||
const limit = 20;
|
||||
|
||||
while (true) {
|
||||
const searchUrl = `https://api.bgm.tv/v0/search/subjects?limit=${limit}&offset=${offset}`;
|
||||
const proxySearchUrl = this._applyProxyForSearch(searchUrl);
|
||||
const searchPath = `/v0/search/subjects?limit=${limit}&offset=${offset}`;
|
||||
const officialUrl = `https://api.bgm.tv${searchPath}`;
|
||||
const mirrorUrl = `https://api.bangumi.one${searchPath}`;
|
||||
|
||||
// 获取可能被代理的官方 URL
|
||||
const proxySearchUrl = this._applyProxyForSearch(officialUrl);
|
||||
|
||||
// 依据代理配置状态构建查询队列
|
||||
// 代理状态:优先使用被代理的官方节点,降级至无代理的镜像节点
|
||||
// 直连状态:优先使用无代理的镜像节点,降级至无代理的官方节点
|
||||
let endpointQueue = [];
|
||||
if (proxySearchUrl !== officialUrl) {
|
||||
endpointQueue = [
|
||||
{ id: 'PROXY_OFFICIAL', url: proxySearchUrl },
|
||||
{ id: 'MIRROR', url: mirrorUrl }
|
||||
];
|
||||
} else {
|
||||
endpointQueue = [
|
||||
{ id: 'MIRROR', url: mirrorUrl },
|
||||
{ id: 'OFFICIAL', url: officialUrl }
|
||||
];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
keyword: searchKeyword,
|
||||
@@ -203,12 +379,25 @@ export default class AnimekoSource extends BaseSource {
|
||||
}
|
||||
};
|
||||
|
||||
const resp = await httpPost(proxySearchUrl, JSON.stringify(payload), {
|
||||
headers: this.headers
|
||||
});
|
||||
let resp = null;
|
||||
for (const ep of endpointQueue) {
|
||||
try {
|
||||
resp = await httpPost(ep.url, JSON.stringify(payload), {
|
||||
headers: this.headers,
|
||||
timeout: 5000 // 限制搜索超时时间,确保多节点故障转移的流畅性
|
||||
});
|
||||
|
||||
if (resp && resp.data) {
|
||||
log("info", `[Animeko] 搜索节点 [${ep.id}] 请求成功 (offset: ${offset})`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
log("warn", `[Animeko] 搜索节点 [${ep.id}] 请求失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp || !resp.data) {
|
||||
log("info", `[Animeko] 搜索请求失败或无数据返回 (offset: ${offset})`);
|
||||
log("info", `[Animeko] 搜索请求所有节点均失败或无数据返回 (offset: ${offset})`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -332,7 +521,7 @@ export default class AnimekoSource extends BaseSource {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用 Animeko API 获取关联数据
|
||||
// 获取关联数据进行校验
|
||||
const v2Data = await this._fetchAnimekoSubject(subjectA.id);
|
||||
const relations = this._extractRelations(v2Data);
|
||||
const relationInfo = relations.find(r => r.id === subjectB.id);
|
||||
@@ -356,7 +545,7 @@ export default class AnimekoSource extends BaseSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Animeko API 响应中提取关联条目
|
||||
* 从关联数据中提取关联条目
|
||||
* @param {Object|null} v2Data 动画详情数据
|
||||
* @returns {Array} 关联条目数组
|
||||
*/
|
||||
@@ -365,7 +554,7 @@ export default class AnimekoSource extends BaseSource {
|
||||
|
||||
const rel = v2Data.relations;
|
||||
|
||||
// 处理数组格式的关联数据
|
||||
// 兼容旧版数组格式
|
||||
if (Array.isArray(rel)) {
|
||||
return rel.filter(item => item.type === 2).map(item => ({
|
||||
id: item.id,
|
||||
@@ -374,9 +563,8 @@ export default class AnimekoSource extends BaseSource {
|
||||
}));
|
||||
}
|
||||
|
||||
// 处理结构化对象的关联数据,仅解析条目标识符
|
||||
// Animeko API 结构化关联对象,仅解析条目标识符
|
||||
const results = [];
|
||||
|
||||
const mapRelations = (idArray, relationType) => {
|
||||
if (Array.isArray(idArray)) {
|
||||
idArray.forEach(id => {
|
||||
@@ -500,7 +688,7 @@ export default class AnimekoSource extends BaseSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剧集列表(通过 Animeko API,含主备降级)
|
||||
* 获取剧集列表(含多源节点层级降级适配)
|
||||
* @param {number} subjectId 条目 ID
|
||||
* @returns {Promise<Array>} 剧集数组
|
||||
*/
|
||||
@@ -508,7 +696,7 @@ export default class AnimekoSource extends BaseSource {
|
||||
try {
|
||||
const v2Data = await this._fetchAnimekoSubject(subjectId);
|
||||
if (!v2Data || !v2Data.episodes || !Array.isArray(v2Data.episodes)) {
|
||||
if (v2Data) log("info", `[Animeko] Subject ${subjectId} 无剧集数据 (Animeko API)`);
|
||||
if (v2Data) log("info", `[Animeko] Subject ${subjectId} 无剧集数据`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -654,16 +842,17 @@ export default class AnimekoSource extends BaseSource {
|
||||
return [];
|
||||
}
|
||||
|
||||
const servers = this._getAnimekoServerPriority();
|
||||
const servers = this._getDanmuServerPriority();
|
||||
|
||||
let currentHost = API_HEALTH.danmu;
|
||||
if (!currentHost || !servers.includes(currentHost)) {
|
||||
currentHost = servers[0];
|
||||
API_HEALTH.danmu = servers[0];
|
||||
}
|
||||
|
||||
let startIndex = servers.indexOf(currentHost);
|
||||
if (startIndex === -1) startIndex = 0;
|
||||
|
||||
// 初始状态与列表变更的安全校验,防止缓存失效节点导致路由异常
|
||||
if (!currentHost || startIndex === -1) {
|
||||
startIndex = 0;
|
||||
currentHost = servers[0];
|
||||
API_HEALTH.danmu = currentHost;
|
||||
}
|
||||
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const hostIndex = (startIndex + i) % servers.length;
|
||||
|
||||
Reference in new issue
Block a user