feat(domain): rule groups — MP rule-string parser/serializer, builtin keywords, chain evaluation wired into slot

- rules/: RuleExpr tree, '>' layers with ! & | and parens, position-aware errors
  (unknown atom with suggestions, unsupported music atoms, parens, empty layer)
- builtin keyword table aligned with MP BUILTIN_RULE_SET; field-based judgement
  where parsed fields exist, MP regex otherwise; custom atoms (MP 7 fields)
- chain evaluation: hard excludes, ladder pri 100-k, groups AND-ed, pri from last
  laddered group, readable per-atom trace
- slot: SlotSpec.rules, rule.hard_exclude / rule.no_layer_matched violations,
  pri basis switches to priority when a rule ladder is present
- differential: 66 rule chains x 32 torrents vs MP python path (parse 64/64,
  pri 2090/2112, 22 registered mp_bug)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 14:50:43 +08:00
1 parent 22856d0447
commit e994a7bcfa
13 files changed
+23919 -10

No files matched your search

+1
View File
@@ -7,4 +7,5 @@ export * from "./identity/index.ts";
export * from "./organize/index.ts"; export * from "./organize/index.ts";
export * from "./path/index.ts"; export * from "./path/index.ts";
export * from "./release-parser/index.ts"; export * from "./release-parser/index.ts";
export * from "./rules/index.ts";
export * from "./slot/index.ts"; export * from "./slot/index.ts";
+470
View File
@@ -0,0 +1,470 @@
import type { ParsedRelease } from "../release-parser/types.ts";
import type { CustomAtom, RuleContext } from "./types.ts";
// 内置关键字表:名称与 MP BUILTIN_RULE_SET(app/application/rules.py)一一对应。
// mp 字段原样记录上游的判定口径(正则/条件),供关键字说明与差分核对;
// test 是我们的判定:能用解析字段的用字段(分辨率/来源/HDR/编码/字幕),其余沿用 MP 正则。
// 音乐 13 条不支持(解析时报 unsupported_atom)。
export interface AtomInput {
r: ParsedRelease;
ctx: RuleContext;
/** 标题 + 副标题 + 标签(对照 MP `title description labels`) */
text: string;
}
export interface AtomHit {
hit: boolean;
/** 可读依据,如 "分辨率 1080p"、"原语言 zh 放行" */
why: string;
}
export interface BuiltinAtom {
id: string;
name: string;
/** 一句话语义 */
description: string;
/** 我们的判定依据:field = 解析字段;regex = 沿用 MP 正则;site = 站点事实 */
basis: "field" | "regex" | "site";
supported: boolean;
/** MP 上游定义(只读,说明用) */
mp: {
include?: string[];
exclude?: string[];
match?: string[];
tmdb?: Record<string, string>;
downloadvolumefactor?: number;
};
test?: (i: AtomInput) => AtomHit;
}
/** MP 正则是 Python 语法:去掉内联 (?i),统一按不区分大小写编译;非法返回 null */
export function compileMpPattern(p: string): RegExp | null {
try {
return new RegExp(p.replace(/\(\?i\)/g, ""), "i");
} catch {
return null;
}
}
const reCache = new Map<string, RegExp | null>();
function re(p: string): RegExp | null {
if (!reCache.has(p)) {
if (reCache.size > 500) reCache.clear();
reCache.set(p, compileMpPattern(p));
}
return reCache.get(p)!;
}
const anyRe = (ps: readonly string[], text: string) => ps.some((p) => re(p)?.test(text) ?? false);
function regexAtom(ps: string[], label: string) {
return ({ text }: AtomInput): AtomHit =>
anyRe(ps, text) ? { hit: true, why: `含「${label}」` } : { hit: false, why: `不含「${label}」` };
}
const zhLang = (lang: string | undefined, accept: readonly string[]) =>
!!lang && accept.includes(lang.toLowerCase().split("-")[0]!);
const MP_CNSUB =
"[中国國繁简](/|\\s|\\\\|\\|)?[繁简英粤]|[英简繁](/|\\s|\\\\|\\|)?[中繁简]" +
"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体" +
"|([\\s,.-\\[])(chs|cht)(|[\\s,.-\\]])" +
"|(?<![a-z0-9])(?<!\\d\\s)(gb|big5)(?![a-z0-9])";
const MP_CNVOI = "[国國][语語]配音|[国國]配|[国國][语語]";
const res =
(...rs: string[]) =>
(i: AtomInput): AtomHit =>
i.r.resolution && rs.includes(i.r.resolution)
? { hit: true, why: `分辨率 ${i.r.resolution}` }
: { hit: false, why: `分辨率 ${i.r.resolution ?? "未知"}` };
const src =
(...ss: string[]) =>
(i: AtomInput): AtomHit =>
i.r.source && ss.includes(i.r.source)
? { hit: true, why: `来源 ${i.r.source}` }
: { hit: false, why: `来源 ${i.r.source ?? "未知"}` };
const codec =
(c: string) =>
(i: AtomInput): AtomHit =>
i.r.videoCodec === c ? { hit: true, why: `编码 ${c}` } : { hit: false, why: `编码 ${i.r.videoCodec ?? "未知"}` };
const hdrList = (i: AtomInput) => (i.r.hdr.length ? i.r.hdr.join("/") : "无");
const MUSIC: [string, string, string][] = [
[
"HIRES",
"Hi-Res 无损",
"(?i)\\b(?:Hi[ ._-]?Res(?:olution)?|DSD(?:64|128|256|512)?)\\b|高解析|(?:24|32)\\s*(?:-?bit|位)",
],
["LOSSLESS", "无损音频", "(?i)\\b(?:Lossless|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|DSD|DSF|DFF)\\b|无损"],
["FLAC", "FLAC", "(?i)(?<![A-Z0-9])FLAC(?![A-Z0-9])"],
["ALAC", "ALAC", "(?i)(?<![A-Z0-9])ALAC(?![A-Z0-9])"],
["APE", "APE", "(?i)(?<![A-Z0-9])APE(?![A-Z0-9])"],
["WAV", "WAV", "(?i)(?<![A-Z0-9])WAV(?:E)?(?![A-Z0-9])"],
["DSD", "DSD", "(?i)(?<![A-Z0-9])(?:DSD(?:64|128|256|512)?|DSF|DFF)(?![A-Z0-9])"],
["MP3", "MP3", "(?i)(?<![A-Z0-9])MP3(?![A-Z0-9])"],
["AAC", "AAC", "(?i)(?<![A-Z0-9])(?:AAC|M4A)(?![A-Z0-9])"],
["OPUS", "OPUS", "(?i)(?<![A-Z0-9])OPUS(?![A-Z0-9])"],
["BITRATE320", "320k", "(?i)(?<!\\d)320\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
["BITRATE256", "256k", "(?i)(?<!\\d)256\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
["BITRATE192", "192k", "(?i)(?<!\\d)192\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
];
export const BUILTIN_ATOMS: readonly BuiltinAtom[] = [
{
id: "BLU",
name: "蓝光原盘",
description: "来源为蓝光原盘(BDMV/ISO,非 Remux、非压制)",
basis: "field",
supported: true,
mp: {
include: [
"(?i)(\\bBlu-?Ray\\b.*\\b(?:VC-?1|AVC|MPEG-?2)\\b|\\b(?:UHD|4K|2160p)\\b(?:.*Blu-?Ray)?.*\\b(?:HEVC|H\\.?265)\\b|\\bBlu-?Ray\\b.*\\b(?:UHD|4K|2160p)\\b.*\\b(?:HEVC|H\\.?265)\\b|\\b(?:COMPLETE|FULL)\\b.*\\b(?:(?:UHD|4K|2160p)\\b.*)?Blu-?Ray\\b|\\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\\b)",
],
exclude: [
"(?i)(\\b[XH]\\.?264\\b|\\b[XH]\\.?265\\b|\\bWEB-?DL\\b|\\bWEB-?RIP\\b|\\bHDTV(?:RIP)?\\b|\\bREMUX\\b|\\bBDRip\\b|\\bBRRip\\b|\\bHDRip\\b|\\bENCODE\\b|\\b(?<!WEB-|HDTV)RIP\\b)",
],
},
test: src("BluRay-Disc"),
},
{
id: "4K",
name: "4K",
description: "分辨率 2160p",
basis: "field",
supported: true,
mp: { include: ["4k|2160p|x2160"] },
test: res("2160p"),
},
{
id: "1080P",
name: "1080P",
description: "分辨率 1080p / 1080i",
basis: "field",
supported: true,
mp: { include: ["1080[pi]|x1080"] },
test: res("1080p", "1080i"),
},
{
id: "720P",
name: "720P",
description: "分辨率 720p",
basis: "field",
supported: true,
mp: { include: ["720[pi]|x720"] },
test: res("720p"),
},
{
id: "CNSUB",
name: "中字",
description: "含中文字幕(含 CHS/CHT/GB/BIG5);作品原语言为中文时直接满足",
basis: "field",
supported: true,
mp: { include: [MP_CNSUB], tmdb: { original_language: "zh,cn" } },
test: (i) => {
if (zhLang(i.ctx.originalLanguage, ["zh", "cn"]))
return { hit: true, why: `原语言 ${i.ctx.originalLanguage} 放行` };
const zh = (i.r.subtitles ?? []).filter((s) => s.startsWith("zh"));
if (zh.length) return { hit: true, why: `字幕 ${zh.join("/")}` };
return { hit: false, why: i.r.subtitles === undefined ? "字幕未知" : `字幕 ${i.r.subtitles.join("/") || "无"}` };
},
},
{
id: "GZ",
name: "官种",
description: "站点标签含 官方/官种/官组(只看标签)",
basis: "site",
supported: true,
mp: { include: ["官方", "官种", "官组"], match: ["labels"] },
test: (i) => {
const labels = (i.ctx.labels ?? []).join(" ");
return anyRe(["官方", "官种", "官组"], labels)
? { hit: true, why: "官方标签" }
: { hit: false, why: labels ? `标签 ${labels}` : "无站点标签" };
},
},
{
id: "SPECSUB",
name: "特效字幕",
description: "标题/副标题/标签含「特效」",
basis: "regex",
supported: true,
mp: { include: ["特效"] },
test: regexAtom(["特效"], "特效"),
},
{
id: "BLURAY",
name: "蓝光",
description: "蓝光来源(原盘、蓝光 Remux、蓝光压制)",
basis: "field",
supported: true,
mp: { include: ["Blu-?Ray"] },
test: (i) => {
const s = i.r.source;
if (s === "BluRay" || s === "BluRay-Disc") return { hit: true, why: `来源 ${s}` };
if (s === "REMUX" && /blu-?ray/i.test(i.r.raw)) return { hit: true, why: "蓝光 REMUX" };
return { hit: false, why: `来源 ${s ?? "未知"}` };
},
},
{
id: "UHD",
name: "UHD",
description: "标题含 UHD / UltraHD",
basis: "field",
supported: true,
mp: { include: ["UHD|UltraHD"] },
test: (i) => (i.r.uhd || /UltraHD/i.test(i.text) ? { hit: true, why: "UHD" } : { hit: false, why: "无 UHD 标记" }),
},
{
id: "H265",
name: "H265",
description: "视频编码 H.265/HEVC",
basis: "field",
supported: true,
mp: { include: ["[Hx].?265|HEVC"] },
test: codec("H.265"),
},
{
id: "H264",
name: "H264",
description: "视频编码 H.264/AVC",
basis: "field",
supported: true,
mp: { include: ["[Hx].?264|AVC"] },
test: codec("H.264"),
},
{
id: "DOLBY",
name: "杜比视界",
description: "HDR 含杜比视界(DV/DoVi)",
basis: "field",
supported: true,
mp: { include: ["Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+|杜比视界"] },
test: (i) =>
i.r.hdr.includes("DV") || /杜比视界/.test(i.text)
? { hit: true, why: "杜比视界" }
: { hit: false, why: `HDR ${hdrList(i)}` },
},
{
id: "ATMOS",
name: "杜比全景声",
description: "音轨含 Atmos(杜比全景声)",
basis: "field",
supported: true,
mp: { include: ["Dolby[\\s.+]+Atmos|Atmos|杜比全景[声聲]"] },
test: (i) =>
i.r.audio.some((a) => /atmos/i.test(a)) || anyRe(["Atmos|杜比全景[声聲]"], i.text)
? { hit: true, why: "Atmos" }
: { hit: false, why: "无 Atmos" },
},
{
id: "HDR",
name: "HDR",
description: "HDR10 / HDR10+ / HDR Vivid(不含只有杜比视界的)",
basis: "field",
supported: true,
mp: { include: ["[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+|HDRVivid"] },
test: (i) =>
i.r.hdr.some((h) => h === "HDR10" || h === "HDR10+" || h === "HDR Vivid")
? { hit: true, why: `HDR ${hdrList(i)}` }
: { hit: false, why: `HDR ${hdrList(i)}` },
},
{
id: "SDR",
name: "SDR",
description: "标题明确标 SDR",
basis: "regex",
supported: true,
mp: { include: ["[\\s.]+SDR[\\s.]+"] },
test: regexAtom(["[\\s.]+SDR[\\s.]+"], "SDR"),
},
{
id: "REMUX",
name: "Remux",
description: "来源为 Remux",
basis: "field",
supported: true,
mp: { include: ["REMUX"] },
test: src("REMUX"),
},
{
id: "WEBDL",
name: "WEB-DL",
description: "来源为 WEB-DL 或 WEBRip",
basis: "field",
supported: true,
mp: { include: ["WEB-?DL|WEB-?RIP"] },
test: src("WEB-DL", "WEBRip"),
},
{
id: "FREE",
name: "免费",
description: "站点促销为免费(下载量不计)",
basis: "site",
supported: true,
mp: { downloadvolumefactor: 0 },
test: (i) =>
i.ctx.downloadVolumeFactor === 0
? { hit: true, why: "免费" }
: {
hit: false,
why: i.ctx.downloadVolumeFactor === undefined ? "促销未知" : `下载系数 ${i.ctx.downloadVolumeFactor}`,
},
},
{
id: "CNVOI",
name: "国语配音",
description: "含国语配音;作品原语言为中文时直接满足",
basis: "regex",
supported: true,
mp: { include: [MP_CNVOI], tmdb: { original_language: "zh" } },
test: (i) =>
zhLang(i.ctx.originalLanguage, ["zh"])
? { hit: true, why: `原语言 ${i.ctx.originalLanguage} 放行` }
: regexAtom([MP_CNVOI], "国语")(i),
},
{
id: "HKVOI",
name: "粤语配音",
description: "含粤语配音",
basis: "regex",
supported: true,
mp: { include: ["粤语配音|粤语"] },
test: regexAtom(["粤语配音|粤语"], "粤语"),
},
{
id: "60FPS",
name: "60帧",
description: "帧率 ≥ 60",
basis: "field",
supported: true,
mp: { include: ["60fps|60帧"] },
test: (i) =>
(i.r.fps ?? 0) >= 60 || anyRe(["60fps|60帧"], i.text)
? { hit: true, why: `帧率 ${i.r.fps ?? 60}` }
: { hit: false, why: `帧率 ${i.r.fps ?? "未知"}` },
},
{
id: "3D",
name: "3D",
description: "标题含 3D",
basis: "regex",
supported: true,
mp: { include: ["3D"] },
test: regexAtom(["3D"], "3D"),
},
...MUSIC.map(
([id, name, p]): BuiltinAtom => ({
id,
name,
description: "音乐资源条件,暂不支持",
basis: "regex",
supported: false,
mp: { include: [p] },
}),
),
];
export const BUILTIN_ATOM_MAP: ReadonlyMap<string, BuiltinAtom> = new Map(BUILTIN_ATOMS.map((a) => [a.id, a]));
/** 自定义条件 id:大写字母开头或数字+字母(对照 MP pyparsing 原子),规范化为大写 */
export const ATOM_ID_RE = /^(?:[A-Z][A-Z0-9]*|[0-9]+[A-Z][A-Z0-9]*)$/;
const MB = 1024 * 1024;
function unitCount(i: AtomInput): number {
const { r, ctx } = i;
if (ctx.unitCount !== undefined && ctx.unitCount > 0) return ctx.unitCount;
if (r.episodes) return Math.max(1, r.episodes.end - r.episodes.start + 1);
if (r.totalEpisodes && r.totalEpisodes > 0) return r.totalEpisodes;
return 1;
}
/** 自定义条件判定(对照 MP __match_rule:包含 → 排除 → 体积(每集)→ 做种 → 促销 → 发布时间) */
export function testCustomAtom(a: CustomAtom, i: AtomInput): AtomHit {
let text = i.text;
if (a.matchFields?.length) {
const parts: string[] = [];
if (a.matchFields.includes("title")) parts.push(i.r.raw);
if (a.matchFields.includes("subtitle") && i.ctx.subtitle) parts.push(i.ctx.subtitle);
if (a.matchFields.includes("labels")) parts.push(...(i.ctx.labels ?? []));
text = parts.join(" ");
}
if (a.include?.length && !anyRe(a.include, text)) return { hit: false, why: `不含 ${a.include.join(" / ")}` };
const ex = (a.exclude ?? []).find((p) => re(p)?.test(text));
if (ex) return { hit: false, why: `含排除词 ${ex}` };
if (a.unitSizeMB && (a.unitSizeMB.min !== undefined || a.unitSizeMB.max !== undefined)) {
if (i.r.sizeBytes === undefined) return { hit: false, why: "体积未知" };
const n = unitCount(i);
const per = i.r.sizeBytes / n;
const mb = Math.round(per / MB);
const tag = n > 1 ? `每集 ${mb}MB` : `${mb}MB`;
if (a.unitSizeMB.min !== undefined && per < a.unitSizeMB.min * MB)
return { hit: false, why: `${tag} 小于 ${a.unitSizeMB.min}MB` };
if (a.unitSizeMB.max !== undefined && per > a.unitSizeMB.max * MB)
return { hit: false, why: `${tag} 大于 ${a.unitSizeMB.max}MB` };
}
if (a.minSeeders !== undefined && a.minSeeders > 0) {
if (i.ctx.seeders === undefined) return { hit: false, why: "做种数未知" };
if (i.ctx.seeders < a.minSeeders) return { hit: false, why: `做种 ${i.ctx.seeders} 小于 ${a.minSeeders}` };
}
if (a.free && i.ctx.downloadVolumeFactor !== 0)
return { hit: false, why: i.ctx.downloadVolumeFactor === undefined ? "促销未知" : "非免费" };
const pd = a.publishDelayMinutes;
if (pd && (pd.min !== undefined || pd.max !== undefined)) {
const m = i.ctx.publishedMinutesAgo;
if (m === undefined) return { hit: false, why: "发布时间未知" };
if (pd.min !== undefined && m < pd.min) return { hit: false, why: `发布 ${Math.round(m)} 分钟,不足 ${pd.min}` };
if (pd.max !== undefined && m > pd.max) return { hit: false, why: `发布 ${Math.round(m)} 分钟,超过 ${pd.max}` };
}
return { hit: true, why: "满足" };
}
/** MP 自定义规则(导入格式)→ CustomAtom;size_range 形如 "a-b" / ">a" / "<b",publish_time 形如 "n" / "a-b" */
export function customAtomFromMp(
id: string,
mp: {
name?: string;
include?: string | string[];
exclude?: string | string[];
size_range?: string;
seeders?: string | number;
publish_time?: string;
downloadvolumefactor?: number;
match?: string[];
},
): CustomAtom {
const list = (v: string | string[] | undefined) => (v === undefined || v === "" ? [] : Array.isArray(v) ? v : [v]);
const out: CustomAtom = { id: id.toUpperCase() };
if (mp.name) out.name = mp.name;
const inc = list(mp.include);
const exc = list(mp.exclude);
if (inc.length) out.include = inc;
if (exc.length) out.exclude = exc;
const sr = mp.size_range?.trim();
if (sr) {
if (sr.includes("-")) {
const [a, b] = sr.split("-").map((x) => Number(x.trim()));
out.unitSizeMB = { min: a!, max: b! };
} else if (sr.startsWith(">")) out.unitSizeMB = { min: Number(sr.slice(1).trim()) };
else if (sr.startsWith("<")) out.unitSizeMB = { max: Number(sr.slice(1).trim()) };
}
if (mp.seeders !== undefined && mp.seeders !== "") out.minSeeders = Number(mp.seeders);
if (mp.publish_time) {
const ts = mp.publish_time.split("-").map((x) => Number(x.trim()));
out.publishDelayMinutes = ts.length === 1 ? { min: ts[0]! } : { min: ts[0]!, max: ts[1]! };
}
if (mp.downloadvolumefactor === 0) out.free = true;
const fields = (mp.match ?? []).flatMap((m) =>
m === "title"
? ["title" as const]
: m === "description"
? ["subtitle" as const]
: m === "labels"
? ["labels" as const]
: [],
);
if (fields.length) out.matchFields = fields;
return out;
}
+127
View File
@@ -0,0 +1,127 @@
import type { ParsedRelease } from "../release-parser/types.ts";
import { type AtomHit, type AtomInput, BUILTIN_ATOM_MAP, testCustomAtom } from "./atoms.ts";
import { serializeExpr } from "./expr.ts";
import type { CustomAtom, RuleChainResult, RuleContext, RuleExpr, RuleGroupResult, RuleGroupSpec } from "./types.ts";
// 规则组求值(对照 MP FilterModule.__get_order / __match_group / __match_rule):
// - 组内:硬排除任一命中 → 拒绝;层按序匹配,第 k 层(0 起)命中 pri = 100 - k;一层不中且 ladderRequired → 拒绝。
// - 多组串联取交集(任一组拒绝即拒绝);pri 取最后一个带层的组。
// - 未知原子视为不匹配(MP 语义),trace 里写明。
export type AtomCatalog = ReadonlyMap<string, CustomAtom>;
export function catalogOf(atoms: readonly CustomAtom[] = []): AtomCatalog {
return new Map(atoms.map((a) => [a.id.toUpperCase(), a]));
}
function inputOf(r: ParsedRelease, ctx: RuleContext): AtomInput {
return { r, ctx, text: [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join(" ") };
}
/** 单个原子判定 */
export function testAtom(id: string, input: AtomInput, catalog: AtomCatalog = new Map()): AtomHit {
const key = id.toUpperCase();
const b = BUILTIN_ATOM_MAP.get(key);
if (b) {
if (!b.supported || !b.test) return { hit: false, why: "不支持" };
return b.test(input);
}
const c = catalog.get(key);
if (c) return testCustomAtom(c, input);
return { hit: false, why: "条件不存在" };
}
/** 表达式求值(短路,同 MP);notes 收集已求值原子的依据(按字面:`!X✗` 表示取反后不满足),供 trace */
function evalExpr(e: RuleExpr, input: AtomInput, catalog: AtomCatalog, notes: string[], neg = false): boolean {
switch (e.op) {
case "atom": {
const h = testAtom(e.id, input, catalog);
const lit = neg ? !h.hit : h.hit;
notes.push(`${neg ? "!" : ""}${e.id.toUpperCase()}${lit ? "✓" : "✗"} ${h.why}`);
return h.hit;
}
case "not":
return !evalExpr(e.arg, input, catalog, notes, !neg);
case "and":
return e.args.every((a) => evalExpr(a, input, catalog, notes, neg));
case "or":
return e.args.some((a) => evalExpr(a, input, catalog, notes, neg));
}
}
/** 表达式是否命中(不要 trace 时用) */
export function matchesExpr(e: RuleExpr, r: ParsedRelease, ctx: RuleContext = {}, catalog?: AtomCatalog): boolean {
return evalExpr(e, inputOf(r, ctx), catalog ?? new Map(), []);
}
export function evaluateRuleGroup(
g: RuleGroupSpec,
r: ParsedRelease,
ctx: RuleContext = {},
catalog: AtomCatalog = new Map(),
): RuleGroupResult {
const input = inputOf(r, ctx);
const label = `规则组「${g.name}」${g.revision !== undefined ? `@r${g.revision}` : ""}`;
const trace: string[] = [];
const excludedBy: string[] = [];
for (const x of g.hardExcludes ?? []) {
const notes: string[] = [];
if (evalExpr(x, input, catalog, notes)) {
const s = serializeExpr(x);
excludedBy.push(s);
trace.push(`${label} 硬排除 ${s} 命中(${notes.join(";")})`);
}
}
const base = {
...(g.id !== undefined ? { groupId: g.id } : {}),
name: g.name,
...(g.revision !== undefined ? { revision: g.revision } : {}),
};
if (g.layers.length === 0) {
return { ...base, excludedBy, level: null, pri: null, passed: excludedBy.length === 0, trace };
}
let level: number | null = null;
for (let k = 0; k < g.layers.length; k++) {
const layer = g.layers[k]!;
const notes: string[] = [];
const hit = evalExpr(layer.expr, input, catalog, notes);
const name = layer.name ? `第${k + 1}层「${layer.name}」` : `第${k + 1}层`;
const expr = serializeExpr(layer.expr);
if (hit) {
trace.push(`${label} ${name} ${expr}:命中,pri=${100 - k}(${notes.join(";")})`);
level = k;
break;
}
trace.push(`${label} ${name} ${expr}:未命中(${notes.join(";")})`);
}
const required = g.ladderRequired !== false;
if (level === null && required) trace.push(`${label} 不命中任何一层,过滤`);
return {
...base,
excludedBy,
level,
pri: level === null ? 0 : 100 - level,
passed: excludedBy.length === 0 && (level !== null || !required),
trace,
};
}
/** 多组串联:任一组不通过即不通过;pri 取最后一个带层的组 */
export function evaluateRuleChain(
groups: readonly RuleGroupSpec[],
r: ParsedRelease,
ctx: RuleContext = {},
catalog: AtomCatalog = new Map(),
): RuleChainResult {
const results = groups.map((g) => evaluateRuleGroup(g, r, ctx, catalog));
let deciding: number | null = null;
results.forEach((x, i) => {
if (x.pri !== null) deciding = i;
});
const passed = results.every((x) => x.passed);
const pri = deciding === null ? null : results[deciding]!.pri;
const trace = results.flatMap((x) => x.trace);
if (deciding !== null && results.filter((x) => x.pri !== null).length > 1)
trace.push(`pri 取最后一个带层的规则组「${results[deciding]!.name}」:${pri}`);
return { passed, pri, decidingGroup: deciding, groups: results, trace };
}
+278
View File
@@ -0,0 +1,278 @@
import { BUILTIN_ATOM_MAP } from "./atoms.ts";
import type { RuleExpr, RuleLayer, RuleParseError } from "./types.ts";
// MP 规则串 ↔ 结构化层。语法对齐 MP RuleParser(pyparsing infix_notation):
// 规则串 = 层 ('>' 层)*;层内优先级 ! > & > |,可加括号;原子 = 字母开头的字母数字串,或数字+字母(4K、60FPS)。
// 偏离:MP 对未知原子视为不匹配、对多余 token 静默截断;我们在解析时报错并给位置(allowUnknown 时保留未知原子)。
export interface RuleParseOptions {
/** 自定义条件 id(大写);不在内置表也不在这里的原子报 unknown_atom */
customAtoms?: Iterable<string>;
/** 保留未知原子(求值时视为不匹配,对齐 MP);缺省 false 报错 */
allowUnknown?: boolean;
}
export interface RuleParseResult {
layers: RuleLayer[];
errors: RuleParseError[];
}
type Tok = { t: "atom" | "(" | ")" | "!" | "&" | "|" | "bad"; v: string; pos: number };
function lex(s: string, base: number): Tok[] {
const out: Tok[] = [];
let i = 0;
while (i < s.length) {
const ch = s[i]!;
if (/\s/.test(ch)) {
i++;
continue;
}
if ("()!&|".includes(ch)) {
out.push({ t: ch as Tok["t"], v: ch, pos: base + i });
i++;
continue;
}
const m = /^[A-Za-z0-9]+/.exec(s.slice(i));
if (m) {
out.push({ t: "atom", v: m[0], pos: base + i });
i += m[0].length;
continue;
}
out.push({ t: "bad", v: ch, pos: base + i });
i++;
}
return out;
}
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
let prev = dp[0]!;
dp[0] = i;
for (let j = 1; j <= b.length; j++) {
const tmp = dp[j]!;
dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = tmp;
}
}
return dp[b.length]!;
}
/** 相近原子名(给 unknown_atom 提示) */
export function suggestAtoms(id: string, custom: Iterable<string> = []): string[] {
const all = [...[...BUILTIN_ATOM_MAP.values()].filter((a) => a.supported).map((a) => a.id), ...custom];
return all
.map((x) => ({ x, d: levenshtein(id, x) }))
.filter((e) => e.d <= Math.max(1, Math.floor(id.length / 2)) || e.x.startsWith(id) || id.startsWith(e.x))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((e) => e.x);
}
const ATOM_SHAPE = /^(?:[A-Za-z][A-Za-z0-9]*|[0-9]+[A-Za-z][A-Za-z0-9]*)$/;
class LayerParser {
i = 0;
constructor(
readonly toks: Tok[],
readonly layer: number,
readonly errors: RuleParseError[],
readonly custom: Set<string>,
readonly opts: RuleParseOptions,
) {}
peek() {
return this.toks[this.i];
}
err(code: RuleParseError["code"], message: string, pos: number, extra: Partial<RuleParseError> = {}) {
this.errors.push({ code, message, position: pos, layer: this.layer, ...extra });
}
or(): RuleExpr | null {
const first = this.and();
if (!first) return null;
const args = [first];
while (this.peek()?.t === "|") {
this.i++;
const n = this.and();
if (!n) return null;
args.push(n);
}
return args.length === 1 ? first : flatten({ op: "or", args });
}
and(): RuleExpr | null {
const first = this.unary();
if (!first) return null;
const args = [first];
while (this.peek()?.t === "&") {
this.i++;
const n = this.unary();
if (!n) return null;
args.push(n);
}
return args.length === 1 ? first : flatten({ op: "and", args });
}
unary(): RuleExpr | null {
const t = this.peek();
if (t?.t === "!") {
this.i++;
const a = this.unary();
return a ? { op: "not", arg: a } : null;
}
return this.primary();
}
primary(): RuleExpr | null {
const t = this.peek();
if (!t) {
const last = this.toks[this.toks.length - 1];
this.err("unexpected_token", "表达式不完整,缺少条件", last ? last.pos + last.v.length : 0);
return null;
}
if (t.t === "(") {
this.i++;
const e = this.or();
if (!e) return null;
if (this.peek()?.t !== ")") {
this.err("unbalanced_paren", "括号不配对:缺少 )", t.pos);
return null;
}
this.i++;
return e;
}
if (t.t === "atom") {
this.i++;
if (!ATOM_SHAPE.test(t.v)) {
this.err("unexpected_token", `「${t.v}」不是合法条件名(字母开头,或数字加字母如 4K)`, t.pos);
return null;
}
const id = t.v.toUpperCase();
const b = BUILTIN_ATOM_MAP.get(id);
if (b && !b.supported)
this.err("unsupported_atom", `${id}(${b.name})不支持:音乐资源条件没有迁移`, t.pos, { atom: id });
else if (!b && !this.custom.has(id) && !this.opts.allowUnknown) {
const suggestions = suggestAtoms(id, this.custom);
this.err(
"unknown_atom",
`未知条件 ${id}${suggestions.length ? `,是不是 ${suggestions.join(" / ")}` : ""}`,
t.pos,
{ atom: id, suggestions },
);
}
return { op: "atom", id };
}
if (t.t === ")") this.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
else if (t.t === "bad") this.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
else this.err("unexpected_token", `「${t.v}」前缺少条件`, t.pos);
return null;
}
}
function flatten(e: RuleExpr): RuleExpr {
if (e.op !== "and" && e.op !== "or") return e;
const args: RuleExpr[] = [];
for (const a of e.args) {
const f = flatten(a);
if (f.op === e.op) args.push(...f.args);
else args.push(f);
}
return { op: e.op, args };
}
/** 规范化:同类 AND/OR 展平、原子大写(结构等价比较与指纹用) */
export function normalizeExpr(e: RuleExpr): RuleExpr {
switch (e.op) {
case "atom":
return { op: "atom", id: e.id.toUpperCase() };
case "not":
return { op: "not", arg: normalizeExpr(e.arg) };
default:
return flatten({ op: e.op, args: e.args.map(normalizeExpr) });
}
}
/** 解析一层表达式(不含 `>`);offset 用于报错位置 */
export function parseExpr(
s: string,
opts: RuleParseOptions = {},
layer = 0,
offset = 0,
): { expr: RuleExpr | null; errors: RuleParseError[] } {
const errors: RuleParseError[] = [];
const toks = lex(s, offset);
if (toks.length === 0) {
errors.push({ code: "empty_layer", message: `第 ${layer + 1} 层是空的`, position: offset, layer });
return { expr: null, errors };
}
const p = new LayerParser(
toks,
layer,
errors,
new Set([...(opts.customAtoms ?? [])].map((x) => x.toUpperCase())),
opts,
);
const expr = p.or();
if (expr && p.i < toks.length && errors.length === 0) {
const t = toks[p.i]!;
if (t.t === ")") p.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
else if (t.t === "bad") p.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
else p.err("unexpected_token", `「${t.v}」前缺少 & 或 |`, t.pos);
}
return { expr: errors.length ? null : expr, errors };
}
/** 规则串 → 结构化层(`>` 分层,第 1 层 pri=100) */
export function parseRuleString(s: string, opts: RuleParseOptions = {}): RuleParseResult {
const errors: RuleParseError[] = [];
const layers: RuleLayer[] = [];
if (!s.trim()) return { layers, errors: [{ code: "empty", message: "规则串是空的", position: 0, layer: 0 }] };
let offset = 0;
s.split(">").forEach((part, k) => {
const r = parseExpr(part, opts, k, offset);
errors.push(...r.errors);
if (r.expr) layers.push({ expr: r.expr });
offset += part.length + 1;
});
return { layers: errors.length ? [] : layers, errors };
}
const PREC = { or: 1, and: 2, not: 3, atom: 4 } as const;
/** 表达式 → 规则串(原子大写,只加必要的括号) */
export function serializeExpr(e: RuleExpr, parent = 0): string {
switch (e.op) {
case "atom":
return e.id.toUpperCase();
case "not":
return `!${serializeExpr(e.arg, PREC.not)}`;
case "and": {
const s = e.args.map((a) => serializeExpr(a, PREC.and)).join(" & ");
return parent > PREC.and ? `(${s})` : s;
}
case "or": {
const s = e.args.map((a) => serializeExpr(a, PREC.or)).join(" | ");
return parent > PREC.or ? `(${s})` : s;
}
}
}
/** 结构化层 → 规则串 */
export function serializeLayers(layers: readonly RuleLayer[]): string {
return layers.map((l) => serializeExpr(l.expr)).join(" > ");
}
/** 表达式引用到的原子 id(去重) */
export function atomsOf(e: RuleExpr, out = new Set<string>()): Set<string> {
if (e.op === "atom") out.add(e.id.toUpperCase());
else if (e.op === "not") atomsOf(e.arg, out);
else for (const a of e.args) atomsOf(a, out);
return out;
}
/** MP pyparsing as_list() 形状 → RuleExpr(差分测试用):"A" | ["not", X] | [X, "and", Y, "and", Z] */
export function fromMpParseList(v: unknown): RuleExpr {
if (typeof v === "string") return { op: "atom", id: v.toUpperCase() };
const a = v as unknown[];
if (a.length === 1) return fromMpParseList(a[0]);
if (a[0] === "not") return { op: "not", arg: fromMpParseList(a.length === 2 ? a[1] : a.slice(1)) };
const op = a[1] as "and" | "or";
return flatten({ op, args: a.filter((_, i) => i % 2 === 0).map(fromMpParseList) });
}
+32
View File
@@ -0,0 +1,32 @@
export {
ATOM_ID_RE,
type AtomHit,
type AtomInput,
BUILTIN_ATOM_MAP,
BUILTIN_ATOMS,
type BuiltinAtom,
compileMpPattern,
customAtomFromMp,
testCustomAtom,
} from "./atoms.ts";
export {
type AtomCatalog,
catalogOf,
evaluateRuleChain,
evaluateRuleGroup,
matchesExpr,
testAtom,
} from "./evaluate.ts";
export {
atomsOf,
fromMpParseList,
normalizeExpr,
parseExpr,
parseRuleString,
type RuleParseOptions,
type RuleParseResult,
serializeExpr,
serializeLayers,
suggestAtoms,
} from "./expr.ts";
export * from "./types.ts";
+100
View File
@@ -0,0 +1,100 @@
import type { EvaluationContext } from "../slot/types.ts";
// 规则组(对照 MP 规则组 `name + rule_string`):结构化层 + 硬排除。规则串只是它的一种文本表示。
/** 条件表达式:内置关键字 / 自定义条件的 AND / OR / NOT 树 */
export type RuleExpr =
| { op: "atom"; id: string }
| { op: "not"; arg: RuleExpr }
| { op: "and"; args: RuleExpr[] }
| { op: "or"; args: RuleExpr[] };
/** 优先级阶梯的一层;第 k 层(0 起)命中 → pri = 100 - k */
export interface RuleLayer {
name?: string;
expr: RuleExpr;
}
export type RuleGroupKind = "acquisition" | "upgrade" | "search";
/** 参与求值的规则组(server 从 rule_group 行编译) */
export interface RuleGroupSpec {
id?: string;
name: string;
revision?: number;
layers: RuleLayer[];
/** 任一表达式命中即拒绝 */
hardExcludes?: RuleExpr[];
/** 一层都不命中即拒绝(MP 语义);缺省 true。软偏好组为 false:不中只是 pri 低 */
ladderRequired?: boolean;
}
/** 自定义条件(对照 MP 自定义规则 7 字段;同 id 不允许覆盖内置) */
export interface CustomAtom {
id: string;
name?: string;
/** 正则(不区分大小写),至少命中一条 */
include?: string[];
/** 正则,一条都不能命中 */
exclude?: string[];
/** 匹配范围,缺省 标题+副标题+标签 */
matchFields?: ("title" | "subtitle" | "labels")[];
/** 体积区间(MB,1 MB = 1024²),剧集按每集折算 */
unitSizeMB?: { min?: number; max?: number };
minSeeders?: number;
/** 发布后经过的分钟数区间(只给 min = 至少发布这么久) */
publishDelayMinutes?: { min?: number; max?: number };
/** 只要免费 */
free?: boolean;
}
/** 规则求值上下文:站点事实 + 作品事实(tmdb 放行条件) */
export interface RuleContext extends EvaluationContext {
/** 作品原语言(TMDB original_language),CNSUB/CNVOI 据此放行 */
originalLanguage?: string;
/** 发布后经过的分钟数 */
publishedMinutesAgo?: number;
}
export interface RuleParseError {
code:
| "empty"
| "empty_layer"
| "unknown_atom"
| "unsupported_atom"
| "unbalanced_paren"
| "unexpected_token"
| "invalid_char";
message: string;
/** 在整个规则串里的字符偏移(0 起) */
position: number;
/** 第几层(0 起) */
layer: number;
atom?: string;
suggestions?: string[];
}
export interface RuleGroupResult {
groupId?: string;
name: string;
revision?: number;
/** 命中的硬排除表达式(规则串形式) */
excludedBy: string[];
/** 命中的层(0 起);null = 未命中或没有层 */
level: number | null;
/** 本组 pri;没有层时为 null(不参与"pri 取最后一组") */
pri: number | null;
/** 本组放行(未被硬排除,且有层时命中某层或 ladderRequired=false) */
passed: boolean;
trace: string[];
}
export interface RuleChainResult {
passed: boolean;
/** 最后一个带层的组的 pri;整条链都没有层时为 null */
pri: number | null;
/** 决定 pri 的组(下标) */
decidingGroup: number | null;
groups: RuleGroupResult[];
trace: string[];
}
+79 -8
View File
@@ -1,4 +1,6 @@
import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts"; import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts";
import { catalogOf, evaluateRuleChain } from "../rules/evaluate.ts";
import type { RuleChainResult } from "../rules/types.ts";
import type { import type {
EvaluationContext, EvaluationContext,
HardField, HardField,
@@ -288,6 +290,10 @@ export function describeViolation(v: Violation): string {
return `非免费(下载系数 ${a})`; return `非免费(下载系数 ${a})`;
case "tier.no_tier_matched": case "tier.no_tier_matched":
return "不命中优先级阶梯的任何一层"; return "不命中优先级阶梯的任何一层";
case "rule.hard_exclude":
return `命中规则组「${e}」的硬排除 ${fmtList(a)}`;
case "rule.no_layer_matched":
return `不命中规则组「${e}」的任何一层`;
case "upgrade.upgrade_disabled": case "upgrade.upgrade_disabled":
return "已有版本且未开启升级"; return "已有版本且未开启升级";
case "upgrade.at_ceiling": case "upgrade.at_ceiling":
@@ -366,8 +372,47 @@ export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: Evaluation
/** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */ /** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */
export function currentPri(spec: SlotSpec, current: QualityFacts): number { export function currentPri(spec: SlotSpec, current: QualityFacts): number {
if (typeof current.pri === "number") return current.pri; if (typeof current.pri === "number") return current.pri;
const pseudo = { raw: "", hdr: [], isEncode: false, ...current } as unknown as ParsedRelease; const pseudo = { raw: "", hdr: [], audio: [], isEncode: false, ...current } as unknown as ParsedRelease;
return priorityLadder(spec, pseudo).pri; return effectiveLadder(spec, pseudo, {}, ruleChainOf(spec, pseudo, {})).pri;
}
/** 版本目标引用的规则组链求值;没有规则组时为 null */
export function ruleChainOf(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): RuleChainResult | null {
if (!spec.rules?.groups.length) return null;
return evaluateRuleChain(spec.rules.groups, r, ctx, catalogOf(spec.rules.atoms));
}
/**
* 实际生效的阶梯:版本目标自身 soft.tiers 视为排在最前的匿名组,规则组链里有带层的组时 pri 取最后一个带层的组。
*/
export function effectiveLadder(
spec: SlotSpec,
r: ParsedRelease,
ctx: EvaluationContext,
chain: RuleChainResult | null,
): LadderResult {
const own = priorityLadder(spec, r, ctx);
if (!chain) return own;
if (chain.pri === null || chain.decidingGroup === null) return { ...own, trace: [...own.trace, ...chain.trace] };
const g = chain.groups[chain.decidingGroup]!;
return {
pri: chain.pri,
level: g.level,
levelName: g.level === null ? null : `${g.name}·第${g.level + 1}层`,
trace: [...own.trace, ...chain.trace],
};
}
function chainViolations(chain: RuleChainResult | null, spec: SlotSpec): Violation[] {
if (!chain) return [];
const out: Violation[] = [];
chain.groups.forEach((g, i) => {
if (g.excludedBy.length) out.push({ field: "rule", code: "hard_exclude", expected: g.name, actual: g.excludedBy });
const required = spec.rules!.groups[i]!.ladderRequired !== false;
if (g.pri !== null && g.level === null && required)
out.push({ field: "rule", code: "no_layer_matched", expected: g.name });
});
return out;
} }
function prefIndexScore<T>(pref: readonly T[] | undefined, have: readonly T[]): number { function prefIndexScore<T>(pref: readonly T[] | undefined, have: readonly T[]): number {
@@ -420,21 +465,43 @@ export function evaluateCandidate(
current?: QualityFacts | null, current?: QualityFacts | null,
ctx: EvaluationContext = {}, ctx: EvaluationContext = {},
): SlotEvaluation { ): SlotEvaluation {
const { violations, missing, accepted } = checkHardConditions(spec, r, ctx); const hard = checkHardConditions(spec, r, ctx);
const ladder = priorityLadder(spec, r, ctx); const { missing, accepted } = hard;
const chain = ruleChainOf(spec, r, ctx);
const withRules = chain ? { rules: chain } : {};
const violations = [...hard.violations, ...chainViolations(chain, spec)];
const ladder = effectiveLadder(spec, r, ctx, chain);
if (violations.length > 0) if (violations.length > 0)
return { decision: "reject", violations, trace: [...violations.map(describeViolation), ...ladder.trace], ladder }; return {
decision: "reject",
violations,
trace: [...violations.map(describeViolation), ...ladder.trace],
ladder,
...withRules,
};
if (missing.length > 0) if (missing.length > 0)
return { decision: "unknown_field", missing, trace: missing.map((f) => `字段未知,需要补充:${f}`) }; return { decision: "unknown_field", missing, trace: missing.map((f) => `字段未知,需要补充:${f}`) };
if (spec.soft?.tiers?.length && spec.soft.tiersRequired && ladder.level === null) { if (spec.soft?.tiers?.length && spec.soft.tiersRequired && ladder.level === null) {
const v: Violation = { field: "tier", code: "no_tier_matched" }; const v: Violation = { field: "tier", code: "no_tier_matched" };
return { decision: "reject", violations: [v], trace: [describeViolation(v), ...ladder.trace], ladder }; return {
decision: "reject",
violations: [v],
trace: [describeViolation(v), ...ladder.trace],
ladder,
...withRules,
};
} }
const tier = qualityTier(r, spec); const tier = qualityTier(r, spec);
const rank = softRank(spec, r); const rank = softRank(spec, r);
const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`); const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`);
const tiered = !!spec.soft?.tiers?.length; const tiered = !!spec.soft?.tiers?.length || (chain?.pri ?? null) !== null;
const priReasons = tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : []; const ruleReasons = (chain?.groups ?? []).flatMap((g) =>
g.groupId ? [`rule_group:${g.groupId}${g.revision !== undefined ? `@${g.revision}` : ""}`] : [],
);
const priReasons = [
...(tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : []),
...ruleReasons,
];
if (!current) if (!current)
return { return {
decision: "accept", decision: "accept",
@@ -444,6 +511,7 @@ export function evaluateCandidate(
pri: ladder.pri, pri: ladder.pri,
ladder, ladder,
trace: ["硬条件全部满足", ...ladder.trace], trace: ["硬条件全部满足", ...ladder.trace],
...withRules,
}; };
const reject = (v: Violation): SlotEvaluation => ({ const reject = (v: Violation): SlotEvaluation => ({
@@ -451,6 +519,7 @@ export function evaluateCandidate(
violations: [v], violations: [v],
trace: [describeViolation(v), ...ladder.trace], trace: [describeViolation(v), ...ladder.trace],
ladder, ladder,
...withRules,
}); });
const up = spec.upgrade; const up = spec.upgrade;
if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" }); if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" });
@@ -473,6 +542,7 @@ export function evaluateCandidate(
fromPri, fromPri,
ladder, ladder,
trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace], trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace],
...withRules,
}; };
} }
if (!current.source) if (!current.source)
@@ -509,6 +579,7 @@ export function evaluateCandidate(
pri: ladder.pri, pri: ladder.pri,
ladder, ladder,
trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace], trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace],
...withRules,
}; };
} }
+2
View File
@@ -18,6 +18,7 @@ export {
currentPri, currentPri,
DEFAULT_SOURCE_TIERS, DEFAULT_SOURCE_TIERS,
describeViolation, describeViolation,
effectiveLadder,
evaluateCandidate, evaluateCandidate,
improvableUnits, improvableUnits,
priorityLadder, priorityLadder,
@@ -25,6 +26,7 @@ export {
type RankedEvaluation, type RankedEvaluation,
RESOLUTION_RANK, RESOLUTION_RANK,
rankForSlot, rankForSlot,
ruleChainOf,
softRank, softRank,
unitCountOf, unitCountOf,
validateKeywordPattern, validateKeywordPattern,
+17 -2
View File
@@ -7,6 +7,7 @@ import type {
Resolution, Resolution,
VideoCodec, VideoCodec,
} from "../release-parser/types.ts"; } from "../release-parser/types.ts";
import type { CustomAtom, RuleChainResult, RuleGroupSpec } from "../rules/types.ts";
/** 可判定的硬条件字段 */ /** 可判定的硬条件字段 */
export type HardField = export type HardField =
@@ -115,10 +116,18 @@ export interface SlotUpgradePolicy {
export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" }; export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" };
/** 版本目标引用的规则组链(已按 scope 过滤、按引用顺序排列)与自定义条件 */
export interface SlotRuleChain {
groups: RuleGroupSpec[];
atoms?: CustomAtom[];
}
export interface SlotSpec { export interface SlotSpec {
id?: string; id?: string;
name: string; name: string;
hard: SlotHardConditions; hard: SlotHardConditions;
/** 规则组:硬排除 → 拒绝;各组须命中某层(ladderRequired);pri 取最后一个带层的组(覆盖 soft.tiers 的 pri) */
rules?: SlotRuleChain;
unknownPolicy?: UnknownPolicy | Partial<Record<HardField, UnknownPolicy>>; unknownPolicy?: UnknownPolicy | Partial<Record<HardField, UnknownPolicy>>;
soft?: SlotSoftPreferences; soft?: SlotSoftPreferences;
upgrade?: SlotUpgradePolicy; upgrade?: SlotUpgradePolicy;
@@ -151,6 +160,10 @@ export interface EvaluationContext {
downloadVolumeFactor?: number; downloadVolumeFactor?: number;
/** 候选覆盖的内容单元数(每集体积折算);未给时按解析出的集范围/总集数推断 */ /** 候选覆盖的内容单元数(每集体积折算);未给时按解析出的集范围/总集数推断 */
unitCount?: number; unitCount?: number;
/** 作品原语言(TMDB original_language),规则组 CNSUB/CNVOI 据此放行 */
originalLanguage?: string;
/** 发布后经过的分钟数(自定义条件 publishDelayMinutes) */
publishedMinutesAgo?: number;
} }
/** 阶梯判定结果:pri 0–100 与逐层说明 */ /** 阶梯判定结果:pri 0–100 与逐层说明 */
@@ -169,7 +182,7 @@ export interface QualityTier {
} }
export interface Violation { export interface Violation {
field: HardField | "upgrade" | "tier"; field: HardField | "upgrade" | "tier" | "rule";
code: string; code: string;
expected?: unknown; expected?: unknown;
actual?: unknown; actual?: unknown;
@@ -184,6 +197,7 @@ export type SlotEvaluation =
pri?: number; pri?: number;
ladder?: LadderResult; ladder?: LadderResult;
trace?: string[]; trace?: string[];
rules?: RuleChainResult;
} }
| { | {
decision: "upgrade"; decision: "upgrade";
@@ -195,6 +209,7 @@ export type SlotEvaluation =
fromPri?: number; fromPri?: number;
ladder?: LadderResult; ladder?: LadderResult;
trace?: string[]; trace?: string[];
rules?: RuleChainResult;
} }
| { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult } | { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult; rules?: RuleChainResult }
| { decision: "unknown_field"; missing: HardField[] | ["current_asset"]; trace?: string[] }; | { decision: "unknown_field"; missing: HardField[] | ["current_asset"]; trace?: string[] };
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,184 @@
/**
* MoviePilot 规则串 / 规则组链 差分测试(W9-D)。
*
* rules-cases.json 由 scripts/mp-sync/gen-rules-differential.py 用 MP 的 Python 实现生成:
* - parse:每个规则串按 `>` 切层后 pyparsing RuleParser 的 as_list();
* - cases:FilterModule.filter_torrents(多组串联,pri 取最后一组)的 pri_order 与 debug trace。
* 我们用 parseRuleString → evaluateRuleChain 求同样的 pri。
* 断言:解析结构全部一致;pri 不一致集合 == deviations 登记集合(补迁消除差异后测试会失败,提醒删登记)。
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
BUILTIN_ATOMS,
catalogOf,
customAtomFromMp,
evaluateRuleChain,
fromMpParseList,
normalizeExpr,
parseRelease,
parseRuleString,
type RuleChainResult,
type RuleGroupSpec,
serializeLayers,
} from "../../src/index.ts";
interface Torrent {
id: string;
title: string;
description: string;
size: number;
seeders: number;
labels?: string[];
downloadvolumefactor?: number;
}
interface Rule {
id: string;
groups: string[];
mediainfo?: { original_language?: string };
}
interface Case {
caseId: string;
ruleId: string;
torrentId: string;
mp: { pri: number | null; trace: string[]; rustTrace?: string[] };
}
type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "not_expressible";
interface Deviation {
kind: DeviationKind;
note: string;
mp?: number | null;
ours?: number | null;
}
interface Fixture {
generatedFrom: { commit: string };
customRules: Record<string, Record<string, string>>;
torrents: Torrent[];
rules: Rule[];
parse: Record<string, unknown[]>;
cases: Case[];
deviations: Record<string, Deviation>;
}
const fx = JSON.parse(readFileSync(new URL("./rules-cases.json", import.meta.url), "utf8")) as Fixture;
const custom = Object.entries(fx.customRules).map(([id, r]) => customAtomFromMp(id, r));
const catalog = catalogOf(custom);
const customIds = custom.map((a) => a.id);
const torrents = new Map(fx.torrents.map((t) => [t.id, t]));
const rules = new Map(fx.rules.map((r) => [r.id, r]));
function groupsOf(rule: Rule): RuleGroupSpec[] {
return rule.groups.map((s, i) => {
const p = parseRuleString(s, { customAtoms: customIds, allowUnknown: true });
if (p.errors.length) throw new Error(`${rule.id}: ${JSON.stringify(p.errors)}`);
return { name: `${rule.id}#${i}`, layers: p.layers };
});
}
function ours(c: Case): RuleChainResult {
const rule = rules.get(c.ruleId)!;
const t = torrents.get(c.torrentId)!;
const parsed = { ...parseRelease(t.title, t.description ? { subtitle: t.description } : {}), sizeBytes: t.size };
return evaluateRuleChain(
groupsOf(rule),
parsed,
{
...(t.description ? { subtitle: t.description } : {}),
labels: t.labels ?? [],
seeders: t.seeders,
downloadVolumeFactor: t.downloadvolumefactor ?? 1,
...(rule.mediainfo?.original_language ? { originalLanguage: rule.mediainfo.original_language } : {}),
},
catalog,
);
}
const priOf = (r: RuleChainResult) => (r.passed ? (r.pri ?? 100) : null);
describe("MP 规则串解析差分(rules-cases.json#parse)", () => {
it("≥40 条规则链,覆盖全部支持的内置关键字", () => {
expect(fx.rules.length).toBeGreaterThanOrEqual(40);
const used = new Set(Object.keys(fx.parse).flatMap((s) => s.match(/[A-Za-z0-9]+/g) ?? []));
const supported = BUILTIN_ATOMS.filter((a) => a.supported).map((a) => a.id);
expect(supported.filter((id) => !used.has(id))).toEqual([]);
});
it("每个规则串的分层与层内结构 == MP RuleParser", () => {
for (const [s, mpLayers] of Object.entries(fx.parse)) {
const p = parseRuleString(s, { customAtoms: customIds, allowUnknown: true });
expect(p.errors, s).toEqual([]);
expect(
p.layers.map((l) => l.expr),
s,
).toEqual(mpLayers.map((l) => normalizeExpr(fromMpParseList(l))));
}
});
it("序列化后再解析结构不变(规则串双向映射)", () => {
for (const s of Object.keys(fx.parse)) {
const a = parseRuleString(s, { customAtoms: customIds, allowUnknown: true }).layers;
const text = serializeLayers(a);
const b = parseRuleString(text, { customAtoms: customIds, allowUnknown: true }).layers;
expect(b, `${s} → ${text}`).toEqual(a);
}
});
});
describe("MP 规则组过滤差分(rules-cases.json#cases)", () => {
it("pri 不一致集合 == deviations 登记集合", () => {
const actual: Record<string, { mp: number | null; ours: number | null }> = {};
for (const c of fx.cases) {
const o = priOf(ours(c));
if (o !== c.mp.pri) actual[c.caseId] = { mp: c.mp.pri, ours: o };
}
if (process.env.UMT_RULES_DUMP) console.log(`UMT_RULES_DUMP ${JSON.stringify(actual)}`);
expect(Object.keys(actual).sort()).toEqual(Object.keys(fx.deviations).sort());
for (const [k, v] of Object.entries(fx.deviations)) {
expect(["intentional", "missing", "our_bug", "mp_bug", "not_expressible"]).toContain(v.kind);
expect(actual[k], k).toEqual({ mp: v.mp ?? null, ours: v.ours ?? null });
}
// 一致率(报告用):登记外全部一致
expect(fx.cases.length - Object.keys(actual).length).toBeGreaterThan(fx.cases.length * 0.9);
});
it("trace 可读:过滤给出未命中层与原子依据,放行给出命中层与 pri,未知条件写明不存在", () => {
for (const c of fx.cases) {
const r = ours(c);
const text = r.trace.join("\n");
if (!r.passed) expect(text, c.caseId).toMatch(/未命中|硬排除|过滤/);
const withLayers = r.groups.some((g) => g.pri !== null);
if (r.passed && withLayers) expect(text, c.caseId).toMatch(/命中,pri=\d+/);
// MP trace 里提到的"不包含任何项"对应我们 ✗ 的原子依据
if (c.mp.trace.some((x) => x.startsWith("不包含任何项")) && !r.passed) expect(text).toMatch(/✗/);
if (c.mp.trace.some((x) => /规则 \w+ 不存在/.test(x))) expect(text).toMatch(/条件不存在/);
}
});
});
describe("规则串错误", () => {
it("未知条件给位置与相近名;音乐条件不支持;括号/空层", () => {
const a = parseRuleString("4K & CNSUBB > 1080P");
expect(a.errors[0]).toMatchObject({ code: "unknown_atom", position: 5, layer: 0, atom: "CNSUBB" });
expect(a.errors[0]!.suggestions).toContain("CNSUB");
expect(parseRuleString("FLAC").errors[0]).toMatchObject({ code: "unsupported_atom", atom: "FLAC" });
expect(parseRuleString("(4K | 1080P").errors[0]).toMatchObject({ code: "unbalanced_paren", position: 0 });
expect(parseRuleString("4K)").errors[0]).toMatchObject({ code: "unbalanced_paren", position: 2 });
expect(parseRuleString("4K > > 720P").errors[0]).toMatchObject({ code: "empty_layer", layer: 1, position: 4 });
expect(parseRuleString("4K 1080P").errors[0]).toMatchObject({ code: "unexpected_token", position: 3 });
expect(parseRuleString("4K & $").errors[0]).toMatchObject({ code: "invalid_char", position: 5 });
expect(parseRuleString("").errors[0]!.code).toBe("empty");
expect(parseRuleString("4k & !remux").layers.map((l) => l.expr)).toEqual([
{
op: "and",
args: [
{ op: "atom", id: "4K" },
{ op: "not", arg: { op: "atom", id: "REMUX" } },
],
},
]);
});
it("序列化只加必要括号", () => {
const s = "SPECSUB & CNSUB > (4K | 1080P) & !(BLU | REMUX) > !!3D | 720P & H265";
expect(serializeLayers(parseRuleString(s).layers)).toBe(s);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
currentPri,
evaluateCandidate,
parseRelease,
parseRuleString,
type RuleGroupSpec,
rankForSlot,
type SlotSpec,
} from "../src/index.ts";
const group = (name: string, s: string, extra: Partial<RuleGroupSpec> = {}): RuleGroupSpec => ({
name,
layers: s ? parseRuleString(s).layers : [],
...extra,
});
const base = (groups: RuleGroupSpec[], extra: Partial<SlotSpec> = {}): SlotSpec => ({
name: "main",
hard: {},
rules: { groups },
...extra,
});
const rel = (t: string, subtitle?: string) => parseRelease(t, subtitle ? { subtitle } : {});
describe("版本目标接入规则组", () => {
it("硬排除命中 → reject(rule.hard_exclude),trace 带组名与修订号", () => {
const g = group("不要原盘", "", {
id: "g1",
revision: 3,
hardExcludes: parseRuleString("BLU | REMUX").layers.map((l) => l.expr),
});
const ev = evaluateCandidate(base([g]), rel("Movie.2023.1080p.BluRay.REMUX.AVC-EPSiLON"));
expect(ev.decision).toBe("reject");
if (ev.decision !== "reject") return;
expect(ev.violations).toEqual([
{ field: "rule", code: "hard_exclude", expected: "不要原盘", actual: ["BLU | REMUX"] },
]);
expect(ev.trace?.join("\n")).toContain("规则组「不要原盘」@r3 硬排除 BLU | REMUX 命中");
});
it("多组串联取交集,pri 取最后一个带层的组", () => {
const spec = base([group("分辨率", "4K > 1080P"), group("字幕", "CNSUB > !CNSUB"), group("空组", "")]);
const a = evaluateCandidate(spec, rel("Dune.2024.1080p.WEB-DL.H264-HHWEB", "中字"));
expect(a).toMatchObject({ decision: "accept", pri: 100 });
const b = evaluateCandidate(spec, rel("Dune.2024.2160p.WEB-DL.H265-FLUX"));
expect(b).toMatchObject({ decision: "accept", pri: 99 });
const c = evaluateCandidate(spec, rel("Dune.2024.720p.WEBRip.x264-GRP"));
expect(c.decision).toBe("reject");
if (c.decision === "reject")
expect(c.violations[0]).toMatchObject({ code: "no_layer_matched", expected: "分辨率" });
});
it("ladderRequired=false 的软偏好组不中只降 pri,不拒绝", () => {
const spec = base([group("偏好", "SPECSUB", { ladderRequired: false })]);
const r = rel("X.2024.1080p.WEB-DL.H264-G", "特效中字");
expect(evaluateCandidate(spec, r, null, { subtitle: "特效中字" })).toMatchObject({ pri: 100 });
expect(evaluateCandidate(spec, rel("X.2024.1080p.WEB-DL.H264-G"))).toMatchObject({ decision: "accept", pri: 0 });
});
it("有规则组阶梯时升级按 pri 严格提升(洗版)", () => {
const spec = base([group("洗版", "REMUX > BLURAY > WEBDL")], { upgrade: { enabled: true } });
const up = evaluateCandidate(spec, rel("M.2023.1080p.BluRay.REMUX.AVC-E"), {
source: "WEB-DL",
resolution: "1080p",
pri: 98,
});
expect(up).toMatchObject({ decision: "upgrade", pri: 100, fromPri: 98 });
const same = evaluateCandidate(spec, rel("M.2023.1080p.WEB-DL.H264-G"), { source: "WEB-DL", pri: 98 });
expect(same.decision).toBe("reject");
expect(currentPri(spec, { source: "REMUX", resolution: "1080p" })).toBe(100);
});
it("作品原语言中文时 CNSUB 放行(MP tmdb 条件)", () => {
const spec = base([group("中字", "CNSUB")]);
const r = rel("Movie.2023.1080p.WEB-DL.H264-G");
expect(evaluateCandidate(spec, r).decision).toBe("reject");
expect(evaluateCandidate(spec, r, null, { originalLanguage: "zh" })).toMatchObject({ decision: "accept" });
});
it("rankForSlot 按规则组 pri 排序", () => {
const spec = base([group("阶梯", "4K > 1080P > 720P")]);
const ranked = rankForSlot(spec, [
rel("A.720p.WEB-DL.x264-G"),
rel("A.2160p.WEB-DL.x265-G"),
rel("A.1080p.WEB-DL.x264-G"),
]);
expect(ranked.map((x) => x.evaluation.decision === "accept" && x.evaluation.pri)).toEqual([100, 99, 98]);
});
it("没有规则组时行为不变", () => {
const r = rel("Movie.2023.1080p.WEB-DL.H264-G");
expect(evaluateCandidate(base([]), r)).toEqual(evaluateCandidate({ name: "main", hard: {} }, r));
});
});
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""规则组(W9-D)差分期望:MP 规则串解析 + 多规则组串联过滤的 pri 与 trace。
用法(必须用 MP 自己的 venv):
MP_ROOT=/opt/moviepilot-plus/upstream/MoviePilot \
$MP_ROOT/.venv/bin/python scripts/mp-sync/gen-rules-differential.py
输出 packages/domain/test/differential/rules-cases.json:
- parse:每个规则串按 `>` 切层后 RuleParser.parse(layer).as_list()(pyparsing,Python 参考实现)
- cases:FilterModule.filter_torrents(Python 回退路径)对每个 (规则组链 × 种子) 的 pri_order;
trace 为 Python 路径的 debug 日志(逐条规则失败原因),rustTrace 为 filter_torrents_with_trace_fast 的调试输出
- deviations 不由本脚本生成,重跑时保留已有登记。
只读调用上游,不复制上游源码。
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import patch
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("gen_differential", HERE / "gen-differential.py")
gd = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
assert spec and spec.loader
spec.loader.exec_module(gd) # type: ignore[union-attr]
OUT = gd.DOMAIN_DIFF / "rules-cases.json"
GiB = 1024 ** 3
EXTRA_TORRENTS: list[dict[str, Any]] = [
{"id": "t29", "title": "Infernal.Affairs.2002.1080p.BluRay.x264.DTS-HK-GRP", "description": "无间道 粤语 中字", "size": 12 * GiB, "seeders": 6},
{"id": "t30", "title": "Movie.2019.1080p.WEB-DL.SDR.H264-GRP", "description": "", "size": 5 * GiB, "seeders": 5},
{"id": "t31", "title": "Show.S01E05.2160p.WEB-DL.HDR10+.H265-GRP", "description": "官种 特效中字", "labels": ["官种"], "size": 4 * GiB, "seeders": 30, "downloadvolumefactor": 0.5},
{"id": "t32", "title": "Movie.2018.1080p.BluRay.Remux.AVC.Atmos-GRP", "description": "国配 杜比全景声", "size": 28 * GiB, "seeders": 2},
]
TORRENTS = gd.TORRENTS + EXTRA_TORRENTS
# groups:规则组链(按顺序串联取交集,pri 取最后一组);单组即单条规则串
RULES: list[dict[str, Any]] = [
*({"id": r["id"], "groups": [r["rule"]], **({"mediainfo": r["mediainfo"]} if r.get("mediainfo") else {})} for r in gd.RULES),
{"id": "X01", "groups": ["SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU"]},
{"id": "X02", "groups": ["HKVOI"]},
{"id": "X03", "groups": ["SDR"]},
{"id": "X04", "groups": ["HDR & !DOLBY"]},
{"id": "X05", "groups": ["DOLBY | HDR"]},
{"id": "X06", "groups": ["H264 > H265"]},
{"id": "X07", "groups": ["!(BLU | REMUX) & 4K"]},
{"id": "X08", "groups": ["BLURAY"]},
{"id": "X09", "groups": ["FREE | SEED5"]},
{"id": "X10", "groups": ["UHD & REMUX > 4K"]},
{"id": "X11", "groups": ["CNVOI | HKVOI > !CNVOI"]},
{"id": "X12", "groups": ["!!4K"]},
{"id": "X13", "groups": ["4K & (CNSUB | SPECSUB) & !3D"]},
{"id": "X14", "groups": ["ATMOS & (REMUX | BLU)"]},
{"id": "X15", "groups": ["GZ > FREE > 4K"]},
{"id": "X16", "groups": ["CNVOI"], "mediainfo": {"original_language": "zh"}},
{"id": "X17", "groups": ["SIZESMALL > SIZEBIG"]},
{"id": "X18", "groups": ["1080P & CNSUB | 4K & !CNSUB"]},
{"id": "X19", "groups": ["60FPS | 3D > ATMOS"]},
{"id": "M01", "groups": ["4K | 1080P", "CNSUB > !CNSUB"]},
{"id": "M02", "groups": ["!BLU", "4K > 1080P > 720P"]},
{"id": "M03", "groups": ["4K > 1080P", "WEBDL"]},
{"id": "M04", "groups": ["CNSUB", "SIZEMOVIE", "H265 > H264"]},
{"id": "M05", "groups": ["REMUX > BLU > WEBDL", "!DOLBY"]},
]
def main() -> int:
root = gd._filter_mp_root(None)
if str(root) not in sys.path:
sys.path.insert(0, str(root))
old = os.getcwd()
os.chdir(root)
try:
from app.adapters.system import rust as rust_accel
from app.application.rules import BUILTIN_RULE_SET, RuleParser
from app.domain.context import TorrentInfo
from app.modules import filter as filter_mod
from app.modules.filter import FilterModule
finally:
os.chdir(old)
rule_set = {**BUILTIN_RULE_SET, **gd.CUSTOM_RULES}
class _RuleHelper:
def __init__(self, groups):
self._groups = groups
def get_rule_group_by_media(self, media=None, group_names=None): # noqa: ARG002
return self._groups
def mk(t: dict) -> Any:
return TorrentInfo(
site_name="S", title=t["title"], description=t.get("description") or "",
size=t["size"], seeders=t.get("seeders") or 0, labels=t.get("labels") or [],
downloadvolumefactor=t.get("downloadvolumefactor", 1.0),
)
parser = RuleParser()
parse_out: dict[str, Any] = {}
for r in RULES:
for g in r["groups"]:
if g in parse_out:
continue
with patch("app.application.rules._filter_rule_parser", None):
parse_out[g] = [parser.expr.parse_string(layer.strip()).as_list()[0] for layer in g.split(">")]
def run(r: dict, t: dict) -> tuple[Optional[int], list[str], list[str]]:
module = FilterModule()
groups = [SimpleNamespace(name=f"{r['id']}#{i}", rule_string=g) for i, g in enumerate(r["groups"])]
module.rulehelper = _RuleHelper(groups)
module.rule_set = rule_set
mi = SimpleNamespace(**r["mediainfo"]) if r.get("mediainfo") else None
logs: list[str] = []
with patch("app.modules.filter.rust_accel.is_enabled", return_value=False), \
patch.object(filter_mod.logger, "debug", side_effect=lambda m, *a, **k: logs.append(str(m))):
res = module.filter_torrents(rule_groups=[g.name for g in groups], torrent_list=[mk(t)], mediainfo=mi)
pri = res[0].pri_order if res else None
rust_trace: list[str] = []
native = getattr(rust_accel, "_moviepilot_rust", None)
if rust_accel.is_enabled() and native is not None and hasattr(native, "filter_torrents_with_trace_fast"):
try:
_m, rust_trace = native.filter_torrents_with_trace_fast(
[vars(g) for g in groups], [mk(t)], rule_set, mi, filter_mod._rust_parse_options(),
)
except BaseException: # noqa: BLE001
rust_trace = []
return pri, logs, [str(x) for x in rust_trace]
cases = []
for r in RULES:
for t in TORRENTS:
pri, logs, rtrace = run(r, t)
# 去掉每行重复的 "种子 S - <标题> " 前缀;Rust trace 与 Python 相同时不重复存
prefix = f"种子 S - {t['title']} "
strip = lambda xs: [x[len(prefix):].strip() if x.startswith(prefix) else x for x in xs] # noqa: E731
mp: dict[str, Any] = {"pri": pri, "trace": strip(logs)}
if rtrace and rtrace != logs:
mp["rustTrace"] = strip(rtrace)
cases.append({"caseId": f"{r['id']}:{t['id']}", "ruleId": r["id"], "torrentId": t["id"], "mp": mp})
deviations: dict = {}
if OUT.exists():
try:
deviations = json.loads(OUT.read_text("utf-8")).get("deviations", {})
except Exception: # noqa: BLE001
deviations = {}
doc = {
"generatedFrom": {
"repo": "MoviePilot", "commit": gd._filter_mp_commit(root),
"engine": "python-fallback (FilterModule, rust_accel disabled); parse = pyparsing RuleParser",
"generator": "scripts/mp-sync/gen-rules-differential.py",
"semantics": "groups 串联取交集,pri = 最后一组命中层的 pri_order(100 起逐层 -1);null = 被过滤",
},
"customRules": gd.CUSTOM_RULES,
"torrents": TORRENTS,
"rules": RULES,
"parse": parse_out,
"cases": cases,
"deviations": deviations,
}
OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=1) + "\n", "utf-8")
print(f"rules: rules={len(RULES)} cases={len(cases)} parse={len(parse_out)}")
return 0
if __name__ == "__main__":
sys.exit(main())