fix: 修复严格匹配下记住手动选择结果功能失效及匹配遍历过早终止 (#401)

* fix: matchAniAndEp 移除对非优先条目的独占过滤,保留评分优先

原代码通过 preferAnimeId 直接 continue 跳过所有不匹配的优先条目,
其意图是确保优先条目被命中。但该过滤存在两个问题:

1. 与 isPreferredAnime 的 +9999 评分加成功能重复——评分已提供
   压倒性优先权,独占过滤属于冗余保护。
2. 独占过滤阻止了优先源缺失目标集时的自然回退:其他源被
   跳过导致无匹配,需要额外走 fallbackMatchAniAndEp 兜底。

移除独占过滤后,isPreferredAnime 的 +9999 评分加成和匹配后的
break 仍然确保优先条目在正常匹配中绝对优先,同时允许优先源
无目标集时自然回退到其他可用源。

* fix: TITLE_NOISE_FILTER 默认规则增加方括号杂音词支持

默认 TITLE_NOISE_FILTER 仅匹配圆括号内的杂音词(如(日配)、(国配)),
遗漏方括号形式的变种(如[日语版]、[国语版]、[中字])。
tencent 源的标题格式"假面骑士ZZZ[日语版](2025)"中[日语版]未被清理,
导致 titleMatches 在严格匹配下的标题清理不完整。

修复:默认规则扩展为单字符类同时匹配圆括号(中英文)和方括号,
在原有词表基础上新增方括号特供词:日语版、国语版、英语版、中字。
圆括号兼容保持不变。

* fix: 偏好记录流程的titleMatches强制使用非严格匹配,不因STRICT_TITLE_MATCH受影响

titleMatches 在全局 STRICT_TITLE_MATCH 开启时统一走 strictTitleMatch,
但偏好记录(setPreferByAnimeId)场景应始终使用非严格匹配。原因:

1. 用户手动选择某个源的弹幕即表示"我认可这是正确匹配",
   不应因标题含[日语版]等形式差异而被拒。
2. strictTitleMatch 限制 suffix 只能为 seasonPattern 中的模式,
   而(2025)、[日语版]等源补充标识均不符合,导致有意义的用户选择被丢弃。
3. "记住手动选择结果"强调自由选择权,严格匹配的搜索过滤逻辑不适用于此。

修复:titleMatches 增加 forceNonStrict 参数(默认false,完全向后兼容),
偏好记录流程传入 true,跳过 strictTitleMatch 直接走包含匹配(includes)。

* fix: matchAniAndEp 无平台偏好时 break 应感知 preferAnimeId,防止跳过优先条目

第1435行 if(!platform || isPreferredAnime) break 在 platform 为空时
无条件跳出循环,导致即使 preferAnimeId 指向靠后的条目,
循环也会在第一个匹配条目后终止,优先条目从未被评估。

修复:break 仅在两种场景触发:
1. isPreferredAnime=true — 已命中优先条目,无需继续
2. !platform && !preferAnimeId — 无平台偏好且无优先条目,首个匹配即可
当 preferAnimeId 存在但尚未命中时,继续循环评估后续条目。
This commit is contained in:
Elegy233 authored and GitHub committed 2026-07-29 22:37:17 +08:00
1 parent c9112ee7cf
commit 825ddc25bb
5 files changed
+15 -20

No files matched your search

+1 -1
View File
@@ -492,7 +492,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
```plain
# TITLE_NOISE_FILTER 默认值
[((](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版)[))]
[((\\[](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版|日语版|国语版|英语版|中字|字幕|助听|原版)[\\]))]
```
```regex
+1 -1
View File
@@ -401,7 +401,7 @@ ANIME_TITLE_FILTER=
# 示例:只清理括号臻彩和真彩
# TITLE_NOISE_FILTER=[((](?:臻彩|真彩)[))]
# 设为空值可禁用
TITLE_NOISE_FILTER=[((](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版)[))]
TITLE_NOISE_FILTER=[((\\[](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版|日语版|国语版|英语版|中字|字幕|助听|原版)[\\]))]
# ==================== AI 配置 ====================
+8 -13
View File
@@ -1291,13 +1291,6 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
// 遍历所有搜索结果,寻找最佳匹配
for (const anime of searchData.animes) {
// 偏好过滤
const animeIsNotPrefer =
globals.rememberLastSelect &&
preferAnimeId &&
String(anime.bangumiId) !== String(preferAnimeId) &&
String(anime.animeId) !== String(preferAnimeId);
if (animeIsNotPrefer) continue;
let isMatch = false;
@@ -1330,7 +1323,8 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
// 构造一个虚拟的 anime 对象传入 matchSeason,这样当命中别名时,matchSeason 才能正确判断后缀
const tempAnime = { ...anime, animeTitle: candTitle };
if (matchSeason(tempAnime, title, season) || animeIsPrefer) {
const seasonOk = matchSeason(tempAnime, title, season);
if (seasonOk || animeIsPrefer) {
isMatch = true;
break; // 别名命中跳出
}
@@ -1449,8 +1443,8 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
};
}
// 如果没有指定平台偏好 (platform 为空) 或已命中最高优先级的手动优选,立刻跳出查找
if (!platform || isPreferredAnime) {
// 已命中最高优先级的手动优选,或不存在平台偏好且无待匹配的优选条目时立刻跳出查找
if (isPreferredAnime || (!platform && !preferAnimeId)) {
break;
}
@@ -2400,11 +2394,12 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu
}
}
log("info", `[system] [LogVar-API] animeTitle:${animeTitle}; lastTitle:${lastTitle}; titleMatches:${titleMatches(animeTitle, lastTitle)}`);
log("info", `[system] [LogVar-API] animeTitle:${animeTitle}; lastTitle:${lastTitle}; titleMatches:${titleMatches(animeTitle, lastTitle, null, true)}`);
// 校验番剧标题或别名是否匹配最新搜索/匹配上下文,别名检查用于兼容不同源对同一番剧的标题命名差异
const titleOrAliasMatches = titleMatches(animeTitle, lastTitle) ||
(Array.isArray(animeAliases) && animeAliases.some(alias => titleMatches(alias, lastTitle)));
// 偏好记录使用非严格匹配(forceNonStrict=true),因为用户手动选择不应受严格标题匹配限制
const titleOrAliasMatches = titleMatches(animeTitle, lastTitle, null, true) ||
(Array.isArray(animeAliases) && animeAliases.some(alias => titleMatches(alias, lastTitle, null, true)));
if (titleOrAliasMatches) {
log("info", `[system] [LogVar-API] excute setPreferByAnimeId`);
+2 -2
View File
@@ -542,7 +542,7 @@ export class Envs {
* @returns {RegExp|null} 全局正则,显式设为空时返回 null(禁用)
*/
static resolveTitleNoiseFilter() {
const defaultPattern = '[((](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版)[))]';
const defaultPattern = '[((\\[](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版|日语版|国语版|英语版|中字|字幕|助听|原版)[\\]))]';
const raw = this.get('TITLE_NOISE_FILTER', '', 'string').trim();
const hasKey = (this.env && 'TITLE_NOISE_FILTER' in this.env)
|| (typeof process !== 'undefined' && 'TITLE_NOISE_FILTER' in process.env);
@@ -638,7 +638,7 @@ export class Envs {
'TITLE_TO_CHINESE': { category: 'match', type: 'boolean', description: '外语标题转换中文开关' },
'ANIME_TITLE_SIMPLIFIED': { category: 'match', type: 'boolean', description: '搜索的剧名标题自动繁转简' },
'TITLE_MAPPING_TABLE': { category: 'match', type: 'map', description: '剧名映射表,用于自动匹配时替换标题进行搜索,格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华"' },
'TITLE_NOISE_FILTER': { category: 'match', type: 'text', description: '剧名杂音清理规则,按正则表达式清理搜索与匹配阶段的剧名杂音词(如`百花杀(真彩)`→`百花杀`)。默认值:[((](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版)[))],中英文括号均匹配。设为空值可禁用' },
'TITLE_NOISE_FILTER': { category: 'match', type: 'text', description: '剧名杂音清理规则,按正则表达式清理搜索与匹配阶段的剧名杂音词(如`百花杀(真彩)`→`百花杀`)。默认值:[((\\[](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版|日语版|国语版|英语版|中字|字幕|助听|原版)[\\]))],中英文圆方括号均匹配。设为空值可禁用' },
'AI_BASE_URL': { category: 'match', type: 'text', description: 'AI服务基础URL,不填默认为https://api.openai.com/v1' },
'AI_MODEL': { category: 'match', type: 'text', description: 'AI模型名称,不填默认为gpt-4o' },
'AI_API_KEY': { category: 'match', type: 'text', description: 'AI服务API密钥,默认为空,需手动填写' },
+3 -3
View File
@@ -269,15 +269,15 @@ export function getExplicitSeasonNumber(text) {
* @param {number|null} parsedSeason - 解析出的目标季度
* @returns {boolean} 是否匹配
*/
export function titleMatches(title, query, parsedSeason = null) {
export function titleMatches(title, query, parsedSeason = null, forceNonStrict = false) {
if (title == null || query == null) return false;
const titleText = String(title);
const queryText = String(query);
if (!titleText || !queryText) return false;
// 策略1:严格模式仅允许头部或完全匹配
if (globals.strictTitleMatch) return strictTitleMatch(titleText, queryText);
// 策略1:严格模式仅允许头部或完全匹配(forceNonStrict 为 true 时跳过,用于偏好记录等场景)
if (!forceNonStrict && globals.strictTitleMatch) return strictTitleMatch(titleText, queryText);
// 剧名杂音清理:移除画质/配音/版本等杂音词,避免阻塞匹配
const tagFilter = globals.titleNoiseFilter || null;