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

@@ -0,0 +1,793 @@
// 识别词语法(docs/design/identify-words.md):全局识别词照搬 MoviePilot 的自定义识别词,作品识别词是本项目的季集换算行。
// 这里只有纯函数(解析、打印、可视化行模型、安全算术),server、domain、web 共用;套用到标题的引擎在 domain。
// ── Python 正则 → JS 正则(MP 的识别词按 Python regex 写) ──
export class WordsSyntaxError extends Error {
override name = "WordsSyntaxError";
}
const SCRIPTS = new Set([
"han",
"hiragana",
"katakana",
"hangul",
"latin",
"cyrillic",
"greek",
"thai",
"arabic",
"hebrew",
]);
/**
* 把 Python 正则改写成 JS 能编译的写法:`(?P<n>…)` → `(?<n>…)`、`(?P=n)` → `\k<n>`、`\A` / `\Z` → `^` / `$`,
* 开头的内联标志 `(?i)(?s)(?m)` 变成 RegExp 标志。先按 Unicode 模式编译(`\p{Han}` 需要),不行再退回普通模式。
*/
export function pyRegex(pattern: string, flags = ""): RegExp {
let src = pattern;
let f = flags;
for (let m = /^\(\?([aiLmsux]+)\)/.exec(src); m; m = /^\(\?([aiLmsux]+)\)/.exec(src)) {
for (const c of m[1]!) {
if (c === "x") throw new WordsSyntaxError("不支持 (?x) 写法");
if ("ims".includes(c) && !f.includes(c)) f += c;
}
src = src.slice(m[0].length);
}
let out = "";
let inClass = false;
for (let i = 0; i < src.length; i++) {
const c = src[i]!;
if (c === "\\") {
const n = src[i + 1] ?? "";
// Python regex 的 \p{Han} 等脚本名,JS 要写成 \p{Script=Han}
const prop = /^[pP]\{([A-Za-z_]+)\}/.exec(src.slice(i + 1));
if (prop && SCRIPTS.has(prop[1]!.toLowerCase())) {
out += `\\${prop[0][0]}{Script=${prop[1]}}`;
i += prop[0].length;
continue;
}
if (!inClass && n === "A") out += "^";
else if (!inClass && n === "Z") out += "$";
else out += c + n;
i++;
continue;
}
if (inClass) {
if (c === "]") inClass = false;
out += c;
continue;
}
if (c === "[") {
inClass = true;
out += c;
// `[]…]` 与 `[^]…]` 里开头的 ] 是字面量
if (src[i + 1] === "^") out += src[++i];
if (src[i + 1] === "]") out += `\\${src[++i]}`;
continue;
}
if (src.startsWith("(?P<", i)) {
out += "(?<";
i += 3;
continue;
}
const back = /^\(\?P=(\w+)\)/.exec(src.slice(i));
if (back) {
out += `\\k<${back[1]}>`;
i += back[0].length - 1;
continue;
}
out += c;
}
try {
return new RegExp(out, `${f}u`);
} catch {
try {
return new RegExp(out, f);
} catch (e) {
throw new WordsSyntaxError(`正则写法不对:${(e as Error).message.replace(/^Invalid regular expression: /, "")}`);
}
}
}
/** 正则里的捕获组数 */
const groupCount = (re: RegExp) => (new RegExp(`${re.source}|`, re.flags.replace("g", "")).exec("")?.length ?? 1) - 1;
const PY_ESCAPES: Record<string, string> = { n: "\n", t: "\t", r: "\r", f: "\f", v: "\v", a: "\x07", b: "\b" };
type ReplacePart = string | { group: number | string };
/**
* Python `re.sub` 的替换模板:`\1`、`\g<1>`、`\g<name>` 引用分组,`\\` 是反斜杠,`\n` 等是控制字符;
* 其余反斜杠 + ASCII 字母报错(Python 同样报错),反斜杠 + 其他字符原样保留。返回按匹配拼替换串的函数。
*/
export function pyReplacement(
template: string,
re?: RegExp,
): (match: string, groups: (string | undefined)[], named?: Record<string, string | undefined>) => string {
const parts: ReplacePart[] = [];
let lit = "";
const max = re ? groupCount(re) : Number.POSITIVE_INFINITY;
const push = (g: number | string) => {
if (typeof g === "number" && g > max) throw new WordsSyntaxError(`替换词引用了不存在的分组 \\${g}`);
if (lit) parts.push(lit);
lit = "";
parts.push({ group: g });
};
for (let i = 0; i < template.length; i++) {
const c = template[i]!;
if (c !== "\\") {
lit += c;
continue;
}
const n = template[i + 1];
if (n === undefined) throw new WordsSyntaxError("替换词末尾多了一个反斜杠");
if (n === "g") {
const m = /^<(\w+)>/.exec(template.slice(i + 2));
if (!m) throw new WordsSyntaxError("替换词里的 \\g 要写成 \\g<1> 或 \\g<名字>");
push(/^\d+$/.test(m[1]!) ? Number(m[1]) : m[1]!);
i += 1 + m[0].length;
continue;
}
const d = /^[1-9]\d?/.exec(template.slice(i + 1));
if (d) {
push(Number(d[0]));
i += d[0].length;
continue;
}
if (n === "\\") lit += "\\";
else if (n in PY_ESCAPES) lit += PY_ESCAPES[n];
else if (/[A-Za-z]/.test(n)) throw new WordsSyntaxError(`替换词里的 \\${n} 不是合法的转义`);
else lit += `\\${n}`;
i++;
}
if (lit) parts.push(lit);
return (match, groups, named) =>
parts
.map((p) => {
if (typeof p === "string") return p;
if (p.group === 0) return match;
if (typeof p.group === "number") return groups[p.group - 1] ?? "";
return named?.[p.group] ?? "";
})
.join("");
}
/** Python `pattern.subn(template, text)`:全部替换,返回新串与次数 */
export function pySubn(re: RegExp, template: string, text: string): [string, number] {
const g = re.flags.includes("g") ? re : new RegExp(re.source, `${re.flags}g`);
const build = pyReplacement(template, g);
let count = 0;
const out = text.replace(g, (...args: unknown[]) => {
count++;
const hasNamed = typeof args[args.length - 1] === "object" && args[args.length - 1] !== null;
const named = hasNamed ? (args[args.length - 1] as Record<string, string | undefined>) : undefined;
const groups = args.slice(1, hasNamed ? -3 : -2) as (string | undefined)[];
return build(args[0] as string, groups, named);
});
return [out, count];
}
// ── 集号算术(MP calculate_episode_offset 的白名单求值) ──
const IMPLICIT_EP = /(?:\d|\))\s*EP|EP\s*(?:\d|\()/;
const EP_TOKEN = /(?<![A-Za-z0-9_])EP(?![A-Za-z0-9_])/g;
type Tok = { t: "num"; v: number } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" };
function lexArith(src: string): Tok[] {
const out: Tok[] = [];
for (let i = 0; i < src.length; ) {
const c = src[i]!;
if (c === " " || c === "\t") {
i++;
continue;
}
if (/\d/.test(c)) {
let j = i;
while (j < src.length && /\d/.test(src[j]!)) j++;
out.push({ t: "num", v: Number(src.slice(i, j)) });
i = j;
continue;
}
if (src.startsWith("//", i)) {
out.push({ t: "op", v: "//" });
i += 2;
continue;
}
if (src.startsWith("**", i)) throw new WordsSyntaxError("集号表达式不支持 **");
if ("+-*/%".includes(c)) out.push({ t: "op", v: c });
else if (c === "(") out.push({ t: "lp" });
else if (c === ")") out.push({ t: "rp" });
else throw new WordsSyntaxError(`集号表达式只能有数字、EP、括号和 + - * / // %,多了「${c}」`);
i++;
}
return out;
}
/** Python 的 % 与 //:结果符号跟除数 */
const pyMod = (a: number, b: number) => a - b * Math.floor(a / b);
function evalArith(src: string): number {
const toks = lexArith(src);
let pos = 0;
const peek = () => toks[pos];
const expr = (): number => {
let v = term();
for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) {
pos++;
const r = term();
v = p.v === "+" ? v + r : v - r;
}
return v;
};
const term = (): number => {
let v = unary();
for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) {
pos++;
const r = unary();
if (p.v !== "*" && r === 0) throw new WordsSyntaxError("集号表达式除以 0");
if (p.v === "*") v *= r;
else if (p.v === "/") v /= r;
else if (p.v === "//") v = Math.floor(v / r);
else v = pyMod(v, r);
}
return v;
};
const unary = (): number => {
const p = peek();
if (p?.t === "op" && (p.v === "-" || p.v === "+")) {
pos++;
const v = unary();
return p.v === "-" ? -v : v;
}
return atom();
};
const atom = (): number => {
const p = toks[pos++];
if (!p) throw new WordsSyntaxError("集号表达式不完整");
if (p.t === "num") return p.v;
if (p.t === "lp") {
const v = expr();
if (toks[pos++]?.t !== "rp") throw new WordsSyntaxError("集号表达式括号不配对");
return v;
}
throw new WordsSyntaxError("集号表达式写法不对");
};
const v = expr();
if (pos !== toks.length) throw new WordsSyntaxError("集号表达式写法不对");
if (!Number.isFinite(v)) throw new WordsSyntaxError("集号表达式算不出结果");
return Math.trunc(v);
}
/**
* MP calculate_episode_offset:`EP` 换成集号后按白名单算术求值(+ - * / // % 与括号,结果取整)。
* `2EP`、`EP(…)` 这类省略运算符的写法报错;不含 EP 的表达式直接算出常数(与 MP 一致)。
*/
export function calculateEpisodeOffset(offset: string, episode: number): number {
if (IMPLICIT_EP.test(offset)) throw new WordsSyntaxError("EP 前后要写运算符,如 2*EP");
let count = 0;
const expr = offset.replace(EP_TOKEN, () => {
count++;
return String(episode);
});
if (offset.includes("EP") && count === 0) throw new WordsSyntaxError("EP 占位符写法不对");
return evalArith(expr);
}
/**
* 作品识别词的集号换算:同一套算术,EP 不分大小写,必须出现 EP(`EP` 单独写 = 集号不变)。
*/
export function evalEpisodeExpr(expr: string, episode: number): number {
const e = expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
if (!e.includes("EP")) throw new WordsSyntaxError("集号换算里要有 EP,如 EP+12");
return calculateEpisodeOffset(e, episode);
}
/** 规范写法:EP 大写、运算符两边不留空格(`ep + 12` → `EP+12`) */
export const normalizeEpisodeExpr = (expr: string) =>
expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP").replace(/\s+/g, "");
// ── 全局识别词(MP 自定义识别词,逐行) ──
export type GlobalWordKind = "blank" | "comment" | "block" | "replace" | "offset" | "replace_offset";
export interface GlobalWordFields {
/** 屏蔽词 / 被替换词(正则) */
pattern?: string;
/** 替换词 */
replacement?: string;
/** 集数偏移的前定位词 / 后定位词(正则)与偏移表达式 */
front?: string;
back?: string;
offset?: string;
}
export interface GlobalWordLine extends GlobalWordFields {
/** 行号(从 1 起) */
line: number;
/** 原文 */
text: string;
kind: GlobalWordKind;
error?: string;
}
const COMBINED = /^\s*(.*?)\s*=>\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$/;
/** 按 MP __parse_word 拆一行(不校验正则);注释与空行另算 */
export function parseGlobalWordLine(text: string, line = 1): GlobalWordLine {
const base = { line, text };
if (!text.trim()) return { ...base, kind: "blank" };
if (text.trimStart().startsWith("#")) return { ...base, kind: "comment" };
let w: GlobalWordLine;
if (text.includes(" => ") && text.includes(" && ") && text.includes(" >> ") && text.includes(" <> ")) {
const m = COMBINED.exec(text);
if (!m)
return {
...base,
kind: "replace_offset",
error: "替换加偏移的写法是:被替换词 => 替换词 && 前定位词 <> 后定位词 >> 偏移",
};
w = {
...base,
kind: "replace_offset",
pattern: m[1]!,
replacement: m[2]!,
front: m[3]!,
back: m[4]!,
offset: m[5]!,
};
} else if (text.includes(" => ")) {
const parts = text.split(" => ");
w = { ...base, kind: "replace", pattern: parts[0]!, replacement: parts[1]! };
} else if (text.includes(" >> ") && text.includes(" <> ")) {
const parts = text.split(" <> ");
const rest = parts[1]!.split(" >> ");
w = { ...base, kind: "offset", front: parts[0]!, back: rest[0]!, offset: rest[1]! };
} else {
w = { ...base, kind: "block", pattern: text };
}
const error = validateGlobalWord(w);
return error ? { ...w, error } : w;
}
/** 校验正则、替换模板与偏移表达式能否用(MP 在套用时才发现,这里提前报) */
export function validateGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string | undefined {
try {
if (w.kind === "block" || w.kind === "replace" || w.kind === "replace_offset") {
if (!w.pattern) return "被替换词不能为空";
const re = pyRegex(w.pattern, "g");
if (w.kind !== "block") pyReplacement(w.replacement ?? "", re);
}
if (w.kind === "offset" || w.kind === "replace_offset") {
if (!w.front && !w.back) return "前定位词和后定位词至少写一个";
if (w.front) pyRegex(w.front);
if (w.back) pyRegex(w.back);
// copy-allow: 正则,不是文案
pyRegex(`(?<=${w.front ?? ""}.*?)[0-9一二三四五六七八九十]+(?=.*?${w.back ?? ""})`, "g");
if (!w.offset?.trim()) return "偏移不能为空,如 EP+12";
calculateEpisodeOffset(w.offset, 1);
}
} catch (e) {
return (e as Error).message;
}
return undefined;
}
export function printGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string {
switch (w.kind) {
case "block":
return w.pattern ?? "";
case "replace":
return `${w.pattern ?? ""} => ${w.replacement ?? ""}`;
case "offset":
return `${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`;
case "replace_offset":
return `${w.pattern ?? ""} => ${w.replacement ?? ""} && ${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`;
default:
return "";
}
}
export const splitLines = (text: string) => text.replace(/\r\n?/g, "\n").split("\n");
export function parseGlobalWords(text: string): GlobalWordLine[] {
return splitLines(text).map((t, i) => parseGlobalWordLine(t, i + 1));
}
/** 生效的识别词行(非空、非注释、没有错误),顺序同原文 */
export const activeGlobalWords = (text: string) =>
parseGlobalWords(text).filter((w) => w.kind !== "blank" && w.kind !== "comment" && !w.error);
// ── `{[tmdbid=…;type=…;s=…;e=…]}` 指定作品 ──
export interface IdentityTag {
tmdbId?: string;
doubanId?: string;
type?: "movie" | "tv";
season?: string;
episode?: string;
/** 其余键(g=剧集组等)原样保留 */
extra: string[];
}
const TAG_RE = /\{\[([^\]]*)\]\}/;
/** 替换词里的 `{[…]}`;没有返回 null */
export function parseIdentityTag(replacement: string): { tag: IdentityTag; before: string; after: string } | null {
const m = TAG_RE.exec(replacement);
if (!m) return null;
const tag: IdentityTag = { extra: [] };
for (const kv of m[1]!.split(";")) {
const [k, ...rest] = kv.split("=");
const key = k?.trim().toLowerCase() ?? "";
const v = rest.join("=").trim();
if (!key) continue;
if (key === "tmdbid") tag.tmdbId = v;
else if (key === "doubanid") tag.doubanId = v;
else if (key === "type") tag.type = v === "tv" ? "tv" : v === "movie" || v === "movies" ? "movie" : undefined;
else if (key === "s") tag.season = v;
else if (key === "e") tag.episode = v;
else tag.extra.push(kv.trim());
}
return { tag, before: replacement.slice(0, m.index), after: replacement.slice(m.index + m[0].length) };
}
export function printIdentityTag(t: IdentityTag): string {
const kv = [
t.tmdbId ? `tmdbid=${t.tmdbId}` : null,
t.doubanId ? `doubanid=${t.doubanId}` : null,
t.type ? `type=${t.type}` : null,
t.season ? `s=${t.season}` : null,
t.episode ? `e=${t.episode}` : null,
...t.extra,
].filter(Boolean);
return `{[${kv.join(";")}]}`;
}
// ── 作品识别词(季集换算,逐行) ──
export type EntityWordKind = "blank" | "comment" | "map" | "table" | "special";
export type EntityWordSpecial = "special" | "extra" | "ignore";
export const ENTITY_SPECIAL_WORDS: Record<EntityWordSpecial, string> = {
special: "特别篇",
extra: "花絮",
ignore: "忽略",
};
const SPECIAL_OF: Record<string, EntityWordSpecial> = {
特别篇: "special",
特別篇: "special",
special: "special",
花絮: "extra",
extra: "extra",
忽略: "ignore",
ignore: "ignore",
};
export interface EntityWordFields {
/** 来源季;null = 任意季(写 `*`) */
season: number | null;
/** 发布组;空 = 任意发布组 */
groups: string[];
/** map:目标季(null = 来源季)与集号换算 */
targetSeason?: number | null;
expr?: string;
/** table:来源集号区间 → 目标季集 */
from?: { start: number; end: number };
to?: { season: number; episode: number };
/** special:特别篇 / 花絮 / 忽略 */
special?: EntityWordSpecial;
}
export interface EntityWordLine extends Partial<EntityWordFields> {
line: number;
text: string;
kind: EntityWordKind;
error?: string;
}
/** 按空白切词,`@"带 空格"` 引号内不切 */
function tokens(s: string): string[] {
const out: string[] = [];
const re = /@"([^"]*)"|"([^"]*)"|(\S+)/g;
for (let m = re.exec(s); m; m = re.exec(s)) out.push(m[1] !== undefined ? `@${m[1]}` : (m[2] ?? m[3]!));
return out;
}
const SEASON_RE = /^S(\d{1,3})$/i;
const EPISODE_RE = /^(?:S(\d{1,3}))?E(\d{1,4})(?:-E?(\d{1,4}))?$/i;
const TARGET_EP_RE = /^S(\d{1,3})E(\d{1,4})$/i;
/** 解析作品识别词的一行(不含跨行检查) */
export function parseEntityWordLine(text: string, line = 1): EntityWordLine {
const base = { line, text };
const t = text.trim();
if (!t) return { ...base, kind: "blank" };
if (t.startsWith("#")) return { ...base, kind: "comment" };
const fail = (kind: EntityWordKind, error: string): EntityWordLine => ({ ...base, kind, error });
const i = t.indexOf(">>");
if (i < 0) return fail("map", "缺少 >>,写成 S02 >> S01 EP+24");
const left = tokens(t.slice(0, i));
const right = t.slice(i + 2).trim();
const head = left[0];
if (!head) return fail("map", ">> 左边要写来源季,如 S02,任意季写 *");
const groups: string[] = [];
for (const g of left.slice(1)) {
if (!g.startsWith("@") || g.length < 2) return fail("map", `「${g}」前面要加 @ 才是发布组`);
const name = g.slice(1).trim();
if (!groups.some((x) => x.toLowerCase() === name.toLowerCase())) groups.push(name);
}
// 单集对照:S01E13 >> S00E01
const ep = EPISODE_RE.exec(head);
if (ep) {
const start = Number(ep[2]);
const end = ep[3] ? Number(ep[3]) : start;
if (end < start) return fail("table", "集号区间写反了");
const to = TARGET_EP_RE.exec(right);
if (!to) return fail("table", "单集对照的右边写目标季集,如 S00E01");
return {
...base,
kind: "table",
season: ep[1] !== undefined ? Number(ep[1]) : null,
groups,
from: { start, end },
to: { season: Number(to[1]), episode: Number(to[2]) },
...(Number(to[2]) < 1 ? { error: "目标集号从 1 开始" } : {}),
};
}
let season: number | null;
if (head === "*" || /^S\*$/i.test(head)) season = null;
else {
const s = SEASON_RE.exec(head);
if (!s) return fail("map", `「${head}」不是季号,写成 S02,任意季写 *`);
season = Number(s[1]);
}
const special = SPECIAL_OF[right.toLowerCase()];
if (special) return { ...base, kind: "special", season, groups, special };
if (!right) return fail("map", ">> 右边要写目标季和集号换算,如 S01 EP+24");
const parts = right.split(/\s+/);
let targetSeason: number | null = null;
let exprParts = parts;
const ts = SEASON_RE.exec(parts[0]!);
if (ts) {
targetSeason = Number(ts[1]);
exprParts = parts.slice(1);
}
const expr = exprParts.length ? normalizeEpisodeExpr(exprParts.join(" ")) : "EP";
try {
evalEpisodeExpr(expr, 1);
} catch (e) {
return { ...base, kind: "map", season, groups, targetSeason, expr, error: (e as Error).message };
}
return { ...base, kind: "map", season, groups, targetSeason, expr };
}
const pad = (n: number) => String(n).padStart(2, "0");
const quoteGroup = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`);
export function printEntityWord(w: EntityWordFields & { kind: EntityWordKind }): string {
const groups = w.groups.map(quoteGroup).join(" ");
const g = groups ? ` ${groups}` : "";
if (w.kind === "table") {
const f = w.from ?? { start: 1, end: 1 };
const range = f.start === f.end ? `E${pad(f.start)}` : `E${pad(f.start)}-E${pad(f.end)}`;
const s = w.season === null ? "" : `S${pad(w.season)}`;
const to = w.to ?? { season: 0, episode: 1 };
return `${s}${range}${g} >> S${pad(to.season)}E${pad(to.episode)}`;
}
const scope = `${w.season === null ? "*" : `S${pad(w.season)}`}${g}`;
if (w.kind === "special") return `${scope} >> ${ENTITY_SPECIAL_WORDS[w.special ?? "ignore"]}`;
const target = w.targetSeason == null ? "" : `S${pad(w.targetSeason)} `;
return `${scope} >> ${target}${normalizeEpisodeExpr(w.expr?.trim() || "EP")}`;
}
/** 编译后的规则:与 domain EpisodeMappingRule 同形,多了出处行 */
export interface CompiledEntityRule {
id: string;
season: number | null;
releaseGroup: string | null;
kind: "expression" | "table" | "special";
expression?: string;
targetSeason?: number;
table?: { from: { start: number; end: number }; to: { season: number; episode: number } }[];
special?: EntityWordSpecial;
/** 出处:行号与原文(单集对照合并多行时取第一行) */
line: number;
text: string;
lines?: number[];
}
export interface EntityWordsCompiled {
lines: EntityWordLine[];
rules: CompiledEntityRule[];
errors: { line: number; message: string }[];
}
const scopeKey = (season: number | null, group: string | null) => `${season ?? "*"}|${group?.toLowerCase() ?? ""}`;
const scopeLabel = (season: number | null, group: string | null) =>
`${season === null ? "任意季" : `S${pad(season)}`}${group ? ` @${group}` : ""}`;
/**
* 解析整块作品识别词并编译成规则:一行多个发布组拆成多条;同一范围(季 + 发布组)只能有一条换算或特别篇,
* 单集对照可以多行(合并成一张对照表),但不能与换算 / 特别篇同范围,区间不能重叠。
*/
export function compileEntityWords(text: string): EntityWordsCompiled {
const lines = splitLines(text).map((t, i) => parseEntityWordLine(t, i + 1));
const errors: { line: number; message: string }[] = [];
const byScope = new Map<string, CompiledEntityRule>();
const rules: CompiledEntityRule[] = [];
for (const l of lines) {
if (l.error) errors.push({ line: l.line, message: l.error });
if (l.error || l.kind === "blank" || l.kind === "comment") continue;
const groups = l.groups?.length ? l.groups : [null];
const season = l.season ?? null;
for (const [gi, group] of groups.entries()) {
const key = scopeKey(season, group);
const prev = byScope.get(key);
if (l.kind === "table") {
const entry = { from: l.from!, to: l.to! };
if (prev && prev.kind !== "table") {
errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` });
continue;
}
if (prev) {
const hit = prev.table!.find((e) => entry.from.start <= e.from.end && entry.from.end >= e.from.start);
if (hit) {
errors.push({ line: l.line, message: `集号与第 ${prev.lines?.find(Boolean) ?? prev.line} 行的对照重叠` });
continue;
}
prev.table!.push(entry);
prev.lines!.push(l.line);
continue;
}
const r: CompiledEntityRule = {
id: ruleId(l.line, gi),
season,
releaseGroup: group,
kind: "table",
table: [entry],
line: l.line,
text: l.text.trim(),
lines: [l.line],
};
byScope.set(key, r);
rules.push(r);
continue;
}
if (prev) {
errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` });
continue;
}
const r: CompiledEntityRule = {
id: ruleId(l.line, gi),
season,
releaseGroup: group,
kind: l.kind === "special" ? "special" : "expression",
...(l.kind === "special" ? { special: l.special! } : { expression: l.expr! }),
...(l.kind === "map" && l.targetSeason != null ? { targetSeason: l.targetSeason } : {}),
line: l.line,
text: l.text.trim(),
};
byScope.set(key, r);
rules.push(r);
}
}
return { lines, rules, errors };
}
const ruleId = (line: number, gi: number) => `L${String(line).padStart(4, "0")}${gi ? `.${gi}` : ""}`;
// ── 可视化行模型:文本 ⇄ 行,原文逐字保留 ──
export type WordsScope = "global" | "entity";
/**
* 一行一个规则行;紧贴在规则上方(中间没有空行)的普通注释是它的备注。
* 注释掉的规则(`# S02 >> S01 EP+24`)是停用的规则行;解析不了的行是原文行(可视化里只读)。
* lines 是这一行在文本里占的全部原文(备注 + 规则),没改过就原样拼回,保证往返不丢字。
*/
export type WordsRow =
| { kind: "rule"; id: string; lines: string[]; note: string[]; enabled: boolean; rule: string }
| { kind: "comment"; id: string; lines: string[] }
| { kind: "blank"; id: string; lines: string[] }
| { kind: "invalid"; id: string; lines: string[]; rule: string; error: string };
/** 去掉注释号:`# xxx` / `#xxx` → `xxx` */
export const uncomment = (line: string) => line.trimStart().replace(/^#\s?/, "");
/** 注释行的内容是不是一条规则(停用的规则) */
function commentedRule(line: string, scope: WordsScope): string | null {
const body = uncomment(line);
if (!body.trim()) return null;
if (scope === "entity") {
const p = parseEntityWordLine(body);
return p.kind !== "comment" && p.kind !== "blank" && !p.error ? body : null;
}
// 全局:任何文字都是合法的屏蔽词,只把带运算符的当成停用规则
const p = parseGlobalWordLine(body);
return (p.kind === "replace" || p.kind === "offset" || p.kind === "replace_offset") && !p.error ? body : null;
}
export function lineError(line: string, scope: WordsScope): string | undefined {
return scope === "entity" ? parseEntityWordLine(line).error : parseGlobalWordLine(line).error;
}
/** 规则文本 → 可视化行模型 */
export function wordsToRows(text: string, scope: WordsScope): WordsRow[] {
const src = splitLines(text);
const rows: WordsRow[] = [];
let pendingNote: string[] = [];
let n = 0;
const id = () => `r${n++}`;
const flushNote = () => {
if (pendingNote.length) rows.push({ kind: "comment", id: id(), lines: pendingNote });
pendingNote = [];
};
// 文本以换行结尾时 split 多出一个空串,不算一行
const body = src.length > 1 && src[src.length - 1] === "" ? src.slice(0, -1) : src;
for (const line of body) {
const trimmed = line.trim();
if (!trimmed) {
flushNote();
const last = rows[rows.length - 1];
if (last?.kind === "blank") last.lines.push(line);
else rows.push({ kind: "blank", id: id(), lines: [line] });
continue;
}
if (trimmed.startsWith("#")) {
const rule = commentedRule(line, scope);
if (rule !== null) {
rows.push({
kind: "rule",
id: id(),
lines: [...pendingNote, line],
note: pendingNote.map(uncomment),
enabled: false,
rule,
});
pendingNote = [];
} else pendingNote.push(line);
continue;
}
const error = lineError(line, scope);
if (error) {
flushNote();
rows.push({ kind: "invalid", id: id(), lines: [line], rule: line, error });
continue;
}
rows.push({
kind: "rule",
id: id(),
lines: [...pendingNote, line],
note: pendingNote.map(uncomment),
enabled: true,
rule: line,
});
pendingNote = [];
}
flushNote();
return rows;
}
/** 可视化行模型 → 文本;结尾带换行与否跟原文一致由调用方决定(trailingNewline) */
export function rowsToText(rows: readonly WordsRow[], trailingNewline = false): string {
const text = rows.flatMap((r) => r.lines).join("\n");
return trailingNewline && text ? `${text}\n` : text;
}
/** 按备注、启用状态、规则文本重新生成规则行的原文 */
export function ruleRowLines(note: readonly string[], enabled: boolean, rule: string): string[] {
return [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`];
}
/** 改规则行的某一部分,返回新行(lines 随之重写) */
export function updateRuleRow(
row: Extract<WordsRow, { kind: "rule" }>,
patch: Partial<Pick<Extract<WordsRow, { kind: "rule" }>, "note" | "enabled" | "rule">>,
): Extract<WordsRow, { kind: "rule" }> {
const next = { ...row, ...patch };
// 只改启用状态时备注原文不动
const noteLines = patch.note ? next.note.map((x) => `# ${x}`.trimEnd()) : row.lines.slice(0, row.note.length);
return { ...next, lines: [...noteLines, next.enabled ? next.rule : `# ${next.rule}`] };
}