fix: fongmi 接口自动匹配增加季数比对,修复跨季同号集错配 (#471)

* fix: fongmi 接口自动匹配增加季数比对,修复跨季同号集错配

episode 文本中的季号此前被丢弃(S02E05 只取 E 后集数),第一季/第二季同号集得分完全相同,自动首条由源返回顺序决定:线上实测 庆余年 S01E01 与 S02E01 返回完全一致、S01E05 与 S02E05 亦逐字节相同。现从分集文本提取显式季号,与候选剧名的季标注比对:同季 +5000、跨季 -12000;任一侧无季标注不调整,电影/综艺日期场景保持原行为。

* Update worker.test.js

---------

Co-authored-by: xiangyang <xiangyang2001@outlook.com>
Co-authored-by: huangxd- <649924757@qq.com>
This commit is contained in:
authored and GitHub committed 2026-09-12 07:50:30 +08:00
1 parent be4135ea04
commit c81f63993c
2 files changed
+72 -2

No files matched your search

+33 -2
View File
@@ -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 };
+39
View File
@@ -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);