feat(parser,sites): W5-D port title/zhconv/site parsing gaps found by MP differential

Title recognition (parseRelease / parseReleasePath):
- anime prefix tags (★N月新番, [国漫], 【22年日剧】), category lead bracket, `A / B` cn aliases
- `Name [01]`, `[TV 04]`, `#13`, `名称 12.mkv`, `S01 13-14`, `E13-14` episodes
- lead bracket as title vs group, trailing [Group], YTS.MX/Hall_of_C style groups, 字幕组 suffix
- `[dot.release.name][…]` unbracketing, `Season 2017` as year, leading date not airDate,
  date-only parent dir does not merge as TV
- original disc: Blu-ray + AVC/HEVC/VC-1/MPEG-2 without x26x/H.26x -> BluRay-Disc
- `{[tmdbid=;type=;s=;e=]}` explicit block, bangumi/anilist ids
- bare [1080]/[5.1], DTS-HD MA5.1, £ multi-group, 合集, pinyin title -> cn from subtitle,
  streaming platform table, CHS/CHT/GB/BIG5 in subtitle
zh-map: add 迴滾魷瑯臥駭; document one-to-many chars kept out.
Sites: tbody branch + header-row tightening via movieclaw-import rowSelector (18 definitions),
row-level case/hr/labels include the row itself, subtitle branch (searchType: "subtitles"),
parseSizeMp banker's rounding.
Differential snapshots regenerated; MP test sample sources registered in ledger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:09:14 +08:00
1 parent 8352e5e881
commit 36dc048e89
41 files changed
+3111 -2372

No files matched your search

+260 -81
View File
@@ -1,5 +1,6 @@
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 {
type Confidence,
@@ -38,7 +39,14 @@ export function fileKindOf(name: string): FileKind | undefined {
// ---------------------------------------------------------------- 可变草稿
type Draft = ParsedRelease & { _complete?: boolean; _fin?: boolean; _disc?: boolean; _remux?: boolean };
type Draft = ParsedRelease & {
_complete?: boolean;
_fin?: boolean;
_disc?: boolean;
_remux?: boolean;
/** `{[k=v;…]}` 显式身份块里的季/集/类型,finalize 时覆盖推断值 */
_explicit?: { season?: number; seasonEnd?: number; episodes?: EpisodeRange; tv?: boolean };
};
function emptyDraft(raw: string): Draft {
return {
@@ -67,6 +75,9 @@ const MARK_HARD = " 〆 "; // 日期/动漫集号被剥离后的占位:之后不
const GROUP_SUFFIX_EXCLUDE =
/^(?:DL|HD|MA|HR|RIP|RAY|X|ES|\d+|\d+BIT|\d{3,4}[PI]|[XH]\.?26[45]|HEVC|AVC|AAC|FLAC|DTS|DDP?[\d.]*|ATMOS|WEB|BLURAY|REMUX|HDR\d*|DV|HLG|SDR|FIN|END|S\d+|E\d+|EP\d+|CD\d*|PART\d*|\d+CH|MKV|MP4)$/i;
const TECH_IN_BRACKET = /\b(?:\d{3,4}[pi]|x26[45]|hevc|avc|web-?dl|webrip|bluray|bdrip|remux)\b|^\d+$/i;
/** 开头方括号里明显是组名的写法:字幕组/汉化组/Raws 等后缀,或 `YTS.MX`、`Hall_of_C` 这种单个点/下划线连接的词元 */
const GROUPISH_BRACKET =
/(?:字幕[组組社]|[汉漢]化[组組]|[发發][布佈][组組]|[压壓]制[组組]|工作室|搬运[组組]?|Raws?|Subs?|Studio|Fansubs?|Team)$|^(?=.{3,16}$)[A-Za-z0-9]+(?:[._][A-Za-z0-9]+){1,2}$/iu;
function extractGroup(s: string, extra: readonly string[]): { s: string; group?: ReleaseGroup; ev?: string } {
const re = extra.length > 0 ? buildGroupRegex(extra) : defaultGroupRegex();
@@ -86,7 +97,23 @@ function extractGroup(s: string, extra: readonly string[]): { s: string; group?:
if (lead) {
const content = lead[1]!.trim();
const rest = s.slice(lead[0].length);
if (!TECH_IN_BRACKET.test(content) && (/[[【]/u.test(rest) || /\s-\s\d{1,3}\b/.test(rest))) {
// `[片名][01]…`:开头方括号紧跟集号,它就是片名
if (/^\s*[[【]\d{1,3}(?:v\d)?[\]】]/u.test(rest)) return { s };
// `[中文片名][English Title]…[Group]`:开头是纯中文片名,组名取末尾方括号
if (/^\p{Script=Han}+$/u.test(content) && !GROUPISH_BRACKET.test(content)) {
const tail = /[[【]([A-Za-z][A-Za-z0-9-]{1,23})[\]】]\s*$/u.exec(rest);
if (tail && !TECH_IN_BRACKET.test(tail[1]!) && !GROUP_SUFFIX_EXCLUDE.test(tail[1]!)) {
return {
s: s.slice(0, s.length - tail[0].length),
group: { name: tail[1]!, source: "bracket" },
ev: "group:bracket_tail",
};
}
}
if (
!TECH_IN_BRACKET.test(content) &&
(/[[【]/u.test(rest) || /\s-\s\d{1,3}\b/.test(rest) || GROUPISH_BRACKET.test(content))
) {
return { s: rest, group: { name: content.replace(/&/g, "@"), source: "bracket" }, ev: "group:bracket" };
}
}
@@ -137,6 +164,8 @@ function extractAirDate(s: string): { s: string; airDate?: string; ev?: string }
if (!m) continue;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
if (!validDate(y, mo, d)) continue;
// 标题开头的日期(`2022.8.2.Twelve.Monkeys.1995`)是发布/收藏日期,不是播出日期
if (/^[\s[【(]*$/u.test(s.slice(0, m.index))) return { s: s.slice(m.index + m[0].length) };
return {
s: s.slice(0, m.index) + MARK_HARD + s.slice(m.index + m[0].length),
airDate: `${y}${pad2(mo)}${pad2(d)}`,
@@ -267,61 +296,41 @@ export function scanCnMarkers(input: string): { s: string } & CnMarkers {
const AUDIO_CODEC_ALT = "DDP|DD|AAC|TrueHD|DTSHDMA|DTSHDHR|DTSHD|DTSX|DTS|FLAC|LPCM|PCM|AC3|EAC3|Opus|Atmos";
function normalizeCompounds(s: string): string {
return s
.replace(/'/g, "")
.replace(/\bWEB[-. ]?DL\b/gi, " WEBDL ")
.replace(/\bWEB[-. ]?Rip\b/gi, " WEBRip ")
.replace(/\bBlu[-. ]?Ray\b/gi, " BluRay ")
.replace(/\bDTS[-. ]?HD[-. ]?MA\b/gi, " DTSHDMA ")
.replace(/\bDTS[-. ]?HD[-. ]?(?:HRA|HR)\b/gi, " DTSHDHR ")
.replace(/\bDTS[-. ]?HD\b/gi, " DTSHD ")
.replace(/\bDTS[-. :]?X\b/gi, " DTSX ")
.replace(/\b([HhXx])[. ]?26([45])\b/g, " $126$2 ")
.replace(/\bHDR10(?:\+|Plus|P)(?![A-Za-z0-9])/gi, " HDR10PLUS ")
.replace(/\bDD\+/gi, " DDP")
.replace(/\bE-?AC-?3\b/gi, " EAC3 ")
.replace(/\bAC-?3\b/gi, " AC3 ")
.replace(/\bDolby[ .]?Vision\b/gi, " DV ")
.replace(/\bVC-?1\b/gi, " VC1 ")
.replace(/\bMPEG-?2\b/gi, " MPEG2 ")
.replace(/\b(8|10|12)[ .\-_]?bits?\b/gi, " $1BIT ")
.replace(
new RegExp(`\\b(${AUDIO_CODEC_ALT})[ ._]*([1-9])[ .]([0-2])(?![0-9])`, "gi"),
(_all, codec: string, a: string, b: string) => ` ${codec} CH${a}${b} `,
)
.replace(/(\p{Script=Han})([A-Za-z])/gu, "$1 $2")
.replace(/([A-Za-z])(\p{Script=Han})/gu, "$1 $2");
return (
s
.replace(/'/g, "")
.replace(/\bWEB[-. ]?DL\b/gi, " WEBDL ")
.replace(/\bWEB[-. ]?Rip\b/gi, " WEBRip ")
.replace(/\bBlu[-. ]?Ray\b/gi, " BluRay ")
.replace(/\bDTS[-. ]?HD[-. ]?MA(?![A-Za-z])/gi, " DTSHDMA ")
.replace(/\bDTS[-. ]?HD[-. ]?(?:HRA|HR)\b/gi, " DTSHDHR ")
.replace(/\bDTS[-. ]?HD\b/gi, " DTSHD ")
.replace(/\bDTS[-. :]?X\b/gi, " DTSX ")
.replace(/\b([HhXx])[. ]?26([45])\b/g, " $126$2 ")
.replace(/\bHDR10(?:\+|Plus|P)(?![A-Za-z0-9])/gi, " HDR10PLUS ")
.replace(/\bDD\+/gi, " DDP")
.replace(/\bE-?AC-?3\b/gi, " EAC3 ")
.replace(/\bAC-?3\b/gi, " AC3 ")
.replace(/\bDolby[ .]?Vision\b/gi, " DV ")
.replace(/\bVC-?1\b/gi, " VC1 ")
.replace(/\bMPEG-?2\b/gi, " MPEG2 ")
.replace(/\b(8|10|12)[ .\-_]?bits?\b/gi, " $1BIT ")
.replace(
new RegExp(`\\b(${AUDIO_CODEC_ALT})[ ._]*([1-9])[ .]([0-2])(?![0-9])`, "gi"),
(_all, codec: string, a: string, b: string) => ` ${codec} CH${a}${b} `,
)
.replace(/\b(EP?)(\d{1,4})-(?:EP?)?(\d{1,4})\b/gi, " $1$2 $1$3 ")
.replace(/\bS(\d{1,2})\s+(\d{1,3})-(\d{1,3})(?![\d.])/gi, " S$1 E$2 E$3 ")
.replace(/\bB-Global\b/gi, " BGLOBAL ")
.replace(/[[【]\s*(4320|2160|1440|1080|720|576|480)\s*[\]】]/gu, " $1p ")
.replace(/[[【(]\s*([2-9])\.([0-2])\s*[\]】)]/gu, " CH$1$2 ")
// 汉字之间夹单个拉丁字母(哆啦A梦)不拆开
.replace(/(\p{Script=Han})([A-Za-z])(?!\p{Script=Han})/gu, "$1 $2")
.replace(/(?<!\p{Script=Han})([A-Za-z])(\p{Script=Han})/gu, "$1 $2")
);
}
const TOKEN_SPLIT = /[\s._[\]()【】/~~;&|#「」,,()::"!!??、+\-《》]+/u;
const PLATFORMS: Record<string, string> = {
NF: "Netflix",
AMZN: "Amazon",
MAX: "Max",
HMAX: "HBO Max",
DSNP: "Disney+",
ATVP: "Apple TV+",
IT: "iTunes",
HULU: "Hulu",
PCOK: "Peacock",
PMTP: "Paramount+",
CR: "Crunchyroll",
IQ: "iQIYI",
IQIYI: "iQIYI",
TX: "Tencent Video",
WETV: "WeTV",
YK: "Youku",
MGTV: "MGTV",
BILI: "Bilibili",
BG: "B-Global",
VIU: "Viu",
KKTV: "KKTV",
FRIDAY: "friDay",
LINETV: "LINE TV",
HBO: "HBO",
STAN: "Stan",
};
const TOKEN_SPLIT = /[\s._[\]()【】/~~;&|#「」,,()::"!!??、+\-《》£]+/u;
const AUDIO_TOKENS: Record<string, string> = {
TRUEHD: "TrueHD",
@@ -389,7 +398,14 @@ function walkTokens(tokens: string[], d: Draft): void {
const ev = d.evidence;
const en: string[] = [];
const cn: string[] = [];
/** 纯假名词元(日文原名):不混进英文名,英文名为空时才用 */
const ja: string[] = [];
let lastTitleList: string[] | undefined;
// 原盘判定:Blu-ray 字样 + AVC/HEVC/VC-1/MPEG-2 且没有 x26x/H.26x(对照 MP 内置规则 BLU)
let blurayWord = false;
let discCodec = false;
let encodeCodec = false;
let ripWord = false;
let stopped = false;
let hanClosed = false;
let nonYearStops = 0;
@@ -398,9 +414,15 @@ function walkTokens(tokens: string[], d: Draft): void {
stopped = true;
if (kind !== "year") nonYearStops++;
};
const hasTitle = () => en.length > 0 || cn.length > 0;
const hasTitle = () => en.length > 0 || cn.length > 0 || ja.length > 0;
const pushTitle = (t: string) => {
const list = /\p{Script=Han}/u.test(t) ? cn : /^\d+$/.test(t) ? (lastTitleList ?? en) : en;
const list = /\p{Script=Han}/u.test(t)
? cn
: /^\d+$/.test(t)
? (lastTitleList ?? en)
: /^[\p{Script=Hiragana}\p{Script=Katakana}ー・]+$/u.test(t)
? ja
: en;
list.push(t);
lastTitleList = list;
};
@@ -472,7 +494,8 @@ function walkTokens(tokens: string[], d: Draft): void {
stop("season");
continue;
}
if ((u === "SEASON" || u === "EPISODE") && next && /^\d{1,4}$/.test(next)) {
// `Season 2017` 是片名 + 年份(The Long Season 2017),季号最多 3 位
if ((u === "SEASON" || u === "EPISODE") && next && /^\d{1,3}$/.test(next)) {
if (u === "SEASON") {
if (d.season === undefined) d.season = Number(next);
else if (Number(next) > d.season) d.seasonEnd = Number(next);
@@ -556,8 +579,11 @@ function walkTokens(tokens: string[], d: Draft): void {
// 来源
if (gate) {
let src: ReleaseSource | undefined;
if (u === "BLURAY" || u === "BDRIP" || u === "BRRIP" || u === "BD") src = "BluRay";
else if (u === "WEBDL") src = "WEB-DL";
if (u === "BLURAY" || u === "BDRIP" || u === "BRRIP" || u === "BD") {
src = "BluRay";
if (u === "BLURAY") blurayWord = true;
else if (u !== "BD") ripWord = true;
} else if (u === "WEBDL") src = "WEB-DL";
else if (u === "WEBRIP") src = "WEBRip";
else if (u === "WEB" && (stopped || (next !== undefined && /^\d{3,4}[pi]$/i.test(next)))) src = "WEB-DL";
else if (u === "HDTV" || u === "UHDTV") src = "HDTV";
@@ -609,6 +635,8 @@ function walkTokens(tokens: string[], d: Draft): void {
if (codec) {
d.videoCodec ??= codec;
if (u === "X264" || u === "X265") d.encoder ??= u === "X264" ? "x264" : "x265";
if (u === "AVC" || u === "HEVC" || u === "VC1" || u === "MPEG2") discCodec = true;
else if (/^[XH]26[45]$/.test(u)) encodeCodec = true;
ev.push("codec");
stop("codec");
continue;
@@ -626,9 +654,12 @@ function walkTokens(tokens: string[], d: Draft): void {
continue;
}
m = /^CH(\d)(\d)$/.exec(u);
if (m && d.audio.length > 0) {
if (m) {
const last = d.audio.length - 1;
if (!/\d\.\d$/.test(d.audio[last]!)) d.audio[last] = `${d.audio[last]} ${m[1]}.${m[2]}`;
// 裸声道 `[5.1]`:没有音频编码时单独记一条
if (last < 0) d.audio.push(`${m[1]}.${m[2]}`);
else if (!/\d\.\d$/.test(d.audio[last]!)) d.audio[last] = `${d.audio[last]} ${m[1]}.${m[2]}`;
stop("audio");
continue;
}
m = /^(\d)AUDIOS?$/.exec(u);
@@ -661,12 +692,14 @@ function walkTokens(tokens: string[], d: Draft): void {
stop("platform");
continue;
}
if (stopped && (u === "CHS" || u === "GB")) {
if (u === "CHS" || u === "GB") {
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hans"])];
stop("subtitle");
continue;
}
if (stopped && (u === "CHT" || u === "BIG5")) {
if (u === "CHT" || u === "BIG5") {
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hant"])];
stop("subtitle");
continue;
}
if (stopped && (u === "ENG" || u === "EN")) {
@@ -680,20 +713,26 @@ function walkTokens(tokens: string[], d: Draft): void {
if (stopped) continue;
if (/\p{Script=Han}/u.test(t)) {
if (!hanClosed) {
cn.push(t);
// 「三部曲合集」「合集」是打包说明,不是片名的一部分
const name = t.length > 2 ? t.replace(/合集$/u, "") : t;
cn.push(name);
lastTitleList = cn;
}
continue;
}
pushTitle(t);
}
if (en.length === 0 && ja.length > 0) en.push(...ja);
const enTitle = en.join(" ").trim();
const cnTitle = cn.join(" ").trim();
if (enTitle) d.titleEn = enTitle;
if (cnTitle) d.titleCn = cnTitle;
if (d._remux) d.source = "REMUX";
else if (d._disc) d.source = "BluRay-Disc";
else {
else if (blurayWord && discCodec && !encodeCodec && !ripWord && !d.encoder && sources.size === 1) {
d.source = "BluRay-Disc";
ev.push("source:disc_codec");
} else {
for (const s of ["BluRay", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVDRip", "DVD"] as const) {
if (sources.has(s)) {
d.source = s;
@@ -703,6 +742,82 @@ function walkTokens(tokens: string[], d: Draft): void {
}
}
// ---------------------------------------------------------------- 预处理
const BRACKET_DOT_TITLE =
/^\s*\[([A-Za-z0-9][^\s[\]]*?\.(?:19|20)\d{2}\.[^\s[\]]*?\b(?:\d{3,4}p|Blu-?Ray|WEB-?DL|WEBRip|HDTV|REMUX|UHD)\b[^\s[\]]*)\]/i;
const ANIME_TAG_CORE =
"(?:(?:\\d{2,4}\\s*年\\s*)?(?:\\d{1,2}\\s*月\\s*)?(?:新番|番剧|番)|(?:\\d{2,4}\\s*年\\s*)?[日美国國韩韓港台][漫剧劇])";
const ANIME_TAG_BRACKET = new RegExp(`[[【]\\s*★?\\s*${ANIME_TAG_CORE}\\s*★?\\s*[\\]】]`, "gu");
const ANIME_TAG_BARE = new RegExp(`★\\s*${ANIME_TAG_CORE}\\s*★?|(?<![\\p{Script=Han}\\d])\\d{1,2}月新番★?`, "gu");
/** 开头方括号是分类词(动画/电影/纪录片/Animation/Movie…),MP 同样整块去掉 */
const CATEGORY_LEAD =
/^\s*[[【](?:[^\]】]*?(?:Animations?|Movies?|Documentar(?:y|ies)|Anime)\b[^\]】]*|[动動漫画畫纪紀录錄片电電影视視连連续續剧劇集日美韩韓中港台海外亚亞洲华華语語大陆陸综綜艺藝原盘盤高清]{2,})[\]】]\s*(?=[[【])/iu;
const CN_ALIAS_SLASH =
/(?<=^|[\s\]】])[^\s/[\]【】]*\p{Script=Han}[^\s/[\]【】]*\s*\/\s*(?=[^\s/[\]【】]*\p{Script=Han})/u;
function addExplicit(d: Draft, x: ExplicitId): void {
if (x.id === "0" || d.explicitIds.some((e) => e.provider === x.provider)) return;
d.explicitIds.push(x);
d.evidence.push(`explicit:${x.provider}`);
}
const BLOCK_PROVIDERS: Record<string, ExplicitId["provider"]> = {
tmdbid: "tmdb",
tmdb: "tmdb",
doubanid: "douban",
douban: "douban",
imdbid: "imdb",
imdb: "imdb",
tvdbid: "tvdb",
tvdb: "tvdb",
bangumiid: "bangumi",
bangumi: "bangumi",
anilistid: "anilist",
anilist: "anilist",
};
/** `{[tmdbid=94997;type=tv;s=1;e=3-4;g=…]}`:返回 ID 与季/集/类型;没有任何可识别键时返回 undefined(原样保留) */
function parseExplicitBlock(body: string): { ids: ExplicitId[]; meta: NonNullable<Draft["_explicit"]> } | undefined {
const ids: ExplicitId[] = [];
const meta: NonNullable<Draft["_explicit"]> = {};
let hit = false;
const range = (v: string) => {
const m = /^(\d{1,4})(?:\s*-\s*(\d{1,4}))?$/.exec(v.trim());
return m ? { start: Number(m[1]), end: Number(m[2] ?? m[1]) } : undefined;
};
for (const part of body.split(";")) {
const kv = /^\s*([A-Za-z]+)\s*=\s*(.*?)\s*$/.exec(part);
if (!kv) continue;
const key = kv[1]!.toLowerCase();
const val = kv[2]!;
const provider = BLOCK_PROVIDERS[key];
if (provider) {
if (/^[A-Za-z0-9]+$/.test(val)) ids.push({ provider, id: val });
hit = true;
} else if (key === "type") {
if (/^(tv|电视剧|電視劇)$/i.test(val)) meta.tv = true;
hit = true;
} else if (key === "s") {
const r = range(val);
if (r) {
meta.season = r.start;
if (r.end > r.start) meta.seasonEnd = r.end;
}
hit = true;
} else if (key === "e") {
const r = range(val);
if (r) meta.episodes = r;
hit = true;
} else if (key === "g") hit = true;
}
return hit ? { ids, meta } : undefined;
}
/** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */
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;
// ---------------------------------------------------------------- 主入口
/**
@@ -724,19 +839,31 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
s = s.slice(0, -(ext.length + 1));
} else if (opts.isFile) d.isFile = true;
// 2. 显式身份标签
// 2. 显式身份标签:先取 `{[k=v;…]}` 块(季/集/类型一并给出),再取单个 `[tmdbid=…]`
s = s.replace(/\{\[([^\]{}]*)\]\}/g, (all, body: string) => {
const block = parseExplicitBlock(body);
if (!block) return all;
for (const x of block.ids) addExplicit(d, x);
d._explicit = block.meta;
ev.push("explicit:block");
return " ";
});
s = s.replace(
/[[{]\s*(tmdbid|tmdb|imdbid|imdb|doubanid|douban|tvdbid|tvdb)\s*[=-]\s*([A-Za-z0-9]+)\s*[\]}]/gi,
/[[{]\s*(tmdbid|tmdb|imdbid|imdb|doubanid|douban|tvdbid|tvdb|bangumiid|bangumi|anilistid|anilist)\s*[=-]\s*([A-Za-z0-9]+)\s*[\]}]/gi,
(_all, key: string, id: string) => {
const k = key.toLowerCase().replace(/id$/, "") as ExplicitId["provider"];
if (id !== "0" && !d.explicitIds.some((x) => x.provider === k)) {
d.explicitIds.push({ provider: k, id });
ev.push(`explicit:${k}`);
}
addExplicit(d, { provider: key.toLowerCase().replace(/id$/, "") as ExplicitId["provider"], id });
return " ";
},
);
// 2b. 开头方括号里是完整的点分隔发布名(`[Name.2023.2160p.UHD.Blu-ray…-GRP][DIY中字原盘]…`):去括号保留内容
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, "");
// 2d. `中文名A / 中文名B`:同一作品的两个中文译名只留后一个(与 MP 取法一致),避免拼成一个名字
s = s.replace(CN_ALIAS_SLASH, "");
// 3. 纯数字文件名 → 集号
if (d.isFile && /^\s*\d{1,4}\s*$/.test(s)) {
const n = Number(s.trim());
@@ -765,9 +892,11 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
for (const [re, cut] of CUT_RULES) {
s = s.replace(re, () => {
d.cut ??= cut;
return " ";
return "\uE001";
});
}
// `Caligula.The.Ultimate.Cut` 去掉剪辑版本后,紧挨着的冠词 The 一并去掉
s = s.replace(/\bThe[ .\-_]*\uE001/gi, " ").replace(/\uE001/g, " ");
if (d.cut) ev.push("cut");
// 7. 综艺播出日期(在年份识别之前,避免被当成年份或被丢弃)
@@ -786,7 +915,26 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
// 9. 年份区间 2019-2020 只保留前者
s = s.replace(/(?<!\d)((?:19|20)\d{2})\s*[-~]\s*(?:19|20)\d{2}(?!\d)/, "$1");
// 10. 动漫式集号:" - 12 "、"[01-11 Fin]"、"[12]"
// 10. 动漫式集号:" - 12 "、"[01-11 Fin]"、"[12]"、"[TV 04]"、"#13"
s = s.replace(
/[[【]\s*TV\s*(\d{1,4})(?:\s*[-~]\s*(\d{1,4}))?(\s*(?:Fin|END|完))?\s*[\]】]/iu,
(all, a: string, b, fin) => {
if (d.episodes) return all;
d.episodes = { start: Number(a), end: Number(b ?? a) };
ev.push("episode:bracket_tv");
if (fin) {
d._fin = true;
ev.push("pack:fin");
}
return MARK_HARD;
},
);
s = s.replace(/(?<=\S\s+)#(\d{1,4})(?=\s|[[((]|$)/u, (all, n: string) => {
if (d.episodes) return all;
d.episodes = { start: Number(n), end: Number(n) };
ev.push("episode:hash");
return MARK_HARD;
});
s = s.replace(
/[[【]\s*(\d{1,4})\s*[-~]\s*(\d{1,4})(\s*(?:Fin|END|完))?\s*[\]】]/iu,
(all, a: string, b: string, fin) => {
@@ -814,11 +962,18 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
}
return MARK_HARD;
});
if (d.releaseGroup?.source === "bracket") {
s = s.replace(/[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => {
if (d.episodes) return all;
// `[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;
d.episodes = { start: Number(n), end: Number(n) };
ev.push("episode:anime_bracket");
return MARK_HARD;
});
// 文件名 `西部世界 12`:中文名后空格加 2~3 位数字
if (d.isFile && !d.episodes) {
s = s.replace(/(?<=\p{Script=Han})\s+(\d{2,3})\s*$/u, (_all, n: string) => {
d.episodes = { start: Number(n), end: Number(n) };
ev.push("episode:anime_bracket");
ev.push("episode:cn_name_number");
return MARK_HARD;
});
}
@@ -837,7 +992,22 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
// 13. 副标题补缺
if (opts.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];
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);
// 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算)
for (const m of opts.subtitle.matchAll(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) {
const lang = /^(CHS|GB)$/i.test(m[1]!) ? "zh-Hans" : "zh-Hant";
sub.subtitles = [...new Set([...(sub.subtitles ?? []), lang])];
if (!sub.evidence.includes("subtitle:latin")) sub.evidence.push("subtitle:latin");
}
applyCnMarkers(d, sub, "subtitle:");
if (!d.airDate) {
const sad = extractAirDate(opts.subtitle);
@@ -900,13 +1070,22 @@ function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
} else ev.push("episode:offset_rejected");
}
if (d.totalEpisodes !== undefined && d.episodes === undefined && d.isFile) d.totalEpisodes = undefined;
// 显式身份块的季/集最后覆盖(对照 MP _apply_explicit_metainfo)
const x = d._explicit;
if (x?.season !== undefined) {
d.season = x.season;
d.seasonEnd = x.seasonEnd;
d.seasonInferred = undefined;
}
if (x?.episodes) d.episodes = x.episodes;
const tvSignals =
d.season !== undefined ||
d.episodes !== undefined ||
d.airDate !== undefined ||
d.issue !== undefined ||
d.totalEpisodes !== undefined;
d.totalEpisodes !== undefined ||
d._explicit?.tv === true;
d.mediaTypeHint = tvSignals ? "tv" : "unknown";
// 季号推定:有集号/总集数但无季号,且不是综艺日期/期号
@@ -929,7 +1108,7 @@ function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
d.title = d.titleCn ?? d.titleEn;
d.confidence = confidenceOf(d);
const { _complete, _fin, _disc, _remux, ...out } = d;
const { _complete, _fin, _disc, _remux, _explicit, ...out } = d;
return out;
}