Files
rootandClaude Opus 5.5 63e94b308e 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
2026-10-08 17:03:00 +08:00

176 lines
7.8 KiB
TypeScript

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