diff --git a/README.md b/README.md index a58e914..41ab91b 100644 --- a/README.md +++ b/README.md @@ -370,8 +370,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | -| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,custom` | -| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, custom` | +| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,animeko,custom` | +| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko, custom` | +| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段`,示例:`imgo&iqiyi,dandan&animeko,dandan&bahamut`, 允许多组、允许同时存在、一组配置一对,目前允许合并的源字段有`qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko` | | EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 | | ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 | | STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` | @@ -447,6 +448,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | hanjutv | hanjutv | | bahamut | bahamut | | dandan | dandan | +| animeko | [animeko](https://github.com/open-ani/animeko) | | custom | custom | ## 项目结构 @@ -486,6 +488,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 │ ├── models/ │ │ └── dandan-model.js # 弹弹play数据模型 │ ├── sources/ +│ │ ├── animeko.js # Animeko源 │ │ ├── bahamut.js # 巴哈姆特源 │ │ ├── base.js # 弹幕源获取基类 │ │ ├── bilibili.js # b站源 @@ -522,11 +525,13 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 │ ├── cache-util.js # 缓存数据处理工具 │ ├── codec-util.js # 编解码工具 │ ├── common-util.js # 通用工具 +│ ├── cookie-util.js # b站 cookie获取工具 │ ├── danmu-util.js # 弹幕处理工具 │ ├── douban-util.js # 豆瓣API请求工具 │ ├── http-util.js # 请求工具 │ ├── imdb-util.js # IMDB API请求工具 │ ├── log-util.js # 日志工具 +│ ├── merge-util.js # 源合并处理工具 │ ├── redis-util.js # redis工具 │ ├── time-util.js # 时间日期工具 │ ├── tmdb-util.js # TMDB API请求处理工具 diff --git a/config/.env.example b/config/.env.example index 275500e..a6d5989 100644 --- a/config/.env.example +++ b/config/.env.example @@ -68,7 +68,7 @@ YOUKU_CONCURRENCY=8 # ==================== 源排序配置 ==================== -# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, custom) +# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, animeko, custom) # 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili 和 sohu) # 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源或搜狐源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,ren,hanjutv,sohu # 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut @@ -81,9 +81,17 @@ SOURCE_ORDER=360,vod,renren,hanjutv # 平台优先级排序(用于自动匹配) # 默认值:空(自动匹配) -# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, custom +# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko, custom # PLATFORM_ORDER= +# ==================== 源合并配置 ==================== +# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, animeko) +# 默认值:空 +# 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、一组配置一对 +# 格式:源字段&源字段 +# 示例:MERGE_SOURCE_PAIRS=imgo&iqiyi,dandan&animeko,dandan&bahamut +# MERGE_SOURCE_PAIRS= + # ==================== 剧集标题过滤 ==================== # 剧集标题过滤正则表达式(用于过滤花絮、预告等非正片内容) diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index b86b482..85f57dc 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -1,5 +1,5 @@ import { globals } from '../configs/globals.js'; -import { getPageTitle, jsonResponse } from '../utils/http-util.js'; +import { getPageTitle, jsonResponse, httpGet } from '../utils/http-util.js'; import { log } from '../utils/log-util.js' import { setRedisKey, updateRedisCaches } from "../utils/redis-util.js"; import { @@ -7,9 +7,10 @@ import { getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile, updateLocalCaches } from "../utils/cache-util.js"; -import { formatDanmuResponse } from "../utils/danmu-util.js"; +import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js"; import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, extractYear } from "../utils/common-util.js"; import { getTMDBChineseTitle } from "../utils/tmdb-util.js"; +import { applyMergeLogic, mergeDanmakuList, MERGE_DELIMITER } from "../utils/merge-util.js"; import Kan360Source from "../sources/kan360.js"; import VodSource from "../sources/vod.js"; import TmdbSource from "../sources/tmdb.js"; @@ -25,6 +26,7 @@ import MangoSource from "../sources/mango.js"; import BilibiliSource from "../sources/bilibili.js"; import YoukuSource from "../sources/youku.js"; import SohuSource from "../sources/sohu.js"; +import AnimekoSource from "../sources/animeko.js"; import OtherSource from "../sources/other.js"; import { Anime, AnimeMatch, Episodes, Bangumi } from "../models/dandan-model.js"; @@ -45,10 +47,14 @@ const iqiyiSource = new IqiyiSource(); const mangoSource = new MangoSource(); const bilibiliSource = new BilibiliSource(); const sohuSource = new SohuSource(); +const animekoSource = new AnimekoSource(); const otherSource = new OtherSource(); const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource); const tmdbSource = new TmdbSource(doubanSource); +// 用于聚合请求的去重Map +const PENDING_DANMAKU_REQUESTS = new Map(); + // 匹配年份函数,优先于季匹配 function matchYear(anime, queryYear) { if (!queryYear) { @@ -195,6 +201,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null if (source === "imgo") return mangoSource.search(queryTitle); if (source === "bilibili") return bilibiliSource.search(queryTitle); if (source === "sohu") return sohuSource.search(queryTitle); + if (source === "animeko") return animekoSource.search(queryTitle); }); // 执行所有请求并等待结果 @@ -213,7 +220,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null vod: animesVodResults, 360: animes360, tmdb: animesTmdb, douban: animesDouban, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom, tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili, - sohu: animesSohu + sohu: animesSohu, animeko: animesAnimeko } = resultData; // 按顺序处理每个来源的结果 @@ -269,12 +276,20 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null } else if (key === 'sohu') { // 等待处理Sohu来源 await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes); + } else if (key === 'animeko') { + // 等待处理Animeko来源 + await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes); } } } catch (error) { log("error", "发生错误:", error); } + // 执行源合并逻辑 + if (globals.mergeSourcePairs.length > 0) { + await applyMergeLogic(curAnimes); + } + storeAnimeIdsToMap(curAnimes, queryTitle); // 如果启用了集标题过滤,则为每个动漫添加过滤后的 episodes @@ -915,6 +930,111 @@ export async function getBangumi(path) { }); } +/** + * 处理聚合源弹幕获取 + * @param {string} url 聚合URL + * @returns {Promise} 合并后的弹幕列表 + */ +async function fetchMergedComments(url) { + const parts = url.split(MERGE_DELIMITER); + const sourceNames = parts.map(part => part.split(':')[0]).filter(Boolean); + const sourceTag = sourceNames.join('&'); + + log("info", `[Merge] 开始获取 [${sourceTag}] 聚合弹幕...`); + + // 1. 检查聚合缓存 + const cached = getCommentCache(url); + if (cached) { + log("info", `[Merge] 命中缓存 [${sourceTag}],返回 ${cached.length} 条`); + return cached; + } + + const stats = {}; + + // 2. 并行获取所有源的弹幕 + const tasks = parts.map(async (part) => { + const firstColonIndex = part.indexOf(':'); + if (firstColonIndex === -1) return []; + + const sourceName = part.substring(0, firstColonIndex); + const realId = part.substring(firstColonIndex + 1); + + if (!sourceName || !realId) return []; + + // 构建去重Key + const pendingKey = `${sourceName}:${realId}`; + + // 检查是否有正在进行的相同请求(请求合并) + if (PENDING_DANMAKU_REQUESTS.has(pendingKey)) { + log("info", `[Merge] 复用正在进行的请求: ${pendingKey}`); + try { + const list = await PENDING_DANMAKU_REQUESTS.get(pendingKey); + return list || []; + } catch (e) { + return []; + } + } + + // 定义请求任务 + const fetchTask = (async () => { + let sourceInstance = null; + + if (sourceName === 'renren') sourceInstance = renrenSource; + else if (sourceName === 'hanjutv') sourceInstance = hanjutvSource; + else if (sourceName === 'bahamut') sourceInstance = bahamutSource; + else if (sourceName === 'dandan') sourceInstance = dandanSource; + else if (sourceName === 'tencent') sourceInstance = tencentSource; + else if (sourceName === 'youku') sourceInstance = youkuSource; + else if (sourceName === 'iqiyi') sourceInstance = iqiyiSource; + else if (sourceName === 'imgo') sourceInstance = mangoSource; + else if (sourceName === 'bilibili') sourceInstance = bilibiliSource; + else if (sourceName === 'sohu') sourceInstance = sohuSource; + else if (sourceName === 'animeko') sourceInstance = animekoSource; + // 如有新增允许的源合并,在此处添加 + + if (sourceInstance) { + try { + // 获取原始数据 -> 格式化 + const raw = await sourceInstance.getEpisodeDanmu(realId); + const formatted = sourceInstance.formatComments(raw); + stats[sourceName] = formatted.length; + return formatted; + } catch (e) { + log("error", `[Merge] 获取 ${sourceName} 失败: ${e.message}`); + stats[sourceName] = 0; + return []; + } + } + return []; + })(); + + // 将任务加入队列 + PENDING_DANMAKU_REQUESTS.set(pendingKey, fetchTask); + + try { + return await fetchTask; + } finally { + // 任务完成后移除队列 + PENDING_DANMAKU_REQUESTS.delete(pendingKey); + } + }); + + // 等待所有源请求完成 + const results = await Promise.all(tasks); + + // 3. 合并数据 + let mergedList = []; + results.forEach(list => { + mergedList = mergeDanmakuList(mergedList, list); + }); + + const statDetails = Object.entries(stats).map(([k, v]) => `${k}: ${v}`).join(', '); + log("info", `[Merge] 聚合原始数据完成: 总计 ${mergedList.length} 条 (${statDetails})`); + + // 4. 统一处理(去重、过滤、转JSON) + return convertToDanmakuJson(mergedList, sourceTag); +} + // Extracted function for GET /api/v2/comment/:commentId export async function getComment(path, queryFormat, segmentFlag) { const commentId = parseInt(path.split("/").pop()); @@ -939,57 +1059,70 @@ export async function getComment(path, queryFormat, segmentFlag) { log("info", "开始从本地请求弹幕...", url); let danmus = []; - if (url.includes('.qq.com')) { - danmus = await tencentSource.getComments(url, plat, segmentFlag); - } else if (url.includes('.iqiyi.com')) { - danmus = await iqiyiSource.getComments(url, plat, segmentFlag); - } else if (url.includes('.mgtv.com')) { - danmus = await mangoSource.getComments(url, plat, segmentFlag); - } else if (url.includes('.bilibili.com') || url.includes('b23.tv')) { - // 如果是 b23.tv 短链接,先解析为完整 URL - if (url.includes('b23.tv')) { - url = await bilibiliSource.resolveB23Link(url); - } - danmus = await bilibiliSource.getComments(url, plat, segmentFlag); - } else if (url.includes('.youku.com')) { - danmus = await youkuSource.getComments(url, plat, segmentFlag); - } else if (url.includes('.sohu.com')) { - danmus = await sohuSource.getComments(url, plat, segmentFlag); - } - // 请求其他平台弹幕 - const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; - if (!urlPattern.test(url)) { - if (plat === "renren") { - danmus = await renrenSource.getComments(url, plat, segmentFlag); - } else if (plat === "hanjutv") { - danmus = await hanjutvSource.getComments(url, plat, segmentFlag); - } else if (plat === "bahamut") { - danmus = await bahamutSource.getComments(url, plat, segmentFlag); - } else if (plat === "dandan") { - danmus = await dandanSource.getComments(url, plat, segmentFlag); - } else if (plat === "custom") { - danmus = await customSource.getComments(url, plat, segmentFlag); + if (url && url.includes(MERGE_DELIMITER)) { + danmus = await fetchMergedComments(url); + } else { + if (url.includes('.qq.com')) { + danmus = await tencentSource.getComments(url, plat, segmentFlag); + } else if (url.includes('.iqiyi.com')) { + danmus = await iqiyiSource.getComments(url, plat, segmentFlag); + } else if (url.includes('.mgtv.com')) { + danmus = await mangoSource.getComments(url, plat, segmentFlag); + } else if (url.includes('.bilibili.com') || url.includes('b23.tv')) { + // 如果是 b23.tv 短链接,先解析为完整 URL + if (url.includes('b23.tv')) { + url = await bilibiliSource.resolveB23Link(url); + } + danmus = await bilibiliSource.getComments(url, plat, segmentFlag); + } else if (url.includes('.youku.com')) { + danmus = await youkuSource.getComments(url, plat, segmentFlag); + } else if (url.includes('.sohu.com')) { + danmus = await sohuSource.getComments(url, plat, segmentFlag); } - } - // 如果弹幕为空,则请求第三方弹幕服务器作为兜底 - if (danmus.length === 0 && urlPattern.test(url)) { - danmus = await otherSource.getComments(url, "other_server", segmentFlag); + // 请求其他平台弹幕 + const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; + if (!urlPattern.test(url)) { + if (plat === "renren") { + danmus = await renrenSource.getComments(url, plat, segmentFlag); + } else if (plat === "hanjutv") { + danmus = await hanjutvSource.getComments(url, plat, segmentFlag); + } else if (plat === "bahamut") { + danmus = await bahamutSource.getComments(url, plat, segmentFlag); + } else if (plat === "dandan") { + danmus = await dandanSource.getComments(url, plat, segmentFlag); + } else if (plat === "custom") { + danmus = await customSource.getComments(url, plat, segmentFlag); + } else if (plat === "animeko") { + danmus = await animekoSource.getComments(url, plat, segmentFlag); + } + } + + // 如果弹幕为空,则请求第三方弹幕服务器作为兜底 + if ((!danmus || danmus.length === 0) && urlPattern.test(url)) { + danmus = await otherSource.getComments(url, "other_server", segmentFlag); + } } const [animeId, source] = findAnimeIdByCommentId(commentId); - setPreferByAnimeId(animeId, source); - if (globals.localCacheValid && animeId) { - writeCacheToFile('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap))); - } - if (globals.redisValid && animeId) { - await setRedisKey('lastSelectMap', globals.lastSelectMap); + if (animeId && source) { + setPreferByAnimeId(animeId, source); + if (globals.localCacheValid && animeId) { + writeCacheToFile('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap))); + } + if (globals.redisValid && animeId) { + setRedisKey('lastSelectMap', globals.lastSelectMap).catch(e => log("error", "Redis set error", e)); + } } // 缓存弹幕结果 - if (danmus.length > 0) { - setCommentCache(url, danmus); + if (!segmentFlag) { + if (danmus && danmus.comments) danmus = danmus.comments; + if (!Array.isArray(danmus)) danmus = []; + if (danmus.length > 0) { + setCommentCache(url, danmus); + } } const responseData = { count: danmus.length, comments: danmus }; @@ -1144,6 +1277,8 @@ export async function getSegmentComment(segment, queryFormat) { danmus = await renrenSource.getSegmentComments(segment); } else if (platform === "dandan") { danmus = await dandanSource.getSegmentComments(segment); + } else if (platform === "animeko") { + danmus = await animekoSource.getSegmentComments(segment); } else if (platform === "custom") { danmus = await customSource.getSegmentComments(segment); } else if (platform === "other_server") { diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 7bf01dc..f39c73f 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -10,8 +10,9 @@ export class Envs { static accessedEnvVars = new Map(); static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu']; // vod允许的播放平台 - static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'custom']; // 全部源允许的播放平台 - static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'custom']; // 允许的源 + static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko', 'custom']; // 全部源允许的播放平台 + static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko', 'custom']; // 允许的源 + static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko']; // 允许的源合并 /** * 获取环境变量 @@ -135,6 +136,36 @@ export class Envs { return orderArr.length > 0 ? [...orderArr, null] : [null]; } + /** + * 解析源合并配置 + * 从环境变量 MERGE_SOURCE_PAIRS 获取配置 + * 支持使用分号或逗号分隔多组配置 + * 格式示例: bilibili&animeko,animeko&dandan + * @returns {Array} 合并配置数组 [{primary: 'animeko', secondary: 'bilibili'}, ...] + */ + static resolveMergeSourcePairs() { + const config = this.get('MERGE_SOURCE_PAIRS', '', 'string'); + if (!config) return []; + + // 使用正则同时支持分号(;)和逗号(,)作为分隔符 + return config.split(/[,;]/) + .map(pair => { + // 过滤空字符串 + if (!pair || !pair.includes('&')) return null; + + const [primary, secondary] = pair.split('&').map(s => s.trim()); + + // 仅当两个源都在 MERGE_ALLOWED_SOURCES 允许列表中时才生效 + if (primary && secondary && + this.MERGE_ALLOWED_SOURCES.includes(primary) && + this.MERGE_ALLOWED_SOURCES.includes(secondary)) { + return { primary, secondary }; + } + return null; + }) + .filter(Boolean); + } + /** * 解析剧集标题过滤正则 * @returns {RegExp} 过滤正则表达式 @@ -238,6 +269,7 @@ export class Envs { 'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 }, 'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' }, 'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 }, + 'MERGE_SOURCE_PAIRS': { category: 'source', type: 'text', description: '源合并配置,格式:主源&副源,主源2&副源2,例如:dandan&animeko' }, // 匹配配置 'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置' }, @@ -288,6 +320,7 @@ export class Envs { vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '10000', 'string'), // vod超时时间(默认10秒) bilibliCookie: this.get('BILIBILI_COOKIE', '', 'string', true), // b站cookie youkuConcurrency: Math.min(this.get('YOUKU_CONCURRENCY', 8, 'number'), 16), // 优酷并发配置 + mergeSourcePairs: this.resolveMergeSourcePairs(), // 源合并配置,用于将源合并获取 platformOrderArr: this.resolvePlatformOrder(), // 自动匹配优选平台 episodeTitleFilter: this.resolveEpisodeTitleFilter(), // 剧集标题正则过滤 blockedWords: this.get('BLOCKED_WORDS', '', 'string'), // 屏蔽词列表 diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 53ddb1a..a22d014 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.11.5', + VERSION: '1.12.0', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, diff --git a/danmu_api/sources/animeko.js b/danmu_api/sources/animeko.js new file mode 100644 index 0000000..28511c5 --- /dev/null +++ b/danmu_api/sources/animeko.js @@ -0,0 +1,477 @@ +import BaseSource from './base.js'; +import { globals } from '../configs/globals.js'; +import { log } from "../utils/log-util.js"; +import { httpGet, httpPost } from "../utils/http-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { simplized } from "../utils/zh-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; + +// ===================== +// 获取Animeko弹幕(https://github.com/open-ani/animeko) +// ===================== + +/** + * Animeko 源适配器 (基于 Bangumi API V0) + * 提供深度元数据搜索、结果过滤及条目关系检测功能 + */ +export default class AnimekoSource extends BaseSource { + + /** + * 获取标准 HTTP 请求头 + * @returns {Object} 请求头对象 + */ + get headers() { + return { + "Content-Type": "application/json", + "User-Agent": `huangxd-/danmu_api/${globals.version}(https://github.com/huangxd-/danmu_api)`, + }; + } + + /** + * 搜索动画条目 + * 使用 Bangumi V0 POST 接口进行搜索,并进行后置过滤和关系检测 + * @param {string} keyword 搜索关键词 + * @returns {Promise} 转换后的搜索结果列表 + */ + async search(keyword) { + try { + log("info", `[Animeko] 开始搜索 (V0): ${keyword}`); + + const searchUrl = `https://api.bgm.tv/v0/search/subjects?limit=5`; + + const payload = { + keyword: keyword, + filter: { + type: [2] // 2 代表动画类型 + } + }; + + const resp = await httpPost(searchUrl, JSON.stringify(payload), { + headers: this.headers + }); + + if (!resp || !resp.data) { + log("info", "[Animeko] 搜索请求失败或无数据返回"); + return []; + } + + let resultsList = resp.data.data || []; + + if (resultsList.length === 0) { + log("info", "[Animeko] 未找到相关条目"); + return []; + } + + // 执行结果相关度过滤 + resultsList = this.filterSearchResults(resultsList, keyword); + + if (resultsList.length === 0) { + log("info", "[Animeko] 过滤后无匹配结果"); + return []; + } + + // 检测条目间关系 (如处理续篇、剧场版等层级关系) + if (resultsList.length > 1) { + resultsList = await this.checkRelationsAndModifyTitles(resultsList); + } + + log("info", `[Animeko] 搜索完成,找到 ${resultsList.length} 个有效结果`); + return this.transformResults(resultsList); + } catch (error) { + log("error", "[Animeko] Search error:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + return []; + } + } + + /** + * 过滤搜索结果 + * 计算关键词与标题(含中文名、原名及别名)的相似度,剔除低相关度结果 + * @param {Array} list 原始 API 返回结果列表 + * @param {string} keyword 用户搜索关键词 + * @returns {Array} 过滤后的结果列表 + */ + filterSearchResults(list, keyword) { + const threshold = 0.4; // 相似度阈值 + const normalizedKeyword = simplized(keyword).toLowerCase().trim(); + + return list.filter(item => { + // 收集所有可能的标题用于比对 + const titles = new Set(); + if (item.name) titles.add(item.name); + if (item.name_cn) titles.add(item.name_cn); + + // 解析 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) titles.add(v.v); }); + } + if (info.key === '中文名' && typeof info.value === 'string') { + titles.add(info.value); + } + }); + } + + // 计算最高相似度得分 + let maxScore = 0; + for (const t of titles) { + const normalizedTitle = simplized(t).toLowerCase().trim(); + const score = this.calculateSimilarity(normalizedKeyword, normalizedTitle); + if (score > maxScore) maxScore = score; + } + + return maxScore >= threshold; + }); + } + + /** + * 计算字符串相似度 + * 结合包含关系与编辑距离算法 + * @param {string} s1 字符串1 + * @param {string} s2 字符串2 + * @returns {number} 相似度得分 (0.0 - 1.0) + */ + calculateSimilarity(s1, s2) { + if (!s1 || !s2) return 0; + if (s1 === s2) return 1.0; + if (s1.includes(s2) || s2.includes(s1)) { + const lenRatio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); + return 0.8 + (lenRatio * 0.2); + } + + // Levenshtein 距离计算 + const len1 = s1.length; + const len2 = s2.length; + const matrix = []; + for (let i = 0; i <= len1; i++) matrix[i] = [i]; + for (let j = 0; j <= len2; j++) matrix[0][j] = j; + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = s1.charAt(i - 1) === s2.charAt(j - 1) ? 0 : 1; + matrix[i][j] = Math.min(matrix[i-1][j]+1, matrix[i][j-1]+1, matrix[i-1][j-1]+cost); + } + } + const distance = matrix[len1][len2]; + const maxLength = Math.max(len1, len2); + return maxLength === 0 ? 1.0 : 1.0 - (distance / maxLength); + } + + /** + * 检查标题是否包含明确的季度或类型标识 + * @param {string} title 标题文本 + * @returns {boolean} 是否包含明确标识 + */ + hasExplicitSeasonInfo(title) { + if (!title) return false; + + const patterns = [ + /第\s*[0-9一二三四五六七八九十]+\s*[季期部]/i, // 第2季 + /Season\s*\d+/i, // Season 2 + /S\d+/i, // S2 + /Part\s*\d+/i, // Part 2 + /OVA/i, /OAD/i, + /剧场版|Movie|Film/i, + /续篇|续集/i, + /SP/i, + /(? p.test(title)); + } + + /** + * 批量检查条目关系并修正标题 + * 对于检测到的续作或衍生关系,在标题后追加标识 + * @param {Array} list 条目列表 + * @returns {Promise} 修正后的列表 + */ + async checkRelationsAndModifyTitles(list) { + const checkLimit = Math.min(list.length, 3); + + 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; + const nameB = subjectB.name_cn || subjectB.name; + + // 简单的包含关系预检 + if (nameB.includes(nameA) && nameB.length > nameA.length) { + + // 如果标题已有明确区分,跳过耗时的 API 检查 + if (this.hasExplicitSeasonInfo(nameB)) { + continue; + } + + // 查询 API 确认具体关系 + const relations = await this.getSubjectRelations(subjectA.id); + 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})`; + } + } + } + } + } + return list; + } + + /** + * 获取指定条目的关联条目列表 + * @param {number} subjectId 条目 ID + * @returns {Promise} 关联条目数组 + */ + 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 []; + + return resp.data.filter(item => item.type === 2).map(item => ({ + id: item.id, + name: item.name_cn || item.name, + relation: item.relation + })); + } catch (e) { + log("warn", `[Animeko] 获取关系失败 ID:${subjectId}: ${e.message}`); + return []; + } + } + + /** + * 将 API 结果转换为统一的数据格式 + * @param {Array} results API 原始结果 + * @returns {Array} 转换后的数据 + */ + transformResults(results) { + return results.map(item => { + let typeDesc = "动漫"; + if (item.platform) { + switch (item.platform) { + case "TV": typeDesc = "TV动画"; break; + case "Web": typeDesc = "Web动画"; break; + case "OVA": typeDesc = "OVA"; break; + case "Movie": typeDesc = "剧场版"; break; + default: typeDesc = item.platform; + } + } + + const titleSuffix = item._relation_mark ? ` ${item._relation_mark}` : ""; + + return { + id: item.id, + name: item.name, + name_cn: (item.name_cn || item.name) + titleSuffix, + images: item.images, + air_date: item.date, + score: item.score, + typeDescription: typeDesc + }; + }); + } + + /** + * 获取剧集列表 + * @param {number} subjectId 条目 ID + * @returns {Promise} 剧集数组 + */ + async getEpisodes(subjectId) { + try { + const resp = await httpGet(`https://api.bgm.tv/v0/episodes?subject_id=${subjectId}`, { + headers: this.headers, + }); + + if (!resp || !resp.data) { + log("info", `[Animeko] Subject ${subjectId} 无剧集数据`); + return []; + } + + const body = resp.data; + if (Array.isArray(body.data)) return body.data; + + return []; + } catch (error) { + log("error", "[Animeko] GetEpisodes error:", { + message: error.message, + id: subjectId + }); + return []; + } + } + + /** + * 处理并存储番剧及剧集信息 + * @param {Array} sourceAnimes 搜索到的番剧列表 + * @param {string} queryTitle 原始查询标题 + * @param {Array} curAnimes 当前缓存的番剧列表 + */ + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + if (!sourceAnimes || !Array.isArray(sourceAnimes)) { + if (sourceAnimes) log("error", "[Animeko] sourceAnimes is not a valid array"); + return []; + } + + const processAnimekoAnimes = await Promise.all(sourceAnimes.map(async (anime) => { + try { + const eps = await this.getEpisodes(anime.id); + let links = []; + + let effectiveStartDate = anime.air_date || ""; + + if (Array.isArray(eps)) { + eps.sort((a, b) => (a.sort || 0) - (b.sort || 0)); + + for (const ep of eps) { + if (ep.type !== 0) continue; // 仅保留本篇 + + if (!effectiveStartDate && ep.airdate) { + effectiveStartDate = ep.airdate; + } + + const epNum = ep.sort || ep.ep; + const epName = ep.name_cn || ep.name || ""; + const fullTitle = `EP${epNum} ${epName}`.trim(); + + links.push({ + "name": `${epNum}`, + "url": ep.id.toString(), + "title": `【animeko】 ${fullTitle}` + }); + } + } + + if (links.length > 0) { + const yearStr = effectiveStartDate ? new Date(effectiveStartDate).getFullYear() : ""; + + let transformedAnime = { + animeId: anime.id, + bangumiId: String(anime.id), + animeTitle: `${anime.name_cn || anime.name}(${yearStr})【${anime.typeDescription || '动漫'}】from animeko`, + type: "动漫", + typeDescription: anime.typeDescription || "动漫", + imageUrl: anime.images ? (anime.images.common || anime.images.large) : "", + startDate: effectiveStartDate, + episodeCount: links.length, + rating: anime.score || 0, + isFavorited: true, + source: "animeko", + }; + + tmpAnimes.push(transformedAnime); + addAnime({...transformedAnime, links: links}); + + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + } + } catch (error) { + log("error", `[Animeko] Error processing anime ${anime.id}: ${error.message}`); + } + }) + ); + + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); + return processAnimekoAnimes; + } + + /** + * 获取完整弹幕列表 + * 支持传入纯数字ID或完整URL + * @param {string} episodeId 剧集 ID 或 完整 API URL + * @returns {Promise} 弹幕数组 + */ + async getEpisodeDanmu(episodeId) { + try { + // 兼容分片请求传递过来的完整 URL + const url = episodeId.startsWith('http') + ? episodeId + : `https://danmaku-global.myani.org/v1/danmaku/${episodeId}`; + // 目前使用的服务器是全球区域的,备用大陆区域:https://danmaku-cn.myani.org + + const resp = await httpGet(url, { headers: this.headers }); + + if (!resp || !resp.data) return []; + const body = resp.data; + if (body.danmakuList) return body.danmakuList; + return []; + } catch (error) { + log("error", "[Animeko] GetDanmu error:", { message: error.message, url: episodeId }); + return []; + } + } + + /** + * 获取分段弹幕列表定义 + * 使用完整的 API URL 填充 url 字段,以通过 format 校验 + */ + async getEpisodeDanmuSegments(id) { + return new SegmentListResponse({ + "type": "animeko", + "segmentList": [{ + "type": "animeko", + "segment_start": 0, + "segment_end": 30000, + "url": `https://danmaku-global.myani.org/v1/danmaku/${id}` // 使用完整 URL + }] + }); + } + + /** + * 获取具体分片的弹幕数据 + * 标准实现:返回原始数据,格式化交由父类统一处理 + */ + async getEpisodeSegmentDanmu(segment) { + // 增加 trim 防止 URL 意外空格 + const url = (segment.url || '').trim(); + if (!url) return []; + + // 返回原始数据 + return this.getEpisodeDanmu(url); + } + + /** + * 格式化弹幕为标准格式 + * @param {Array} comments 原始弹幕数据 + * @returns {Array} 格式化后的弹幕 + */ + formatComments(comments) { + if (!Array.isArray(comments)) return []; + const locationMap = { "NORMAL": 1, "TOP": 5, "BOTTOM": 4 }; + + return comments + .filter(item => item && item.danmakuInfo) + .map(item => { + const info = item.danmakuInfo; + const time = (Number(info.playTime) / 1000).toFixed(2); + const mode = locationMap[info.location] || 1; + const color = info.color === -1 ? 16777215 : info.color; + const text = globals.danmuSimplified ? simplized(info.text) : info.text; + + return { + cid: item.id, + p: `${time},${mode},${color},[animeko]`, + m: text + }; + }); + } +} diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js new file mode 100644 index 0000000..a363c46 --- /dev/null +++ b/danmu_api/utils/merge-util.js @@ -0,0 +1,739 @@ +import { globals } from '../configs/globals.js'; +import { log } from './log-util.js'; +import { normalizeSpaces } from './common-util.js'; +import { addAnime } from './cache-util.js'; +import { simplized } from '../utils/zh-util.js'; + +// ===================== +// 源合并处理工具 +// ===================== + +// 定义组合ID的分隔符 (URL Safe) +export const MERGE_DELIMITER = '$$$'; +// 定义前端显示的源连接符 +export const DISPLAY_CONNECTOR = '&'; + +/** + * 文本清洗工具函数 + * 将文本转为简体,并移除特定源标识、地区限制及标点符号,用于提高匹配精度 + * @param {string} text 原始文本 + * @returns {string} 清洗后的文本 + */ +function cleanText(text) { + if (!text) return ''; + // 1. 繁体转简体 + let clean = simplized(text); + // 2. 移除源标识如 【dandan】 + clean = clean.replace(/【.*?】/g, ''); + // 3. 移除地区限制标识如 (仅限台湾地区) + clean = clean.replace(/(\(|()仅限.*?地区(\)|))/g, ''); + // 4. 移除常见标点符号 (避免 "不行!" 和 "不行。" 被判为不同) + clean = clean.replace(/[!!??,,.。、~~]/g, ' '); + + return normalizeSpaces(clean).toLowerCase().trim(); +} + +/** + * 移除标题中的所有括号内容 + * 用于提取主标题进行比对,规避副标题翻译差异(如:(※不是不可能!?) vs (※似乎可行?)) + * @param {string} text 清洗后的文本 + * @returns {string} 移除括号后的文本 + */ +function removeParentheses(text) { + if (!text) return ''; + // 移除 () 和 () 及其内部的所有内容 + return text.replace(/(\(|().*?(\)|))/g, '').trim(); +} + +/** + * 清洗并提取真实的 ID/URL + * 用于从组合或带前缀的字符串中还原出原始的请求 ID + * @param {string} urlStr 原始 URL 字符串 + * @returns {string} 清洗后的 ID 或 完整 URL + */ +function sanitizeUrl(urlStr) { + if (!urlStr) return ''; + + // 去除可能存在的组合后缀,只取当前部分 + let clean = String(urlStr).split(MERGE_DELIMITER)[0].trim(); + + // 自动修复被错误截断协议头的 URL + if (clean.startsWith('//')) { + return 'https:' + clean; + } + + // 尝试解析 "source:id" 格式 + const match = clean.match(/^([^:]+):(.+)$/); + if (match) { + const prefix = match[1].toLowerCase(); + const body = match[2]; + + // 如果前缀是 http/https,说明是原始 URL,保留 + if (prefix === 'http' || prefix === 'https') { + return clean; + } + + // 如果 body 是 http 开头,直接返回 + if (/^https?:\/\//i.test(body)) { + return body; + } + + // 如果 body 是 // 开头,自动补全协议 + if (body.startsWith('//')) { + return 'https:' + body; + } + + // 普通 ID + return body; + } + + return clean; +} + +/** + * 解析日期字符串为对象 + * @param {string} dateStr 日期字符串 + * @returns {Object} { year: number|null, month: number|null } + */ +function parseDate(dateStr) { + if (!dateStr) return { year: null, month: null }; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return { year: null, month: null }; + return { + year: d.getFullYear(), + month: d.getMonth() + 1 + }; +} + +/** + * 计算编辑距离 (Levenshtein Distance) + * 用于衡量两个字符串的差异程度(对顺序敏感) + * @param {string} s1 字符串1 + * @param {string} s2 字符串2 + * @returns {number} 编辑距离 + */ +function editDistance(s1, s2) { + const len1 = s1.length; + const len2 = s2.length; + const matrix = []; + + for (let i = 0; i <= len1; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= len2; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = s1.charAt(i - 1) === s2.charAt(j - 1) ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost + ); + } + } + return matrix[len1][len2]; +} + +/** + * 计算 Dice 相似度系数 (基于字符集合) + * 用于解决长标题意译差异(如 "我怎么可能" vs "我们不可能"),对语序不敏感 + * @param {string} s1 字符串1 + * @param {string} s2 字符串2 + * @returns {number} Dice 系数 (0.0 - 1.0) + */ +function calculateDiceSimilarity(s1, s2) { + if (!s1 || !s2) return 0; + + // 将字符串转换为去重的字符集合 (移除空格) + const set1 = new Set(s1.replace(/\s/g, '')); + const set2 = new Set(s2.replace(/\s/g, '')); + + if (set1.size === 0 && set2.size === 0) return 1.0; + if (set1.size === 0 || set2.size === 0) return 0.0; + + // 计算交集大小 + let intersection = 0; + for (const char of set1) { + if (set2.has(char)) { + intersection++; + } + } + + // Dice 公式: 2 * |A∩B| / (|A| + |B|) + return (2.0 * intersection) / (set1.size + set2.size); +} + +/** + * 计算两个字符串的综合相似度 (0.0 - 1.0) + * 结合了 编辑距离(顺序敏感)和 Dice系数(字符重合度),并预先进行清洗 + * @param {string} str1 字符串1 + * @param {string} str2 字符串2 + * @returns {number} 相似度得分 (取多种算法的最大值) + */ +function calculateSimilarity(str1, str2) { + if (!str1 || !str2) return 0; + + // 使用增强的 cleanText 进行预处理 + const s1 = cleanText(str1); + const s2 = cleanText(str2); + + // 1. 精确匹配 + if (s1 === s2) return 1.0; + + // 2. 包含关系 (给予较高基础分) + if (s1.includes(s2) || s2.includes(s1)) { + const lenRatio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); + return 0.8 + (lenRatio * 0.2); + } + + // 3. 编辑距离得分 + const distance = editDistance(s1, s2); + const maxLength = Math.max(s1.length, s2.length); + const editScore = maxLength === 0 ? 1.0 : 1.0 - (distance / maxLength); + + // 4. Dice 系数得分 (处理长标题意译) + const diceScore = calculateDiceSimilarity(s1, s2); + + // 返回两种算法中较高的分数 + return Math.max(editScore, diceScore); +} + +/** + * 提取标题和类型中的季度/类型标识 + * 支持提取:第N季, Season N, Part N, OVA, OAD, 剧场版, 续篇, 以及末尾数字 + * 同时从 typeDesc (类型描述) 中提取特征,解决标题未写明但类型明确的情况 + * @param {string} title 标题文本 + * @param {string} typeDesc 类型描述 (可选) + * @returns {Set} 标识集合 + */ +function extractSeasonMarkers(title, typeDesc = '') { + const markers = new Set(); + // 使用 cleanText 确保繁简统一 + const t = cleanText(title); + const type = cleanText(typeDesc || ''); + + const patterns = [ + { regex: /第(\d+)[季期]/, prefix: 'S' }, + { regex: /season\s*(\d+)/, prefix: 'S' }, + { regex: /s(\d+)/, prefix: 'S' }, + { regex: /part\s*(\d+)/, prefix: 'P' }, + { regex: /(ova|oad)/, val: 'OVA' }, + { regex: /(剧场版|movie|film)/, val: 'MOVIE' }, + { regex: /(续篇|续集)/, val: 'SEQUEL' }, + { regex: /sp/, val: 'SP' }, + { regex: /[^0-9](\d)$/, prefix: 'S' } + ]; + + patterns.forEach(p => { + const match = t.match(p.regex); + if (match) { + if (p.prefix) { + markers.add(`${p.prefix}${parseInt(match[1])}`); + } else { + markers.add(p.val); + } + } + }); + + // 从 Type 字段中补全标记 + if (type.includes('剧场版') || type.includes('movie') || type.includes('film')) markers.add('MOVIE'); + if (type.includes('ova') || type.includes('oad')) markers.add('OVA'); + if (type.includes('sp') || type.includes('special')) markers.add('SP'); + + const cnNums = {'一':1, '二':2, '三':3, '四':4, '五':5, 'final': 99}; + for (const [cn, num] of Object.entries(cnNums)) { + if (t.includes(`第${cn}季`)) markers.add(`S${num}`); + } + + return markers; +} + +/** + * 校验季度/续作标记是否冲突 + * @param {string} titleA 标题A + * @param {string} titleB 标题B + * @param {string} typeA 类型A + * @param {string} typeB 类型B + * @returns {boolean} true 表示冲突(禁止合并),false 表示无冲突 + */ +function checkSeasonMismatch(titleA, titleB, typeA, typeB) { + const markersA = extractSeasonMarkers(titleA, typeA); + const markersB = extractSeasonMarkers(titleB, typeB); + + // 两者都无标记 -> 无冲突 + if (markersA.size === 0 && markersB.size === 0) return false; + + // 1. 如果两者都有标记,必须有交集,不能互斥 + if (markersA.size > 0 && markersB.size > 0) { + for (const m of markersA) { + // 如果 A 的标记在 B 中不存在,且 B 也有同类标记(如都是S开头的季数),则视为冲突 + if (m.startsWith('S') && !markersB.has(m) && Array.from(markersB).some(b => b.startsWith('S'))) return true; + } + return false; + } + + // 2. 一方有标记,一方无标记 -> 冲突 + if (markersA.size !== markersB.size) { + return true; + } + + return false; +} + +/** + * 检查两个标题是否包含相同的季度/季数标记 + * 用于在年份不匹配时进行“豁免”判断 + * @param {string} titleA 标题A + * @param {string} titleB 标题B + * @param {string} typeA 类型A + * @param {string} typeB 类型B + * @returns {boolean} 是否包含相同的明确季度标记(如都包含 S1) + */ +function hasSameSeasonMarker(titleA, titleB, typeA, typeB) { + const markersA = extractSeasonMarkers(titleA, typeA); + const markersB = extractSeasonMarkers(titleB, typeB); + + const seasonsA = Array.from(markersA).filter(m => m.startsWith('S')); + const seasonsB = Array.from(markersB).filter(m => m.startsWith('S')); + + if (seasonsA.length > 0 && seasonsB.length > 0) { + return seasonsA.some(sa => seasonsB.includes(sa)); + } + return false; +} + +/** + * 校验日期匹配度 + * @param {Object} dateA 日期对象A + * @param {Object} dateB 日期对象B + * @returns {number} 匹配得分 (-1 表示硬性不匹配) + */ +function checkDateMatch(dateA, dateB) { + if (!dateA.year || !dateB.year) return 0; + const yearDiff = Math.abs(dateA.year - dateB.year); + + // 年份相差 > 1,硬性抛弃 + if (yearDiff > 1) return -1; + + // 年份相同 + if (yearDiff === 0) { + if (dateA.month && dateB.month) { + const monthDiff = Math.abs(dateA.month - dateB.month); + // 月份差异大也不扣分 (可能是占位符 01-01) + if (monthDiff > 2) return 0; + return monthDiff === 0 ? 0.2 : 0.1; + } + return 0.1; + } + return 0; +} + +/** + * 在副源列表中寻找最佳匹配的动画对象 + * 采用“双重对比策略”:同时计算“完整标题相似度”和“去括号主标题相似度”,取最大值。 + * 并结合类型信息进行更精准的冲突检测(如剧场版vsTV版)。 + * @param {Object} primaryAnime 主源动画对象 + * @param {Array} secondaryList 副源动画列表 + * @returns {Object|null} 匹配的动画对象或 null + */ +export function findSecondaryMatch(primaryAnime, secondaryList) { + if (!secondaryList || secondaryList.length === 0) return null; + + const primaryTitle = primaryAnime.animeTitle.replace(/\(\d{4}\).*$/, '').trim(); + const primaryDate = parseDate(primaryAnime.startDate); + + let bestMatch = null; + let maxScore = 0; + + for (const secAnime of secondaryList) { + const secDate = parseDate(secAnime.startDate); + const secTitle = secAnime.animeTitle.replace(/\(\d{4}\).*$/, '').trim(); + + // 检查是否具有完全一致的显式季度标记 (豁免日期校验) + // 传入 typeDescription 以便提取隐含的类型标记 + const isSeasonExactMatch = hasSameSeasonMarker(primaryTitle, secTitle, primaryAnime.typeDescription, secAnime.typeDescription); + + // 1. 日期校验 + const dateScore = checkDateMatch(primaryDate, secDate); + if (!isSeasonExactMatch && dateScore === -1) { + continue; + } + + // 2. 季度/续作标记冲突硬性校验 + // 传入 typeDescription,利用元数据补全标题中缺失的 "剧场版/OVA" 等信息 + if (checkSeasonMismatch(primaryTitle, secTitle, primaryAnime.typeDescription, secAnime.typeDescription)) { + continue; + } + + // 3. 综合文本相似度计算 + // 策略 A: 完整标题对比 (保留括号内容,适合精准匹配) + let scoreFull = calculateSimilarity(primaryTitle, secTitle); + + // 策略 B: 主标题对比 (移除括号内的副标题/注译,解决如 (※不是不可能) vs (※似乎可行) 的翻译差异) + const baseA = removeParentheses(primaryTitle); + const baseB = removeParentheses(secTitle); + let scoreBase = calculateSimilarity(baseA, baseB); + + // 取两者的最大值作为最终得分 + let score = Math.max(scoreFull, scoreBase); + + if (dateScore !== -1) { + score += dateScore; + } + + if (score > maxScore) { + maxScore = score; + bestMatch = secAnime; + } + } + + return maxScore >= 0.6 ? bestMatch : null; +} + +/** + * 提取集数信息 + * 增强正则以支持紧凑格式,并预先进行去噪清洗 + * @param {string} title 分集标题 + * @returns {Object} { isMovie: boolean, num: number|null } + */ +function extractEpisodeInfo(title) { + // 使用 cleanText 移除干扰前缀和地区文字 + const t = cleanText(title || ""); + + // 1. 判断是否是剧场版 + const isMovie = /剧场版|movie|film/i.test(t); + + let num = null; + + // 2. 提取数字 + + // 策略 A: 强前缀 (EP, O, S, Part, 第) + const strongPrefixMatch = t.match(/(?:ep|o|s|part|第)\s*(\d+(\.\d+)?)/i); + if (strongPrefixMatch) { + num = parseFloat(strongPrefixMatch[1]); + } else { + // 策略 B: 弱前缀 (行首或空格) + // 必须有后缀分隔符 (话/集/空格/行尾) 或者数字是独立的 + const weakPrefixMatch = t.match(/(?:^|\s)(\d+(\.\d+)?)(?:话|集|\s|$)/); + if (weakPrefixMatch) { + num = parseFloat(weakPrefixMatch[1]); + } + } + + return { isMovie, num }; +} + +/** + * 过滤无效剧集 (预告/花絮等) + * 完全依赖传入的正则,不再内置硬编码规则 + * @param {Array} links 剧集链接列表 + * @param {RegExp} filterRegex 过滤正则 + * @returns {Array} 包含原始索引的有效剧集列表 + */ +function filterEpisodes(links, filterRegex) { + if (!links) return []; + + // 如果没有传入过滤正则(例如全局开关关闭),则不执行过滤,仅保留索引映射 + if (!filterRegex) { + return links.map((link, index) => ({ link, originalIndex: index })); + } + + return links + .map((link, index) => ({ link, originalIndex: index })) + .filter(item => { + const title = item.link.title || item.link.name || ""; + return !filterRegex.test(title); + }); +} + +/** + * 寻找最佳对齐偏移量 + * 解决策略:滑动窗口 + 匹配集数权重 + 类型惩罚 + 数字一致性 + * @param {Array} primaryLinks 主源链接列表 + * @param {Array} secondaryLinks 副源链接列表 + * @returns {number} 最佳偏移量 + */ +function findBestAlignmentOffset(primaryLinks, secondaryLinks) { + if (primaryLinks.length === 0 || secondaryLinks.length === 0) return 0; + + let bestOffset = 0; + let maxScore = -999; + let maxMatchCount = 0; // 记录最大匹配数量,用于 Tie-Breaker + + // 限制滑动范围 (假设差异 +/- 10 集) + const maxShift = Math.min(primaryLinks.length, 10); + + for (let offset = -maxShift; offset <= maxShift; offset++) { + let totalTextScore = 0; + let matchCount = 0; + let numericDiffs = new Map(); + + for (let i = 0; i < secondaryLinks.length; i++) { + const pIndex = i + offset; + + if (pIndex >= 0 && pIndex < primaryLinks.length) { + const titleA = primaryLinks[pIndex].link.title || ""; + const titleB = secondaryLinks[i].link.title || ""; + const infoA = extractEpisodeInfo(titleA); + const infoB = extractEpisodeInfo(titleB); + + // 1. 类型惩罚 (关键:阻止剧场版与正片匹配) + let pairScore = 0; + if (infoA.isMovie !== infoB.isMovie) { + pairScore -= 5.0; // 强惩罚 + } + + // 2. 文本相似度 (使用 calculateSimilarity 自动清洗对比) + pairScore += calculateSimilarity(titleA, titleB); + + totalTextScore += pairScore; + + // 3. 数字差值记录 + if (infoA.num !== null && infoB.num !== null) { + const diff = infoB.num - infoA.num; + const count = numericDiffs.get(diff) || 0; + numericDiffs.set(diff, count + 1); + } + + matchCount++; + } + } + + if (matchCount > 0) { + // 平均文本得分 + let finalAvgScore = totalTextScore / matchCount; + + // 4. 计算数字一致性加分 + let maxFrequency = 0; + for (const count of numericDiffs.values()) { + if (count > maxFrequency) maxFrequency = count; + } + + // 如果大多数配对都遵循同一个数字差值规律,给予高额加分 + const consistencyRatio = maxFrequency / matchCount; + if (consistencyRatio > 0.5) { + finalAvgScore += (consistencyRatio * 2.0); // 一致性奖励 + } + + // 选择逻辑 (Tie-Breaker) + if (finalAvgScore > maxScore + 0.1) { + maxScore = finalAvgScore; + bestOffset = offset; + maxMatchCount = matchCount; + } else if (Math.abs(finalAvgScore - maxScore) <= 0.1) { + if (matchCount > maxMatchCount) { + maxScore = finalAvgScore; + bestOffset = offset; + maxMatchCount = matchCount; + } + } + } + } + + // 只有当得分 > 0.3 时才采用偏移,否则默认对齐 + return maxScore > 0.3 ? bestOffset : 0; +} + +/** + * 生成符合 int32 范围的安全 ID + * 通过哈希组合 ID 并映射到 10亿~21亿 区间,避免溢出并减少冲突 + * @param {string|number} id1 原始ID 1 + * @param {string|number} id2 原始ID 2 + * @returns {number} 安全的 Int32 ID + */ +function generateSafeMergedId(id1, id2) { + const str = `${id1}_${id2}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + // 取绝对值并映射到 1,000,000,000 (10亿) ~ 2,147,483,647 (int32 max) 之间 + // 这样既保证了是正数,又避开了通常较小的原始 ID,同时确保不超过客户端 int32 限制 + return (Math.abs(hash) % 1000000000) + 1000000000; +} + +/** + * 执行源合并逻辑 + * 遍历配置的源对,合并符合条件的动画条目及其集数 + * @param {Array} curAnimes 当前所有的动画条目列表 + */ +export async function applyMergeLogic(curAnimes) { + const pairs = globals.mergeSourcePairs; + if (!pairs || pairs.length === 0) return; + + log("info", `[Merge] 启动源合并策略,配置: ${JSON.stringify(pairs)}`); + + // 获取过滤正则 (直接从 globals 获取,支持 RegExp 或字符串) + let epFilter = globals.episodeTitleFilter; + if (epFilter && typeof epFilter === 'string') { + try { epFilter = new RegExp(epFilter, 'i'); } catch (e) { epFilter = null; } + } + + const newMergedAnimes = []; + const usedBaseAnimeIds = new Set(); + const mergedSecondaryAnimes = new Set(); + + for (const { primary, secondary } of pairs) { + const primaryItems = curAnimes.filter(a => a.source === primary && !a._isMerged); + const secondaryItems = curAnimes.filter(a => a.source === secondary && !a._isMerged); + + if (primaryItems.length === 0 || secondaryItems.length === 0) continue; + + for (const pAnime of primaryItems) { + const match = findSecondaryMatch(pAnime, secondaryItems); + + if (match) { + const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId)); + const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId)); + + if (!cachedPAnime?.links || !cachedMatch?.links) { + log("warn", `[Merge] 数据不完整,跳过合并: ${pAnime.animeTitle}`); + continue; + } + + const derivedAnime = JSON.parse(JSON.stringify(cachedPAnime)); + + // 1. 预过滤 (保留 index 映射) + const filteredPLinksWithIndex = filterEpisodes(derivedAnime.links, epFilter); + derivedAnime.links = filteredPLinksWithIndex.map(item => item.link); + + const filteredMLinksWithIndex = filterEpisodes(cachedMatch.links, epFilter); + + // 2. 计算最佳对齐偏移量 + const offset = findBestAlignmentOffset(filteredPLinksWithIndex, filteredMLinksWithIndex); + + if (offset !== 0) { + log("info", `[Merge] 集数自动对齐: Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`); + } + + // [修复] 生成安全的 int32 ID,防止客户端溢出错误 + derivedAnime.animeId = generateSafeMergedId(pAnime.animeId, match.animeId); + derivedAnime.bangumiId = String(derivedAnime.animeId); + + let mergedCount = 0; + const mappingEntries = []; // 用于存储所有的映射记录(包含匹配和未匹配) + const matchedPIndices = new Set(); // 记录已被副源匹配的主源索引 + + // 3. 执行合并 (应用偏移量) - 以副源为驱动 + for (let i = 0; i < filteredMLinksWithIndex.length; i++) { + const pIndex = i + offset; + const sourceLink = filteredMLinksWithIndex[i].link; + const sTitleShort = sourceLink.name || sourceLink.title || `Index ${i}`; + + if (pIndex >= 0 && pIndex < derivedAnime.links.length) { + // [匹配成功] + const targetLink = derivedAnime.links[pIndex]; + const pTitleShort = targetLink.name || targetLink.title || `Index ${pIndex}`; + + const idA = sanitizeUrl(targetLink.url); + const idB = sanitizeUrl(sourceLink.url); + + const newUrl = `${primary}:${idA}${MERGE_DELIMITER}${secondary}:${idB}`; + targetLink.url = newUrl; + + // 记录映射日志 + mappingEntries.push({ + idx: pIndex, + text: ` [匹配] ${pTitleShort} <-> ${sTitleShort}` + }); + + matchedPIndices.add(pIndex); + + // 修改分集标题 + if (targetLink.title) { + const prefixRegex = new RegExp(`【${primary}(\\d*)】`); + targetLink.title = targetLink.title.replace( + prefixRegex, + (match, num) => `【${primary}${num}${DISPLAY_CONNECTOR}${secondary}】` + ); + } + mergedCount++; + } else { + // [副源落单] (主源越界) + mappingEntries.push({ + idx: pIndex, // 使用虚拟索引进行排序 + text: ` [落单] (主源越界) <-> ${sTitleShort}` + }); + } + } + + // 4. 检查主源是否有落单集数 + for (let j = 0; j < derivedAnime.links.length; j++) { + if (!matchedPIndices.has(j)) { + const targetLink = derivedAnime.links[j]; + const pTitleShort = targetLink.name || targetLink.title || `Index ${j}`; + + mappingEntries.push({ + idx: j, + text: ` [落单] ${pTitleShort} <-> (副源缺失)` + }); + } + } + + // 按集数顺序排序日志 + mappingEntries.sort((a, b) => a.idx - b.idx); + + derivedAnime.animeTitle = derivedAnime.animeTitle.replace(`from ${primary}`, `from ${primary}${DISPLAY_CONNECTOR}${secondary}`); + derivedAnime.source = primary; + + addAnime(derivedAnime); + + newMergedAnimes.push(derivedAnime); + usedBaseAnimeIds.add(pAnime.animeId); + mergedSecondaryAnimes.add(match); + + const logTitleA = cachedPAnime.animeTitle.replace(/\s*from\s+.*$/i, ''); + const logTitleB = cachedMatch.animeTitle.replace(/\s*from\s+.*$/i, ''); + log("info", `[Merge] 关联成功: [${primary}] ${logTitleA} <-> [${secondary}] ${logTitleB} (共合并 ${mergedCount} 集) (MergedID: ${derivedAnime.animeId})`); + + // 打印详细的集数映射日志 + if (mappingEntries.length > 0) { + log("info", `[Merge] 集映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`); + } + } + } + } + + curAnimes.push(...newMergedAnimes); + + mergedSecondaryAnimes.forEach(item => { + item._isMerged = true; + }); + + for (let i = curAnimes.length - 1; i >= 0; i--) { + const item = curAnimes[i]; + if (item._isMerged || usedBaseAnimeIds.has(item.animeId)) { + curAnimes.splice(i, 1); + } + } +} + +/** + * 合并两个弹幕列表并按时间排序 + * @param {Array} listA 弹幕列表A + * @param {Array} listB 弹幕列表B + * @returns {Array} 合并后的弹幕列表 + */ +export function mergeDanmakuList(listA, listB) { + const final = [...(listA || []), ...(listB || [])]; + + const getTime = (item) => { + if (!item) return 0; + if (item.t !== undefined && item.t !== null) return Number(item.t); + if (item.p && typeof item.p === 'string') { + const pTime = parseFloat(item.p.split(',')[0]); + return isNaN(pTime) ? 0 : pTime; + } + return 0; + }; + + final.sort((a, b) => { + return getTime(a) - getTime(b); + }); + + return final; +} \ No newline at end of file diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 2aa338c..634b083 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -19,6 +19,7 @@ import MangoSource from "./sources/mango.js"; import BilibiliSource from "./sources/bilibili.js"; import YoukuSource from "./sources/youku.js"; import SohuSource from "./sources/sohu.js"; +import AnimekoSource from "./sources/animeko.js"; import OtherSource from "./sources/other.js"; import { NodeHandler } from "./configs/handlers/node-handler.js"; import { VercelHandler } from "./configs/handlers/vercel-handler.js"; @@ -60,6 +61,7 @@ test('worker.js API endpoints', async (t) => { const bilibiliSource = new BilibiliSource(); const youkuSource = new YoukuSource(); const sohuSource = new SohuSource(); + const animekoSource = new AnimekoSource(); const otherSource = new OtherSource(); await t.test('GET / should return welcome message', async () => { @@ -303,6 +305,43 @@ test('worker.js API endpoints', async (t) => { // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); // }); + // // 测试Animeko源 + // await t.test('Animeko Source Search', async () => { + // const source = new AnimekoSource(); + // const result = await source.search("我们不可能成为恋人!绝对不行。 (※似乎可行?)"); + // console.log(JSON.stringify(result, null, 2)); + // assert(result.length > 0); + // + // const curAnimes = []; + // await source.handleAnimes(result, "我们不可能成为恋人!绝对不行。 (※似乎可行?)", curAnimes); + // assert(curAnimes.length > 0); + // + // const animeId = result[0].id; + // const episodes = await source.getEpisodes(animeId); + // + // if (episodes && episodes.length > 0) { + // const firstEp = episodes.find(e => e.type === 0) || episodes[0]; + // const testId = firstEp.id; + // + // console.log(`Testing getSegmentComments with ID: ${testId}`); + // + // const segment = { + // url: String(testId), + // type: 'animeko' + // }; + // + // const danmu = await source.getSegmentComments(segment); + // + // console.log("Danmu count:", danmu ? danmu.length : 0); + // assert(Array.isArray(danmu)); + // + // if (danmu.length > 0) { + // assert(danmu[0].p !== undefined); + // assert(danmu[0].m !== undefined); + // } + // } + // }); + // await t.test('GET realistic danmu', async () => { // // tencent // // const keyword = "子夜归";