diff --git a/danmu_api/apis/clients/fongmi-api.js b/danmu_api/apis/clients/fongmi-api.js index c3aa2da..832e93f 100644 --- a/danmu_api/apis/clients/fongmi-api.js +++ b/danmu_api/apis/clients/fongmi-api.js @@ -2,7 +2,7 @@ import { globals } from "../../configs/globals.js"; import { jsonResponse } from "../../utils/http-util.js"; import { log } from "../../utils/log-util.js"; import { simplized } from "../../utils/zh-util.js"; -import { convertChineseNumber, extractEpisodeTitle, extractEpisodeNumberFromTitle, normalizeSpaces } from "../../utils/common-util.js"; +import { convertChineseNumber, extractEpisodeTitle, extractEpisodeNumberFromTitle, extractSeasonNumberFromAnimeTitle, getExplicitSeasonNumber, normalizeSpaces } from "../../utils/common-util.js"; import { filterSameEpisodeTitle, getBangumiDataForMatch, searchAnime } from "../dandan-api.js"; // ===================== @@ -157,6 +157,26 @@ function extractFongmiEpisodeNumber(episode) { return null; } +/** + * 从 FongMi 集数文本中提取显式季号。 + * 支持 S02E05 / 第2季第3集 / Season 2 / Part 2 / 2x05 等写法。 + * 仅接受 1~50 的合理季号,避免把综艺日期"第20180512期"等误判为季。 + * @param {string} episode 原始集数文本 + * @returns {number|null} 季号,无法识别时返回 null + */ +function extractFongmiSeasonNumber(episode) { + if (!episode) return null; + const text = String(episode); + const season = getExplicitSeasonNumber(text); + if (season !== null && Number.isFinite(season) && season >= 1 && season <= 50) return season; + const match = text.match(/(?:^|[\s_\-])(\d{1,2})x(\d{1,4})(?:$|[\s_\-.])/i); + if (match) { + const seasonNum = parseInt(match[1], 10); + if (seasonNum >= 1 && seasonNum <= 50) return seasonNum; + } + return null; +} + /** * 为 FongMi 标题生成搜索关键词列表。 * 先保留原始标题,再追加正则清洗后的标题作为回退搜索词。 @@ -276,7 +296,7 @@ async function parseFongmiRequestParams(url, req) { /** * 计算单个候选分集与目标分集的匹配得分。 - * 当前综合标题、集数、日期做排序,后续可以继续扩展更多维度。 + * 当前综合标题、季数、集数、日期做排序,后续可以继续扩展更多维度。 * @param {Object} anime 候选剧集对象 * @param {Object} episode 候选分集对象 * @param {string} targetEpisode FongMi 传入的分集文本 @@ -305,6 +325,15 @@ function scoreFongmiEpisodeMatch(anime, episode, targetEpisode, index) { if (targetNum !== null && episodeNum !== null && targetNum === episodeNum) score += 7000; if (targetNum !== null && Number.isFinite(episodeIndexNum) && targetNum === episodeIndexNum) score += 4000; + // 季数比对:目标与候选都带显式季号时严格区分(如"剧名 S02E05"应优先命中"第二季"条目)。 + // 集数加分对跨季同号集完全同分,胜负此前只由源返回顺序决定;这里同季加分、跨季重罚, + // 保证跨季候选排到任何同季候选之后。任一侧无季标注则不调整,保持原有行为。 + const targetSeason = extractFongmiSeasonNumber(targetEpisode); + const animeSeason = extractSeasonNumberFromAnimeTitle(anime?.animeTitle || "").season; + if (targetSeason !== null && animeSeason !== null) { + score += targetSeason === animeSeason ? 5000 : -12000; + } + const targetDate = extractDateDigits(normalizedTargetEpisode); const episodeDate = extractDateDigits(episodeTitle); if (targetDate && episodeDate && targetDate === episodeDate) score += 9000; @@ -418,3 +447,5 @@ export async function getFongmiDanmaku(url, req) { log("info", `[system] [fongmi] name=${name}, episode=${episode}, candidates=${items.length}`); return jsonResponse(items, 200); } + +export { extractFongmiEpisodeNumber, extractFongmiSeasonNumber, scoreFongmiEpisodeMatch }; diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index c0e20db..5a67a31 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -45,6 +45,7 @@ import { convertToDanmakuJson, handleDanmusLike, splitBlockedWords, parseBlocked import { Segment, SegmentListResponse } from "./models/dandan-model.js" import { initBangumiData, searchBangumiData, clearBangumiDataCache, dedupeBangumiSearchResults } from "./utils/bangumi-data-util.js"; import { generateNipaplaySignature, parseNipaplayRelatedLinks, resolveNipaplayLink, applyShiftToDanmu } from "./utils/nipaplay-util.js"; +import { extractFongmiSeasonNumber, scoreFongmiEpisodeMatch } from "./apis/clients/fongmi-api.js"; import { localDanmuJsContent } from './ui/js/localdanmu.js'; import { buildLocalDanmuResourceKey, groupLocalDanmuResources, parseLocalDanmu, normalizeLocalSeason } from './utils/local-danmu-parser.js'; import { handleLocalDanmuUpload, handleLocalDanmuList, handleLocalDanmuDelete, handleLocalDanmuGet } from './apis/local-danmu-api.js'; @@ -3124,9 +3125,47 @@ test('worker.js API endpoints', async (t) => { // assert.strictEqual(applyShiftToDanmu(null, 5), null, '空对象直接返回'); // }); +test('fongmi-api season aware scoring', () => { + // 季号提取: SxxExx / 第x季 / Season N / 2x05; 综艺日期与纯集数不误判 + assert.equal(extractFongmiSeasonNumber('人生切割术 S02E05'), 2); + assert.equal(extractFongmiSeasonNumber('Show.S02.E05.2160p.WEB-DL.mkv'), 2); + assert.equal(extractFongmiSeasonNumber('庆余年 第2季第03集'), 2); + assert.equal(extractFongmiSeasonNumber('Show Season 3 EP01'), 3); + assert.equal(extractFongmiSeasonNumber('剧名 2x05'), 2); + assert.equal(extractFongmiSeasonNumber('1920x1080'), null); + assert.equal(extractFongmiSeasonNumber('凡人修仙传 第01集'), null); + assert.equal(extractFongmiSeasonNumber('奔跑吧 第20180512期'), null); + assert.equal(extractFongmiSeasonNumber(''), null); + const mk = (animeTitle, episodeTitle, index) => ({ anime: { animeTitle }, episode: { episodeTitle }, index }); + const scoreOf = (c, target) => scoreFongmiEpisodeMatch(c.anime, c.episode, target, c.index); + // 跨季同号集: 集数加分对第一/二季完全同分(11196), 修复后第二季必须稳定胜出, 不再由源返回顺序决定 + const targetS2 = '人生切割术 S02E05'; + const s2e5 = mk('人生切割术 第二季(2025)【电视剧】from renren', '【renren】 第05集', 4); + const s1e5 = mk('人生切割术 第一季(2022)【电视剧】from renren', '【renren】 第05集', 4); + assert.equal(scoreOf(s2e5, targetS2), 196 + 7000 + 4000 + 5000); + assert.equal(scoreOf(s1e5, targetS2), 196 + 7000 + 4000 - 12000); + // 候选枚举顺序翻转也不影响自动首条 + for (const ordered of [[s1e5, s2e5], [s2e5, s1e5]]) { + const best = ordered.map(c => ({ ...c, score: scoreOf(c, targetS2) })) + .sort((a, b) => b.score - a.score)[0]; + assert.ok(best.anime.animeTitle.includes('第二季'), 'S02E05 自动首条必须是第二季'); + } + // 目标 S01 时同样必须回到第一季 + const best = [s1e5, s2e5].map(c => ({ ...c, score: scoreOf(c, '人生切割术 S01E05') })) + .sort((a, b) => b.score - a.score)[0]; + assert.ok(best.anime.animeTitle.includes('第一季'), 'S01E05 自动首条必须是第一季'); + + // 目标带季但候选剧名无季标注: 不调整, 保持原有行为 + const anon = mk('人生切割术(2022)【韩剧】from hanjutv', '【hanjutv】 第5集', 4); + assert.equal(scoreOf(anon, targetS2), 196 + 7000 + 4000); + + // 目标无季标注: 完全不受影响(向后兼容); 文本包含加分(+4500)为原有行为 + const plain = mk('凡人修仙传', '第05集', 4); + assert.equal(scoreOf(plain, '凡人修仙传 第05集'), 196 + 7000 + 4000 + 4500); +}); const comment = '标题警告‼️ 中文弹幕 😀 \uFFFD'; const json = JSON.stringify({ count: 1, comments: [{ p: '1.00,1,16777215,[qiyi]', m: comment }] }, null, 2);