feat(animeko): 详情接口迁移至 Animeko V2 API 并新增 animeko@ 代理反代支持 (#330)

- animeko.js:
  1. 详情与关联接口全面迁移至 Animeko V2 API (/v2/subjects),并新增 animeko API 隔离逻辑(API请求不走代理,符合预期)。
  2. 为 Bangumi API 搜索接口新增 animeko@ 代理/反代支持。
  3. 完整集成 4 个 Animeko 备用域名,根据部署时区(UTC+8/其他)动态反转分配节点的轮询优先级。
  4. 新增模块级短效缓存 (3分钟 / 100条限制),消除在“关系检测”与“剧集获取”跨方法调用时产生的重复接口请求。
  5. 修复 _extractRelations 方法中因原始接口 ID 数组与 Name 数组长度不对齐导致的潜在数据错位问题,改为纯 ID 校验提取。
  6. 为 API 降级轮询机制显式注入 3000ms 超时控制,加速不可用节点的故障跳过。

- globals.js:
  新增 animeko@ 代理/反代规则的解析与支持,配合 animeko.js 实现按需代理。

- http-util.js:
  使 httpGet 与 httpGetWithStreamCheck 支持接收 options.timeout 参数,提供单次请求维度的自定义超时配置能力。

- README.md & config/.env.example:
  添加 Animeko 代理及反代的配置说明与使用文档。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-05-31 12:54:27 +08:00
1 parent 0cb28cea9a
commit ad303b1b8e
5 files changed
+312 -175

No files matched your search

+2 -2
View File
@@ -425,7 +425,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 |
| DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) |
| DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒(不指定来源则对所有来源生效),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02@bilibili:120, re-zero/S02/E03@dandan&bilibili:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。支持百分比模式,在路径/来源末尾添加 `%`,例如:`东方/S03/E02@tencent%:11`,按 `原时间 * (视频时长 + 偏移秒数) / 视频时长` 计算新的弹幕发送时间。 |
| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili生效,支持格式:<br> 正常代理:`http://127.0.0.1:7890` <br> 万能反代:`@http://127.0.0.1` <br> 特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)<br> 混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址` <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 |
| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili、animeko生效,支持格式:<br> 正常代理:`http://127.0.0.1:7890` <br> 万能反代:`@http://127.0.0.1` <br> 特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili,animeko`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)<br> 混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址` <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了 bahamut / animeko 源或 TMDB API ,请配置代理/反代地址(animeko 也可通过开启 Bangumi Data 解决)([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 |
| TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后并行从TMDB获取日语原名搜索巴哈(如果TMDB条目类型不是动画或制作地区不是jp则不会进行巴哈搜索)可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) |
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
| IP_BLACKLIST | 【可选】IP 黑名单列表,命中则拒绝请求。支持逗号/分号/换行分隔,支持 `/regex/` 或 `/regex/i` 正则,支持 IPv4/IPv6 CIDR,例如:`192.168.1.10,10.0.0.0/24,2001:db8::/64,/^203\.0\.113\./` |
@@ -677,7 +677,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
- cloudflare貌似有单次请求数量限制,会导致后半部分没有弹幕。
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
- 如果想使用自定义弹幕源,请添加环境变量`CUSTOM_SOURCE_API_URL`,并在`SOURCE_ORDER`环境变量中添加`custom`源。
- 如果想启用bilibili港澳台番剧弹幕源,请添加环境变量`PROXY_URL`并填写`bilibili@`字段的解析/反代服务地址,示例:`bilibili@https://233.233.233`,支持部分[公共解析服务器](https://github.com/yujincheng08/BiliRoaming/wiki/%E5%85%AC%E5%85%B1%E8%A7%A3%E6%9E%90%E6%9C%8D%E5%8A%A1%E5%99%A8),另外港澳台区域搜索最好在`BILIBILI_COOKIE`环境变量中加入包含`bili_jct`或`access_key`字段的cookie使用App接口,如果没有会使用不稳定的web接口进行搜索。(如果你填写的服务器遇到了App接口报错说明不支持App接口,Web接口报错-500、502正常,风控严重,但只要一直搜索总会成功)
- 如果想搜索bilibili港澳台番剧,请开启`Bangumi Data`匹配或添加环境变量`PROXY_URL`并填写`bilibili@`字段的解析/反代服务地址,示例:`bilibili@https://233.233.233`,支持部分[公共解析服务器](https://github.com/yujincheng08/BiliRoaming/wiki/%E5%85%AC%E5%85%B1%E8%A7%A3%E6%9E%90%E6%9C%8D%E5%8A%A1%E5%99%A8),另外港澳台区域搜索最好在`BILIBILI_COOKIE`环境变量中加入包含`bili_jct`或`access_key`字段的cookie使用App接口,如果没有会使用不稳定的web接口进行搜索。(如果你填写的服务器遇到了App接口报错说明不支持App接口,Web接口报错-500、502正常,风控严重,但只要一直搜索总会成功)
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`(支持多个服务器并发查询)。
- 当配置多个VOD站点时,可通过`VOD_RETURN_MODE`环境变量控制返回结果方式:`all`(返回所有站点结果)或`fastest`(默认,只返回最快的站点结果,避免结果过多)。
- 推荐vercel/netlify部署,cloudflare/edgeone不稳定,当然最稳定还是自己本地docker部署最佳。
+3 -2
View File
@@ -258,8 +258,9 @@ USE_BANGUMI_DATA=false
# - 设置为巴哈反代为 bahamut@http://proxy-server:port 或 bahamut@https://proxy-server
# - 设置为TMDB反代为 tmdb@http://proxy-server:port 或 tmdb@https://proxy-server
# - 设置为bilibili反代为 bilibili@http://proxy-server:port 或 bilibili@https://proxy-server
# 说明:目前只对巴哈姆特、TMDB API、bilibili生效
# 如果使用 Docker 部署并且访问不了 bahamut 源或 TMDB API ,请配置代理/反代地址
# - 设置为animeko反代为 animeko@http://proxy-server:port 或 animeko@https://proxy-server
# 说明:目前只对巴哈姆特、TMDB API、bilibili、animeko生效
# 如果使用 Docker 部署并且访问不了 bahamut / animeko 源或 TMDB API ,请配置代理/反代地址(animeko 也可通过开启 Bangumi Data 解决)
# 如果想启用B站港澳台番剧的弹幕搜索与获取请配置反代/解析服务器地址
# 反代教程参考:https://github.com/wan0ge/bahamut-api-proxy
# PROXY_URL=
+3
View File
@@ -105,6 +105,9 @@ export const Globals = {
} else if (conf.startsWith('bilibili@') && hostname.includes('bilibili')) {
specificProxy = conf.substring(9);
break;
} else if (conf.startsWith('animeko@') && (hostname.includes('animeko') || hostname.includes('bgm.tv'))) {
specificProxy = conf.substring(8);
break;
} else if (conf.startsWith('@') && !universalProxy) {
universalProxy = conf.substring(1);
} else if (!conf.includes('@') && !forwardProxy) {
+302 -169
View File
@@ -13,17 +13,23 @@ import { searchBangumiData } from '../utils/bangumi-data-util.js';
// =====================
// 接口健康状态缓存 (全局共享,跨请求持久化,实现业务级智能路由)
// 链路层级规范: Danmu (GLOBAL -> CN)
// 根据部署时区动态确定节点优先级,并记录当前健康的节点 URL
const API_HEALTH = {
danmu: 'GLOBAL'
danmu: null,
subject: null
};
// Animeko 详情数据短效缓存
const SUBJECT_CACHE = new Map();
const SUBJECT_CACHE_TTL = 3 * 60 * 1000; // 缓存有效期:3 分钟 (180000 毫秒)
const SUBJECT_CACHE_MAX_SIZE = 100; // 最大缓存条目数
/**
* Animeko 源适配器 (基于 Bangumi API V0)
* Animeko 源适配器 (基于 Bangumi API 搜索 + Animeko API 详情)
* 提供深度元数据搜索、结果过滤及条目关系检测功能
*/
export default class AnimekoSource extends BaseSource {
/**
* 获取标准 HTTP 请求头
* @returns {Object} 请求头对象
@@ -35,6 +41,110 @@ export default class AnimekoSource extends BaseSource {
};
}
/**
* 为 Bangumi API 搜索请求应用代理(Animeko API 不走代理)
*/
_applyProxyForSearch(targetUrl) {
return globals.makeProxyUrl(targetUrl);
}
/**
* 动态获取基于部署时区的 Animeko 节点优先级列表 (完全反转官方逻辑)
* @returns {Array<string>} 节点 URL 数组
*/
_getAnimekoServerPriority() {
// 获取当前运行环境的时区偏移 (分钟),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
// 完全反转后优先级:
return [
"https://s1.animeko.openani.org",
"https://danmaku-cn.myani.org",
"https://danmaku-global.myani.org",
"https://api.animeko.org"
];
} else {
// 原其他时区优先级: 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://api.animeko.org",
"https://danmaku-global.myani.org"
];
}
}
/**
* 通过 Animeko API 获取条目详情(含剧集与关联数据,智能路由降级)
* 使用短效缓存策略兼顾请求去重与动态数据(剧集)的时效性
* @param {number} subjectId 条目 ID
* @returns {Promise<Object|null>}
*/
async _fetchAnimekoSubject(subjectId) {
// 检查缓存是否存在且在有效期内
const cached = SUBJECT_CACHE.get(subjectId);
if (cached && (Date.now() - cached.timestamp < SUBJECT_CACHE_TTL)) {
return cached.data;
}
const servers = this._getAnimekoServerPriority();
let currentHost = API_HEALTH.subject;
if (!currentHost || !servers.includes(currentHost)) {
currentHost = servers[0];
API_HEALTH.subject = servers[0];
}
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}`;
try {
log("info", `[Animeko] Animeko API 请求节点: ${hostUrl} (${subjectId})`);
const resp = await httpGet(targetUrl, {
headers: this.headers,
timeout: 3000 // 限制节点请求超时时间为 3000 毫秒,加速故障节点跳过与轮询降级
});
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;
}
// 将成功获取的数据连同当前时间戳一并写入缓存
SUBJECT_CACHE.set(subjectId, {
data: resp.data,
timestamp: Date.now()
});
// 限制最大缓存数量,执行先进先出清理
if (SUBJECT_CACHE.size > SUBJECT_CACHE_MAX_SIZE) {
const firstKey = SUBJECT_CACHE.keys().next().value;
SUBJECT_CACHE.delete(firstKey);
}
return resp.data;
}
log("warn", `[Animeko] Animeko API 节点 ${hostUrl} 返回无效数据`);
} catch (error) {
log("warn", `[Animeko] Animeko API 节点请求失败: ${hostUrl} - ${error.message}`);
}
}
// 所有节点均失败,重置至优先级最高的首选节点
log("warn", `[Animeko] Animeko API 所有详情节点均失败,重置至首选节点`);
API_HEALTH.subject = servers[0];
return null;
}
/**
* 搜索动画条目
* 使用 Bangumi V0 POST 接口进行搜索,支持偏移翻页、结果过滤及关系检测
@@ -46,7 +156,7 @@ export default class AnimekoSource extends BaseSource {
const localMatches = await searchBangumiData(keyword, ['bangumi']);
if (localMatches.length > 0) {
log("info", `[Animeko] Bangumi-Data 本地命中 ${localMatches.length} 条数据`);
return this.transformResults(localMatches.map(m => {
const mapped = localMatches.map(m => {
const displayTitle = m.titles.find(t => t && t.includes(keyword)) || m.titles[1] || m.title;
const finalTitle = displayTitle + (m.titleSuffix || '');
@@ -54,13 +164,22 @@ export default class AnimekoSource extends BaseSource {
id: parseInt(m.siteId),
name: m.title,
name_cn: finalTitle,
imageUrl: "",
imageUrl: "",
date: m.begin,
score: 0,
platform: m.typeStr,
platform: m.typeStr,
aliases: [...m.titles]
};
}));
});
// 本地匹配结果同样执行关系检测
if (mapped.length > 1) {
const withRelations = await this.checkRelationsAndModifyTitles(mapped);
log("info", `[Animeko] 搜索完成(Bangumi-Data + 关系检测),共找到 ${withRelations.length} 个有效结果`);
return this.transformResults(withRelations);
}
return this.transformResults(mapped);
}
}
@@ -75,7 +194,8 @@ export default class AnimekoSource extends BaseSource {
while (true) {
const searchUrl = `https://api.bgm.tv/v0/search/subjects?limit=${limit}&offset=${offset}`;
const proxySearchUrl = this._applyProxyForSearch(searchUrl);
const payload = {
keyword: searchKeyword,
filter: {
@@ -83,7 +203,7 @@ export default class AnimekoSource extends BaseSource {
}
};
const resp = await httpPost(searchUrl, JSON.stringify(payload), {
const resp = await httpPost(proxySearchUrl, JSON.stringify(payload), {
headers: this.headers
});
@@ -100,7 +220,7 @@ export default class AnimekoSource extends BaseSource {
// 执行结果相关度过滤 (剔除强大的模糊搜索带来的杂项)
const filteredBatch = this.filterSearchResults(currentBatch, keyword);
if (filteredBatch.length > 0) {
allFilteredResults = allFilteredResults.concat(filteredBatch);
}
@@ -125,14 +245,14 @@ export default class AnimekoSource extends BaseSource {
return [];
}
// 跨页合并后,为了防止多页触发同样的“智能季度匹配兜底”导致重复,这里做一次 ID 去重
// 跨页合并后,为了防止多页触发同样的"智能季度匹配兜底"导致重复,这里做一次 ID 去重
let uniqueResults = Array.from(new Map(allFilteredResults.map(item => [item.id, item])).values());
// 检测条目间关系 (如处理续篇、剧场版等层级关系)
if (uniqueResults.length > 1) {
uniqueResults = await this.checkRelationsAndModifyTitles(uniqueResults);
}
log("info", `[Animeko] 搜索完成,共找到 ${uniqueResults.length} 个有效结果`);
return this.transformResults(uniqueResults);
} catch (error) {
@@ -156,14 +276,14 @@ export default class AnimekoSource extends BaseSource {
return list.filter(item => {
const titles = [item.name, item.name_cn];
// 提取 infobox 中的别名和中文名扩充对比池
// 提取 Bangumi API infobox 中的别名和中文名扩充对比池
if (item.infobox && Array.isArray(item.infobox)) {
item.infobox.forEach(info => {
if (info.key === '别名' && Array.isArray(info.value)) {
info.value.forEach(v => { if (v && v.v) titles.push(v.v); });
}
if (info.key === '中文名' && typeof info.value === 'string') {
titles.push(info.value);
titles.push(item.value);
}
});
}
@@ -187,7 +307,7 @@ export default class AnimekoSource extends BaseSource {
}
/**
* 批量检查条目关系并修正标题
* 批量检查条目关系并修正标题(使用 Animeko API 的 relations 数据)
* 对于检测到的续作或衍生关系,在标题后追加标识
* @param {Array} list 条目列表
* @returns {Promise<Array>} 修正后的列表
@@ -198,7 +318,7 @@ export default class AnimekoSource extends BaseSource {
for (let i = 0; i < checkLimit; i++) {
for (let j = 0; j < checkLimit; j++) {
if (i === j) continue;
const subjectA = list[i];
const subjectB = list[j];
const nameA = subjectA.name_cn || subjectA.name;
@@ -206,26 +326,27 @@ export default class AnimekoSource extends BaseSource {
// 简单的包含关系预检
if (nameB.includes(nameA) && nameB.length > nameA.length) {
// 如果标题已有明确区分,跳过耗时的 API 检查
if (this.hasExplicitSeasonInfo(nameB)) {
continue;
}
// 查询 API 确认具体关系
const relations = await this.getSubjectRelations(subjectA.id);
// 使用 Animeko API 获取关联数据
const v2Data = await this._fetchAnimekoSubject(subjectA.id);
const relations = this._extractRelations(v2Data);
const relationInfo = relations.find(r => r.id === subjectB.id);
if (relationInfo) {
log("info", `[Animeko] 检测到关系: [${nameA}] -> ${relationInfo.relation} -> [${nameB}]`);
const targetRelations = ["续集", "番外篇", "主线故事", "前传", "不同演绎", "衍生"];
if (targetRelations.includes(relationInfo.relation)) {
let mark = relationInfo.relation;
if (mark === '续集') mark = '续篇'; // 归一化处理
subjectB._relation_mark = `(${mark})`;
subjectB._relation_mark = `(${mark})`;
}
}
}
@@ -235,26 +356,68 @@ export default class AnimekoSource extends BaseSource {
}
/**
* 获取指定条目的关联条目列表
* @param {number} subjectId 条目 ID
* @returns {Promise<Array>} 关联条目数组
* 从 Animeko API 响应中提取关联条目
* @param {Object|null} v2Data 动画详情数据
* @returns {Array} 关联条目数组
*/
async getSubjectRelations(subjectId) {
try {
const url = `https://api.bgm.tv/v0/subjects/${subjectId}/subjects`;
const resp = await httpGet(url, { headers: this.headers });
if (!resp || !resp.data || !Array.isArray(resp.data)) return [];
_extractRelations(v2Data) {
if (!v2Data || !v2Data.relations) return [];
return resp.data.filter(item => item.type === 2).map(item => ({
const rel = v2Data.relations;
// 处理数组格式的关联数据
if (Array.isArray(rel)) {
return rel.filter(item => item.type === 2).map(item => ({
id: item.id,
name: item.name_cn || item.name,
relation: item.relation
relation: item.relation
}));
} catch (e) {
log("warn", `[Animeko] 获取关系失败 ID:${subjectId}: ${e.message}`);
return [];
}
// 处理结构化对象的关联数据,仅解析条目标识符
const results = [];
const mapRelations = (idArray, relationType) => {
if (Array.isArray(idArray)) {
idArray.forEach(id => {
if (id) results.push({ id: id, name: '', relation: relationType });
});
}
};
mapRelations(rel.sequelSubjects, '续集');
mapRelations(rel.prequelSubjects, '前传');
mapRelations(rel.sideStorySubjects, '番外篇');
mapRelations(rel.alternativeSettingSubjects, '不同演绎');
mapRelations(rel.characterSubjects, '衍生');
return results;
}
/**
* 从 Animeko API 模板化 infobox 中提取指定键值
* @param {Object} infobox
* @param {string} targetKey
* @returns {string|null}
*/
_extractInfoboxValue(infobox, targetKey) {
if (!infobox || !infobox.fields || !Array.isArray(infobox.fields)) return null;
const field = infobox.fields.find(f => f.key === targetKey);
if (field && field.values && field.values.length > 0) {
return field.values[0].v || null;
}
return null;
}
_ExtractInfoboxAliases(infobox) {
const aliases = [];
if (!infobox || !infobox.fields || !Array.isArray(infobox.fields)) return aliases;
infobox.fields.forEach(field => {
if (field.key === '别名' && Array.isArray(field.values)) {
field.values.forEach(v => { if (v && v.v && !aliases.includes(v.v)) aliases.push(v.v); });
}
});
return aliases;
}
/**
@@ -267,11 +430,11 @@ export default class AnimekoSource extends BaseSource {
let typeDesc = "动漫";
if (item.platform) {
switch (item.platform) {
case "TV": typeDesc = "TV动画"; break;
case "TV": case 1: typeDesc = "TV动画"; break;
case "Web": typeDesc = "Web动画"; break;
case "OVA": typeDesc = "OVA"; break;
case "Movie": typeDesc = "剧场版"; break;
default: typeDesc = item.platform;
default: typeDesc = typeof item.platform === 'number' ? "TV动画" : item.platform;
}
}
@@ -280,8 +443,9 @@ export default class AnimekoSource extends BaseSource {
let is2D = false;
if (item.tags && Array.isArray(item.tags)) {
item.tags.forEach(tag => {
if (tag.name === '3D') is3D = true;
if (tag.name === '2D') is2D = true;
const tagName = tag.name || tag;
if (tagName === '3D') is3D = true;
if (tagName === '2D') is2D = true;
});
}
if (is3D) typeDesc = "3D" + typeDesc;
@@ -291,99 +455,77 @@ export default class AnimekoSource extends BaseSource {
// 提取别名列表 (用于合并工具进行模糊匹配)
const aliases = Array.isArray(item.aliases) ? [...item.aliases] : [];
if (item.infobox && Array.isArray(item.infobox)) {
// Animeko API infobox 为模板结构,Bangumi V0 为扁平结构,统一处理
if (item.infobox) {
if (Array.isArray(item.infobox)) {
// Bangumi V0 扁平格式
item.infobox.forEach(info => {
if (info.key === '别名' && Array.isArray(info.value)) {
info.value.forEach(v => {
if (v && v.v && !aliases.includes(v.v)) aliases.push(v.v);
});
}
if (info.key === '别名' && Array.isArray(info.value)) {
info.value.forEach(v => {
if (v && v.v && !aliases.includes(v.v)) aliases.push(v.v);
});
}
});
} else if (item.infobox.fields) {
// Animeko API 模板格式
const boxAliases = this._ExtractInfoboxAliases(item.infobox);
boxAliases.forEach(a => { if (!aliases.includes(a)) aliases.push(a); });
}
}
// 将中文名也作为别名的一种补充,防止 name 字段是日文而 name_cn 未被命中的情况
// 将中文名也作为别名的一种补充
if (item.name_cn && item.name_cn !== item.name) {
aliases.push(item.name_cn);
}
// Animeko API 使用 nameCn 字段
if (item.nameCn && item.nameCn !== item.name) {
if (!aliases.includes(item.nameCn)) aliases.push(item.nameCn);
}
// 统一日期字段(Animeko API 用 airDate,Bangumi V0 用 date)
const airDate = item.airDate || item.date || "";
return {
id: item.id,
name: item.name,
name_cn: (item.name_cn || item.name) + titleSuffix,
name_cn: (item.name_cn || item.nameCn || item.name) + titleSuffix,
aliases: aliases,
images: item.images,
air_date: item.date,
score: item.score,
air_date: airDate,
score: item.score ? parseFloat(item.score) : 0,
typeDescription: typeDesc
};
});
}
/**
* 获取剧集列表
* Bangumi API 限制单次 limit=200,需循环获取完整列表
* 获取剧集列表(通过 Animeko API,含主备降级)
* @param {number} subjectId 条目 ID
* @returns {Promise<Array>} 剧集数组
*/
async getEpisodes(subjectId) {
let allEpisodes = [];
let offset = 0;
const limit = 200;
try {
while (true) {
// 构造分页 URL
const url = `https://api.bgm.tv/v0/episodes?subject_id=${subjectId}&limit=${limit}&offset=${offset}`;
const resp = await httpGet(url, {
headers: this.headers,
});
// 1. 结构校验:确保 resp.data.data 存在且为数组
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;
}
const v2Data = await this._fetchAnimekoSubject(subjectId);
if (!v2Data || !v2Data.episodes || !Array.isArray(v2Data.episodes)) {
if (v2Data) log("info", `[Animeko] Subject ${subjectId} 无剧集数据 (Animeko API)`);
return [];
}
return allEpisodes;
return v2Data.episodes.map(ep => ({
id: ep.episodeId,
sort: parseInt(ep.sort) || 0,
ep: ep.ep,
type: ep.type === 'MAIN' ? 0 : (ep.type === 'SP' ? 1 : ep.type),
name: ep.name || "",
name_cn: ep.nameCn || "",
airdate: ep.airdate || ""
}));
} catch (error) {
log("error", "[Animeko] GetEpisodes error:", {
message: error.message,
id: subjectId,
offset: offset
id: subjectId
});
return [];
}
@@ -429,7 +571,7 @@ export default class AnimekoSource extends BaseSource {
try {
const eps = await this.getEpisodes(anime.id);
let links = [];
let effectiveStartDate = anime.air_date || "";
if (Array.isArray(eps)) {
@@ -442,14 +584,14 @@ export default class AnimekoSource extends BaseSource {
effectiveStartDate = ep.airdate;
}
const epNum = ep.sort || ep.ep;
const epNum = ep.sort || ep.ep;
const epName = ep.name_cn || ep.name || "";
const fullTitle = `第${epNum}话 ${epName}`.trim();
links.push({
"name": `${epNum}`,
"url": ep.id.toString(),
"title": `【animeko】 ${fullTitle}`
"name": `${epNum}`,
"url": ep.id.toString(),
"title": `【animeko】 ${fullTitle}`
});
}
}
@@ -465,7 +607,7 @@ export default class AnimekoSource extends BaseSource {
type: "动漫",
typeDescription: anime.typeDescription || "动漫",
imageUrl: anime.images ? (anime.images.common || anime.images.large) : "",
startDate: effectiveStartDate,
startDate: effectiveStartDate,
episodeCount: links.length,
rating: anime.score || 0,
isFavorited: true,
@@ -489,7 +631,7 @@ export default class AnimekoSource extends BaseSource {
/**
* 获取完整弹幕列表
* 支持自动降级:Global -> CN
* 采用与详情一致的独立健康检查轮询机制与动态时区优先级
* @param {string} episodeId 剧集 ID 或 完整 API URL
* @returns {Promise<Array>} 弹幕数组
*/
@@ -497,74 +639,65 @@ export default class AnimekoSource extends BaseSource {
// 1. 提取真实 ID
// 兼容分片请求传递过来的完整 URL 或 纯 ID
let realId = String(episodeId).trim();
// 如果是完整 URL (包含 /),尝试提取最后一部分
if (realId.includes('/')) {
const parts = realId.split('/');
realId = parts[parts.length - 1];
realId = parts[parts.length - 1];
}
// 去除可能存在的 URL 参数干扰 (例如 ?v=1)
if (realId.includes('?')) {
realId = realId.split('?')[0];
}
if (!realId) {
log("error", "[Animeko] 无效的 episodeId");
return [];
}
const endpoints = {
'GLOBAL': "https://danmaku-global.myani.org",
'CN': "https://danmaku-cn.myani.org"
};
const tiers = ['GLOBAL', 'CN'];
const servers = this._getAnimekoServerPriority();
let currentHost = API_HEALTH.danmu;
if (!currentHost || !servers.includes(currentHost)) {
currentHost = servers[0];
API_HEALTH.danmu = servers[0];
}
// 定义内部通用请求函数
const fetchDanmu = async (hostUrl) => {
const targetUrl = `${hostUrl}/v1/danmaku/${realId}`;
try {
const resp = await httpGet(targetUrl, { headers: this.headers });
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}/v1/danmaku/${realId}`;
if (!resp || !resp.data) return null;
const body = resp.data;
if (body.danmakuList) return body.danmakuList;
return null;
} catch (error) {
log("warn", `[Animeko] 请求节点失败: ${hostUrl} - ${error.message}`);
return null;
}
};
log("info", `[Animeko] 尝试使用 ${hostUrl} 节点获取弹幕`);
let danmuList = null;
let currentTierIndex = tiers.indexOf(API_HEALTH.danmu);
if (currentTierIndex === -1) currentTierIndex = 0;
try {
const resp = await httpGet(targetUrl, {
headers: this.headers,
timeout: 3000 // 限制节点请求超时时间为 3000 毫秒,加速故障节点跳过与轮询降级
});
// 智能路由检测与降级回路
for (let i = currentTierIndex; i < tiers.length; i++) {
const tier = tiers[i];
const hostUrl = endpoints[tier];
log("info", `[Animeko] 尝试使用 ${tier} 节点获取弹幕`);
danmuList = await fetchDanmu(hostUrl);
if (danmuList && Array.isArray(danmuList)) {
// 记录当前健康的节点层级
if (API_HEALTH.danmu !== tier) {
log("info", `[Animeko] 弹幕域节点健康状态更新: ${API_HEALTH.danmu} -> ${tier}`);
API_HEALTH.danmu = tier;
if (resp && resp.data) {
const danmuList = resp.data.danmakuList;
if (danmuList && Array.isArray(danmuList)) {
if (API_HEALTH.danmu !== hostUrl) {
log("info", `[Animeko] 弹幕域节点健康状态更新: ${API_HEALTH.danmu} -> ${hostUrl}`);
API_HEALTH.danmu = hostUrl;
}
log("info", `[Animeko] 成功获取弹幕,共 ${danmuList.length} 条 (${hostUrl}节点)`);
return danmuList;
}
}
log("info", `[Animeko] 成功获取弹幕,共 ${danmuList.length} 条 (${tier}节点)`);
return danmuList;
} else {
log("info", `[Animeko] ${tier} 节点获取失败/无数据,触发降级`);
log("info", `[Animeko] ${hostUrl} 节点获取失败/无数据,触发降级`);
} catch (error) {
log("warn", `[Animeko] 请求节点失败: ${hostUrl} - ${error.message}`);
}
}
// 所有节点轮换完毕仍未获取到数据,重置健康状态
log("info", `[Animeko] 弹幕域所有降级节点均失败,重置健康状态至 GLOBAL 节点`);
API_HEALTH.danmu = 'GLOBAL';
// 所有节点轮换完毕仍未获取到数据,重置健康状态至首选节点
log("info", `[Animeko] 弹幕域所有降级节点均失败,重置健康状态至首选节点`);
API_HEALTH.danmu = servers[0];
return [];
}
@@ -579,7 +712,7 @@ export default class AnimekoSource extends BaseSource {
"segmentList": [{
"type": "animeko",
"segment_start": 0,
"segment_end": 30000,
"segment_end": 30000,
"url": String(id)
}]
});
@@ -593,8 +726,8 @@ export default class AnimekoSource extends BaseSource {
// 增加 trim 防止 URL 意外空格
const url = (segment.url || '').trim();
if (!url) return [];
// 返回原始数据
// 返回原始数据,格式化交由父类统一处理
return this.getEpisodeDanmu(url);
}
@@ -606,7 +739,7 @@ export default class AnimekoSource extends BaseSource {
formatComments(comments) {
if (!Array.isArray(comments)) return [];
const locationMap = { "NORMAL": 1, "TOP": 5, "BOTTOM": 4 };
return comments
.filter(item => item && item.danmakuInfo)
.map(item => {
@@ -618,7 +751,7 @@ export default class AnimekoSource extends BaseSource {
return {
cid: item.id,
p: `${time},${mode},${color},[animeko]`,
p: `${time},${mode},${color},[animeko]`,
m: text
};
});
+2 -2
View File
@@ -40,7 +40,7 @@ export async function httpGet(url, options = {}) {
}
// 设置超时时间(默认5秒)
const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000;
const timeout = parseInt(options.timeout || globals.vodRequestTimeout || '5000', 10) || 5000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -609,7 +609,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
const { headers = {}, sniffLimit } = options;
// 默认限制为 32KB
const SNIFF_LIMIT = parseInt(sniffLimit || '32768', 10) || 32768;
const timeout = parseInt(globals.vodRequestTimeout || '5000', 10) || 5000;
const timeout = parseInt(options.timeout || globals.vodRequestTimeout || '5000', 10) || 5000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);