diff --git a/packages/domain/src/release-parser/parse.ts b/packages/domain/src/release-parser/parse.ts index 6abf3e25..e554f379 100644 --- a/packages/domain/src/release-parser/parse.ts +++ b/packages/domain/src/release-parser/parse.ts @@ -2,6 +2,7 @@ import { CN_NUM, cnToInt } from "./cn-number.ts"; import { evalEpisodeOffset } from "./offset.ts"; import { PLATFORMS } from "./platforms.ts"; import { buildGroupRegex, defaultGroupRegex } from "./release-groups.ts"; +import { splitEmbeddedDescription, subtitleTitles } from "./subtitle-titles.ts"; import { type Confidence, type ContentType, @@ -753,6 +754,9 @@ const ANIME_TAG_BARE = new RegExp(`★\\s*${ANIME_TAG_CORE}\\s*★?|(? 24) return undefined; + if (/\p{Script=Han}\s+\p{Script=Han}/u.test(core)) return undefined; + return /[。??]/u.test(core) ? undefined : core; +} + /** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */ const PINYIN_SYLLABLE = /^(?:[bpmfdtnlgkhjqxrzcsyw]|zh|ch|sh)?(?:iang|iong|uang|ueng|ang|eng|ing|ong|ian|iao|uai|uan|ai|ei|ao|ou|an|en|in|un|ia|ie|iu|ua|uo|ui|ue|er|a|o|e|i|u|v)$/i; @@ -839,6 +851,17 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele s = s.slice(0, -(ext.length + 1)); } else if (opts.isFile) d.isFile = true; + // 1b. RSS 标题末尾内嵌的站点描述 `…-GRP[中文名 / … | 第N集 | …][体积]`:拆出来当副标题用 + let subtitle = opts.subtitle; + if (!kind) { + const emb = splitEmbeddedDescription(s); + if (emb.description) { + s = emb.title; + subtitle = subtitle ? `${emb.description} | ${subtitle}` : emb.description; + ev.push("subtitle:embedded"); + } + } + // 2. 显式身份标签:先取 `{[k=v;…]}` 块(季/集/类型一并给出),再取单个 `[tmdbid=…]` s = s.replace(/\{\[([^\]{}]*)\]\}/g, (all, body: string) => { const block = parseExplicitBlock(body); @@ -860,7 +883,11 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele s = s.replace(BRACKET_DOT_TITLE, " $1 "); // 2c. 动漫前缀标签(★7月新番★、[国漫]、【22年日剧】、[2022年7月番])与开头的分类方括号([Animations(动画片)]) s = s.replace(ANIME_TAG_BRACKET, " ").replace(ANIME_TAG_BARE, " "); - s = s.replace(CATEGORY_LEAD, ""); + s = s.replace(CATEGORY_LEAD, "").replace(CATEGORY_WORD_LEAD, (_all, c: string) => { + d.category = c; + ev.push("category:lead"); + return ""; + }); // 2d. `中文名A / 中文名B`:同一作品的两个中文译名只留后一个(与 MP 取法一致),避免拼成一个名字 s = s.replace(CN_ALIAS_SLASH, ""); @@ -955,13 +982,17 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele ev.push("pack:fin"); return MARK_HARD; }); - s = s.replace(/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)/u, (_all, n: string) => { - if (!d.episodes) { - d.episodes = { start: Number(n), end: Number(n) }; - ev.push("episode:anime_dash"); - } - return MARK_HARD; - }); + // `Travis - 12 Memories`:数字后紧跟一个普通单词时是片名的一部分,不是集号 + s = s.replace( + /\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)(?!\s+(?!(?:END|Fin|Final|RAW)\b)[A-Za-z][a-z]+\b)/u, + (_all, n: string) => { + if (!d.episodes) { + d.episodes = { start: Number(n), end: Number(n) }; + ev.push("episode:anime_dash"); + } + return MARK_HARD; + }, + ); // `[Group][Name][05]`、`Name [01][1080p]`:名称后的方括号纯数字是集号 s = s.replace(/(?<=\S\s*)[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => { if (d.episodes) return all; @@ -991,26 +1022,36 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele } // 13. 副标题补缺 - if (opts.subtitle) { + if (subtitle) { // 英文名其实是拼音(Mai Xiang ↔ 麦香):中文名取副标题第一个词 if (!d.titleCn && d.titleEn) { const words = d.titleEn.split(/\s+/); - const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(opts.subtitle)?.[1]; + const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(subtitle)?.[1]; if (first && words.length === [...first].length && words.every((w) => PINYIN_SYLLABLE.test(w))) { d.titleCn = first; ev.push("subtitle:title_cn_pinyin"); } } - const sub = scanCnMarkers(opts.subtitle); + // 副标题第一段的片名(ID-2):主标题没有中文名时取第一个中文名;其余作为别名参与匹配 + const names = subtitleTitles(subtitle); + const first = names.find((n) => /\p{Script=Han}/u.test(n)); + const cn = first ? cnTitleFromSubtitle(first) : undefined; + if (!d.titleCn && cn) { + d.titleCn = cn; + ev.push("subtitle:title_cn"); + } + const alts = names.filter((n) => n !== d.titleCn && n !== d.titleEn).slice(0, 6); + if (alts.length) d.altTitles = alts; + const sub = scanCnMarkers(subtitle); // 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算) - for (const m of opts.subtitle.matchAll(/(?= 0; i--) { + const c = s[i]!; + if (c === "]" || c === "】") depth++; + else if (c === "[" || c === "【") { + depth--; + if (depth === 0) { + start = i; + break; + } + } + } + if (start <= 0) return { title: input }; + const content = s.slice(start + 1, -1).trim(); + const head = s.slice(0, start).trimEnd(); + if (!/\p{Script=Han}/u.test(content) || !/\||\s\/\s/u.test(content)) return { title: input }; + // 前面必须像一个发布名(含拉丁字母) + if (!/[A-Za-z]{2,}/.test(head)) return { title: input }; + return { title: head, description: content }; +} + +/** 名称里出现这些词就不是片名(字幕/音轨/版本/简介字段) */ +const NOT_A_TITLE = + /中字|字幕|[国國粤粵]语|[国國]配|双语|雙語|多语|音轨|音軌|配音|杜比|原盘|原盤|特效|章节|章節|类型|類型|导演|導演|主演|简繁|簡繁|简体|繁体|繁體|内封|内嵌|內嵌|高码|作品|收藏|合集|更新|完结|完結|[全共]\s*\d+\s*[集期]|第\s*\d+\s*[集期]|\d+\s*集|4K|HDR|\d{3,4}[pP]|DTS|TrueHD|Atmos|FLAC|\bAAC\b|DDP|\d\.\d|\bTop\b|\*|#/u; +const REGION_NOTE = /\s*[((]\s*(?:港|台|澳|港台|大陆|陆|新|马)\s*[))]\s*/gu; +const BRACKETED = /[[【][^\]】]*[\]】]/gu; +const VERSION_SUFFIX = /\s*\S*版\s*$/u; + +/** + * 副标题第一段(第一个 `|` 之前)里的片名,按 `/` 拆分,顺序保留;去掉地区注记与方括号标签, + * 丢弃字幕/音轨/简介字段。只返回看起来像片名的项(含字母或汉字,长度合理)。 + */ +export function subtitleTitles(subtitle: string | undefined): string[] { + if (!subtitle) return []; + const first = subtitle.split(/\s*[||]\s*/u)[0] ?? ""; + const out: string[] = []; + for (const raw of first.replace(BRACKETED, " ").split(/\s*[//]\s*/u)) { + let t = raw.replace(REGION_NOTE, " ").replace(/\s+/g, " ").trim(); + if (!t) continue; + if (NOT_A_TITLE.test(t)) continue; + // `星际穿越 IMAX版`:版本后缀不属于片名 + if (/\p{Script=Han}/u.test(t) && /\s\S*版$/u.test(t)) t = t.replace(VERSION_SUFFIX, "").trim(); + if (!/[\p{L}\p{N}]/u.test(t) || [...t].length > 40) continue; + // 非拉丁/汉字文字(天城文等)不参与匹配 + if (!/^[\p{Script=Han}\p{Script=Latin}\p{N}\p{P}\p{S}\p{Zs}ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+$/u.test(t)) continue; + if (!out.includes(t)) out.push(t); + } + return out; +} diff --git a/packages/domain/src/release-parser/types.ts b/packages/domain/src/release-parser/types.ts index 432483ca..a4f8f398 100644 --- a/packages/domain/src/release-parser/types.ts +++ b/packages/domain/src/release-parser/types.ts @@ -1,5 +1,5 @@ /** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */ -export const PARSER_VERSION = 1; +export const PARSER_VERSION = 2; export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p"; @@ -75,6 +75,10 @@ export interface ParsedRelease { title?: string; titleCn?: string; titleEn?: string; + /** 标题开头的站点分类方括号(`[电视剧]`、`[音乐]`),不属于片名 */ + category?: string; + /** 副标题 / RSS 内嵌描述第一段里的其他片名(译名、别名),参与身份匹配,不用于展示 */ + altTitles?: string[]; year?: number; season?: number; seasonEnd?: number; @@ -125,7 +129,7 @@ export interface ParsedRelease { } export interface ParseOptions { - /** 站点副标题,只补主标题缺失的季集信息 */ + /** 站点副标题:补主标题缺失的季集信息;第一段里的片名补中文名与别名(altTitles) */ subtitle?: string; /** 附加发布组(设置表扩展) */ releaseGroups?: string[]; diff --git a/packages/domain/test/differential/meta-deviations.json b/packages/domain/test/differential/meta-deviations.json index 44a9c226..df314240 100644 --- a/packages/domain/test/differential/meta-deviations.json +++ b/packages/domain/test/differential/meta-deviations.json @@ -1467,6 +1467,18 @@ "mp": null, "ours": "swtyblz", "note": "未知发布组兜底(末段 -GROUP / 开头方括号),MP 组表未命中为 None" + }, + "name": { + "kind": "intentional", + "mp": "thematrix", + "ours": "黑客帝国", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + }, + "cn_name": { + "kind": "intentional", + "mp": null, + "ours": "黑客帝国", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" } }, "Z-17": { @@ -1554,6 +1566,48 @@ "ours": "douban:35593344", "note": "MP 不认 {doubanid=..} 花括号写法,我们认" } + }, + "MP-61": { + "name": { + "kind": "intentional", + "mp": "iwokeupavampire", + "ours": "醒来变成吸血鬼", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + }, + "cn_name": { + "kind": "intentional", + "mp": null, + "ours": "醒来变成吸血鬼", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + } + }, + "MP-62": { + "name": { + "kind": "intentional", + "mp": "shadowsofthevoid", + "ours": "虚无边境", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + }, + "cn_name": { + "kind": "intentional", + "mp": null, + "ours": "虚无边境", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + } + }, + "Z-15": { + "name": { + "kind": "intentional", + "mp": "duneparttwo", + "ours": "沙丘2", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + }, + "cn_name": { + "kind": "intentional", + "mp": null, + "ours": "沙丘2", + "note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md" + } } } } diff --git a/packages/domain/test/release-parser.test.ts b/packages/domain/test/release-parser.test.ts index 86b3adf0..0e2d4691 100644 --- a/packages/domain/test/release-parser.test.ts +++ b/packages/domain/test/release-parser.test.ts @@ -566,7 +566,13 @@ const MP_PARITY: Sample[] = [ subtitle: "麦香 | 全36集 | 4K", expect: { titleCn: "麦香", title: "麦香" }, }, - { id: "M4b", input: "Blade Runner 2017 1080p WEB-DL", subtitle: "银翼杀手 | 科幻", expect: { titleCn: undefined } }, + // ID-2:副标题第一段的中文片名补作中文名(M4b 原先断言不取) + { + id: "M4b", + input: "Blade Runner 2017 1080p WEB-DL", + subtitle: "银翼杀手 | 科幻", + expect: { titleCn: "银翼杀手", titleEn: "Blade Runner" }, + }, { id: "M5", input: "Thor Love and Thunder (2022) [1080p] [WEBRip] [5.1]", expect: { audio: ["5.1"] } }, { id: "M6", @@ -629,7 +635,7 @@ describe("parseRelease 样本表", () => { it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => { const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {}); check(r, s.expect); - expect(r.parserVersion).toBe(1); + expect(r.parserVersion).toBe(2); expect(r.raw).toBe(s.input); }); } diff --git a/packages/mapping/test/convert.test.ts b/packages/mapping/test/convert.test.ts index ee6a8b97..5fc0ce8e 100644 --- a/packages/mapping/test/convert.test.ts +++ b/packages/mapping/test/convert.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_TEMPLATES, evaluateCandidate, type IdentityCandidate, + PARSER_VERSION, parseRelease, planOrganize, resolveIdentity, @@ -237,7 +238,7 @@ describe("SiteRelease ↔ source_release", () => { expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true); expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" }); expect(JSON.stringify(create)).not.toContain("download.php"); - expect(create.parserVersion).toBe("1"); + expect(create.parserVersion).toBe(String(PARSER_VERSION)); }); it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => {