- 新表 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
794 lines
29 KiB
TypeScript
794 lines
29 KiB
TypeScript
// 识别词语法(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}`] };
|
|
}
|