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

@@ -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[];
}