feat(parser): site category prefix, RSS embedded description and subtitle titles (ID-2)

Strip [电视剧]/[动漫]/[音乐]… prefixes into parsed.category instead of titleCn,
split RSS-embedded site descriptions into the subtitle, take the first Chinese
name of the subtitle as titleCn and the rest as altTitles, and stop reading
'Travis - 12 Memories' as episode 12. PARSER_VERSION 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:41:22 +08:00
1 parent c3aaf901ad
commit 51e0032d14
6 files changed
+195 -18

No files matched your search

+54 -13
View File
@@ -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*★?|(?<![\\p{Scr
/** 开头方括号是分类词(动画/电影/纪录片/Animation/Movie…),MP 同样整块去掉 */
const CATEGORY_LEAD =
/^\s*[[【](?:[^\]】]*?(?:Animations?|Movies?|Documentar(?:y|ies)|Anime)\b[^\]】]*|[动動漫画畫纪紀录錄片电電影视視连連续續剧劇集日美韩韓中港台海外亚亞洲华華语語大陆陸综綜艺藝原盘盤高清]{2,})[\]】]\s*(?=[[【])/iu;
/** 开头方括号只有一个分类词(RSS 标题 `[动漫]Name S01 …`、`[电视剧]…`),后面直接接片名 */
const CATEGORY_WORD_LEAD =
/^\s*[[【](动漫|動漫|动画|動畫|电影|電影|电视剧|電視劇|剧集|劇集|综艺|綜藝|纪录片|紀錄片|短剧|短劇|体育|體育|音乐|音樂|MV|游戏|遊戲|软件|軟體|学习|學習|其他|其它)[\]】]\s*/u;
const CN_ALIAS_SLASH =
/(?<=^|[\s\]】])[^\s/[\]【】]*\p{Script=Han}[^\s/[\]【】]*\s*\/\s*(?=[^\s/[\]【】]*\p{Script=Han})/u;
@@ -814,6 +818,14 @@ function parseExplicitBlock(body: string): { ids: ExplicitId[]; meta: NonNullabl
return hit ? { ids, meta } : undefined;
}
/** 副标题里的中文名能否当主中文名:去掉季号后不含空白分隔的多段汉字、不是句子、长度合理;返回去掉季号的名字 */
function cnTitleFromSubtitle(t: string): string | undefined {
const core = t.replace(new RegExp(`\\s*第\\s*${CN_NUM}\\s*[季部]\\s*$`, "u"), "").trim();
if (!core || [...core].length > 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(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) {
for (const m of 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);
const sad = extractAirDate(subtitle);
if (sad.airDate) {
d.airDate = sad.airDate;
ev.push(`subtitle:${sad.ev}`);