From f1f5d285fb4dd76ef17db3b753d3ec40b64a24bb Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:00:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A8=E6=80=81=E6=9B=B4=E6=96=B0renren?= =?UTF-8?q?=E6=BA=90tv=E6=8E=A5=E5=8F=A3aliid=E5=8F=82=E6=95=B0=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=A3=8E=E6=8E=A7=EF=BC=9B=E4=BF=AE=E5=A4=8D=E2=80=9C?= =?UTF-8?q?=E6=88=91=E6=8E=A8=E7=9A=84=E5=AD=A9=E5=AD=90=E2=80=9D=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=20dandan=E7=9A=84=E5=A4=9A=E5=AD=A3=E5=88=92=E5=88=86?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E4=B8=8Ebahamut=E7=9A=84=E5=90=88=E9=9B=86?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E5=85=B3=E8=81=94=E5=90=88=E5=B9=B6=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * renren.js:将App接口更换至数据更多的TV接口 * renren.js:动态更新aliid参数避免风控、增强回退网页接口逻辑、补全缺失的日志输出 * merge-util.js:支持合集参与匹配:修复“我推的孩子”动画 dandan的多季划分无法与bahamut的合集正确关联 * Update globals.js 1.13.8 --- danmu_api/configs/globals.js | 2 +- danmu_api/sources/renren.js | 972 +++++++++++++++++++++------------- danmu_api/utils/merge-util.js | 639 ++++++++++++++++++++-- 3 files changed, 1211 insertions(+), 402 deletions(-) diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 6b16b09..f2556c1 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.13.7', + VERSION: '1.13.8', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_ANIMES: 100, diff --git a/danmu_api/sources/renren.js b/danmu_api/sources/renren.js index 588411d..84fec6d 100644 --- a/danmu_api/sources/renren.js +++ b/danmu_api/sources/renren.js @@ -2,7 +2,7 @@ import BaseSource from './base.js'; import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { getPathname, httpGet, sortedQueryString, updateQueryString } from "../utils/http-util.js"; -import { autoDecode, createHmacSha256, generateRandomSid, generateSign, generateXCaSign } from "../utils/codec-util.js"; +import { autoDecode, createHmacSha256, generateSign } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; @@ -11,83 +11,364 @@ import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取人人视频弹幕 // ===================== + + +// 模块级状态管理 (Instance Level State) +// 缓存当前的 AliID (全局共享,跨请求持久化,模拟设备指纹) +let CACHED_ALI_ID = null; +// 当前 AliID 已请求次数 +let REQUEST_COUNT = 0; +// 触发轮换的阈值 (将在 30-60 之间随机生成) +let ROTATION_THRESHOLD = 0; + +/** + * 人人视频弹幕源 + * 集成 TV 端 API 协议,保留网页版接口作为降级容灾策略。 + * 兼容处理 SeriesId-EpisodeId 复合主键,确保弹幕与剧集详情的关联正确性。 + */ export default class RenrenSource extends BaseSource { + constructor() { + super(); + // 实例级标记:当前是否处于批量请求模式 + // 在此模式下(例如处理 handleAnimes 列表遍历),内部的 AliID 获取请求暂时不增加计数 + // 直到批量操作结束(finally 块)时,才统一结算一次计数 + this.isBatchMode = false; + } + + // API 配置常量 API_CONFIG = { SECRET_KEY: "cf65GPholnICgyw1xbrpA79XVkizOdMq", - SEARCH_HOST: "api.qwdjapp.com", - DRAMA_HOST: "api.zhimeisj.top", - DANMU_HOST: "static-dm.qwdjapp.com", - APP_VERSION: "10.31.2", - USER_AGENT: 'Mozilla/5.0 (Linux; Android 16; 23127PN0CC Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/143.0.7499.146 Mobile Safari/537.36 App/RRSPApp platform/android AppVersion/10.31.2' + + // TV 端接口配置 + TV_HOST: "api.gorafie.com", + TV_DANMU_HOST: "static-dm.qwdjapp.com", + TV_VERSION: "1.2.2", + TV_USER_AGENT: 'okhttp/3.12.13', + TV_CLIENT_TYPE: 'android_qwtv_RRSP', + TV_PKT: 'rrmj', + + // 网页版/旧版接口配置 (降级备用) + WEB_HOST: "api.rrmj.plus", + WEB_DANMU_HOST: "static-dm.rrmj.plus" }; - generateAppCommonHeaders(timestamp, sign, xCaSign = null) { - const headers = { - 'User-Agent': this.API_CONFIG.USER_AGENT, - 'deviceId': 'T2%2Bjh%2FnHhJkWEzPnQT2E0%2FEw865FTT0uL%2BiBwRa2ZdM%3D', - 'aliId': 'aUzmLtnZIYoDAA9KyLdcLQpM', - 'umId': '53e0f078fa8474ae7ba412f766989b54od', - 'clientType': 'android_rrsp_xb_XiaoMi', + /** + * 生成随机的 aliid + * 规律:24位长度,以 'aY' 开头,包含字母数字和 Base64 特殊字符 + * 模拟抓包数据:aYN4D0XfSREDAJaw3UAjG33K + */ + generateRandomAliId() { + const prefix = "aY"; + const length = 24 - prefix.length; + // 标准 Base64 字符集 + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + let result = prefix; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + /** + * 执行 ID 轮换/初始化 + * 生成新的 ID,重置计数器,并随机生成下一次的轮换阈值 + */ + rotateAliId() { + const oldId = CACHED_ALI_ID; + CACHED_ALI_ID = this.generateRandomAliId(); + REQUEST_COUNT = 0; // 重置计数 + // 生成 30 到 60 之间的随机整数作为阈值 + ROTATION_THRESHOLD = Math.floor(Math.random() * (60 - 30 + 1)) + 30; + + if (oldId) { + log("info", `[Renren] AliID 轮换完成: ${oldId} -> ${CACHED_ALI_ID}`); + } else { + log("info", `[Renren] AliID 初始化完成: ${CACHED_ALI_ID}`); + } + log("info", `[Renren] AliID 下次轮换将在 ${ROTATION_THRESHOLD} 次操作后触发`); + } + + /** + * 检查并增加计数 (核心逻辑) + * 负责监控使用次数,达到阈值时触发轮换 + * 并在日志中明确输出 AliID 计数状态 + */ + checkAndIncrementUsage() { + // 1. 如果 ID 未初始化,强制初始化 + if (!CACHED_ALI_ID) { + this.rotateAliId(); + } + + // 2. 检查阈值,决定是否轮换 + if (REQUEST_COUNT >= ROTATION_THRESHOLD) { + log("info", `[Renren] AliID 触发阈值 (${REQUEST_COUNT}/${ROTATION_THRESHOLD}),正在轮换 ID...`); + this.rotateAliId(); + } + + // 3. 增加计数 + REQUEST_COUNT++; + // 输出明确的计数日志,方便排查 + log("info", `[Renren] AliID 计数增加: ${REQUEST_COUNT}/${ROTATION_THRESHOLD} (当前ID: ...${CACHED_ALI_ID.slice(-6)})`); + } + + /** + * 获取有效的 aliid + * 根据 isBatchMode 决定是否增加计数 + */ + getAliId() { + // 兜底:确保 ID 存在 + if (!CACHED_ALI_ID) { + this.rotateAliId(); + } + + // 如果处于批量模式,直接返回当前 ID,不增加计数 + // 逻辑:批量模式(如详情遍历)会在结束时统一调用一次 checkAndIncrementUsage 进行结算 + // 所以过程中获取 ID 是“免费”的,避免一次搜索消耗几十次计数 + if (this.isBatchMode) { + return CACHED_ALI_ID; + } + + // 普通模式(如单独的搜索请求),正常计数(预付费模式) + this.checkAndIncrementUsage(); + return CACHED_ALI_ID; + } + + /** + * 生成 TV 端接口所需的请求头 + * 处理签名、设备标识及版本控制字段 + * @param {number} timestamp 当前时间戳 + * @param {string} sign 接口签名 + * @returns {Object} HTTP Headers + */ + generateTvHeaders(timestamp, sign) { + // 获取 aliid (包含动态轮换和批量锁定逻辑) + const aliId = this.getAliId(); + + return { + 'clientVersion': this.API_CONFIG.TV_VERSION, + 'p': 'Android', + 'deviceid': 'tWEtIN7JG2DTDkBBigvj6A%3D%3D', // 固定设备指纹 + 'token': '', // 必须为空字符串以通过校验 + 'aliid': aliId, // 使用动态aliId + 'umid': '', // 必须为空字符串以通过校验 + 'clienttype': this.API_CONFIG.TV_CLIENT_TYPE, + 'pkt': this.API_CONFIG.TV_PKT, 't': timestamp.toString(), 'sign': sign, 'isAgree': '1', - 'cv': this.API_CONFIG.APP_VERSION, - 'ct': 'android_rrsp_xb_XiaoMi', - 'pkt': 'rrmj', - 'p': 'Android', - 'wcode': '3', 'et': '2', - 'uet': '1', - 'folding-screen': '1', - 'Accept': 'application/json', 'Accept-Encoding': 'gzip', - 'Connection': 'close' + 'User-Agent': this.API_CONFIG.TV_USER_AGENT, }; - - if (xCaSign) { - headers['x-ca-sign'] = xCaSign; - headers['x-ca-method'] = '1'; - } - - return headers; } - async searchAppContent(keyword, size = 15) { + /** + * 搜索剧集 (TV API) + * @param {string} keyword 搜索关键词 + * @param {number} size 分页大小 + * @returns {Array} 统一格式的搜索结果列表 + */ + async searchAppContent(keyword, size = 30) { try { const timestamp = Date.now(); - const path = "/search/content"; + const path = "/qwtv/search"; const queryParams = { - keywords: keyword, - size: size, - search_after: "", - order: "match", - isAgeLimit: false + searchWord: keyword, + num: size, + searchNext: "", + well: "match" }; const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); - const queryString = Object.entries(queryParams) .map(([k, v]) => `${k}=${encodeURIComponent(v === null || v === undefined ? "" : String(v))}`) .join('&'); + + const headers = this.generateTvHeaders(timestamp, sign); - const xCaSign = generateXCaSign(path, timestamp, queryString, this.API_CONFIG.SECRET_KEY); + const url = `https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`; + // log("info", `[Renren] TV搜索请求: ${url}`); - const headers = this.generateAppCommonHeaders(timestamp, sign, xCaSign); - headers['Host'] = this.API_CONFIG.SEARCH_HOST; - headers['Origin'] = 'https://d.rrsp.com.cn'; - headers['Referer'] = 'https://d.rrsp.com.cn/'; - - const resp = await httpGet(`https://${this.API_CONFIG.SEARCH_HOST}${path}?${queryString}`, { + const resp = await httpGet(url, { headers: headers, retries: 1, }); - if (!resp.data) return []; + if (!resp.data || resp.data.code !== "0000") { + log("info", `[Renren] TV搜索接口异常: code=${resp?.data?.code}, msg=${resp?.data?.msg}`); + return []; + } - // 服务端明确提示"版本过低/强制更新"时:直接返回空,让上层走备用搜索 - if (resp?.data?.code === "0001") return []; + const list = resp.data.data || []; + log("info", `[Renren] TV搜索返回结果数量: ${list.length}`); - const list = resp?.data?.data?.searchDramaList || []; + return list.map((item) => ({ + provider: "renren", + mediaId: String(item.id), + title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), + type: "tv_series", + season: null, + year: item.year, + imageUrl: item.cover, + episodeCount: null, // 列表页不返回总集数 + currentEpisodeIndex: null, + })); + } catch (error) { + log("info", "[Renren] searchAppContent error:", error.message); + return []; + } + } + + /** + * 获取剧集详情 (TV API) + * @param {string} dramaId 剧集ID + * @param {string} episodeSid 单集ID (可选) + * @returns {Object} 详情数据对象 + */ + async getAppDramaDetail(dramaId, episodeSid = "") { + try { + const timestamp = Date.now(); + const path = "/qwtv/drama/details"; + const queryParams = { + isAgeLimit: "false", + seriesId: dramaId, + episodeId: episodeSid, + clarity: "HD", + caption: "0", + hevcOpen: "1" + }; + + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); + const queryString = Object.entries(queryParams) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); + + const headers = this.generateTvHeaders(timestamp, sign); + + const resp = await httpGet(`https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`, { + headers: headers, + retries: 1, + }); + + // 1. 基础网络或数据校验 + if (!resp || !resp.data) { + log("info", `[Renren] TV详情接口网络无响应或数据为空: ID=${dramaId}`); + return null; + } + + const resData = resp.data; + const msg = resData.msg || resData.message || ""; + + // 2. 检测特定维护信息 "该剧暂不可播" + // 这通常意味着 App 接口正在维护或该资源被下架,必须降级到 Web 版 + if (msg.includes("该剧暂不可播")) { + log("info", `[Renren] TV接口提示'该剧暂不可播' (ID=${dramaId}),视为维护中,触发Web降级`); + return null; // 触发降级 + } + + // 3. 检测错误码 + if (resData.code !== "0000") { + log("info", `[Renren] TV详情接口返回错误码: ${resData.code}, msg=${msg} (ID=${dramaId})`); + return null; + } + + // 4. 检测分集数据完整性 (关键修改:如果详情成功但没分集,也视为失败) + if (!resData.data || !resData.data.episodeList || resData.data.episodeList.length === 0) { + log("info", `[Renren] TV详情接口返回数据缺失分集列表 (ID=${dramaId}),尝试Web降级`); + return null; // 触发降级 + } + + log("info", `[Renren] TV详情获取成功: ID=${dramaId}, 包含集数=${resData.data.episodeList.length}`); + return resData; + } catch (error) { + log("info", "[Renren] getAppDramaDetail error:", error.message); + return null; + } + } + + /** + * 获取单集弹幕 (TV API) + * 请求 static-dm.qwdjapp.com 获取全量弹幕数据 + * @param {string} episodeSid 单集ID (支持复合ID自动解包) + * @returns {Array} 原始弹幕数据列表 + */ + async getAppDanmu(episodeSid) { + try { + const timestamp = Date.now(); + + // 处理复合ID (SeriesId-EpisodeId),提取真实的 EpisodeId + let realEpisodeId = episodeSid; + if (String(episodeSid).includes("-")) { + realEpisodeId = String(episodeSid).split("-")[1]; + } + + // 构造请求路径 (注意:此处使用 EPISODE 路径,不包含 emo) + const path = `/v1/produce/danmu/EPISODE/${realEpisodeId}`; + const queryParams = {}; // 该接口无查询参数 + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); + const headers = this.generateTvHeaders(timestamp, sign); + + // 请求旧域名 static-dm.qwdjapp.com + const url = `https://${this.API_CONFIG.TV_DANMU_HOST}${path}`; + + const resp = await httpGet(url, { + headers: headers, + retries: 1, + }); + + if (!resp.data) return null; + + const data = autoDecode(resp.data); + + // 兼容直接返回数组或包装在 data 字段中的情况 + if (Array.isArray(data)) return data; + if (data && data.data && Array.isArray(data.data)) return data.data; + + return []; + } catch (error) { + log("info", "[Renren] getAppDanmu error:", error.message); + return null; + } + } + + /** + * 执行网页版网络搜索 (降级逻辑) + */ + async performNetworkSearch(keyword, { lockRef = null, lastRequestTimeRef = { value: 0 }, minInterval = 500 } = {}) { + try { + log("info", `[Renren] 尝试执行网页版搜索: ${keyword}`); + const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/search/drama`; + const params = { + keywords: keyword, + size: 20, + order: "match", + search_after: "", + isExecuteVipActivity: true + }; + + if (lockRef) { + while (lockRef.value) await new Promise(r => setTimeout(r, 50)); + lockRef.value = true; + } + + const now = Date.now(); + const dt = now - lastRequestTimeRef.value; + if (dt < minInterval) await new Promise(r => setTimeout(r, minInterval - dt)); + + const resp = await this.renrenRequest("GET", url, params); + lastRequestTimeRef.value = Date.now(); + + if (lockRef) lockRef.value = false; + + if (!resp.data) { + log("info", "[Renren] 网页版搜索无响应数据"); + return []; + } + + const decoded = autoDecode(resp.data); + const list = decoded?.data?.searchDramaList || []; + log("info", `[Renren] 网页版搜索结果数量: ${list.length}`); + return list.map((item) => ({ provider: "renren", mediaId: String(item.id), @@ -100,117 +381,328 @@ export default class RenrenSource extends BaseSource { currentEpisodeIndex: null, })); } catch (error) { - const msg = String(error?.message || ""); - const is418 = /status:\s*418\b/.test(msg); - - if (is418) { - log("warn", "[Renren] /search/content 被服务端拦截 (418),已降级为备用搜索接口"); - return []; - } - - log("error", "[Renren] searchAppContent error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + log("info", "[Renren] performNetworkSearch error:", error.message); return []; } } - async getAppDramaDetail(dramaId, episodeSid = "") { - try { - if (!episodeSid) episodeSid = generateRandomSid(); + // ===================== + // 标准接口实现 (BaseSource 抽象方法) + // ===================== - const timestamp = Date.now(); - const path = "/app/drama/page"; - const queryParams = { - isAgeLimit: false, - dramaId: dramaId, - episodeSid: episodeSid, - quality: "SD", - subtitle: 3, - hsdrOpen: 1, - hevcOpen: 1, - tria4k: 1 - }; + async search(keyword) { + log("info", `[Renren] 开始搜索: ${keyword}`); + const parsedKeyword = { title: keyword, season: null }; + const searchTitle = parsedKeyword.title; + const searchSeason = parsedKeyword.season; - const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); - const queryString = Object.entries(queryParams) - .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) - .join('&'); - - const headers = this.generateAppCommonHeaders(timestamp, sign); - headers['Host'] = this.API_CONFIG.DRAMA_HOST; - headers['ignore'] = 'false'; - - const resp = await httpGet(`https://${this.API_CONFIG.DRAMA_HOST}${path}?${queryString}`, { - headers: headers, - retries: 1, + let allResults = []; + + // 1. 优先使用 TV 接口 + allResults = await this.searchAppContent(searchTitle); + + // 2. 降级策略: 若 TV 接口无结果,尝试网页接口 + if (allResults.length === 0) { + log("info", "[Renren] TV 搜索无结果,降级到网页接口"); + const lock = { value: false }; + const lastRequestTime = { value: 0 }; + allResults = await this.performNetworkSearch(searchTitle, { + lockRef: lock, + lastRequestTimeRef: lastRequestTime, + minInterval: 400 }); + } - if (!resp.data) return null; + if (searchSeason == null) return allResults; + return allResults.filter(r => r.season === searchSeason); + } + + async getDetail(id) { + // 1. 优先使用 TV 接口 + const resp = await this.getAppDramaDetail(String(id)); + if (resp && resp.data) { return resp.data; - } catch (error) { - log("error", "[Renren] getAppDramaDetail error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + } + + // 2. 降级策略: 尝试网页接口 + log("info", `[Renren] TV详情不可用,尝试请求网页版接口 (ID=${id})`); + const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/drama/page`; + const params = { hsdrOpen: 0, isAgeLimit: 0, dramaId: String(id), hevcOpen: 1 }; + + try { + const fallbackResp = await this.renrenRequest("GET", url, params); + if (!fallbackResp.data) return null; + + const decoded = autoDecode(fallbackResp.data); + if (decoded && decoded.data) { + log("info", `[Renren] 网页版详情获取成功: 包含集数=${decoded.data.episodeList ? decoded.data.episodeList.length : 0}`); + return decoded.data; + } + return null; + } catch (e) { + log("info", `[Renren] 网页版详情请求失败: ${e.message}`); return null; } } - // ========== 弹幕API ========== - async getAppDanmu(episodeSid) { + async getEpisodes(id) { + log("info", `[Renren] 正在获取分集信息: ID=${id}`); + const detail = await this.getDetail(id); + + if (!detail) { + log("info", `[Renren] 获取分集失败: 详情对象为空 ID=${id}`); + return []; + } + + if (!detail.episodeList || !Array.isArray(detail.episodeList)) { + log("info", `[Renren] 获取分集失败: episodeList 字段缺失或非数组 ID=${id}`); + return []; + } + + let episodes = []; + const seriesId = String(id); + + detail.episodeList.forEach((ep, idx) => { + const epSid = String(ep.sid || "").trim(); + if (!epSid) return; + + const showTitle = ep.title ? String(ep.title) : `第${String(ep.episodeNo || idx + 1).padStart(2, "0")}集`; + + // 构建复合ID (SeriesId-EpisodeId) + // TV弹幕接口需要EpisodeId,搜索可能需要SeriesId,保留此结构确保上下文完整 + const compositeId = `${seriesId}-${epSid}`; + + episodes.push({ sid: compositeId, order: ep.episodeNo || idx + 1, title: showTitle }); + }); + + log("info", `[Renren] 成功解析分集数量: ${episodes.length} (ID=${id})`); + + return episodes.map(e => ({ + provider: "renren", + episodeId: e.sid, + title: e.title, + episodeIndex: e.order, + url: null + })); + } + + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + if (!sourceAnimes || !Array.isArray(sourceAnimes)) { + log("info", "[Renren] sourceAnimes is not a valid array"); + return []; + } + + // [标记开始] 进入批量处理模式 + // 注意:此处不再输出冗余日志,也不扣费。开启静默模式。 + this.isBatchMode = true; + try { - const timestamp = Date.now(); - const path = `/v1/produce/danmu/emo/EPISODE/${episodeSid}`; + await Promise.all(sourceAnimes + .filter(s => titleMatches(s.title, queryTitle)) + .map(async (anime) => { + try { + // 在此块中调用的 getEpisodes -> ... -> getAliId + // 会因为 isBatchMode=true 而直接返回缓存ID,不增加计数 + const eps = await this.getEpisodes(anime.mediaId); + + let links = []; + for (const ep of eps) { + links.push({ + "name": ep.episodeIndex.toString(), + "url": ep.episodeId, + "title": `【${ep.provider}】 ${ep.title}` + }); + } - const sign = generateSign(path, timestamp, {}, this.API_CONFIG.SECRET_KEY); - const xCaSign = generateXCaSign(path, timestamp, "", this.API_CONFIG.SECRET_KEY); + if (links.length > 0) { + let transformedAnime = { + animeId: Number(anime.mediaId), + bangumiId: String(anime.mediaId), + animeTitle: `${anime.title}(${anime.year})【${anime.type}】from renren`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year), + episodeCount: links.length, + rating: 0, + isFavorited: true, + source: "renren", + }; - const headers = this.generateAppCommonHeaders(timestamp, sign, xCaSign); - headers['Host'] = this.API_CONFIG.DANMU_HOST; + tmpAnimes.push(transformedAnime); + addAnime({ ...transformedAnime, links: links }); - const resp = await httpGet(`https://${this.API_CONFIG.DANMU_HOST}${path}`, { - headers: headers, - retries: 1, - }); + if (globals.animes.length > globals.MAX_ANIMES) { + removeEarliestAnime(); + } + } + } catch (error) { + log("info", `[Renren] Error processing anime: ${error.message}`); + } + }) + ); + } finally { + // [标记结束] 退出批量模式 + this.isBatchMode = false; + // [结算扣费] 批量操作结束,统一结算一次 AliID 计数 + // 这样日志会出现在所有请求日志的末尾,符合“处理完毕,消耗一次”的直觉 + this.checkAndIncrementUsage(); + } - if (!resp.data) return null; + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); - return resp.data; - } catch (error) { - log("error", "[Renren] getAppDanmu error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); - return null; + return tmpAnimes; + } + + async getEpisodeDanmu(id) { + // 1. 优先尝试 TV 接口 + let danmuList = await this.getAppDanmu(id); + + // 2. 降级策略: TV 接口无数据时,尝试网页版接口 + if (!danmuList || danmuList.length === 0) { + log("info", "[Renren] TV 弹幕接口失败或无数据,尝试降级网页接口"); + danmuList = await this.getWebDanmuFallback(id); + } + + // 3. 返回原始数据列表,BaseSource 会自动调用本类的 formatComments 进行格式化 + if (danmuList && Array.isArray(danmuList) && danmuList.length > 0) { + log("info", `[Renren] 成功获取 ${danmuList.length} 条弹幕`); + return danmuList; + } + + return []; + } + + /** + * 获取网页版弹幕 (降级方法) + * 自动处理复合 ID 的解包 + */ + async getWebDanmuFallback(id) { + let realEpisodeId = id; + if (String(id).includes("-")) { + realEpisodeId = String(id).split("-")[1]; + } + + // 日志保留 + log("info", `[Renren] 降级网页版弹幕,使用 ID: ${realEpisodeId}`); + + const ClientProfile = { + user_agent: "Mozilla/5.0", + origin: "https://rrsp.com.cn", + referer: "https://rrsp.com.cn/", + }; + + const url = `https://${this.API_CONFIG.WEB_DANMU_HOST}/v1/produce/danmu/EPISODE/${realEpisodeId}`; + const headers = { + "Accept": "application/json", + "User-Agent": ClientProfile.user_agent, + "Origin": ClientProfile.origin, + "Referer": ClientProfile.referer, + }; + + try { + const fallbackResp = await this.renrenHttpGet(url, { headers }); + if (!fallbackResp.data) return []; + + const data = autoDecode(fallbackResp.data); + let list = []; + if (Array.isArray(data)) list = data; + else if (data?.data && Array.isArray(data.data)) list = data.data; + + return list; + } catch (e) { + log("info", `[Renren] 网页版弹幕降级失败: ${e.message}`); + return []; } } + async getEpisodeDanmuSegments(id) { + return new SegmentListResponse({ + "type": "renren", + "segmentList": [{ + "type": "renren", + "segment_start": 0, + "segment_end": 30000, + "url": id + }] + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getEpisodeDanmu(segment.url); + } + + // ===================== + // 数据解析与签名工具 + // ===================== + + /** + * 解析 RRSP 的 P 字段 (属性字符串) + * 格式: timestamp,mode,size,color,uid,cid... + * 使用安全数值转换,防止 NaN 污染导致数据被误去重 + */ parseRRSPPFields(pField) { const parts = String(pField).split(","); - const num = (i, cast, dft) => { - try { return cast(parts[i]); } - catch { return dft; } + + // 安全数值转换工具:若解析结果为 NaN,则返回默认值 + const safeNum = (val, parser, defaultVal) => { + if (val === undefined || val === null || val === "") return defaultVal; + const res = parser(val); + return isNaN(res) ? defaultVal : res; }; - const timestamp = num(0, parseFloat, 0); - const mode = num(1, x => parseInt(x, 10), 1); - const size = num(2, x => parseInt(x, 10), 25); - const color = num(3, x => parseInt(x, 10), 16777215); + + const timestamp = safeNum(parts[0], parseFloat, 0); + const mode = safeNum(parts[1], x => parseInt(x, 10), 1); + const size = safeNum(parts[2], x => parseInt(x, 10), 25); + const color = safeNum(parts[3], x => parseInt(x, 10), 16777215); + const userId = parts[6] || ""; const contentId = parts[7] || `${timestamp}:${userId}`; + return { timestamp, mode, size, color, userId, contentId }; } + /** + * 格式化弹幕列表为标准模型 + * 将原始 d/p 字段映射为系统内部对象 + * 兼容处理 item.d 和 item.content 内容字段 + */ + formatComments(comments) { + return comments.map(item => { + // 提取内容 (优先 d,兼容 content) + let text = String(item.d || ""); + if (!text && item.content) text = String(item.content); + + if (!text) return null; + + // 提取属性 (p) + if (item.p) { + const meta = this.parseRRSPPFields(item.p); + return { + cid: Number(meta.contentId) || 0, + p: `${meta.timestamp.toFixed(2)},${meta.mode},${meta.color},[renren]`, + m: text, + t: meta.timestamp + }; + } + return null; + }).filter(Boolean); + } + + /** + * 生成网页版 API 签名 + */ generateSignature(method, aliId, ct, cv, timestamp, path, sortedQuery, secret) { const signStr = `${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${timestamp}\n${path}?${sortedQuery}`; return createHmacSha256(secret, signStr); } + /** + * 构建网页版带签名的请求头 + */ buildSignedHeaders({ method, url, params = {}, deviceId, token }) { const ClientProfile = { client_type: "web_pc", @@ -268,236 +760,4 @@ export default class RenrenSource extends BaseSource { }); return resp; } - - async performNetworkSearch(keyword, { lockRef = null, lastRequestTimeRef = { value: 0 }, minInterval = 500 } = {}) { - try { - const url = `https://api.rrmj.plus/m-station/search/drama`; - const params = { - keywords: keyword, - size: 20, - order: "match", - search_after: "", - isExecuteVipActivity: true - }; - - if (lockRef) { - while (lockRef.value) await new Promise(r => setTimeout(r, 50)); - lockRef.value = true; - } - - const now = Date.now(); - const dt = now - lastRequestTimeRef.value; - if (dt < minInterval) await new Promise(r => setTimeout(r, minInterval - dt)); - - const resp = await this.renrenRequest("GET", url, params); - lastRequestTimeRef.value = Date.now(); - - if (lockRef) lockRef.value = false; - - if (!resp.data) return []; - - const decoded = autoDecode(resp.data); - const list = decoded?.data?.searchDramaList || []; - return list.map((item) => ({ - provider: "renren", - mediaId: String(item.id), - title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), - type: "tv_series", - season: null, - year: item.year, - imageUrl: item.cover, - episodeCount: item.episodeTotal, - currentEpisodeIndex: null, - })); - } catch (error) { - log("error", "[Renren] performNetworkSearch error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); - return []; - } - } - - async search(keyword) { - const parsedKeyword = { title: keyword, season: null }; - const searchTitle = parsedKeyword.title; - const searchSeason = parsedKeyword.season; - - let allResults = []; - - // 优先使用 APP 接口搜索 - // allResults = await this.searchAppContent(searchTitle); - - // APP 接口失败时降级到网页接口 - if (allResults.length === 0) { - log("info", "[Renren] APP 搜索无结果,降级到网页接口"); - const lock = { value: false }; - const lastRequestTime = { value: 0 }; - allResults = await this.performNetworkSearch(searchTitle, { - lockRef: lock, - lastRequestTimeRef: lastRequestTime, - minInterval: 400 - }); - } - - if (searchSeason == null) return allResults; - - return allResults.filter(r => r.season === searchSeason); - } - - async getDetail(id) { - // 优先使用 APP 接口 - // const resp = await this.getAppDramaDetail(String(id)); - // if (resp) { - // return resp.data; - // } - - // // APP 接口失败时降级到网页接口 - // log("info", "[Renren] APP 详情接口失败,降级到网页接口"); - const url = `https://api.rrmj.plus/m-station/drama/page`; - const params = { hsdrOpen: 0, isAgeLimit: 0, dramaId: String(id), hevcOpen: 1 }; - const fallbackResp = await this.renrenRequest("GET", url, params); - if (!fallbackResp.data) return null; - const decoded = autoDecode(fallbackResp.data); - return decoded?.data || null; - } - - async getEpisodes(id) { - const detail = await this.getDetail(id); - if (!detail || !detail.episodeList) return []; - - let episodes = []; - detail.episodeList.forEach((ep, idx) => { - const sid = String(ep.sid || "").trim(); - if (!sid) return; - const title = String(ep.title || `第${String(idx + 1).padStart(2, "0")}集`); - episodes.push({ sid, order: idx + 1, title }); - }); - - return episodes.map(e => ({ - provider: "renren", - episodeId: e.sid, - title: e.title, - episodeIndex: e.order, - url: null - })); - } - - async handleAnimes(sourceAnimes, queryTitle, curAnimes) { - const tmpAnimes = []; - - if (!sourceAnimes || !Array.isArray(sourceAnimes)) { - log("error", "[Renren] sourceAnimes is not a valid array"); - return []; - } - - await Promise.all(sourceAnimes - .filter(s => titleMatches(s.title, queryTitle)) - .map(async (anime) => { - try { - const eps = await this.getEpisodes(anime.mediaId); - let links = []; - for (const ep of eps) { - links.push({ - "name": ep.episodeIndex.toString(), - "url": ep.episodeId, - "title": `【${ep.provider}】 ${ep.title}` - }); - } - - if (links.length > 0) { - let transformedAnime = { - animeId: Number(anime.mediaId), - bangumiId: String(anime.mediaId), - animeTitle: `${anime.title}(${anime.year})【${anime.type}】from renren`, - type: anime.type, - typeDescription: anime.type, - imageUrl: anime.imageUrl, - startDate: generateValidStartDate(anime.year), - episodeCount: links.length, - rating: 0, - isFavorited: true, - source: "renren", - }; - - tmpAnimes.push(transformedAnime); - addAnime({ ...transformedAnime, links: links }); - - if (globals.animes.length > globals.MAX_ANIMES) { - removeEarliestAnime(); - } - } - } catch (error) { - log("error", `[Renren] Error processing anime: ${error.message}`); - } - }) - ); - - this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); - - return tmpAnimes; - } - - async getEpisodeDanmu(id) { - // 优先使用 APP 接口 - // const resp = await this.getAppDanmu(id); - // if (resp) { - // return resp; - // } - - // // APP 接口失败时降级到网页接口 - // log("info", "[Renren] APP 弹幕接口失败,降级到网页接口"); - const ClientProfile = { - user_agent: "Mozilla/5.0", - origin: "https://rrsp.com.cn", - referer: "https://rrsp.com.cn/", - }; - const url = `https://static-dm.rrmj.plus/v1/produce/danmu/EPISODE/${id}`; - const headers = { - "Accept": "application/json", - "User-Agent": ClientProfile.user_agent, - "Origin": ClientProfile.origin, - "Referer": ClientProfile.referer, - }; - - const fallbackResp = await this.renrenHttpGet(url, { headers }); - if (!fallbackResp.data) return null; - - const data = autoDecode(fallbackResp.data); - if (Array.isArray(data)) return data; - if (data?.data && Array.isArray(data.data)) return data.data; - return null; - } - - async getEpisodeDanmuSegments(id) { - log("info", "[Renren] 获取弹幕分段列表:", id); - - return new SegmentListResponse({ - "type": "renren", - "segmentList": [{ - "type": "renren", - "segment_start": 0, - "segment_end": 30000, - "url": id - }] - }); - } - - async getEpisodeSegmentDanmu(segment) { - return this.getEpisodeDanmu(segment.url); - } - - formatComments(comments) { - return comments.map(item => { - const text = String(item.d || ""); - const meta = this.parseRRSPPFields(item.p); - return { - cid: Number(meta.contentId), - p: `${meta.timestamp.toFixed(2)},${meta.mode},${meta.color},[renren]`, - m: text, - t: meta.timestamp - }; - }); - } -} \ No newline at end of file +} diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 9e09f7e..91a1fec 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -64,7 +64,7 @@ const RE_FROM_SUFFIX = /\s*from\s+.*$/i; const RE_PARENTHESES_CONTENT = /(\(|(|\[).*?(\)|)|\])/g; const RE_SEASON_INFO_STRONG = /(?:season|s|第)\s*[0-9一二三四五六七八九十]+\s*(?:季|期|部(?!分))?/gi; const RE_PART_INFO_STRONG = /(?:part|p|第)\s*\d+\s*(?:部分)?/gi; -const RE_MOVIE_KEYWORDS = /剧场版|movie|film|电影|/gi; +const RE_MOVIE_KEYWORDS = /剧场版|the\s*movie|theatrical|movie|film|电影/gi; const RE_LANG_KEYWORDS_STRONG = /(?:中配|普通话|国语|日语|原声|粤配|粤语|日配)(?:版)?/g; const RE_LONE_VER_CHAR = /(\s|^)版(\s|$)/g; const RE_NON_ALPHANUM_CN = /[^\u4e00-\u9fa5a-zA-Z0-9]/g; @@ -127,6 +127,12 @@ const RE_EP_NUM_STRATEGY_A = /(?:第|s)(\d+)[季s].*?(?:第|ep|e)(\d+)/i; const RE_EP_NUM_STRATEGY_B = /(?:ep|o|s|part|第)\s*(\d+(\.\d+)?)(?!\s*[季期部])/i; const RE_EP_NUM_STRATEGY_C = /(?:^|\s)(\d+(\.\d+)?)(?:话|集|\s|$)/; +// dandan/animeko 特殊集排除相关正则 (S1, C1...) +const RE_DANDAN_IGNORE_EP = /^[SC]\d+/i; + +// 非正片集数关键词相关正则 (用于构建季度集数地图时严格过滤) +const RE_MAP_EXCLUDE_KEYWORDS = /(?:^|\s)(?:PV|OP|ED|SP|Special|Drama|OAD|OVA|Opening|Ending|特番|花絮|采访|Behind\s+the\s+Scenes|Making|Interview)(?:\s|$|[::])/i; + // getContentCategory 相关 (内容分类) const RE_ANIME_KW = /(动画|动漫|日漫|国漫)/; const RE_REAL_KW = /(电视剧|真人剧|综艺)/; @@ -140,8 +146,8 @@ const RE_REDUNDANT_VALID_CHARS = /[\u4e00-\u9fa5a-zA-Z]{2,}/; // findBestAlignmentOffset - CN Strict Mode (纯中文模式) const RE_CN_STRICT_CORE_REMOVE = /[0-9a-zA-Z\s第季集话partEPep._\-–—::【】()()]/gi; -// 特殊集沉底相关 -const RE_SPECIAL_SINK_TITLE = /(?:^|\s)(S\d+|SP|Special|PV|OP|ED|O\d+)(?:\s|$)/i; +// 列表沉底检测正则 匹配规则:以 S, C, SP, OP, ED, PV 开头,后跟数字,或者特定的关键词 +const RE_SPECIAL_SINK_TITLE_STRICT = /^(?:S\d+|C\d+|SP\d*|OP\d*|ED\d*|PV\d*|Trailers?|Interview|Making|特番|花絮|特典)(?:\s|$|[::.\-]|\u3000)/i; /** * 获取文本的语言/配音类型 @@ -194,6 +200,7 @@ function cleanText(text) { /** * 相似度计算专用极简清洗函数 * 深度增强清洗能力,强力移除季数、Part、括号内容,确保 "S1" 与 "S1 Part 2" 文本一致 + * 保护开头的【Title】结构,解决 "【Title】(Year)" 被过度清洗的问题 * @param {string} text 原始标题 * @returns {string} 用于相似度计算的极简字符串 */ @@ -201,6 +208,18 @@ function cleanTitleForSimilarity(text) { if (!text) return ''; let clean = simplized(text); + // 标题保护逻辑:如果标题被包裹在【】或[]中且位于开头,尝试提取内容 + // 必须在 RE_SOURCE_TAG (移除所有【】) 之前执行 + // 示例: 【我推的孩子】(2023)【动漫】 -> 我推的孩子 (2023)【动漫】 + const startBracketMatch = clean.match(/^(?:【|\[)(.+?)(?:】|\])/); + if (startBracketMatch) { + const content = startBracketMatch[1]; + // 只有当括号内的内容不像是一个简单的类型标识(如“动漫”、“TV”)时,才认为是标题 + if (!/^(TV|剧场版|movie|film|anime|动漫|动画|AVC|HEVC|MP4|MKV)$/i.test(content)) { + clean = clean.replace(startBracketMatch[0], content + ' '); + } + } + clean = clean.replace(RE_SOURCE_TAG, ''); clean = clean.replace(RE_FROM_SUFFIX, ''); clean = clean.replace(RE_NA_TAG, ''); @@ -617,6 +636,29 @@ function extractSeasonMarkers(title, typeDesc = '') { return markers; } +/** + * 辅助:从标题中提取明确的季度编号 (1, 2, 3...) + * 基于 extractSeasonMarkers 的逻辑,优先返回最大的 S 值 + * @param {string} title + * @param {string} typeDesc + * @returns {number|null} 季度编号 + */ +function getSeasonNumber(title, typeDesc = '') { + const markers = extractSeasonMarkers(title, typeDesc); + let maxSeason = null; + for (const m of markers) { + if (m.startsWith('S')) { + const num = parseInt(m.substring(1)); + if (!isNaN(num)) { + if (maxSeason === null || num > maxSeason) { + maxSeason = num; + } + } + } + } + return maxSeason; +} + /** * 获取严格的媒体类型标识 * 仅用于区分 'MOVIE' 和 'TV' @@ -861,18 +903,30 @@ function checkDateMatch(dateA, dateB, isDub = false) { /** * 验证合并覆盖率是否合规 * 防止剧场版误匹配TV版等低覆盖率情况 + * 增加 isAnyCollection 参数,放宽合集匹配的比例限制 * @param {number} mergedCount 成功匹配集数 * @param {number} totalA 主源总集数 * @param {number} totalB 副源总集数 * @param {string} sourceA 主源名称 * @param {string} sourceB 副源名称 + * @param {boolean} isAnyCollection 是否涉及合集 * @returns {boolean} 是否合规 */ -function isMergeRatioValid(mergedCount, totalA, totalB, sourceA, sourceB) { +function isMergeRatioValid(mergedCount, totalA, totalB, sourceA, sourceB, isAnyCollection = false) { if (sourceA === 'animeko' || sourceB === 'animeko') { return true; // 豁免 animeko (可能包含未放送集) } + if (isAnyCollection) { + // 合集逻辑:只要匹配了较短方(单季)的大部分内容,即视为通过 + const minTotal = Math.min(totalA, totalB); + if (minTotal > 0 && (mergedCount / minTotal) > 0.5) return true; + + // 兜底:如果总数很大但匹配很少,或者完全不匹配,还是拦截 + if (mergedCount < 2) return false; + return true; + } + const maxTotal = Math.max(totalA, totalB); if (maxTotal === 0) return false; @@ -1028,9 +1082,10 @@ function probeContentMatch(primaryAnime, candidateAnime) { * 包含上下文感知、集内容探测、中配优先等复杂逻辑 * @param {Object} primaryAnime 主源动画对象 * @param {Array} secondaryList 副源动画列表 + * @param {Set} collectionAnimeIds 已识别的合集 ID 集合 * @returns {Array} 匹配的动画对象列表(已按分数排序) */ -export function findSecondaryMatches(primaryAnime, secondaryList) { +export function findSecondaryMatches(primaryAnime, secondaryList, collectionAnimeIds = new Set()) { if (!secondaryList || secondaryList.length === 0) return []; const rawPrimaryTitle = primaryAnime.animeTitle || ''; @@ -1060,12 +1115,20 @@ export function findSecondaryMatches(primaryAnime, secondaryList) { const cleanPrimarySim = cleanTitleForSimilarity(primaryTitleForSim); const baseA = removeParentheses(primaryTitleForSim); + // 检查主源是否为合集 (Collection) + const isPrimaryCollection = collectionAnimeIds.has(primaryAnime.animeId); + // 上下文感知:预先标记副源列表中的Ambiguous Sequels const ambiguousSequelsMap = detectPeerContextSequels(secondaryList); for (const secAnime of secondaryList) { const rawSecTitle = secAnime.animeTitle || ''; + // 检查副源是否为合集 + const isSecCollection = collectionAnimeIds.has(secAnime.animeId); + // 只要任意一方是合集,则启用“合集豁免”模式 + const isAnyCollection = isPrimaryCollection || isSecCollection; + // 针对 hanjutv 源进行年份忽略处理,视为无效日期 const isSecIgnoredYear = secAnime.source === 'hanjutv'; const secDate = (rawSecTitle.includes('N/A') || isSecIgnoredYear) ? { year: null, month: null } : parseDate(secAnime.startDate); @@ -1124,23 +1187,56 @@ export function findSecondaryMatches(primaryAnime, secondaryList) { // 提前计算集内容匹配度 (Probe Content Match) const contentProbe = probeContentMatch(primaryAnime, secAnime); + // 标题级剧场版阻断 (Standalone Movie vs Season 1/2/3...) + // 逻辑:如果一方明确是剧场版标题(且无副标题),另一方是普通S,且去除“剧场版”后标题一致,则阻断。 + // 豁免:续篇 (Sequel),因为部分剧场版其实是TV续篇。 + const hasMovieA = rawPrimaryTitle.search(RE_MOVIE_KEYWORDS) !== -1; + const hasMovieB = rawSecTitle.search(RE_MOVIE_KEYWORDS) !== -1; + + if (hasMovieA !== hasMovieB) { + const markersB = extractSeasonMarkers(rawSecTitle, secAnime.typeDescription); + const markersA = extractSeasonMarkers(rawPrimaryTitle, primaryAnime.typeDescription); + + // 1. 尝试剥离电影关键词,检查Base Title是否一致 + const stripMovie = (t) => cleanTitleForSimilarity(t.replace(RE_MOVIE_KEYWORDS, '')); + const cleanA = stripMovie(rawPrimaryTitle); + const cleanB = stripMovie(rawSecTitle); + + // 如果去除“剧场版”字样后,两者标题高度一致,说明触发了同名冲突 + if (calculateSimilarity(cleanA, cleanB) > 0.9) { + const hasSequelA = markersA.has('SEQUEL'); + const hasSequelB = markersB.has('SEQUEL'); + + // 2. 如果双方都没有宣称是“续篇”,则认定为“独立剧场版”撞上了“TV版”,予以阻断 + // 只要有一方是续篇(如Watanare),则允许匹配 + if (!hasSequelA && !hasSequelB) { + logReason(rawSecTitle, `剧场版标题阻断: [${hasMovieA ? 'Movie' : 'TV'}] vs [${hasMovieB ? 'Movie' : 'TV'}] (无续篇标识,强制隔离同名Movie与TV)`); + continue; + } + } + } + // 日期检查 (传入 isDub 参数) - const dateScore = checkDateMatch(primaryDate, secDate, isPrimaryDub); + // 如果是合集,豁免日期检查 + const dateScore = isAnyCollection ? 0 : checkDateMatch(primaryDate, secDate, isPrimaryDub); // 日期严重不匹配处理 if (dateScore === -1) { let allowExemption = isSeasonExactMatch; // 如果内容探测强匹配,强制豁免日期错误 if (contentProbe.isStrongMatch) allowExemption = true; + // 如果是合集,强制豁免 + if (isAnyCollection) allowExemption = true; - if (hasStructureConflict) { - allowExemption = false; // 结构冲突下取消日期豁免 + if (hasStructureConflict && !isAnyCollection) { + allowExemption = false; // 结构冲突下取消日期豁免 (除非是合集) } if (allowExemption && primaryDate.year && secDate.year) { const yearDiff = Math.abs(primaryDate.year - secDate.year); // 仅当Probe强匹配时,允许 >2 年的差异 - if (yearDiff > 2 && !contentProbe.isStrongMatch) { + // 除非是合集,合集允许大跨度 + if (yearDiff > 2 && !contentProbe.isStrongMatch && !isAnyCollection) { allowExemption = false; } } @@ -1152,7 +1248,8 @@ export function findSecondaryMatches(primaryAnime, secondaryList) { } // 季度冲突检查 (支持探测越狱) - if (checkSeasonMismatch(primaryTitleForSim, secTitleForSim, primaryAnime.typeDescription, secAnime.typeDescription)) { + // 如果是合集,豁免季度冲突 + if (!isAnyCollection && checkSeasonMismatch(primaryTitleForSim, secTitleForSim, primaryAnime.typeDescription, secAnime.typeDescription)) { if (contentProbe.isStrongMatch) { log("info", `[Merge-Check] 季度冲突豁免: [${rawPrimaryTitle}] vs [${rawSecTitle}] (检测到集内容强匹配,无视标题季度差异)`); } else { @@ -1285,7 +1382,7 @@ function extractEpisodeInfo(title, sourceName = '') { // 严格检测:必须移除标签后才进行 S1 检测 let rawTemp = title.replace(RE_SOURCE_TAG, '').replace(RE_FROM_SUFFIX, '').trim(); - if (RE_SPECIAL_START.test(rawTemp)) { + if (RE_SPECIAL_START.test(rawTemp) || RE_DANDAN_IGNORE_EP.test(rawTemp)) { isStrictSpecial = true; } } @@ -1298,7 +1395,11 @@ function extractEpisodeInfo(title, sourceName = '') { let num = null; let season = null; - const isSpecial = isPV || isStrictSpecial || RE_SPECIAL_CHECK.test(t); + // 显式检测特殊集类型 (OP, ED, Interview 等),防止其被识别为正片数字 + const specialTypeTag = getSpecialEpisodeType(title); + const isSpecialType = !!specialTypeTag; + + const isSpecial = isPV || isStrictSpecial || isSpecialType || RE_SPECIAL_CHECK.test(t); const seasonMatch = t.match(RE_EP_SEASON_MATCH); if (seasonMatch) { @@ -1543,6 +1644,7 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un let rawTextScoreSum = 0; let matchCount = 0; let numericDiffs = new Map(); + let hasSeasonShiftMatch = false; for (let i = 0; i < secondaryLinks.length; i++) { const pIndex = i + offset; @@ -1596,7 +1698,10 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un if (seasonShift !== null && !infoA.isSpecial && !infoB.isSpecial) { if ((infoA.num - infoB.num) === seasonShift) { - pairScore += 5.0; + // 如果集数差值正好符合全局季度偏移,给予巨额奖励,压倒性优势 + // 这解决了 Absolute vs Relative 编号对齐问题 (如 Ep1 vs Ep25) + pairScore += 15.0; + hasSeasonShiftMatch = true; } } @@ -1650,8 +1755,12 @@ function findBestAlignmentOffset(primaryLinks, secondaryLinks, seriesLangA = 'Un const consistencyRatio = maxFrequency / matchCount; const avgRawTextScore = rawTextScoreSum / matchCount; - if (consistencyRatio > 0.6 && avgRawTextScore > 0.33) { - finalScore += 2.0; + // 如果检测到强烈的数字对应规律 (consistencyRatio高) + // 即使文本相似度极低 (如 CN vs JP),只要有 seasonShift 支撑,或者一致性极高,都给予奖励 + if (consistencyRatio > 0.6) { + if (hasSeasonShiftMatch || avgRawTextScore > 0.33) { + finalScore += 2.0; + } } const coverageBonus = Math.min(matchCount * 0.15, 1.5); @@ -1739,14 +1848,43 @@ function stitchUnmatchedEpisodes(derivedAnime, orphans, sourceName) { const specialList = []; const currentLen = derivedAnime.links.length; + // 计算主源“正片”的有效边界索引 + // 解决 dandan 等源在正片后堆积大量番外(S1, S2...)导致 currentLen 虚高, + // 进而导致副源后续的正片(如13-24集)被误判为“中间缺失/番外”的问题。 + let lastPrimaryMainIndex = -1; + for (let i = currentLen - 1; i >= 0; i--) { + const link = derivedAnime.links[i]; + const title = link.title || link.name || ""; + // 复用 info 提取逻辑,为保证性能和准确性,直接解析 + // 注意:derivedAnime.source 在 processMergeTask 中已被修正为 currentPrimarySource + const info = extractEpisodeInfo(title, derivedAnime.source); + + // 如果是正片(非PV、非SP、有数字),则标记为最后的主线位置 + if (!info.isSpecial && !info.isPV && !info.isStrictSpecial && info.num !== null) { + lastPrimaryMainIndex = i; + break; + } + } + + // 如果整个列表都没找到正片(全是番外),则设为 -1, + // 这样副源任何 index >= 0 的正片都会被视为 Tail 追加 + for (const item of orphans) { const relativeIdx = item.relativeIndex; const isStrictSpecial = item.info && item.info.isStrictSpecial; + // 判断副源集是否为“正片” + const isOrphanMain = item.info && !item.info.isSpecial && !item.info.isPV && !isStrictSpecial && item.info.num !== null; if (relativeIdx < 0 && !isStrictSpecial) { headList.push(item); } - else if (relativeIdx >= currentLen && !isStrictSpecial) { + else if ( + // 场景A: 索引超出了物理长度 (常规追加) + (relativeIdx >= currentLen && !isStrictSpecial) || + // 场景B [Fix]: 索引虽在物理长度内,但已超过了主源“最后正片”的位置,且自身是正片 + // 这意味着主源后面占据坑位的都是番外,副源的正片应当追加到最后,而不是插到番外中间 + (isOrphanMain && relativeIdx > lastPrimaryMainIndex) + ) { tailList.push(item); } else { @@ -1782,6 +1920,67 @@ function stitchUnmatchedEpisodes(derivedAnime, orphans, sourceName) { } } +/** + * 构建季度集数地图 (辅助合集切片) + * 遍历同组内所有动漫,统计各季度的公认集数 + * 优化点:强力排除番外、OP、ED,避免统计出的“单季长度”虚高 + * @param {Array} allGroupAnimes 同组动漫列表 + * @param {RegExp} epFilter 集过滤正则 + * @param {Set} collectionAnimeIds 已知合集ID集合 (用于排除合集本身) + * @returns {Map} 季度 -> 最大集数 + */ +function buildSeasonLengthMap(allGroupAnimes, epFilter, collectionAnimeIds) { + const seasonMap = new Map(); + + // 默认 S1 至少 10 集 (防止某些单集剧场版被误认为S1长度) + // 但如果有明确数据会覆盖 + + for (const anime of allGroupAnimes) { + // 如果该动漫已被识别为合集,则不参与季度长度的统计 + // 避免合集(如Bahamut 28集)将 S1 的统计长度撑大到 28 + if (collectionAnimeIds && collectionAnimeIds.has(anime.animeId)) { + continue; + } + + // 使用 globals.animes 获取完整数据 (links) + const realAnime = globals.animes.find(a => String(a.animeId) === String(anime.animeId)) || anime; + const seasonNum = getSeasonNumber(realAnime.animeTitle, realAnime.typeDescription); + + if (seasonNum !== null && realAnime.links) { + // 计算有效集数 (强力排除番外/OP/ED/Behind the scenes) + const validLinks = filterEpisodes(realAnime.links, epFilter).filter(item => { + const title = item.link.title || item.link.name || ""; + + // 1. 基础清理 + const cleanT = cleanText(title); + const rawTemp = cleanT.replace(RE_SOURCE_TAG, '').replace(RE_FROM_SUFFIX, '').trim(); + + // 2. 检查 dandan/animeko 的特殊标记 + if (/^(dandan|animeko)$/i.test(realAnime.source)) { + if (RE_SPECIAL_CHECK.test(rawTemp) || RE_DANDAN_IGNORE_EP.test(rawTemp)) return false; + } + + // 3. 增强的关键词过滤,拦截 "Behind the Scenes", "Opening" 等漏网之鱼 + if (RE_MAP_EXCLUDE_KEYWORDS.test(rawTemp) || RE_MAP_EXCLUDE_KEYWORDS.test(title)) { + return false; + } + + return true; + }); + + const count = validLinks.length; + if (count > 0) { + const currentMax = seasonMap.get(seasonNum) || 0; + // 取最大值,确保覆盖全集 + if (count > currentMax) { + seasonMap.set(seasonNum, count); + } + } + } + } + return seasonMap; +} + // ========================================================================= // 单个主源合并任务处理函数 // 复用逻辑核心,用于 Phase 1 (CN隔离) 和 Phase 2 (标准合并) @@ -1798,9 +1997,18 @@ async function processMergeTask(params) { groupFingerprint, currentPrimarySource, logPrefix, - limitSecondaryLang + limitSecondaryLang, + collectionAnimeIds, // 传入合集 ID 集合 + allowReuseIds // 允许复用的 ID 集合 (用于 Part 主源复用全集副源) } = params; + // 如果当前主源就是合集,且它已经被部分消费过(在 globalConsumedIds 中),则跳过 + // 防止合集作为主源再次去匹配其他内容(例如剧场版) + if (collectionAnimeIds.has(pAnime.animeId) && globalConsumedIds.has(pAnime.animeId)) { + log("info", `${logPrefix} 跳过: [${currentPrimarySource}] 是合集且已作为副源参与过合并。`); + return null; + } + const cachedPAnime = globals.animes.find(a => String(a.animeId) === String(pAnime.animeId)); if (!cachedPAnime?.links) { log("warn", `${logPrefix} 主源数据不完整,跳过: ${pAnime.animeTitle}`); @@ -1827,8 +2035,37 @@ async function processMergeTask(params) { return rawTitle; }; + // [合集预处理] + // 只要当前任务涉及合集(无论是P还是S),构建“季度-集数”地图 + const isPrimaryCollection = collectionAnimeIds.has(pAnime.animeId); + let seasonLengthMap = new Map(); + // 查找同组内所有的同名不同源动漫 + const pCleanTitle = cleanTitleForSimilarity(pAnime.animeTitle); + const peerAnimes = curAnimes.filter(a => cleanTitleForSimilarity(a.animeTitle) === pCleanTitle); + + // 传入 collectionAnimeIds 以避免合集本身污染统计数据 + seasonLengthMap = buildSeasonLengthMap(peerAnimes, epFilter, collectionAnimeIds); + + if (seasonLengthMap.size > 0) { + const mapDesc = Array.from(seasonLengthMap.entries()).map(([k,v]) => `S${k}=${v}`).join(', '); + // 仅在涉及合集时输出此日志 + if (isPrimaryCollection || availableSecondaries.some(s => curAnimes.some(a => a.source === s && collectionAnimeIds.has(a.animeId)))) { + log("info", `${logPrefix} [合集处理] 构建季度集数地图: { ${mapDesc} }`); + } + } + for (const secSource of availableSecondaries) { - let secondaryItems = curAnimes.filter(a => a.source === secSource && !groupConsumedIds.has(a.animeId)); + // 过滤副源时,检查 groupConsumedIds。 + // 如果 animeId 在 allowReuseIds 中,则豁免 groupConsumedIds 的检查。 + let secondaryItems = curAnimes.filter(a => { + if (a.source !== secSource) return false; + + const isConsumed = groupConsumedIds.has(a.animeId); + const isAllowedReuse = allowReuseIds && allowReuseIds.has(a.animeId); + + if (isConsumed && !isAllowedReuse) return false; + return true; + }); if (limitSecondaryLang) { secondaryItems = secondaryItems.filter(a => getLanguageType(a.animeTitle) === limitSecondaryLang); @@ -1836,10 +2073,14 @@ async function processMergeTask(params) { if (secondaryItems.length === 0) continue; - const matches = findSecondaryMatches(pAnime, secondaryItems); + // 将 collectionAnimeIds 传递给匹配函数 + const matches = findSecondaryMatches(pAnime, secondaryItems, collectionAnimeIds); for (const match of matches) { - if (groupConsumedIds.has(match.animeId)) continue; + // 合集豁免:如果 match 是合集,即使在 globalConsumedIds 中也可以再次使用(但 group 中不能重复) + // 兼容 allowReuseIds,如果明确允许复用,也跳过此检查 + const isReuse = allowReuseIds && allowReuseIds.has(match.animeId); + if (!isReuse && groupConsumedIds.has(match.animeId)) continue; const cachedMatch = globals.animes.find(a => String(a.animeId) === String(match.animeId)); if (!cachedMatch?.links) continue; @@ -1855,9 +2096,85 @@ async function processMergeTask(params) { const seriesLangB = getLanguageType(cachedMatch.animeTitle); - const offset = findBestAlignmentOffset( - filteredPLinksWithIndex, - filteredMLinksWithIndex, + // ========================= + // 双向合集切片逻辑 (Core Logic) + // ========================= + + let activePLinks = filteredPLinksWithIndex; + let activeMLinks = filteredMLinksWithIndex; + let sliceStartP = 0; + let sliceStartS = 0; + + const isSecondaryCollection = collectionAnimeIds.has(match.animeId); + + const performSlicing = (isPrimarySide, collectionLinks, seasonNum) => { + let sliceStart = 0; + let slicedList = collectionLinks; + + if (seasonNum && seasonNum > 1) { + let accumulatedCount = 0; + for (let s = 1; s < seasonNum; s++) { + accumulatedCount += (seasonLengthMap.get(s) || 0); + } + + // 计算切片起点时保留安全缓冲 + // 故意少切 2 集,让 findBestAlignmentOffset 去修正微小误差 + let safeAccumulated = Math.max(0, accumulatedCount - 2); + + // 动态回退逻辑 + // 如果计算出的起点已经超出了合集总长度(说明地图统计可能虚高了),尝试使用“标准季度长度”进行回退 + // 假设每季标准长度为 12 集,计算一个保守的起点 + if (safeAccumulated >= collectionLinks.length) { + const heuristicStart = (seasonNum - 1) * 12; // 简单的启发式估算 + // 如果启发式起点有效,则采用它 + if (heuristicStart < collectionLinks.length) { + log("info", `${logPrefix} [合集切片] 起点越界修正: 原计算 ${safeAccumulated} > Total ${collectionLinks.length},启用回退估算 (S${seasonNum} -> Index ${heuristicStart})`); + safeAccumulated = Math.max(0, heuristicStart - 2); // 依然应用缓冲 + } + } + + if (safeAccumulated > 0 && safeAccumulated < collectionLinks.length) { + const nextStart = accumulatedCount + (seasonLengthMap.get(seasonNum) || 999); + const safeEnd = Math.min(nextStart, collectionLinks.length); + + slicedList = collectionLinks.slice(safeAccumulated, safeEnd); + sliceStart = safeAccumulated; + + const sideName = isPrimarySide ? `主源[${currentPrimarySource}]` : `副源[${secSource}]`; + log("info", `${logPrefix} [合集切片] 检测到${sideName}为合集 (Target: S${seasonNum}): 原Index ${accumulatedCount}, 安全回退至 ${safeAccumulated}~${safeEnd} (共 ${slicedList.length} 集) 参与对齐`); + } else { + log("info", `${logPrefix} [合集切片] 放弃切片: 计算起点 ${safeAccumulated} 超出范围 (Total: ${collectionLinks.length})`); + } + } else if (seasonNum === 1) { + // S1 限制范围防止误伤 S2 + const s1Count = seasonLengthMap.get(1); + if (s1Count && s1Count < collectionLinks.length) { + slicedList = collectionLinks.slice(0, s1Count); + const sideName = isPrimarySide ? `主源[${currentPrimarySource}]` : `副源[${secSource}]`; + log("info", `${logPrefix} [合集切片] 检测到${sideName}为合集 (Target: S1): 使用 Index 0~${s1Count} 参与对齐`); + } + } + return { sliceStart, slicedList }; + }; + + // 场景 1: 主源是合集,副源是分季 + if (isPrimaryCollection && !isSecondaryCollection) { + const secSeason = getSeasonNumber(cachedMatch.animeTitle, cachedMatch.typeDescription); + const res = performSlicing(true, filteredPLinksWithIndex, secSeason); + activePLinks = res.slicedList; + sliceStartP = res.sliceStart; + } + // 场景 2: 副源是合集,主源是分季 + else if (!isPrimaryCollection && isSecondaryCollection) { + const pSeason = getSeasonNumber(pAnime.animeTitle, pAnime.typeDescription); + const res = performSlicing(false, filteredMLinksWithIndex, pSeason); + activeMLinks = res.slicedList; + sliceStartS = res.sliceStart; + } + + const bestOffsetLocal = findBestAlignmentOffset( + activePLinks, + activeMLinks, seriesLangA, seriesLangB, currentPrimarySource, @@ -1866,6 +2183,11 @@ async function processMergeTask(params) { cachedMatch.animeTitle ); + // 还原全局 Offset + // 如果切的是主源:Offset 增大 (使得副源[0] 对齐到 主源[sliceStart]) + // 如果切的是副源:Offset 减小 (使得主源[0] 对齐到 副源[sliceStart]) + const offset = bestOffsetLocal + sliceStartP - sliceStartS; + if (offset !== 0) { log("info", `${logPrefix} 集数自动对齐 (${secSource}): Offset=${offset} (P:${filteredPLinksWithIndex.length}, S:${filteredMLinksWithIndex.length})`); } @@ -1975,7 +2297,10 @@ async function processMergeTask(params) { } if (mergedCount > 0) { - if (isMergeRatioValid(mergedCount, filteredPLinksWithIndex.length, filteredMLinksWithIndex.length, currentPrimarySource, secSource)) { + // 传入合集标识 + const isAnyCollection = collectionAnimeIds.has(pAnime.animeId) || collectionAnimeIds.has(match.animeId); + + if (isMergeRatioValid(mergedCount, filteredPLinksWithIndex.length, filteredMLinksWithIndex.length, currentPrimarySource, secSource, isAnyCollection)) { for (const mutation of pendingMutations) { const link = derivedAnime.links[mutation.linkIndex]; @@ -1989,9 +2314,45 @@ async function processMergeTask(params) { log("info", `${logPrefix} [${secSource}] 映射详情:\n${mappingEntries.map(e => e.text).join('\n')}`); } - stitchUnmatchedEpisodes(derivedAnime, orphanedEpisodes, secSource); + // 智能补全逻辑:如果是合集副源,则不进行补全,避免列表混乱 + if (collectionAnimeIds.has(match.animeId)) { + log("info", `${logPrefix} [智能补全] 跳过: 副源 [${secSource}] 为合集,为避免混入其他季度集数,不执行补全。`); + } else { + stitchUnmatchedEpisodes(derivedAnime, orphanedEpisodes, secSource); + } - groupConsumedIds.add(match.animeId); + // =============================================== + // 立即执行番外沉底排序 + // =============================================== + // 在每次成功合并并补全后,立即清理列表顺序 + // 确保 dandan/animeko 引入的混乱番外集不会影响后续副源的对齐映射 + const normals = []; + const sinkers = []; + + // 注意:这里必须遍历最新的 derivedAnime.links + derivedAnime.links.forEach(link => { + const rawContent = link.title.replace(RE_SOURCE_TAG, '').trim(); + if (RE_SPECIAL_SINK_TITLE_STRICT.test(rawContent)) { + sinkers.push(link); + } else { + normals.push(link); + } + }); + + if (sinkers.length > 0) { + derivedAnime.links = [...normals, ...sinkers]; + log("info", `${logPrefix} [排序优化] 立即执行番外沉底: 移动了 ${sinkers.length} 个番外集到末尾`); + } + + // 合集消费豁免 + // 如果匹配的是合集,不要将其加入 groupConsumedIds,使其能继续参与同组其他(S2/S1)的匹配 + if (!collectionAnimeIds.has(match.animeId)) { + groupConsumedIds.add(match.animeId); + } else { + log("info", `${logPrefix} 合集保留: [${secSource}] ${logTitleB} 是合集,保留以供同组复用。`); + } + + // 依然加入 globalConsumedIds 以标记它被“使用过”(防止后续作为主源),但配合 processMergeTask 开头的检查逻辑 globalConsumedIds.add(match.animeId); hasMergedAny = true; @@ -2005,22 +2366,6 @@ async function processMergeTask(params) { } if (hasMergedAny) { - // 强制将 dandan 和 animeko 的番外集沉底 - const normals = []; - const sinkers = []; - - derivedAnime.links.forEach(link => { - if (RE_SPECIAL_SINK_TITLE.test(cleanText(link.title).replace(RE_SOURCE_TAG, '')) && RE_DANDAN_TAG.test(link.title)) { - sinkers.push(link); - } else { - normals.push(link); - } - }); - - if (sinkers.length > 0) { - derivedAnime.links = [...normals, ...sinkers]; - log("info", `${logPrefix} 执行番外沉底排序: 移动了 ${sinkers.length} 个 dandan/animeko 番外集到末尾`); - } const signature = contentSignatureParts.join('|'); if (generatedSignatures.has(signature)) { @@ -2039,6 +2384,167 @@ async function processMergeTask(params) { return null; } +/** + * 合集探测函数 + * 检测是否存在某个源将多季内容合并,而其他源是分季的情况 + * 引入标题保护逻辑,防止【Title】格式在清洗时丢失,导致分组失败 + * 引入中文数字转换,解决“第三季”与“第3季”清洗不一致问题 + * 引入 Part 聚合逻辑,确保 S1 + S1 Part 2 被正确计算为单季总量 + * @param {Array} curAnimes + * @returns {Set} 疑似合集的 Anime ID 集合 + */ +function detectCollectionCandidates(curAnimes) { + const collectionIds = new Set(); + if (!curAnimes || curAnimes.length === 0) return collectionIds; + + const cnNums = {'一':'1', '二':'2', '三':'3', '四':'4', '五':'5', '六':'6', '七':'7', '八':'8', '九':'9', '十':'10'}; + // 按清洗后的 Base Title 分组 + const groups = new Map(); + + curAnimes.forEach(anime => { + const rawTitle = anime.animeTitle || ''; + + // 引入与相似度计算一致的“标题保护逻辑” + let protectedTitle = simplized(rawTitle); + const startBracketMatch = protectedTitle.match(/^(?:【|\[)(.+?)(?:】|\])/); + if (startBracketMatch) { + const content = startBracketMatch[1]; + if (!/^(TV|剧场版|movie|film|anime|动漫|动画|AVC|HEVC|MP4|MKV)$/i.test(content)) { + protectedTitle = protectedTitle.replace(startBracketMatch[0], content + ' '); + } + } + + // 中文数字转换 + protectedTitle = protectedTitle.replace(/第([一二三四五六七八九十])季/g, (m, num) => `第${cnNums[num]}季`); + + // 移除所有括号、来源、年份,只保留核心标题 + let clean = protectedTitle + .replace(RE_SOURCE_TAG, '') + .replace(RE_FROM_SUFFIX, '') + .replace(RE_YEAR_TAG, '') + .replace(RE_META_SUFFIX, '') // 移除 (TV版) 等后缀 + .trim(); + + // 移除季数标记 (S2, Part 2) + clean = clean + .replace(/(?:第|S)\d+(?:季|期|部)/gi, '') + .replace(/(?:Part|P)\s*\d+/gi, '') + .trim(); + + // 移除空格 + clean = clean.replace(/\s+/g, '').toLowerCase(); + + if (!clean) return; + + if (!groups.has(clean)) { + groups.set(clean, []); + } + groups.get(clean).push(anime); + }); + + for (const [baseTitle, list] of groups.entries()) { + if (list.length < 2) continue; + + log("info", `[Merge-Check] [合集探测] 正在检查分组: "${baseTitle}" (包含 ${list.length} 个条目)`); + + // 预聚合:按源统计各季度集数 (处理 Part 拆分) + const sourceStats = new Map(); // Source -> { seasonCounts: {1:0, 2:0...}, maxSeason: 0, s1Candidates: [] } + + list.forEach(anime => { + const realAnime = globals.animes.find(a => String(a.animeId) === String(anime.animeId)) || anime; + const markers = extractSeasonMarkers(realAnime.animeTitle, realAnime.typeDescription); + + // 确定季度编号 (S2, S3...) + let seasonNum = 1; // 默认为 S1 + for (const m of markers) { + if (m.startsWith('S')) { + const num = parseInt(m.substring(1)); + if (!isNaN(num)) seasonNum = num; + } + } + + // 计算有效集数 (强力排除番外) + let validCount = 0; + if (realAnime.links) { + if (/^(dandan|animeko)$/i.test(realAnime.source)) { + validCount = realAnime.links.filter(l => { + const t = cleanText(l.title || l.name || ''); + return !RE_SPECIAL_CHECK.test(t) && !RE_DANDAN_IGNORE_EP.test(t); + }).length; + } else { + validCount = realAnime.links.length; + } + } + + // 聚合到 Source + if (!sourceStats.has(realAnime.source)) { + sourceStats.set(realAnime.source, { + seasonCounts: {}, + maxSeason: 0, + s1Candidates: [] + }); + } + const stat = sourceStats.get(realAnime.source); + + // 累加集数 (Dandan S1=22 + S1 Part2=18 -> S1 Total=40) + if (!stat.seasonCounts[seasonNum]) stat.seasonCounts[seasonNum] = 0; + stat.seasonCounts[seasonNum] += validCount; + + if (seasonNum > stat.maxSeason) stat.maxSeason = seasonNum; + + if (seasonNum === 1) { + stat.s1Candidates.push({ anime: realAnime, originalCount: validCount }); + } + }); + + // 计算其他源的 S1 基准长度 (排除当前源) + // 规则:取其他拥有 S1 的源的最大 S1 集数 + // 这样可以找到“公认的最长 S1”,如果某源的 S1 远超这个值,才可能是合集 + const allSources = Array.from(sourceStats.keys()); + + for (const [source, stat] of sourceStats.entries()) { + // 豁免规则:如果该源拥有 S2, S3 等独立条目,说明该源已分季,其 S1 不视为合集 + if (stat.maxSeason > 1) { + continue; + } + + // 计算对比基准:其他源的 S1 集数 + let maxOtherS1 = 0; + let hasOtherSources = false; + + for (const otherSource of allSources) { + if (otherSource === source) continue; + const otherStat = sourceStats.get(otherSource); + if (otherStat.seasonCounts[1]) { + hasOtherSources = true; + if (otherStat.seasonCounts[1] > maxOtherS1) { + maxOtherS1 = otherStat.seasonCounts[1]; + } + } + } + + if (!hasOtherSources) continue; + + // 判定:如果当前源的 S1 总集数 > 其他源最大 S1 集数 + 阈值(6) + // 说明它可能包含了 S2 + const currentS1Total = stat.seasonCounts[1]; + const threshold = maxOtherS1 + 6; + + if (currentS1Total > threshold) { + // 标记该源下的所有 S1 候选项为合集 + stat.s1Candidates.forEach(cand => { + collectionIds.add(cand.anime.animeId); + log("info", `[Merge-Check] [合集探测] 发现疑似合集: [${source}] ${cand.anime.animeTitle} (TotalS1:${currentS1Total} > OtherMax:${maxOtherS1}+6) -> 标记为合集`); + }); + } else { + log("info", `[Merge-Check] [合集探测] 未命中: [${source}] (TotalS1:${currentS1Total} <= Threshold:${threshold})`); + } + } + } + + return collectionIds; +} + /** * 执行源合并逻辑 (入口函数) * 引入 CN 隔离逻辑与回退机制 (Phase 1 & Phase 2) @@ -2056,6 +2562,9 @@ export async function applyMergeLogic(curAnimes) { try { epFilter = new RegExp(epFilter, 'i'); } catch (e) { epFilter = null; } } + // 执行合集探测 + const collectionAnimeIds = detectCollectionCandidates(curAnimes); + const newMergedAnimes = []; const generatedSignatures = new Set(); @@ -2133,7 +2642,8 @@ export async function applyMergeLogic(curAnimes) { groupFingerprint, currentPrimarySource, logPrefix: `[Merge][Phase 1: CN-Isolation]`, - limitSecondaryLang: 'CN' + limitSecondaryLang: 'CN', + collectionAnimeIds // 传递合集集合 }); if (resultAnime) { @@ -2162,6 +2672,43 @@ export async function applyMergeLogic(curAnimes) { } for (const pAnime of remainingPrimaryItems) { + // 检查当前主源是否包含 Part 标识 + const markers = extractSeasonMarkers(pAnime.animeTitle, pAnime.typeDescription); + const hasPart = Array.from(markers).some(m => m.startsWith('P')); + const pSeasonNum = getSeasonNumber(pAnime.animeTitle, pAnime.typeDescription) || 1; + + let allowReuseIds = null; + + // 如果主源是 Part (如 S1 Part 2),允许复用已被消费的、属于同一季度且非 Part 的副源 + if (hasPart) { + allowReuseIds = new Set(); + log("info", `[Merge] 检测到主源 [${currentPrimarySource}] ${pAnime.animeTitle} 为 Part 类型,尝试寻找可复用的全集副源...`); + + // 扫描已消费的 globalConsumedIds,看看有没有符合条件的副源 + for (const consumedId of globalConsumedIds) { + const consumedAnime = globals.animes.find(a => String(a.animeId) === String(consumedId)); + if (!consumedAnime) continue; + + // 1. 必须是候选副源之一 + if (!availableSecondaries.includes(consumedAnime.source)) continue; + + // 2. 检查副源是否也是 Part (如果是 Part,则不复用,避免 Part2 匹配到 Part1) + const secMarkers = extractSeasonMarkers(consumedAnime.animeTitle, consumedAnime.typeDescription); + const secHasPart = Array.from(secMarkers).some(m => m.startsWith('P')); + if (secHasPart) continue; + + // 3. 检查季度是否一致 + const sSeasonNum = getSeasonNumber(consumedAnime.animeTitle, consumedAnime.typeDescription) || 1; + if (pSeasonNum !== sSeasonNum) continue; + + // 4. 符合条件:主源 Part 复用 副源 Full + allowReuseIds.add(consumedAnime.animeId); + } + if (allowReuseIds.size > 0) { + log("info", `[Merge] 已豁免 ${allowReuseIds.size} 个副源参与 Part 复用匹配。`); + } + } + const resultAnime = await processMergeTask({ pAnime, availableSecondaries, @@ -2172,7 +2719,9 @@ export async function applyMergeLogic(curAnimes) { epFilter, groupFingerprint, currentPrimarySource, - logPrefix: `[Merge][Phase 2: Standard]` + logPrefix: `[Merge][Phase 2: Standard]`, + collectionAnimeIds, // 传递合集集合 + allowReuseIds // 传递豁免 ID 集合 }); if (resultAnime) { @@ -2207,7 +2756,7 @@ export async function applyMergeLogic(curAnimes) { } } - log("info", `[Merge] 合并完成,最终列表数量: ${curAnimes.length}`); + log("info", `[Merge] 合并执行完毕,最终列表数量: ${curAnimes.length}`); } /**