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

+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();
}
});
});