feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签

- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 17:03:00 +08:00
1 parent 12f8fc59c3
commit 63e94b308e
90 files changed
+6366 -2131

No files matched your search

+2 -1
View File
@@ -12,7 +12,8 @@
| --- | --- | --- |
| `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 |
| `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 |
| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则 → 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
| `identify-words/` | `applyGlobalWords`、`compileGlobalWords`、`wordTouches` | 全局识别词套用(移植 MP WordsMatcher);`parseRelease(title, { words })` 解析前先套。见 docs/design/identify-words.md |
| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则(作品识别词编译而来,照写的用、不停用)→ 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
| `slot/` | `evaluateCandidate(spec, parsed, current?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 |
| `rules/` | `parseRuleString`/`parseExpr`、`serializeLayers`/`serializeExpr`、`normalizeExpr`、`BUILTIN_ATOMS`、`customAtomFromMp`、`evaluateRuleGroup`/`evaluateRuleChain`、`testAtom` | MP 规则串 ↔ 结构化层(`>` 分层,层内 `! & \|` 与括号,错误带位置);内置关键字对齐 MP(能用解析字段的用字段,其余沿用 MP 正则,音乐 13 条不支持);多组串联取交集、pri 取最后一个带层的组;经 `SlotSpec.rules` 接入 `evaluateCandidate`。差分见 `test/differential/rules.test.ts` 与 `docs/runbook/rules.md` |
| `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 |
@@ -10,7 +10,7 @@ export {
tmdbSeasonCandidate,
} from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
export { applyRule, groupKey, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
export {
SUGGEST_MIN_SAMPLES,
SUGGEST_PAIR_DAYS,
@@ -14,7 +14,7 @@ export const CONVERSION_NEAR_DAYS = 7;
/**
* 集数解析总入口(设计文档判定流程 1→4)。纯函数,时间等事实由调用方传入。
* 1 有适用规则 → 按规则换算并校验;fail → unresolved + ruleFailed(不回退到确定性换算)
* 1 有适用规则 → 按规则换算并校验;fail → unresolved(规则照写的用,不回退到确定性换算,也不停用规则)
* 2 原号校验 pass → raw
* 3 确定性换算(只取唯一解):原号 fail 时唯一解即采用;原号滞后放行(unverified)时,
* 唯一解还要发布时间贴近目标集播出日期(按日期对集的除外),否则按滞后放行
@@ -45,32 +45,37 @@ export function resolveEpisodes(input: ResolveEpisodesInput): EpisodeResolution
// 1 规则
const rule = selectRule(rules, season, parsed.releaseGroup?.name ?? null);
if (rule && season !== null) {
const ref = {
ruleId: rule.id,
...(rule.line !== undefined ? { ruleLine: rule.line } : {}),
...(rule.text !== undefined ? { ruleText: rule.text } : {}),
};
const applied = applyRule(rule, season, episodes);
if (applied.kind === "special") {
return result({
method: "rule",
canonical: null,
ruleId: rule.id,
...ref,
special: applied.special,
evidence: [`rule:special:${applied.special}`],
});
}
if (applied.kind === "error") {
return result({ method: "unresolved", canonical: null, ruleFailed: rule.id, evidence: [applied.evidence] });
return result({ method: "unresolved", canonical: null, ...ref, evidence: [applied.evidence] });
}
const v = validateEpisodes(applied.canonical.season, applied.canonical.episodes, table);
if (v === "fail") {
return result({
method: "unresolved",
canonical: null,
ruleFailed: rule.id,
...ref,
evidence: [applied.evidence, "rule:validate_fail"],
});
}
return result({
method: "rule",
canonical: applied.canonical,
ruleId: rule.id,
...ref,
evidence: [applied.evidence, `validate:${v}`],
});
}
@@ -1,8 +1,11 @@
import { evalEpisodeOffset } from "../release-parser/offset.ts";
import { evalEpisodeExpr } from "@umt/contracts";
import { toSimplified } from "../identity/zh-map.ts";
import type { EpisodeRange } from "../release-parser/types.ts";
import type { CanonicalEpisodes, EpisodeMappingRule } from "./types.ts";
const sameGroup = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
/** 发布组比较:忽略大小写与首尾空白,繁简视为同一个(綠茶字幕組 = 绿茶字幕组) */
export const groupKey = (g: string) => toSimplified(g.trim().toLowerCase());
const sameGroup = (a: string, b: string) => groupKey(a) === groupKey(b);
/** 规则对(来源季, 发布组)的具体度:季 + 发布组 3 > 仅季 2 > 仅发布组 1 > 都空 0;不适用返回 -1 */
export function ruleSpecificity(rule: EpisodeMappingRule, season: number | null, releaseGroup: string | null): number {
@@ -49,8 +52,8 @@ export function applyRule(rule: EpisodeMappingRule, season: number, episodes: Ep
if (rule.kind === "expression") {
if (!rule.expression) return { kind: "error", evidence: "rule:expression_missing" };
try {
const start = evalEpisodeOffset(rule.expression, episodes.start);
const end = evalEpisodeOffset(rule.expression, episodes.end);
const start = evalEpisodeExpr(rule.expression, episodes.start);
const end = evalEpisodeExpr(rule.expression, episodes.end);
return {
kind: "mapped",
canonical: { season: rule.targetSeason ?? season, episodes: { start, end } },
@@ -1,4 +1,4 @@
import { evalEpisodeOffset } from "../release-parser/offset.ts";
import { evalEpisodeExpr } from "@umt/contracts";
import type { EpisodeRange } from "../release-parser/types.ts";
import { daysBetween } from "./table.ts";
import type { SeasonTable } from "./types.ts";
@@ -61,7 +61,7 @@ export function suggestRule(samples: readonly SuggestSample[], table: SeasonTabl
for (const c of candidates.values()) {
const pairs: SuggestedRule["samples"] = [];
for (const s of samples) {
const target = evalEpisodeOffset(c.expression, s.episodes.start);
const target = evalEpisodeExpr(c.expression, s.episodes.start);
const hit = aired.find((a) => a.season === c.targetSeason && a.episode === target);
const d = hit ? daysBetween(s.publishedAt, hit.airDate) : null;
if (!hit || d === null || d > SUGGEST_PAIR_DAYS) break;
@@ -45,7 +45,8 @@ export interface EpisodeMappingTableEntry {
}
/**
* 映射规则。作品由调用方过滤,停用的规则也由调用方剔除,这里只看季 + 发布组。
* 映射规则(由作品识别词编译而来,docs/design/identify-words.md)。作品由调用方过滤,注释掉的行不会编译出规则,
* 这里只看季 + 发布组。
* - expression:复用 `evalEpisodeOffset`,目标季缺省为来源季
* - table:按来源区间逐条映射
* - special:映射为特别篇 / 花絮 / 忽略,不再校验
@@ -61,6 +62,9 @@ export interface EpisodeMappingRule {
targetSeason?: number;
table?: EpisodeMappingTableEntry[];
special?: EpisodeSpecialKind;
/** 出处:作品识别词里的行号与原文 */
line?: number;
text?: string;
}
export type EpisodeValidation = "pass" | "unverified" | "fail";
@@ -92,9 +96,10 @@ export interface EpisodeResolution {
* - unresolved / skipped / special 规则:null(skipped 时调用方照用解析结果)
*/
canonical: CanonicalEpisodes | null;
/** 命中的规则(按写的换算,算出来对不上季表时也照记,结果为待确认) */
ruleId?: string;
/** 规则算出的结果校验不通过时,记失败规则的 id(调用方停用规则并开待处理) */
ruleFailed?: string;
ruleLine?: number;
ruleText?: string;
special?: EpisodeSpecialKind;
/** 判定依据(规则名式短串),不含概率 */
evidence: string[];
+244
View File
@@ -0,0 +1,244 @@
import { calculateEpisodeOffset, parseGlobalWords, pyRegex, pySubn } from "@umt/contracts";
import { cnToInt } from "../release-parser/cn-number.ts";
// 全局识别词套用(移植 MP app/domain/meta/words.py WordsMatcher,docs/design/identify-words.md):
// 按行序处理标题与副标题:屏蔽词、`A => B` 正则替换、`前 <> 后 >> EP±N` 集数偏移(标题没有就偏移副标题里的集数)、
// `A => B && 前 <> 后 >> 偏移`(替换成功才偏移)。某行出错只跳过这一行。正则按 Python 写法转成 JS。
export interface CompiledGlobalWord {
/** 原文(命中记录用) */
text: string;
kind: "block" | "replace" | "offset" | "replace_offset";
pattern?: string;
replacement?: string;
front?: string;
back?: string;
offset?: string;
}
/**
* 编译全局识别词原文:留下注释与空行以外的全部行。编辑器里标错的行照样参与(MP 也是套用时才失败):
* 「替换 + 偏移」的偏移写错时替换照做,正则写错的行套用时跳过。
*/
export function compileGlobalWords(text: string): CompiledGlobalWord[] {
return parseGlobalWords(text)
.filter((w) => w.kind !== "blank" && w.kind !== "comment" && (w.pattern !== undefined || w.front !== undefined))
.map((w) => ({
text: w.text,
kind: w.kind as CompiledGlobalWord["kind"],
...(w.pattern !== undefined ? { pattern: w.pattern } : {}),
...(w.replacement !== undefined ? { replacement: w.replacement } : {}),
...(w.front !== undefined ? { front: w.front } : {}),
...(w.back !== undefined ? { back: w.back } : {}),
...(w.offset !== undefined ? { offset: w.offset } : {}),
}));
}
export interface WordsApplied {
title: string;
subtitle: string | null;
/** 命中的识别词原文,按行序 */
applied: string[];
}
const CACHE = new Map<string, RegExp>();
/** 编译缓存(MP 同样 lru_cache 编译结果) */
function re(pattern: string, flags = ""): RegExp {
const key = `${flags}\u0000${pattern}`;
let r = CACHE.get(key);
if (!r) {
if (CACHE.size > 2048) CACHE.clear();
r = pyRegex(pattern, flags);
CACHE.set(key, r);
}
r.lastIndex = 0;
return r;
}
export function applyGlobalWords(
title: string,
subtitle: string | null | undefined,
words: readonly CompiledGlobalWord[],
): WordsApplied {
let t = title;
let sub = subtitle ?? null;
const applied: string[] = [];
for (const w of words) {
try {
let state = false;
if (w.kind === "replace_offset") {
const [next, ok] = replaceRegex(t, w.pattern!, w.replacement ?? "");
t = next;
if (ok) [t, sub, state] = offsetWithSubtitle(t, sub, w.front ?? "", w.back ?? "", w.offset ?? "");
} else if (w.kind === "replace") {
[t, state] = replaceRegex(t, w.pattern!, w.replacement ?? "");
} else if (w.kind === "offset") {
[t, sub, state] = offsetWithSubtitle(t, sub, w.front ?? "", w.back ?? "", w.offset ?? "");
} else {
[t, state] = replaceRegex(t, w.pattern!, "");
}
if (state) applied.push(w.text);
} catch {
// 与 MP 一致:这一行出错只记日志跳过,标题保持当时的样子
}
}
return { title: t, subtitle: sub, applied };
}
/** 这一行对标题 / 副标题有没有作用(重算范围判断用) */
export function wordTouches(word: CompiledGlobalWord, title: string, subtitle: string | null | undefined): boolean {
return applyGlobalWords(title, subtitle, [word]).applied.length > 0;
}
function replaceRegex(title: string, pattern: string, replacement: string): [string, boolean] {
try {
const [out, n] = pySubn(re(pattern, "g"), replacement, title);
return [out, n > 0];
} catch {
return [title, false];
}
}
const NUM = "[0-9一二三四五六七八九十]+";
/** cn2an(…, "smart"):阿拉伯数字、带十百的中文数字、逐位的中文数字(一二 → 12) */
export function smartNumber(s: string): number {
if (/^\d+$/.test(s)) return Number(s);
const digits = "零一二三四五六七八九";
if (/^[零一二三四五六七八九]+$/.test(s) && s.length > 1) return Number([...s].map((c) => digits.indexOf(c)).join(""));
const n = cnToInt(s);
if (n === undefined) throw new Error(`无法识别的数字 ${s}`);
return n;
}
const CN_DIGITS = "零一二三四五六七八九";
/** an2cn(n, "low"):小写中文数字(负数前加「负」) */
export function toChineseNumber(n: number): string {
if (n < 0) return `负${toChineseNumber(-n)}`;
if (n < 10) return CN_DIGITS[n]!;
if (n < 20) return `十${n % 10 ? CN_DIGITS[n % 10] : ""}`;
const units = ["", "十", "百", "千", "万"];
const ds = String(n).split("").map(Number);
let out = "";
let zero = false;
ds.forEach((d, i) => {
const u = units[ds.length - 1 - i] ?? "";
if (d === 0) {
zero = true;
return;
}
if (zero && out) out += "零";
zero = false;
out += CN_DIGITS[d] + u;
});
return out;
}
/** 按原集数字符串的格式写偏移后的集数:前导零保留宽度,中文数字仍写中文 */
function formatOffset(original: string, value: number): string {
if (!/^\d+$/.test(original)) return toChineseNumber(value);
const width = /^0/.test(original) ? original.length : 0;
if (value < 0) return `-${String(Math.abs(value)).padStart(width, "0")}`;
return String(value).padStart(width, "0");
}
function episodeOffset(title: string, front: string, back: string, offset: string): [string, boolean] {
try {
if (back && !re(back).test(title)) return [title, false];
if (front && !re(front).test(title)) return [title, false];
const found = title.match(re(`(?<=${front}.*?)${NUM}(?=.*?${back})`, "g"));
if (!found?.length) return [title, false];
const pairs = new Map<string, string>();
let backward = false;
for (const s of found) {
const n = smartNumber(s);
const v = calculateEpisodeOffset(offset, n);
if (n > v) backward = true;
else if (n < v) backward = false;
pairs.set(s, formatOffset(s, v));
}
// 向前偏移按新值升序、向后偏移按降序逐个替换,避免替换出来的数字再被后面的替换命中
const list = [...pairs.entries()].sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
if (!backward) list.reverse();
let out = title;
for (const [from, to] of list) {
out = out.replace(re(`(?<=${front}.*?)${from}(?=.*?${back})`, "g"), () => to);
}
return [out, true];
} catch {
return [title, false];
}
}
function locatorsMatch(text: string, front: string, back: string): boolean {
try {
if (front && !re(front).test(text)) return false;
if (back && !re(back).test(text)) return false;
return true;
} catch {
return false;
}
}
const SUB_RANGE =
/(?<!\d)\[?\s*(?<begin>\d{1,4})\s*-\s*(?<end>\d{1,4})\s*(?:(?:Fin|End)(?![a-z0-9])|完结(?![一-鿿]))(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)/dgi;
const SUB_EPISODE = /(?<![全共])(?<episode>[0-9一二三四五六七八九十百零]+)\s*[集话話期幕](?!\s*[全共])/dgi;
const SUB_EPISODE_TITLE = /(?<![A-Za-z0-9_])Episode\s+(?<episode>\d{1,4})(?![A-Za-z0-9_])/dgi;
const SUB_EPISODE_TOKEN = /(?<![A-Za-z0-9_])(?:EP|E)(?<episode>\d{1,4})(?![A-Za-z0-9_])/dgi;
/** 副标题里会被识别成集数的数字位置(MP __subtitle_episode_spans) */
function subtitleEpisodeSpans(subtitle: string): [number, number][] {
const spans: [number, number][] = [];
const patterns: [RegExp, string[]][] = [
[SUB_RANGE, ["begin", "end"]],
[SUB_EPISODE, ["episode"]],
[SUB_EPISODE_TITLE, ["episode"]],
[SUB_EPISODE_TOKEN, ["episode"]],
];
for (const [pattern, names] of patterns) {
pattern.lastIndex = 0;
for (const m of subtitle.matchAll(pattern)) {
for (const name of names) {
const span = m.indices?.groups?.[name];
if (!span) continue;
const [start, end] = span;
if (!spans.some(([s, e]) => start < e && end > s)) spans.push([start, end]);
}
}
}
return spans.sort((a, b) => a[0] - b[0]);
}
function offsetSubtitle(subtitle: string, offset: string): [string, boolean] {
const spans = subtitleEpisodeSpans(subtitle);
if (!spans.length) return [subtitle, false];
try {
const repl = spans.map(([s, e]) => {
const str = subtitle.slice(s, e);
return [s, e, formatOffset(str, calculateEpisodeOffset(offset, smartNumber(str)))] as const;
});
let out = subtitle;
for (const [s, e, r] of [...repl].reverse()) out = `${out.slice(0, s)}${r}${out.slice(e)}`;
return [out, true];
} catch {
return [subtitle, false];
}
}
/** 标题里能偏移就偏移标题;否则偏移副标题(定位词在副标题里,或标题 + 副标题合起来能定位时只改副标题里的集数) */
function offsetWithSubtitle(
title: string,
subtitle: string | null,
front: string,
back: string,
offset: string,
): [string, string | null, boolean] {
const [t, ok] = episodeOffset(title, front, back, offset);
if (ok) return [t, subtitle, true];
if (!subtitle) return [t, subtitle, false];
const [s, ok2] = episodeOffset(subtitle, front, back, offset);
if (ok2) return [t, s, true];
if (!locatorsMatch(`${t} ${subtitle}`, front, back)) return [t, subtitle, false];
const [s3, ok3] = offsetSubtitle(subtitle, offset);
return [t, ok3 ? s3 : subtitle, ok3];
}
@@ -0,0 +1,9 @@
export {
applyGlobalWords,
type CompiledGlobalWord,
compileGlobalWords,
smartNumber,
toChineseNumber,
type WordsApplied,
wordTouches,
} from "./apply.ts";
+1
View File
@@ -5,6 +5,7 @@ export * from "./acquisition/index.ts";
export * from "./classification.ts";
export * from "./download-state/index.ts";
export * from "./episode-resolution/index.ts";
export * from "./identify-words/index.ts";
export * from "./identity/index.ts";
export * from "./library/versions.ts";
export * from "./naming/index.ts";
@@ -1,3 +1,4 @@
import { applyGlobalWords } from "../identify-words/apply.ts";
import { CN_NUM, cnToInt } from "./cn-number.ts";
import { evalEpisodeOffset } from "./offset.ts";
import { PLATFORMS } from "./platforms.ts";
@@ -858,6 +859,19 @@ const PINYIN_SYLLABLE =
* 与 MoviePilot MetaInfo 的行为对照及偏离点见 docs/porting/moviepilot-metadata-recognition.md。
*/
export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRelease {
if (opts.words?.length) {
// 全局识别词先改标题与副标题(MP MetaInfo 同样先过 WordsMatcher),命中的行记在结果上
const { words, ...rest } = opts;
const w = applyGlobalWords(input, opts.subtitle ?? null, words);
const { subtitle: _s, ...base } = rest;
// 「替换 + 偏移」替换成功、偏移失败时标题已经改了但这一行不算命中(与 MP 一致),所以总按改后的标题解析
const r = parseRelease(w.title, w.subtitle ? { ...base, subtitle: w.subtitle } : base);
if (w.applied.length) {
r.words = { applied: w.applied, original: input };
r.evidence.push("words:applied");
}
return r;
}
const d = emptyDraft(input);
const ev = d.evidence;
let s = input.trim();
@@ -68,6 +68,10 @@ export function mergeParsed(
variantTags: [...primary.variantTags],
evidence: [...primary.evidence],
};
if (fallback.words) {
const applied = [...new Set([...(primary.words?.applied ?? []), ...fallback.words.applied])];
r.words = { applied, original: primary.words?.original ?? primary.raw };
}
const used: string[] = [];
// 目录只有日期(`…Collection.20240501.FRDS` 这类收藏/打包日期)不算剧集信号,不参与类型合并
const dateOnly =
@@ -1,3 +1,5 @@
import type { CompiledGlobalWord } from "../identify-words/apply.ts";
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
export const PARSER_VERSION = 2;
@@ -128,6 +130,8 @@ export interface ParsedRelease {
confidence: Confidence;
/** 命中的规则名 */
evidence: string[];
/** 命中的全局识别词(原文)与套用前的标题;没命中时没有这个字段 */
words?: { applied: string[]; original: string };
}
export interface ParseOptions {
@@ -139,4 +143,6 @@ export interface ParseOptions {
episodeOffset?: string;
/** 强制视为文件(无扩展名时) */
isFile?: boolean;
/** 全局识别词:解析前先套到标题与副标题上(docs/design/identify-words.md) */
words?: readonly CompiledGlobalWord[];
}
+23 -10
View File
@@ -254,7 +254,7 @@ describe("resolveEpisodes 规则", () => {
expect(map(ep(27, 28)).canonical).toEqual({ season: 2, episodes: ep(2) });
expect(map(ep(29)).canonical).toEqual({ season: 2, episodes: ep(3) });
expect(map(ep(26, 29)).canonical).toEqual({ season: 2, episodes: ep(1, 3) });
expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleFailed: "t1" });
expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleId: "t1" });
});
it("special 规则 → 返回特别篇标记,不校验", () => {
@@ -264,20 +264,33 @@ describe("resolveEpisodes 规则", () => {
expect(r).toMatchObject({ method: "rule", ruleId: "sp", special: "extra", canonical: null });
});
it("规则结果越界 → unresolved + ruleFailed,不回退到确定性换算", () => {
it("规则结果越界 → 待确认,记命中的规则行;不回退到确定性换算,也没有停用一说", () => {
// 原号 S02E13 本可按季内绝对集号换算成 S02E01,但有规则时只认规则
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), {
rules: [rule({ id: "bad", expression: "EP+20" })],
});
expect(r.method).toBe("unresolved");
expect(r.ruleFailed).toBe("bad");
expect(r.ruleId).toBeUndefined();
const bad = rule({ id: "bad", expression: "EP+20", line: 3, text: "S02 >> EP+20" });
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), { rules: [bad] });
expect(r).toMatchObject({ method: "unresolved", ruleId: "bad", ruleLine: 3, ruleText: "S02 >> EP+20" });
expect(r.evidence).toContain("rule:validate_fail");
expect(r.canonical).toBeNull();
expect("ruleFailed" in r).toBe(false);
// 同一条规则对另一条资源照样生效(不会因为上一条对不上就被停用)
const ok = run({ season: 2, episodes: ep(1) }, table([12, 30]), { rules: [bad] });
expect(ok).toMatchObject({ method: "rule", canonical: { season: 2, episodes: ep(21) } });
});
it("表达式非法 → ruleFailed", () => {
it("表达式非法 → 待确认,记规则", () => {
const r = run({ season: 1, episodes: ep(1) }, table([12]), { rules: [rule({ id: "x", expression: "EP-$" })] });
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
expect(r).toMatchObject({ method: "unresolved", ruleId: "x" });
});
it("发布组繁简视为同一个:綠茶字幕組 的规则命中 绿茶字幕组 的资源", () => {
const r = run(
{ season: 3, episodes: ep(2), releaseGroup: { name: "绿茶字幕组", source: "bracket" } },
table([24, 24, 24]),
{
rules: [rule({ id: "g", season: 3, releaseGroup: "綠茶字幕組", expression: "EP", targetSeason: 1 })],
},
);
expect(r).toMatchObject({ method: "rule", canonical: { season: 1, episodes: ep(2) } });
});
});
+175
View File
@@ -0,0 +1,175 @@
import { calculateEpisodeOffset } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
applyGlobalWords,
compileGlobalWords,
parseRelease,
parseReleasePath,
smartNumber,
toChineseNumber,
} from "../src/index.ts";
// 全局识别词套用(移植 MP WordsMatcher)。MP 用例出处:tests/test_metainfo.py、tests/test_rust_accel.py。
const words = (...lines: string[]) => compileGlobalWords(lines.join("\n"));
const parse = (title: string, w: string[], subtitle?: string) =>
parseRelease(title, { words: words(...w), ...(subtitle ? { subtitle } : {}) });
describe("MP 识别词用例", () => {
it("复杂识别词先替换后偏移(test_custom_words_replace_then_episode_offset)", () => {
const w = "旧名 => 新名 && 第 <> 集 >> EP+1";
const r = parse("旧名 第03集", [w]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 4, end: 4 });
expect(r.words?.applied).toEqual([w]);
expect(r.words?.original).toBe("旧名 第03集");
});
it("标题没有集数时只偏移副标题里的集数,季数不动(test_custom_words_episode_offset_applies_to_subtitle)", () => {
const w = "BLEACH Thousand-Year Blood War S04 => BLEACH 2004 S02 && S02 <> 1080p >> EP+40";
const sub = "死神 千年血战篇 -祸进谭- 第四季 第8集 ...";
const out = applyGlobalWords(
"BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB",
sub,
words(w),
);
expect(out.subtitle).toBe("死神 千年血战篇 -祸进谭- 第四季 第48集 ...");
expect(out.applied).toEqual([w]);
const r = parse("BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB", [w], sub);
expect(r.season).toBe(2);
expect(r.episodes).toEqual({ start: 48, end: 48 });
});
it("替换里带 {[tmdbid]} 并跨季偏移(test_get_torrent_episodes_applies_custom_words)", () => {
const w = "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 && S01 <> 2160p >> EP+165";
const r = parse("A.Will.Eternal.S04E05.2026.2160p.WEB-DL.mkv", [w]);
expect(r.episodes).toEqual({ start: 170, end: 170 });
expect(r.season).toBe(1);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "107371" });
});
it("偏移表达式支持乘法与分组引用 \\1(test_custom_words_episode_offset_supports_multiplication_expression)", () => {
const w =
"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\\1.Part1 && S06 <> .Part1 >> 2*EP-1";
const r = parse("Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1", [w]);
expect(r.title).toBe("哈哈哈哈哈");
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "112732" });
expect(r.season).toBe(6);
expect(r.episodes).toEqual({ start: 5, end: 5 });
expect(r.words?.applied).toEqual([w]);
});
it("EP 可以出现多次(test_custom_words_episode_offset_supports_repeated_ep_expression)", () => {
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 5, end: 5 });
});
it("2EP 不当成乘法:替换照做、偏移不做、这一行不算命中(test_custom_words_episode_offset_rejects_implicit_ep_expression)", () => {
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> 2EP"]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 3, end: 3 });
expect(r.words).toBeUndefined();
});
it("{[…;g=…;s=1]} 指定作品与季(test_custom_words_support_episode_group_parameter)", () => {
const w = "Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}";
const r = parse("Bakemonogatari 01", [w]);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "46195" });
expect(r.season).toBe(1);
expect(r.mediaTypeHint).toBe("tv");
expect(r.words?.applied).toEqual([w]);
});
it("s=0 指定为特别季(test_custom_words_support_special_season_zero_parameter)", () => {
const r = parse("Test Show 01", ["Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}"]);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "12345" });
expect(r.season).toBe(0);
});
it("替换为空串删掉词(test_metainfo_preserves_original_name_when_custom_words_applied)", () => {
const r = parse("电影测试替换名称 (2024)", ["测试替换 => "]);
expect(r.title).toBe("电影名称");
expect(r.year).toBe(2024);
});
it("记录命中的识别词(test_custom_words_apply_words_recording)", () => {
const r = parse("电影替换词.2024.mkv", ["替换词 => 新词"]);
expect(r.words?.applied).toEqual(["替换词 => 新词"]);
});
it("路径识别同样套用识别词(test_metainfopath_with_custom_words)", () => {
const r = parseReleasePath("/movies/电影测试替换名称 (2024)/电影测试替换名称.mkv", {
words: words("测试替换 => "),
});
expect(r.title).toBe("电影名称");
expect(r.year).toBe(2024);
expect(r.words?.applied).toEqual(["测试替换 => "]);
});
});
describe("识别词行为细节", () => {
it("屏蔽词:整行当正则删掉", () => {
expect(applyGlobalWords("Show.S01E01.国语中字.1080p", null, words("国语中字")).title).toBe("Show.S01E01..1080p");
});
it("注释、空行跳过;正则写错的行跳过,不影响后面的行", () => {
const out = applyGlobalWords("A B C", null, words("# A => X", "", "([ => Y", "B => Z"));
expect(out.title).toBe("A Z C");
expect(out.applied).toEqual(["B => Z"]);
});
it("按行序处理:前一行的结果交给后一行", () => {
expect(applyGlobalWords("abc", null, words("a => b", "bb => X")).title).toBe("Xc");
});
it("Python 写法:命名分组 (?P<n>…)、\\g<n>、(?i)", () => {
const out = applyGlobalWords("Foo.S01E02", null, words("(?i)foo\\.S(?P<s>\\d+) => Bar.S\\g<s>"));
expect(out.title).toBe("Bar.S01E02");
});
it("只偏移前后定位词之间的数字;前导零保留宽度;向前偏移", () => {
expect(applyGlobalWords("[Grp] Show - 25 [1080p]", null, words("Show - <> \\[1080p >> EP-12")).title).toBe(
"[Grp] Show - 13 [1080p]",
);
expect(applyGlobalWords("Show E05 end", null, words("Show E <> end >> EP+10")).title).toBe("Show E15 end");
});
it("中文集数偏移后仍写中文", () => {
expect(applyGlobalWords("第十二集", null, words("第 <> 集 >> EP+1")).title).toBe("第十三集");
});
it("定位词不在标题里就不动", () => {
const out = applyGlobalWords("Other 05", null, words("Show <> end >> EP+1"));
expect(out).toEqual({ title: "Other 05", subtitle: null, applied: [] });
});
it("中文数字互转", () => {
expect(smartNumber("十二")).toBe(12);
expect(smartNumber("一二")).toBe(12);
expect(smartNumber("08")).toBe(8);
expect(toChineseNumber(13)).toBe("十三");
expect(toChineseNumber(105)).toBe("一百零五");
expect(toChineseNumber(-3)).toBe("负三");
});
});
describe("集号算术(MP calculate_episode_offset)", () => {
it("白名单运算", () => {
expect(calculateEpisodeOffset("EP+1", 3)).toBe(4);
expect(calculateEpisodeOffset("2*EP-1", 3)).toBe(5);
expect(calculateEpisodeOffset("EP+EP-1", 3)).toBe(5);
expect(calculateEpisodeOffset("(EP+1)//2", 5)).toBe(3);
expect(calculateEpisodeOffset("EP/2", 5)).toBe(2);
expect(calculateEpisodeOffset("-EP+20", 5)).toBe(15);
expect(calculateEpisodeOffset("EP%4", 10)).toBe(2);
expect(calculateEpisodeOffset("-7%3", 0)).toBe(2);
expect(calculateEpisodeOffset("5", 3)).toBe(5);
});
it("拒绝省略运算符、乘方、名字与非法字符", () => {
for (const bad of ["2EP", "EP(1)", "EP**2", "EPX+1", "ep+1", "EP+1;", "EP/0", "EP+"]) {
expect(() => calculateEpisodeOffset(bad, 1), bad).toThrow();
}
});
});