feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)
- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue, cut, release group suffix fallback, dir+file merge, safe episode offset) - identity: title normalization (NFKC, trad->simp map, season suffix) and evidence-level candidate ranking (year tolerance ±1, no probabilities) - slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine, movie/unit completion (season pack must be covered by actual file list) - acquisition: new_title/catch_up/upgrade modes, priority order, budget, cache volume hard threshold with resume watermark (no double counting) - path: immutable path bindings, restricted naming templates, sanitize, conflict detection, downloader<->host path mapping - organize: planOrganize with sidecar pairing, sample exclusion, existing target policy, crossDevice marking, H&R -> conflict for move - download-state: qB state mapping, completion, no-progress evidence 230 tests, ~97% statement coverage. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 集号偏移表达式的白名单求值器(禁止 eval/Function)。
|
||||
* 语法:数字、EP 占位符、+ - * / // % ** 和括号。
|
||||
* 省略 EP 时按 MP 约定补全:`-12` → `EP-12`,`+1` → `EP+1`,`*2` → `EP*2`。
|
||||
*/
|
||||
export class OffsetExpressionError extends Error {
|
||||
override name = "OffsetExpressionError";
|
||||
}
|
||||
|
||||
type Tok = { t: "num"; v: number } | { t: "ep" } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" };
|
||||
|
||||
function lex(src: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
if (c === " ") {
|
||||
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.slice(i, i + 2).toUpperCase() === "EP") {
|
||||
out.push({ t: "ep" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (two === "//" || two === "**") {
|
||||
out.push({ t: "op", v: two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/%".includes(c)) {
|
||||
out.push({ t: "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "(") out.push({ t: "lp" });
|
||||
else if (c === ")") out.push({ t: "rp" });
|
||||
else throw new OffsetExpressionError(`非法字符: ${c}`);
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeOffsetExpression(expr: string): string {
|
||||
const e = expr.trim();
|
||||
if (e === "") throw new OffsetExpressionError("空表达式");
|
||||
if (/ep/i.test(e)) return e;
|
||||
return /^[+\-*/%]/.test(e) ? `EP${e}` : `EP+${e}`;
|
||||
}
|
||||
|
||||
export function evalEpisodeOffset(expr: string, ep: number): number {
|
||||
if (expr.length > 64) throw new OffsetExpressionError("表达式过长");
|
||||
const toks = lex(normalizeOffsetExpression(expr));
|
||||
let pos = 0;
|
||||
const peek = () => toks[pos];
|
||||
// expr := term (('+'|'-') term)*
|
||||
const parseExpr = (): number => {
|
||||
let v = parseTerm();
|
||||
for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) {
|
||||
pos++;
|
||||
const r = parseTerm();
|
||||
v = p.v === "+" ? v + r : v - r;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const parseTerm = (): number => {
|
||||
let v = parsePow();
|
||||
for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) {
|
||||
pos++;
|
||||
const r = parsePow();
|
||||
if ((p.v === "/" || p.v === "//" || p.v === "%") && r === 0) throw new OffsetExpressionError("除零");
|
||||
if (p.v === "*") v *= r;
|
||||
else if (p.v === "/") v /= r;
|
||||
else if (p.v === "//") v = Math.floor(v / r);
|
||||
else v = ((v % r) + r) % r;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const parsePow = (): number => {
|
||||
const base = parseUnary();
|
||||
const p = peek();
|
||||
if (p?.t === "op" && p.v === "**") {
|
||||
pos++;
|
||||
return base ** parsePow();
|
||||
}
|
||||
return base;
|
||||
};
|
||||
const parseUnary = (): number => {
|
||||
const p = peek();
|
||||
if (p?.t === "op" && (p.v === "-" || p.v === "+")) {
|
||||
pos++;
|
||||
const v = parseUnary();
|
||||
return p.v === "-" ? -v : v;
|
||||
}
|
||||
return parseAtom();
|
||||
};
|
||||
const parseAtom = (): number => {
|
||||
const p = toks[pos++];
|
||||
if (!p) throw new OffsetExpressionError("表达式不完整");
|
||||
if (p.t === "num") return p.v;
|
||||
if (p.t === "ep") return ep;
|
||||
if (p.t === "lp") {
|
||||
const v = parseExpr();
|
||||
if (toks[pos++]?.t !== "rp") throw new OffsetExpressionError("括号不匹配");
|
||||
return v;
|
||||
}
|
||||
throw new OffsetExpressionError("语法错误");
|
||||
};
|
||||
const v = parseExpr();
|
||||
if (pos !== toks.length) throw new OffsetExpressionError("多余的内容");
|
||||
if (!Number.isFinite(v)) throw new OffsetExpressionError("结果不是有限数");
|
||||
return Math.trunc(v);
|
||||
}
|
||||
Reference in new issue
Block a user