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:
1 parent
0cb28cea9a
commit
ad303b1b8e
5 files changed
+312
-175
No files matched your search
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user