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; 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(); 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,.-\\]])" + "|(? (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)(? { 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 = 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" / " (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; }