From f6ec993b5f966ed5074cdb6192562c8f0b833c17 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 17:41:22 +0800 Subject: [PATCH] feat(identity): weighted evidence table, sequel numbers, generic-name guard (ID-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configurable DEFAULT_MATCH_WEIGHTS; Chinese/English names both count and corroborate; Chapter/Part/第N章 sequel conflicts reject; missing season penalised; category words and single-word aliases colliding in the pool cannot confirm alone; alias-only hits need year or second-script support; same-level ties broken by exact year then matched name scripts. Co-Authored-By: Claude Opus 5.5 --- packages/domain/src/identity/index.ts | 6 +- packages/domain/src/identity/match.ts | 256 +++++++++++++++++++--- packages/domain/src/identity/normalize.ts | 66 ++++++ packages/domain/src/identity/sequel.ts | 60 +++++ packages/domain/test/identity-id2.test.ts | 173 +++++++++++++++ 5 files changed, 527 insertions(+), 34 deletions(-) create mode 100644 packages/domain/src/identity/sequel.ts create mode 100644 packages/domain/test/identity-id2.test.ts diff --git a/packages/domain/src/identity/index.ts b/packages/domain/src/identity/index.ts index 5e2fa1b2..43fd25ee 100644 --- a/packages/domain/src/identity/index.ts +++ b/packages/domain/src/identity/index.ts @@ -1,8 +1,11 @@ export { + DEFAULT_MATCH_WEIGHTS, type EvidenceLevel, type IdentityCandidate, type IdentityResolution, type MatchOptions, + type MatchWeights, + poolGenericNames, queryTitles, type RankedCandidate, rankCandidates, @@ -12,5 +15,6 @@ export { type TypeMatch, type YearMatch, } from "./match.ts"; -export { normalizeTitle, stripSeasonSuffix } from "./normalize.ts"; +export { isGenericAliasName, normalizeTitle, stripSeasonSuffix } from "./normalize.ts"; +export { sequelNumbers } from "./sequel.ts"; export { TRAD_TO_SIMP, toSimplified } from "./zh-map.ts"; diff --git a/packages/domain/src/identity/match.ts b/packages/domain/src/identity/match.ts index 54f8e569..97f9184d 100644 --- a/packages/domain/src/identity/match.ts +++ b/packages/domain/src/identity/match.ts @@ -1,16 +1,21 @@ import type { Confidence, ParsedRelease } from "../release-parser/types.ts"; -import { normalizeTitle } from "./normalize.ts"; +import { isGenericAliasName, normalizeTitle } from "./normalize.ts"; +import { sequelNumbers } from "./sequel.ts"; export interface IdentityCandidate { /** tmdb / local(本地别名召回)/ 其他提供方 */ provider: string; entityType: "movie" | "tv"; externalId: string; + /** 主标题与原标题(以及其他语言的本地化标题);精确命中这里算 exact */ titles: string[]; year?: number; + /** 别名 / 译名(TMDB alternative_titles + translations、本地登记的别名);命中这里算 alias */ aliases: string[]; /** 剧集各季首播年份(发布标题里的年份可能是季年份) */ seasonYears?: number[]; + /** 剧集已有的季号(取了详情才有;用于季号区分) */ + seasonNumbers?: number[]; /** local 候选:本地实体 UUID 及其已登记的外部映射 */ localEntityId?: string; linkedExternalIds?: { provider: string; id: string }[]; @@ -21,6 +26,39 @@ export type TitleMatch = "exact" | "alias" | "partial" | "none"; export type YearMatch = "exact" | "season_year" | "within_tolerance" | "unknown" | "mismatch"; export type TypeMatch = "match" | "mismatch" | "unknown"; +/** + * 评分权重表(ID-2,可配置):各项证据的分值相加,按阈值落到证据等级;硬规则(年份不符、续作序号冲突、 + * 标题无关)直接拒绝,部分包含 / 通用词只靠别名 / 别名命中缺少佐证 / 类型冲突封顶 needs_confirm。 + * 分值只用于定级与同等级排序,不是概率。默认值下 v0.1 的规则式判定不变(identity.test.ts), + * 在此基础上加了第二语言名称、续作序号、季号三项。 + */ +export interface MatchWeights { + title: Record; + year: Record, number>; + /** 年份未知,但剧集季号是明写的且类型一致 */ + tvSeasonNoYear: number; + /** 中文名与英文名(不同文字)各自命中,每多一种加一次 */ + secondName: number; + /** 续作 / 章节 / Part 序号一致 */ + sequelMatch: number; + /** 明写的季号在候选的季列表里 / 不在 */ + seasonMatch: number; + seasonMissing: number; + /** 分值 ≥ 阈值即达到该等级 */ + thresholds: { rule_confirmed: number; multi_evidence: number; needs_confirm: number }; +} + +export const DEFAULT_MATCH_WEIGHTS: MatchWeights = { + title: { exact: 4, alias: 3, partial: 1, none: 0 }, + year: { exact: 3, season_year: 3, within_tolerance: 2, unknown: 0 }, + tvSeasonNoYear: 2, + secondName: 1, + sequelMatch: 1, + seasonMatch: 0, + seasonMissing: -3, + thresholds: { rule_confirmed: 7, multi_evidence: 5, needs_confirm: 1 }, +}; + export interface RankedCandidate { candidate: IdentityCandidate; level: EvidenceLevel; @@ -28,11 +66,46 @@ export interface RankedCandidate { yearMatch: YearMatch; typeMatch: TypeMatch; evidence: string[]; + /** 排序用信号(分值不是概率,只在同等级间比较) */ + signals: { + points: number; + /** 命中(精确或别名)的名称文字种数:中文 / 拉丁 */ + nameLangs: number; + sequel: "match" | "conflict" | "none"; + genericName: boolean; + }; } export interface MatchOptions { /** 年份容差,默认 ±1 */ yearTolerance?: number; + /** 覆盖部分权重 */ + weights?: Partial; + /** + * 候选池里"撞名"的通用词(归一化形态):单个拉丁词且池中 ≥ 3 个候选的名称包含它(`Archives`)。 + * rankCandidates / resolveIdentity 按池自动计算;单独调用 scoreCandidate 时可传入。 + */ + genericNames?: ReadonlySet; +} + +/** 池里撞名的阈值:单个拉丁词被 ≥ 3 个候选的名称包含 */ +const GENERIC_POOL_HITS = 3; + +/** 按候选池找出通用词:单个拉丁词、且池中多个候选的标题/别名都包含它 */ +export function poolGenericNames(p: ParsedRelease, candidates: readonly IdentityCandidate[]): Set { + const out = new Set(); + for (const q of queryNames(p)) { + if (!isSingleLatinWord(q)) continue; + let hits = 0; + for (const c of candidates) { + if ([...c.titles, ...c.aliases].some((t) => normalizeTitle(t).includes(q.norm))) hits++; + if (hits >= GENERIC_POOL_HITS) { + out.add(q.norm); + break; + } + } + } + return out; } const LEVEL_RANK: Record = { @@ -52,30 +125,81 @@ const YEAR_RANK: Record = { const raise = (l: EvidenceLevel): EvidenceLevel => l === "needs_confirm" ? "multi_evidence" : l === "multi_evidence" ? "rule_confirmed" : l; +const capAt = (l: EvidenceLevel, max: EvidenceLevel): EvidenceLevel => (LEVEL_RANK[l] > LEVEL_RANK[max] ? max : l); -export function queryTitles(p: ParsedRelease): string[] { - const out: string[] = []; - for (const t of [p.titleCn, p.titleEn, p.title]) { - if (!t) continue; - const n = normalizeTitle(t); - if (n && !out.includes(n)) out.push(n); - } +function weightsOf(opts: MatchOptions): MatchWeights { + const w = opts.weights; + if (!w) return DEFAULT_MATCH_WEIGHTS; + return { + ...DEFAULT_MATCH_WEIGHTS, + ...w, + title: { ...DEFAULT_MATCH_WEIGHTS.title, ...w.title }, + year: { ...DEFAULT_MATCH_WEIGHTS.year, ...w.year }, + thresholds: { ...DEFAULT_MATCH_WEIGHTS.thresholds, ...w.thresholds }, + }; +} + +interface QueryName { + raw: string; + norm: string; + /** primary = 主标题解析出的中/英文名;alt = 副标题别名(命中只算 alias) */ + primary: boolean; + script: "han" | "latin"; +} + +function queryNames(p: ParsedRelease): QueryName[] { + const out: QueryName[] = []; + const add = (raw: string | undefined, primary: boolean) => { + if (!raw) return; + const norm = normalizeTitle(raw); + // 通用词守卫:分类词 / 单字 / 过短 / 停用词不作为名称参与比对 + if (!norm || isGenericAliasName(norm) || out.some((q) => q.norm === norm)) return; + out.push({ raw, norm, primary, script: /\p{Script=Han}/u.test(raw) ? "han" : "latin" }); + }; + add(p.titleCn, true); + add(p.titleEn, true); + add(p.title, true); + for (const a of p.altTitles ?? []) add(a, false); return out; } -function titleMatch(queries: string[], c: IdentityCandidate): TitleMatch { +/** 召回/比对用的归一化名称:中文名、英文名、展示名,再加副标题里的别名(已排除通用词) */ +export function queryTitles(p: ParsedRelease): string[] { + return queryNames(p).map((q) => q.norm); +} + +/** 单个拉丁词(去掉冠词后);再加上池里撞名(poolGenericNames)才算通用词 */ +const isSingleLatinWord = (q: QueryName) => + q.script === "latin" && + q.raw + .trim() + .replace(/^(?:the|a|an)\s+/i, "") + .split(/[\s._-]+/) + .filter(Boolean).length === 1; + +interface NameResult { + tm: TitleMatch; + matched: QueryName[]; +} + +function titleMatch(queries: QueryName[], c: IdentityCandidate): NameResult { const titles = c.titles.map(normalizeTitle).filter(Boolean); - const aliases = c.aliases.map(normalizeTitle).filter(Boolean); - if (queries.some((q) => titles.includes(q))) return "exact"; - if (queries.some((q) => aliases.includes(q))) return "alias"; + const aliases = c.aliases.map(normalizeTitle).filter((a) => a && !isGenericAliasName(a)); + const exact = queries.filter((q) => q.primary && titles.includes(q.norm)); + const alias = queries.filter((q) => !exact.includes(q) && (titles.includes(q.norm) || aliases.includes(q.norm))); + const matched = [...exact, ...alias]; + if (exact.length) return { tm: "exact", matched }; + if (alias.length) return { tm: "alias", matched }; const all = [...titles, ...aliases]; - const partial = queries.some((q) => - all.some((t) => { - const [short, long] = q.length <= t.length ? [q, t] : [t, q]; - return short.length >= 2 && long.includes(short); - }), + const partial = queries.some( + (q) => + q.primary && + all.some((t) => { + const [short, long] = q.norm.length <= t.length ? [q.norm, t] : [t, q.norm]; + return short.length >= 2 && long.includes(short); + }), ); - return partial ? "partial" : "none"; + return { tm: partial ? "partial" : "none", matched: [] }; } function yearMatch(p: ParsedRelease, c: IdentityCandidate, tol: number): YearMatch { @@ -93,13 +217,31 @@ function typeMatch(p: ParsedRelease, c: IdentityCandidate): TypeMatch { return "unknown"; } -/** 单个候选的证据等级(纯函数,不产生概率数字) */ +function sequelOf(p: ParsedRelease, c: IdentityCandidate): "match" | "conflict" | "none" { + const q = new Set(); + for (const t of [p.titleEn, p.titleCn, ...(p.altTitles ?? [])]) for (const n of sequelNumbers(t ?? "")) q.add(n); + if (!q.size) return "none"; + const cand = new Set(); + for (const t of [...c.titles, ...c.aliases]) for (const n of sequelNumbers(t)) cand.add(n); + if (!cand.size) return "none"; + return [...q].some((n) => cand.has(n)) ? "match" : "conflict"; +} + +/** 单个候选的证据等级(纯函数) */ export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: MatchOptions = {}): RankedCandidate { + const w = weightsOf(opts); const tol = opts.yearTolerance ?? 1; - const tm = titleMatch(queryTitles(p), c); + const queries = queryNames(p); + const { tm, matched } = titleMatch(queries, c); const ym = yearMatch(p, c, tol); const ty = typeMatch(p, c); + const sequel = sequelOf(p, c); + const nameLangs = new Set(matched.map((q) => q.script)).size; + const genericName = + tm === "alias" && matched.every((q) => isSingleLatinWord(q) && (opts.genericNames?.has(q.norm) ?? false)); const evidence: string[] = [`title:${tm}`, `year:${ym}`, `type:${ty}`]; + if (nameLangs > 1) evidence.push(`name_langs:${nameLangs}`); + if (sequel !== "none") evidence.push(`sequel:${sequel}`); const explicit = p.explicitIds.some( (x) => (x.provider === c.provider && x.id === c.externalId) || @@ -108,37 +250,83 @@ export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: Mat if (explicit) evidence.push("explicit_id"); if (c.provider === "local") evidence.push("local_entity"); - let level: EvidenceLevel; - if (ym === "mismatch") level = "rejected"; + // 分值 + let points = w.title[tm] + (ym === "mismatch" ? 0 : w.year[ym]); + const explicitSeason = p.season !== undefined && !p.seasonInferred; + if (ym === "unknown" && ty === "match" && explicitSeason) points += w.tvSeasonNoYear; + if (nameLangs > 1) points += w.secondName * (nameLangs - 1); + if (sequel === "match") points += w.sequelMatch; + if (explicitSeason && p.season! > 0 && c.entityType === "tv" && c.seasonNumbers?.length) { + if (c.seasonNumbers.includes(p.season!)) points += w.seasonMatch; + else { + points += w.seasonMissing; + evidence.push("season:missing"); + } + } + + let level: EvidenceLevel = + points >= w.thresholds.rule_confirmed + ? "rule_confirmed" + : points >= w.thresholds.multi_evidence + ? "multi_evidence" + : points >= w.thresholds.needs_confirm + ? "needs_confirm" + : "rejected"; + // 硬规则 + if (ym === "mismatch" || sequel === "conflict") level = "rejected"; else if (tm === "none") level = explicit ? "needs_confirm" : "rejected"; - else if (tm === "partial") level = "needs_confirm"; - else if (ym === "exact" || ym === "season_year") level = tm === "exact" ? "rule_confirmed" : "multi_evidence"; - else if (ym === "within_tolerance") level = "multi_evidence"; else { - // 年份未知:剧集有季集信息且类型一致时仍算多证据,电影需确认 - level = ty === "match" && p.season !== undefined ? "multi_evidence" : "needs_confirm"; + if (level === "rejected") level = "needs_confirm"; + if (tm === "partial") level = capAt(level, "needs_confirm"); + if (genericName) { + level = capAt(level, "needs_confirm"); + evidence.push("generic_name"); + } + // 只靠别名命中:还要年份相容或另一种文字的名字也命中,否则只作提示 + if (tm === "alias" && nameLangs < 2 && ym === "unknown") { + level = capAt(level, "needs_confirm"); + evidence.push("alias_uncorroborated"); + } } if (level !== "rejected" && ty === "mismatch") { level = "needs_confirm"; evidence.push("type_conflict"); } if (explicit && level !== "rejected" && tm !== "none") level = raise(level); - return { candidate: c, level, titleMatch: tm, yearMatch: ym, typeMatch: ty, evidence }; + return { + candidate: c, + level, + titleMatch: tm, + yearMatch: ym, + typeMatch: ty, + evidence, + signals: { points, nameLangs, sequel, genericName }, + }; } -/** 候选排序:等级 > 标题 > 年份 > 本地优先 > 原顺序 */ +/** 同等级内的取舍:年份精确优先,再按命中的名称文字种数,再按分值 */ +function tieBreak(a: RankedCandidate, b: RankedCandidate): number { + return ( + YEAR_RANK[b.yearMatch] - YEAR_RANK[a.yearMatch] || + b.signals.nameLangs - a.signals.nameLangs || + b.signals.points - a.signals.points + ); +} + +/** 候选排序:等级 > 年份 > 名称文字种数 > 分值 > 标题 > 本地优先 > 原顺序 */ export function rankCandidates( p: ParsedRelease, candidates: readonly IdentityCandidate[], opts: MatchOptions = {}, ): RankedCandidate[] { + const o = opts.genericNames ? opts : { ...opts, genericNames: poolGenericNames(p, candidates) }; return candidates - .map((c, i) => ({ r: scoreCandidate(p, c, opts), i })) + .map((c, i) => ({ r: scoreCandidate(p, c, o), i })) .sort( (a, b) => LEVEL_RANK[b.r.level] - LEVEL_RANK[a.r.level] || + tieBreak(a.r, b.r) || TITLE_RANK[b.r.titleMatch] - TITLE_RANK[a.r.titleMatch] || - YEAR_RANK[b.r.yearMatch] - YEAR_RANK[a.r.yearMatch] || Number(b.r.candidate.provider === "local") - Number(a.r.candidate.provider === "local") || a.i - b.i, ) @@ -173,6 +361,8 @@ export function resolveIdentity( const rivals = ranked.filter( (r) => r !== top && LEVEL_RANK[r.level] >= LEVEL_RANK[top.level] && !sameIdentity(r.candidate, top.candidate), ); - if (rivals.length > 0) return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" }; - return { status: "auto", best: top, ranked, reason: top.level }; + // 同等级多个候选:年份精确 / 名称文字种数 / 分值上严格占优才自动确认,否则进池 + if (rivals.some((r) => tieBreak(top, r) >= 0)) + return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" }; + return { status: "auto", best: top, ranked, reason: rivals.length ? `${top.level}:tie_break` : top.level }; } diff --git a/packages/domain/src/identity/normalize.ts b/packages/domain/src/identity/normalize.ts index ffc7a918..582b1323 100644 --- a/packages/domain/src/identity/normalize.ts +++ b/packages/domain/src/identity/normalize.ts @@ -35,3 +35,69 @@ export function normalizeTitle(s: string): string { .replace(/&/g, "and") .replace(/[^\p{L}\p{N}]+/gu, ""); } + +/** 分类词(站点分类 / RSS 标题前缀)与常见停用词:不能当片名或别名用于身份比对(ID-2) */ +const GENERIC_NAMES = new Set( + [ + "电影", + "电视剧", + "剧集", + "连续剧", + "动漫", + "动画", + "番剧", + "国漫", + "日漫", + "综艺", + "纪录片", + "记录片", + "音乐", + "短剧", + "微短剧", + "体育", + "游戏", + "软件", + "学习", + "其他", + "其它", + "未知", + "新", + "合集", + "全集", + "中字", + "国语", + "粤语", + "高清", + "蓝光", + "原盘", + "movie", + "movies", + "tv", + "tvseries", + "series", + "anime", + "animation", + "documentary", + "music", + "variety", + "show", + "the", + "season", + "complete", + "unknown", + "other", + ].map((s) => s.toLowerCase()), +); + +/** + * 归一化后的名称是否太"通用"不能用于身份比对:分类词、停用词、单个汉字、两个以内的拉丁字符、纯数字。 + * 参数是 normalizeTitle 之后的形态。 + */ +export function isGenericAliasName(norm: string): boolean { + if (!norm) return true; + if (GENERIC_NAMES.has(norm)) return true; + if (/^\d+$/.test(norm)) return true; + const han = norm.match(/\p{Script=Han}/gu)?.length ?? 0; + if (han > 0) return [...norm].length < 2; + return norm.length <= 2; +} diff --git a/packages/domain/src/identity/sequel.ts b/packages/domain/src/identity/sequel.ts new file mode 100644 index 00000000..15f3b611 --- /dev/null +++ b/packages/domain/src/identity/sequel.ts @@ -0,0 +1,60 @@ +/** + * 续作 / 章节 / Part 序号(ID-2):`Chapter III`、`Part Two`、`Vol. 2`、`第3章`、`第三章`、`Ⅲ`。 + * 只认显式关键词或罗马数字符号,不把片名末尾的普通数字当序号(`Blade Runner 2049`、`Apollo 13`)。 + * 季号不在这里(季由解析器单独给出)。 + */ +import { cnToInt } from "../release-parser/cn-number.ts"; + +const WORD_NUM: Record = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + first: 1, + second: 2, + third: 3, + fourth: 4, + final: -1, +}; +const ROMAN: Record = { i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10 }; +const ROMAN_CHAR: Record = { + Ⅰ: 1, + Ⅱ: 2, + Ⅲ: 3, + Ⅳ: 4, + Ⅴ: 5, + Ⅵ: 6, + Ⅶ: 7, + Ⅷ: 8, + Ⅸ: 9, + Ⅹ: 10, +}; + +function toNum(tok: string): number | undefined { + const t = tok.toLowerCase(); + if (/^\d{1,2}$/.test(t)) return Number(t); + if (t in ROMAN) return ROMAN[t]; + if (t in WORD_NUM) return WORD_NUM[t]! > 0 ? WORD_NUM[t] : undefined; + return undefined; +} + +export function sequelNumbers(title: string): number[] { + if (!title) return []; + const out = new Set(); + const s = title.replace(/[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]/g, (c) => ` ${ROMAN_CHAR[c]} `); + for (const m of s.matchAll(/\b(?:chapter|part|vol(?:ume)?)\b\.?\s*[::-]?\s*([0-9]{1,2}|[ivx]{1,4}|[a-z]+)\b/giu)) { + const n = toNum(m[1]!); + if (n !== undefined) out.add(n); + } + for (const m of s.matchAll(/第\s*([0-9]{1,2}|[一二三四五六七八九十]{1,3})\s*章/gu)) { + const n = /^\d+$/.test(m[1]!) ? Number(m[1]) : cnToInt(m[1]!); + if (n !== undefined) out.add(n); + } + return [...out]; +} diff --git a/packages/domain/test/identity-id2.test.ts b/packages/domain/test/identity-id2.test.ts new file mode 100644 index 00000000..44ac946f --- /dev/null +++ b/packages/domain/test/identity-id2.test.ts @@ -0,0 +1,173 @@ +/** ID-2:副标题 / RSS 内嵌描述片名、分类前缀、续作序号、通用词守卫、评分权重与同级取舍 */ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MATCH_WEIGHTS, + type IdentityCandidate, + isGenericAliasName, + normalizeTitle, + parseRelease, + queryTitles, + resolveIdentity, + scoreCandidate, + sequelNumbers, +} from "../src/index.ts"; + +const tv = (id: string, titles: string[], year: number, over: Partial = {}): IdentityCandidate => ({ + provider: "tmdb", + entityType: "tv", + externalId: id, + titles, + year, + aliases: [], + ...over, +}); + +describe("解析:分类前缀、内嵌描述、副标题片名", () => { + it("RSS `[动漫]英文名 … -GRP[中文名 / 别名 | 第N集 | …][体积]`", () => { + const p = parseRelease( + "[动漫]Martial Master S01 2020 1080p WEB-DL H.264 AAC2.0-HHWEB[武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 1080p | 类型: 动画/奇幻/冒险 | 导演: 老鹰][180.86 MB]", + ); + expect(p).toMatchObject({ + category: "动漫", + titleCn: "武神主宰", + titleEn: "Martial Master", + year: 2020, + season: 1, + episodes: { start: 685, end: 685 }, + altTitles: ["武神主宰 第一季", "The God of War Dominates"], + }); + expect(p.evidence).toContain("subtitle:embedded"); + }); + it("分类词不当片名:[电视剧] / [音乐] / [综艺]", () => { + for (const c of ["电视剧", "音乐", "综艺", "纪录片", "短剧", "游戏", "软件", "学习", "其他"]) { + const p = parseRelease(`[${c}]Some Title S01 2026 1080p WEB-DL-GRP`); + expect(p.category, c).toBe(c); + expect(p.titleCn, c).toBeUndefined(); + } + }); + it("数字紧跟普通单词是片名的一部分,不是集号(`Travis - 12 Memories`)", () => { + const p = parseRelease("[音乐]Travis - 12 Memories (2003) - WEB-DL - 16bit ALAC-HHWEB"); + expect(p.episodes).toBeUndefined(); + expect(p.titleEn).toContain("12 Memories"); + expect(parseRelease("[Sub] Some Anime - 12 [1080p]").episodes).toEqual({ start: 12, end: 12 }); + expect(parseRelease("Some Anime - 05 END [1080p]").episodes).toEqual({ start: 5, end: 5 }); + }); + it("副标题第一段:主标题没有中文名时取第一个中文名,其余进 altTitles;字幕/简介字段不算片名", () => { + const p = parseRelease("Archives S01 2009 1080p WEB-DL H.264 AAC-HHWEB", { + subtitle: "档案 / 档案解密 | 第152集 | 1080p | 类型: 纪录片/历史 | 主演: 石凉 [内嵌中字]", + }); + expect(p).toMatchObject({ titleCn: "档案", titleEn: "Archives", altTitles: ["档案解密"], year: 2009 }); + const q = parseRelease("Interstellar 2014 2160p UHD Blu-ray-GRP", { + subtitle: "星际穿越 IMAX版/星際啟示錄(港)/英语DTS-HD MA5.1/原盘英简繁&双语特效SUP字幕/章节", + }); + expect(q.titleCn).toBe("星际穿越"); + expect(q.altTitles).toEqual(["星際啟示錄"]); + expect(parseRelease("Some Movie 2020 1080p-GRP", { subtitle: "官方中字 | 特效字幕" }).titleCn).toBeUndefined(); + }); + it("通用词不进比对名称", () => { + expect(isGenericAliasName(normalizeTitle("电视剧"))).toBe(true); + expect(isGenericAliasName("剧")).toBe(true); + expect(isGenericAliasName("tv")).toBe(true); + expect(isGenericAliasName(normalizeTitle("档案"))).toBe(false); + const p = parseRelease("Lanterns 2026 1080p-GRP"); + expect(queryTitles({ ...p, titleCn: "电视剧" })).toEqual(["lanterns"]); + }); +}); + +describe("续作 / 章节序号", () => { + it("Chapter / Part / 第N章 / 罗马数字符号", () => { + expect(sequelNumbers("Mononoke The Movie Chapter III The Curse of the Serpent")).toEqual([3]); + expect(sequelNumbers("剧场版物怪:第 3 章:蛇神")).toEqual([3]); + expect(sequelNumbers("劇場版モノノ怪 第三章 蛇神")).toEqual([3]); + expect(sequelNumbers("Mononoke the Movie: Chapter Ⅱ")).toEqual([2]); + expect(sequelNumbers("Dune Part Two")).toEqual([2]); + expect(sequelNumbers("Blade Runner 2049")).toEqual([]); + }); + it("序号冲突直接拒绝,一致加分", () => { + const p = parseRelease("Mononoke The Movie Chapter III The Curse of the Serpent 2026 1080p NF WEB-DL-HHWEB"); + const c3: IdentityCandidate = { + provider: "tmdb", + entityType: "movie", + externalId: "3", + titles: ["剧场版物怪:第 3 章:蛇神"], + aliases: ["Mononoke the Movie: Chapter III - The Curse of the Serpent"], + year: 2026, + }; + const c2 = { + ...c3, + externalId: "2", + titles: ["剧场版物怪:第 2 章:火鼠"], + aliases: ["Mononoke the Movie: Chapter II"], + }; + expect(scoreCandidate(p, c3).signals.sequel).toBe("match"); + expect(scoreCandidate(p, c2).level).toBe("rejected"); + expect(resolveIdentity(p, [c2, c3]).best?.candidate.externalId).toBe("3"); + }); +}); + +describe("证据:中英名、通用词、别名佐证、同级取舍", () => { + it("中文名精确 + 年份精确 → rule_confirmed;唯一候选 → auto", () => { + const p = parseRelease("Martial Master S01 2020 1080p WEB-DL-HHWEB", { subtitle: "武神主宰 | 第685集" }); + const r = resolveIdentity(p, [tv("110181", ["武神主宰"], 2020), tv("228707", ["武神主宰"], 2023)]); + expect(r).toMatchObject({ status: "auto", best: { level: "rule_confirmed" } }); + expect(r.best?.candidate.externalId).toBe("110181"); + }); + it("通用英文词只靠别名命中且池里撞名 → 封顶 needs_confirm;中文名命中则确认", () => { + const noCn = parseRelease("Archives S01 2009 1080p WEB-DL-HHWEB"); + const pool = [ + tv("201401", ["档案"], 2009, { aliases: ["Archives"] }), + tv("1", ["Archives secrètes"], 2021), + tv("2", ["Mystères d'archives"], 2009), + tv("3", ["The Magnus Archives"], 2016), + ]; + const r = resolveIdentity(noCn, pool); + expect(r.status).toBe("ambiguous"); + expect(r.best?.evidence).toContain("generic_name"); + const withCn = parseRelease("Archives S01 2009 1080p WEB-DL-HHWEB", { subtitle: "档案 | 第152集" }); + expect(resolveIdentity(withCn, pool)).toMatchObject({ + status: "auto", + best: { candidate: { externalId: "201401" } }, + }); + }); + it("别名命中需要年份或第二种文字的名称佐证", () => { + const p = parseRelease("人中之龙第一季 1080p WEB-DL H265 AAC-HHWEB", { + subtitle: "人中之龙第一季 | 全98集 | 1080p", + }); + const r = scoreCandidate(p, tv("256116", ["如龙", "龍が如く"], 2024, { aliases: ["人中之龙"] })); + expect(r.level).toBe("needs_confirm"); + expect(r.evidence).toContain("alias_uncorroborated"); + }); + it("同级多候选:年份精确优先,再按命中的名称文字种数", () => { + const p = parseRelease("A Prophet S01 2026 2160p TX WEB-DL-HHWEB", { subtitle: "我不是大师 / 长风起 | 第05集" }); + const right = tv("289078", ["我不是大师"], 2026, { aliases: ["A Prophet"] }); + const other = tv("224006", ["预言者", "Un prophète", "A Prophet"], 2026); + const r = resolveIdentity(p, [other, right]); + expect(r.status).toBe("auto"); + expect(r.best?.candidate.externalId).toBe("289078"); + expect(r.reason).toBe("rule_confirmed:tie_break"); + // 完全对称 → 仍进池 + expect(resolveIdentity(p, [tv("1", ["我不是大师"], 2026), tv("2", ["我不是大师"], 2026)]).status).toBe("ambiguous"); + }); + it("季号不在候选季列表里扣分", () => { + const p = parseRelease("Heart Signal S09 2018 1080p WEB-DL-HHWEB"); + const r = scoreCandidate(p, tv("72976", ["Heart Signal"], 2018, { seasonNumbers: [1, 2, 3, 4] })); + expect(r.evidence).toContain("season:missing"); + expect(r.level).toBe("needs_confirm"); + }); + it("权重表可覆盖", () => { + const p = parseRelease("Oppenheimer 2023 1080p-GRP"); + const c: IdentityCandidate = { + provider: "tmdb", + entityType: "movie", + externalId: "1", + titles: ["Oppenheimer"], + year: 2023, + aliases: [], + }; + expect(scoreCandidate(p, c).level).toBe("rule_confirmed"); + expect( + scoreCandidate(p, c, { weights: { thresholds: { ...DEFAULT_MATCH_WEIGHTS.thresholds, rule_confirmed: 9 } } }) + .level, + ).toBe("multi_evidence"); + }); +});