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 -6
View File
@@ -57,7 +57,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
Decision: { row: C.Decision },
DownloadTask: { row: C.DownloadTask },
DownloaderInstance: { row: C.DownloaderInstance },
EpisodeMappingRule: { row: C.EpisodeMappingRuleRow },
IdentifyWords: { row: C.IdentifyWordsRow },
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
IdentityRevision: { row: C.IdentityRevision },
@@ -200,11 +200,7 @@ const ENUMS: Record<string, z.ZodEnum> = {
DownloadTaskPauseReason: C.PauseReason,
DownloaderInstanceKind: C.DownloaderKind,
DownloaderInstanceHealthStatus: C.InstanceHealth,
EpisodeMappingRuleKind: C.EpisodeMappingRuleKind,
EpisodeMappingRuleSpecial: C.EpisodeSpecialKind,
EpisodeMappingRuleStatus: C.EpisodeMappingRuleStatus,
EpisodeMappingRuleEvidenceLevel: C.EvidenceLevel,
EpisodeMappingRuleCreatedByType: C.ActorType,
IdentifyWordsUpdatedByType: C.ActorType,
ExternalIdentityEntityType: C.ExternalEntityType,
ExternalIdentityStatus: C.IdentityStatus,
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
+15 -38
View File
@@ -3,60 +3,37 @@ import {
Api,
EpisodeMappingPreview,
EpisodeMappingPreviewInput,
EpisodeMappingRuleCreate,
EpisodeMappingRuleUpdate,
EpisodeResolutionView,
listEndpoints,
} from "../src/index.ts";
// 集数映射接口(批次 4)的契约:端点登记、写入与预览的入参、解析结果结构。
// 集数映射与识别词接口的契约:端点登记、预览入参、解析结果结构。
describe("集数映射契约", () => {
it("端点登记在 Api 注册表:读用 read,写用 organize.execute", () => {
it("端点登记在 Api 注册表:读和试算用 read,作品识别词写 organize.execute,全局写 admin", () => {
const keys = listEndpoints()
.filter((e) => e.path.includes("episode-mapping"))
.filter((e) => e.path.includes("episode-mapping") || e.path.includes("identify-words"))
.map((e) => `${e.method.toUpperCase()} ${e.path} ${e.scope}`)
.sort();
expect(keys).toEqual([
"DELETE /episode-mapping-rules/{id} organize.execute",
"GET /media-entities/{id}/episode-mapping-rules read",
"PATCH /episode-mapping-rules/{id} organize.execute",
"POST /media-entities/{id}/episode-mapping-rules organize.execute",
"GET /identify-words/entities read",
"GET /identify-words/global read",
"GET /media-entities/{id}/identify-words read",
"POST /identify-words/global/dry-run read",
"POST /identify-words/try read",
"POST /media-entities/{id}/episode-mapping/preview read",
"POST /media-entities/{id}/identify-words/dry-run read",
"PUT /identify-words/global admin",
"PUT /media-entities/{id}/identify-words organize.execute",
]);
expect(Api.episodeMapping.preview.response).toBe(EpisodeMappingPreview);
});
it("新建:三种规则都接受;季号不能为负,发布组去首尾空白", () => {
expect(EpisodeMappingRuleCreate.parse({ kind: "expression", expression: "EP-12", targetSeason: 2 })).toEqual({
kind: "expression",
expression: "EP-12",
targetSeason: 2,
});
expect(
EpisodeMappingRuleCreate.safeParse({
season: 1,
releaseGroup: "ANi",
kind: "table",
tableEntries: [{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } }],
}).success,
).toBe(true);
expect(
EpisodeMappingRuleCreate.parse({ kind: "special", special: "ignore", releaseGroup: " ANi " }).releaseGroup,
).toBe("ANi");
expect(EpisodeMappingRuleCreate.safeParse({ kind: "special", special: "skip" }).success).toBe(false);
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", season: -1 }).success).toBe(false);
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", expression: "E".repeat(65) }).success).toBe(false);
});
it("修改:全部可选,可只改状态", () => {
expect(EpisodeMappingRuleUpdate.parse({ status: "disabled" })).toEqual({ status: "disabled" });
expect(EpisodeMappingRuleUpdate.safeParse({ status: "deleted" }).success).toBe(false);
});
it("预览入参:草稿可空,条数上限 300", () => {
it("预览入参:草稿是整块识别词原文,条数上限 300", () => {
expect(EpisodeMappingPreviewInput.parse({})).toEqual({});
expect(EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", draft: null }).success).toBe(true);
expect(
EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", words: "S02 >> S01 EP+24" }).success,
).toBe(true);
expect(EpisodeMappingPreviewInput.safeParse({ limit: 301 }).success).toBe(false);
});
@@ -0,0 +1,277 @@
import { describe, expect, it } from "vitest";
import {
compileEntityWords,
evalEpisodeExpr,
parseEntityWordLine,
parseGlobalWordLine,
parseGlobalWords,
parseIdentityTag,
printEntityWord,
printGlobalWord,
printIdentityTag,
pyRegex,
pySubn,
rowsToText,
ruleRowLines,
updateRuleRow,
type WordsRow,
wordsToRows,
} from "../src/index.ts";
// 识别词语法(docs/design/identify-words.md):全局行按 MP __parse_word 拆;作品识别词的解析 / 打印往返、
// 编译(范围、优先级、冲突)、可视化行模型与文本往返(注释、停用、顺序、写错的行都不丢)。
describe("全局识别词(MP 语法)", () => {
it("四种写法与注释、空行", () => {
expect(parseGlobalWordLine("国语中字")).toMatchObject({ kind: "block", pattern: "国语中字" });
expect(parseGlobalWordLine("A => B")).toMatchObject({ kind: "replace", pattern: "A", replacement: "B" });
expect(parseGlobalWordLine("测试替换 => ")).toMatchObject({
kind: "replace",
pattern: "测试替换",
replacement: "",
});
expect(parseGlobalWordLine("第 <> 集 >> EP+1")).toMatchObject({
kind: "offset",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("旧名 => 新名 && 第 <> 集 >> EP+1")).toMatchObject({
kind: "replace_offset",
pattern: "旧名",
replacement: "新名",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("# 动漫").kind).toBe("comment");
expect(parseGlobalWordLine(" ").kind).toBe("blank");
});
it("打印与解析往返", () => {
for (const t of ["国语中字", "A => B", "第 <> 集 >> EP+1", "旧名 => 新名 && 第 <> 集 >> 2*EP-1"]) {
const p = parseGlobalWordLine(t);
expect(printGlobalWord(p)).toBe(t);
}
});
it("提前报错:正则写错、替换引用了不存在的分组、偏移表达式写错、两个定位词都空", () => {
expect(parseGlobalWordLine("([ => X").error).toContain("正则");
expect(parseGlobalWordLine("(a) => \\2").error).toContain("分组");
expect(parseGlobalWordLine("第 <> 集 >> 2EP").error).toContain("运算符");
expect(parseGlobalWordLine(" <> >> EP+1").error).toBeDefined();
expect(parseGlobalWords("A => B\n\n# x\nC").map((w) => [w.line, w.kind])).toEqual([
[1, "replace"],
[2, "blank"],
[3, "comment"],
[4, "block"],
]);
});
it("Python 正则与替换模板", () => {
expect(pySubn(pyRegex("(?P<n>\\d+)集"), "第\\g<n>话", "12集")).toEqual(["第12话", 1]);
expect(pySubn(pyRegex("(a)(b)"), "\\2\\1", "abab")).toEqual(["baba", 2]);
expect(pySubn(pyRegex("(?i)foo"), "bar", "FOO foo")).toEqual(["bar bar", 2]);
expect(pySubn(pyRegex("x"), "$1\\\\", "x")).toEqual(["$1\\", 1]);
expect(pySubn(pyRegex("\\p{Han}+"), "中", "ab汉字cd")).toEqual(["ab中cd", 1]);
});
it("指定作品标签", () => {
const t = parseIdentityTag("一念永恒{[tmdbid=107371;type=tv;s=1;g=abc]}S01");
expect(t).toMatchObject({
before: "一念永恒",
after: "S01",
tag: { tmdbId: "107371", type: "tv", season: "1", extra: ["g=abc"] },
});
expect(printIdentityTag(t!.tag)).toBe("{[tmdbid=107371;type=tv;s=1;g=abc]}");
expect(parseIdentityTag("没有标签")).toBeNull();
});
});
describe("作品识别词", () => {
it("季集换算:目标季可省、EP 单独写 = 集号不变、发布组可多个、任意季写 *", () => {
expect(parseEntityWordLine("S02 >> S01 EP+24")).toMatchObject({
kind: "map",
season: 2,
groups: [],
targetSeason: 1,
expr: "EP+24",
});
expect(parseEntityWordLine("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
kind: "map",
season: 3,
groups: ["ADWeb", "MWeb"],
targetSeason: 1,
expr: "EP",
});
expect(parseEntityWordLine("S01 @Lilith-Raws >> ep - 12")).toMatchObject({ targetSeason: null, expr: "EP-12" });
expect(parseEntityWordLine("S02 >> S01")).toMatchObject({ targetSeason: 1, expr: "EP" });
expect(parseEntityWordLine("* @Judas >> EP*2-1")).toMatchObject({ season: null, groups: ["Judas"] });
expect(parseEntityWordLine('S01 @"Some Group" >> EP')).toMatchObject({ groups: ["Some Group"] });
});
it("单集对照与特别篇", () => {
expect(parseEntityWordLine("S01E13 >> S00E01")).toMatchObject({
kind: "table",
season: 1,
from: { start: 13, end: 13 },
to: { season: 0, episode: 1 },
});
expect(parseEntityWordLine("S01E27-E28 @Baha >> S02E02")).toMatchObject({
kind: "table",
groups: ["Baha"],
from: { start: 27, end: 28 },
});
expect(parseEntityWordLine("E05 >> S00E02")).toMatchObject({ kind: "table", season: null });
expect(parseEntityWordLine("S00 @SweetSub >> 特别篇")).toMatchObject({ kind: "special", special: "special" });
expect(parseEntityWordLine("S01 >> 花絮")).toMatchObject({ kind: "special", special: "extra" });
expect(parseEntityWordLine("S01 >> 忽略")).toMatchObject({ kind: "special", special: "ignore" });
});
it("写错的行给出原因", () => {
expect(parseEntityWordLine("S02 S01 EP+1").error).toContain(">>");
expect(parseEntityWordLine("Season2 >> EP").error).toContain("不是季号");
expect(parseEntityWordLine("S02 ADWeb >> EP").error).toContain("@");
expect(parseEntityWordLine("S02 >> S01 2EP").error).toContain("运算符");
expect(parseEntityWordLine("S02 >> S01 12").error).toContain("EP");
expect(parseEntityWordLine("S01E13 >> 第一集").error).toContain("目标季集");
expect(parseEntityWordLine("S01E13-E10 >> S00E01").error).toContain("写反");
});
it("打印与解析往返(规范写法)", () => {
for (const t of [
"S02 >> S01 EP+24",
"S03 @ADWeb @MWeb @黒ネズミたち >> S01 EP",
"* @Judas >> EP*2-1",
"S01E13 >> S00E01",
"S01E27-E28 @Baha >> S02E02",
"E05 >> S00E02",
"S01 @SweetSub >> 花絮",
'S01 @"Some Group" >> EP-1',
]) {
const p = parseEntityWordLine(t);
expect(p.error).toBeUndefined();
expect(printEntityWord({ ...p, kind: p.kind, season: p.season ?? null, groups: p.groups ?? [] })).toBe(t);
}
});
it("编译:一行多个发布组拆成多条;单集对照多行合成一张表;同范围冲突报错带行号", () => {
const c = compileEntityWords(
[
"# 药屋",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"# S03 @Judas >> S01 EP",
"S01E13 @Baha >> S00E01",
"S01E14 @Baha >> S00E02",
"S01E14 @Baha >> S00E03",
"S01 @Baha >> 忽略",
"s02 >> EP",
].join("\n"),
);
expect(c.rules.map((r) => [r.id, r.season, r.releaseGroup, r.kind, r.line])).toEqual([
["L0002", 2, null, "expression", 2],
["L0003", 3, null, "expression", 3],
["L0004", 3, "ADWeb", "expression", 4],
["L0004.1", 3, "MWeb", "expression", 4],
["L0006", 1, "Baha", "table", 6],
]);
expect(c.rules[4]!.table).toEqual([
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
{ from: { start: 14, end: 14 }, to: { season: 0, episode: 2 } },
]);
expect(c.errors).toEqual([
{ line: 8, message: expect.stringContaining("重叠") },
{ line: 9, message: expect.stringContaining("第 6 行已有规则") },
{ line: 10, message: expect.stringContaining("第 2 行已有规则") },
]);
});
it("集号算术与 MP 一致,EP 必须出现", () => {
expect(evalEpisodeExpr("EP+24", 1)).toBe(25);
expect(evalEpisodeExpr("ep*2-1", 3)).toBe(5);
expect(() => evalEpisodeExpr("5", 1)).toThrow();
});
});
describe("可视化行模型 ⇄ 文本", () => {
const ENTITY = [
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"# S03 @Judas >> S01 EP",
"S03 @ADWeb @MWeb >> S01 EP",
"这一行写错了",
"",
"",
"# 结尾注释",
].join("\n");
it("原文逐字往返(含结尾换行、连续空行、注释、写错的行)", () => {
for (const text of [ENTITY, `${ENTITY}\n`, "", "S02 >> S01 EP+24", "#x\n#y\n"]) {
const rows = wordsToRows(text, "entity");
expect(rowsToText(rows, text.endsWith("\n"))).toBe(text);
}
});
it("备注贴在规则上方;注释掉的规则是停用行;写错的行是原文行", () => {
const rows = wordsToRows(ENTITY, "entity");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? [r.enabled, r.note, r.rule] : r.lines])).toEqual([
["comment", ["# 药屋少女的呢喃"]],
["blank", [""]],
["rule", [true, ["第二季接着第一季编号"], "S02 >> S01 EP+24"]],
["rule", [true, [], "S03 >> S01 EP+48"]],
["rule", [false, [], "S03 @Judas >> S01 EP"]],
["rule", [true, [], "S03 @ADWeb @MWeb >> S01 EP"]],
["invalid", ["这一行写错了"]],
["blank", ["", ""]],
["comment", ["# 结尾注释"]],
]);
expect((rows[6] as Extract<WordsRow, { kind: "invalid" }>).error).toContain(">>");
});
it("停用 / 启用、改备注、调顺序、删除,其余行原样", () => {
const rows = wordsToRows(ENTITY, "entity");
const r2 = rows[3] as Extract<WordsRow, { kind: "rule" }>;
const r4 = rows[4] as Extract<WordsRow, { kind: "rule" }>;
const next = [...rows];
next[3] = updateRuleRow(r2, { enabled: false });
next[4] = updateRuleRow(r4, { enabled: true, note: ["Judas 接着第一季"] });
// 交换两行
[next[3], next[4]] = [next[4]!, next[3]!];
next.splice(6, 1);
expect(rowsToText(next)).toBe(
[
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"# Judas 接着第一季",
"S03 @Judas >> S01 EP",
"# S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"",
"",
"# 结尾注释",
].join("\n"),
);
expect(ruleRowLines(["备注"], false, "S01 >> EP")).toEqual(["# 备注", "# S01 >> EP"]);
});
it("全局:带运算符的注释是停用的识别词,其余注释是备注;MP 列表原样往返", () => {
const text = ["#动漫", "Fake => 真", "# 旧名 => 新名", "#屏蔽词也行", "国语中字", "([ => 写错"].join("\n");
const rows = wordsToRows(text, "global");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? r.enabled : null])).toEqual([
["rule", true],
["rule", false],
["rule", true],
["invalid", null],
]);
expect((rows[0] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["动漫"]);
expect((rows[2] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["屏蔽词也行"]);
expect(rowsToText(rows)).toBe(text);
});
});