diff --git a/README.md b/README.md index 7d07a08..fd784d4 100644 --- a/README.md +++ b/README.md @@ -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生效,支持格式:
正常代理:`http://127.0.0.1:7890`
万能反代:`@http://127.0.0.1`
特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)
混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 | +| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili、animeko生效,支持格式:
正常代理:`http://127.0.0.1:7890`
万能反代:`@http://127.0.0.1`
特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili,animeko`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)
混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用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部署最佳。 diff --git a/config/.env.example b/config/.env.example index ca0cb44..723aa68 100644 --- a/config/.env.example +++ b/config/.env.example @@ -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= diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 6a0a86d..1e47ca4 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -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) { diff --git a/danmu_api/sources/animeko.js b/danmu_api/sources/animeko.js index 610d15f..b0c4154 100644 --- a/danmu_api/sources/animeko.js +++ b/danmu_api/sources/animeko.js @@ -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} 节点 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} + */ + 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} 修正后的列表 @@ -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} 关联条目数组 + * 从 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} 剧集数组 */ 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} 弹幕数组 */ @@ -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 }; }); diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index ab46df6..fc260d3 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -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);