test(porting): differential tests against MoviePilot Rust-accelerated parsers

Snapshot differential tests (known deviations registered, current state passes):
- title/path recognition: 216 samples vs MP MetaInfo/MetaInfoPath
- filter rules: 42 rules x 28 torrents vs MP FilterModule
- RSS/indexer parsing: 15 feeds, 72 indexer samples
- zhconv: 69 samples
Generator: scripts/mp-sync/gen-differential.py; report: docs/porting/mp-rust-logic-parity.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:44:11 +08:00
1 parent b86f75c011
commit 591cc78c7d
44 files changed
+32206

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
# MoviePilot Rust 加速函数:产品逻辑迁移核对(差分测试)
> 上游 `/opt/moviepilot-plus/upstream/MoviePilot` @ `e6820a8`,`moviepilot_rust` 0.3.3。函数清单见 `moviepilot-rust-transfer.md` §1。
> 方法:用 MP 自己的 venv 调用上游实现生成期望值(`scripts/mp-sync/gen-differential.py`),我们的 TS 实现跑同样的输入,逐字段比较。已知差异写进快照的 `deviations`,测试断言"实际差异集合等于登记集合"。所以现在测试是通过的;以后补迁消掉一条差异,测试会失败,提醒删掉对应登记。
> 差异分类:**一致** / **intentional**(故意偏离,README 或 porting 文档有记录)/ **missing**(漏迁)/ **our_bug**(疑似我们的 bug)/ **mp_bug**(疑似 MP 的缺陷,我们的结果更好)/ **representation**(只是表示方式不同)/ **not_expressible**(Slot 表达不了)。
## 0. 结论
| Rust 函数 | 业务链路 | 差分规模 | 一致率 | missing / our_bug / mp_bug |
| --- | --- | --- | --- | --- |
| `parse_metainfo_fast` / `parse_metainfo_path_fast` | 搜索/订阅/RSS 的标题识别;整理规划(MetaInfoPath);过滤器按集算体积 | 216 条(标题 186、路径 30)× 18 字段 | 字段级 92.9%;扣掉 intentional/representation/mp_bug 后 97.5%;整条完全一致 84 条(38.9%),产品等价 177 条(81.9%) | 14 条 / 27 条 / 28 条样本 |
| `find_metainfo_fast` | 标题中的显式 ID 标签 | 11 条带标签 | 8/11 | 2 / 0 / 1 |
| `parse_filter_rule_fast` / `filter_torrents(_with_trace)_fast` | 搜索、订阅匹配、洗版、工作流过滤、下载排序 | 43 条规则;28 种子 × 23 条可完整表达规则 = 644 case | 95.5%(615/644);另有 3 条只能近似表达的规则,84 case 里 31 处不一致,全是能力缺口 | 26 / 14 / 7(另 not_expressible 13) |
| `parse_rss_items_fast` | 订阅刷新(站点 RSS 模式)、工作流 fetch_rss、插件 | 15 份 Feed / 36 条,245 字段 | 95.5%(234/245) | 0 / 0 / 4(另 intentional 12) |
| `parse_indexer_torrents_fast` / `parse_indexer_subtitles_fast` | 站点搜索/浏览(SiteSpider.parse);字幕搜索 | 72 样本 / 127 行,2108 字段 | 98.1%(2069/2108) | 1(字幕分支)/ 4 / 35 |
| `zhconv_fast` | 识别查询名简体化、中文别名判定、音乐元数据 | 69 条 | 84.1%(58/69) | 10 / 0 / 1 |
| `jieba_cut` | 仅用于"整理历史按标题模糊搜索"(含 Agent 工具) | 静态对照 | — | 不需要迁 |
MP 自身两条路径也不一致:标题识别的 Python 实现和 Rust 快路径在 4 条样本上输出不同(`meta-mp.json#rustMismatch`:T-06、A-03、Z-06、Z-08);索引解析在 **40/72** 个样本上不同(`indexer.json` 的 `mp_rust`);RSS 的 Atom/RDF 命名空间也不同(Python 0 条,Rust 有结果)。过滤器的 Python 和 Rust 交叉核对 0 处不一致。我们的对照基准一律用 **Python 参考实现**。
## 1. 产物与复现
| 文件 | 内容 |
| --- | --- |
| `scripts/mp-sync/gen-differential.py {meta,zhconv,filter,rss,indexer,all}` | 生成器,必须用 `$MP_ROOT/.venv/bin/python` 运行。只读调用上游,不复制上游源码 |
| `packages/domain/test/differential/meta-{samples,mp,deviations}.json` + `meta-map.ts` + `meta.test.ts` | 标题识别。`meta-map.ts` 把双方输出规范化到同一形式(名称忽略大小写和标点、来源归到 REMUX/BluRay/WEB-DL/…、HDR 集合、编码族、音频去分隔符、发布组集合) |
| `packages/domain/test/differential/filter-cases.json` + `filter.test.ts` | 过滤规则 |
| `packages/domain/test/differential/zhconv-*.json` + `zhconv.test.ts` | 繁简转换 |
| `packages/adapters/test/differential/{rss,indexer}.{json,test.ts}`、`diff-util.ts`、`inputs/**`、`export-inputs.ts` | RSS 和索引解析(§4、§5)。生成前先跑 `deno run -A packages/adapters/test/differential/export-inputs.ts` 导出 testkit fixture;生成器把时区固定为 UTC,并冻结 dateparser 的 now,重跑结果逐字节相同 |
样本来源:我们已有的 107 条(`release-parser.test.ts` 91 条标题加 16 条路径)、MP `tests/cases/meta.py` 的 76 条(只用标题字符串作输入语料,属于事实数据;这份语料的来源**待登记进 ledger**)、补充 33 条(综艺、动漫、多集、合集、国配、特效字幕、显式 ID)。
## 2. parse_metainfo_fast / parse_metainfo_path_fast(标题与路径识别)
**用途**:`MetaInfo()` 用于种子标题、站点副标题和文件名,在搜索、订阅匹配、RSS 刷新、过滤器按集算体积(`total_episode`)时调用。`MetaInfoPath()` 用于整理链路(`transfer/request.py _build_path_meta`)。Rust 只是快路径,返回 None 时回退 Python。
**分组结果**(216 条):
| 来源 | 条数 | 完全一致 | 产品等价 | 说明 |
| --- | --- | --- | --- | --- |
| 我们已有样本 | 107 | 46 | 106 | 差异几乎都是 README 登记的故意偏离 |
| MP 自带用例 | 76 | 29 | 43 | **our_bug 65 处里有 57 处在这里**,主要是动漫/字幕组命名 |
| 补充 | 33 | 9 | 28 | — |
按字段看一致率:resource_effect 和 edition 100%;part 99.1%;year 98.6%;video_encode 96.8%;audio_encode 96.3%;begin_episode 89.8%;type 88.9%;begin_season 84.3%;name 81.9%;resource_team 72.7%(59 处差异中 52 处是故意兜底)。`customization` 双方都没配置,不参与比较。
### 2.1 our_bug(27 条样本,按根因归并)
| # | 输入(节选) | MP | 我们 | 建议 |
| --- | --- | --- | --- | --- |
| B1 | `【爪爪字幕组】★7月新番[欢迎来到…教室 第二季/…]`(MP-03/17/18/20/32/45/56/63) | cn=`欢迎来到实力至上主义的教室` | cn=`★7月新番欢迎来到…` / `国漫寻剑` / `22年日剧据…` | 剥离动漫前缀标签:`★N月新番★`、`N年日剧`、`国漫`、`新番` |
| B2 | `[桜都字幕组] 异世界归来的舅舅 / Isekai Ojisan [01][1080p]`(MP-44/45、Z-07/27) | E=1,T=TV | en=`Isekai Ojisan 01`,无集号 | 名称后紧跟的 `[NN]` / `[NN][分辨率]` 识别为集号(我们已支持 `[Group][Name][05]`,不支持 `Name [05]`) |
| B3 | `[秋叶原冥途战争][Akiba Maid Sensou][2022]…[LeagueWEB]`(MP-05/13/27/57) | grp=LeagueWEB | grp=`秋叶原冥途战争` | 开头方括号含中文或能解析成片名时不当作组;末尾 `[已知组]` 优先 |
| B4 | `[Caligula.The.Ultimate.Cut.2023.2160p…-BHYS@OurBits][DIY中字原盘][…].iso`(MP-74) | 正常解析出 en、y、2160p、UHD BluRay、HEVC、DTS-HD MA 5.1 | 名称取成 `DIY`,技术字段全丢 | 迁 MP 的 `BRACKET_DOT_TITLE_RE`:方括号内是"点分隔发布名 + 年份 + 资源词"时去括号保留内容 |
| B5 | `The Long Season 2017 2160p…`(MP-01) | en=`The Long Season` y=2017 | 第 2017 季,年份被吞 | `Season` 后的数字 ≥1900 时按年份处理(MP 限制 Season 后 <3 位) |
| B6 | `2022.8.2.Twelve.Monkeys.1995…`(MP-52),父目录 `…Collection.20240501.FRDS`(MP-73) | 电影 | 按 airDate 判成 TV | 标题**开头**的日期、以及和年份同时出现的日期,不当作播出日期;父目录的 airDate 不参与类型合并 |
| B7 | `[YTS.MX] …`(MP-76)、`[Hall_of_C] 诛仙 …`(MP-60) | 名称干净 | 组名混进英文名 | 开头方括号里是单个 token(含 `.`/`_`)时当作组名剥离 |
| B8 | `组长女儿与照料专员 / 组长女儿与保姆 …`(MP-21);`传颂之物/Utawarerumono/うたわれるもの`(MP-55) | 取其一 | 两个中文名被拼接,或三段没拆开 | `/` 分隔的多名称按语言拆分,中文取一个 |
| B9 | `哆啦A梦:大雄的宇宙小战争`(MP-06) | cn 含 A | `A` 被拆成英文名 | 中文词中夹的单个拉丁字母并入中文名 |
| B10 | `[Gal to Kyouryuu][02][BDRIP]`(MP-24) | en=`Gal To Kyouryuu` | en=`BDRIP` | 资源词不能当名称 |
| B11 | `DTS-HD MA5.1`(MP-48)、`E13-14`(MP-51)、`【极影字幕社】…GB`(MP-63) | `DTS-HD MA 5.1`、E13-14、组=极影字幕社 | `DTSHD`、只有 E13、组缺失且 GB 进了名称 | 各补一条词元规则 |
### 2.2 missing(14 条样本)
| # | 输入 | MP | 我们 | 建议 |
| --- | --- | --- | --- | --- |
| M1 | `{[tmdbid=94997;type=tv;s=1;e=3-4]}House.of.the.Dragon…`(Z-31) | tmdb:94997,S1,E3-4,TV | 整块进名称 | 迁 `{[k=v;…]}` 显式身份块(也见 §3) |
| M2 | `[bangumiid=400602]`(Z-32) | bangumi:400602 | 无 | ExplicitId 增加 bangumi/anilist |
| M3 | `#13`(MP-25)、`[TV 04]`(MP-57)、`西部世界 12.mkv`(MP-29)、`S01 13-14`(MP-50) | 能识别集号 | 集号为空 | 补充 4 种集号写法 |
| M4 | `Mai Xiang S01 …`,副标题 `麦香 \| 全36集`(MP-64) | cn=麦香 | 无中文名 | 迁"英文名是拼音时从副标题第一个词取中文名" |
| M5 | `[1080]`(MP-05)、`[5.1]`(MP-12) | 1080p、5.1 | 空 | 补方括号内的裸分辨率和裸声道 |
| M6 | `iP`(T-13)、`B-Global`(MP-10) | BBC iPlayer、B-Global | 空 | 流媒体平台表补齐(MP 有约 270 条) |
| M7 | `cXcY@FRDS`(MP-49/54)、`指环王三部曲合集`(Z-12) | 两个组都识别;去掉"合集" | 只有 FRDS;保留"合集" | 支持 `£` 分隔符;"合集/三部曲合集"列为干扰词 |
### 2.3 intentional(与 README 偏离表一致,125 处)
未知发布组兜底 52 条;第 N 期记为 `issue` 12 条;有集号无季号时推定第 1 季 23 条;播出日期 → airDate 且判为 TV 6 条;路径合并(文件无剧集信号、目录是剧集)3 条;辅助词字幕文件(P-07);附加内容目录(P-05);`CD1`/`D02` 作为 part;`第八季 全12期` 保留季号;先导片。**每一条都能在 `packages/domain/README.md` 的偏离表或 `moviepilot-metadata-recognition.md` 里找到依据,没有发现未经记录的故意偏离。**
### 2.4 mp_bug(28 条样本,保留我们的行为)
- MetaAnime(anitopy 路径)不输出来源和编码(15 条)。
- 不认 `1x05`、`S01D02`、`Season.2`、`BD50`、`{doubanid=}`。
- 丢中文名(MP-13/44/55、Z-07)。
- 把 `[Sub]` 当成名称。
- `AC3£` 没切开,音频丢失。
- 把 `7³` 拼进音频。
- `AVC-8bit` 的结果 Python 和 Rust 不一致(Z-06)。
representation 共 10 处:`name` 的取法(我们有中文名就取中文,MP 只在中文名全是汉字时才取);`HMAX` 映射成 Max 还是 HBO Max;`2Audio(s)`;`flac_ac3` 记成一条还是两条。
## 3. find_metainfo_fast(显式 ID 标签)
**用途**:从标题里剥离 `[tmdbid=…]`、`{tmdb-…}`、`{[k=v;…]}`、`[bangumiid=…]`,得到 media_source/media_id 以及季、集、类型。在 `_prepare_meta_input` 里调用。差分并入 §2 的 `media_id` 字段:8 条 Emby 风格的 TMDB 标签(`[tmdbid=]`、`[tmdbid-]`、`[tmdb=]`、`{tmdb=}`、`{tmdb-}`)**全部一致**。不一致的 3 条:M1、M2 是漏迁;`{doubanid=}` 是 mp_bug,我们能认。另外我们多支持 imdb 和 tvdb,MP 不认。**建议**:把 `{[…]}` 块(含 `type/s/e/g`)和 bangumi 补上。按不变量 1,它们仍只作为证据加一级,不直接确认身份。
## 4. parse_rss_items_fast(RSS/Atom)
**用途**:`RssHelper._parse_impl`(`application/rss.py`)先走 Rust(最多 1001 条,截到 1000),返回 None 时回退 lxml。调用方:`TorrentsChain.rss(domain)`(订阅刷新的站点 RSS 模式)、工作流 `fetch_rss`、插件。解析失败时触发 `__renew_rss_url` 重新生成地址。
**MP 字段语义**:title 取第一个后代 `title` 的文本(不 strip,为空就跳过);description 取 `description|summary`;link 取文本,没有就取 href;enclosure 取 `@url`,缺失回退到 link;**size 只取 `enclosure@length` 的纯数字**(description 里的大小、Torznab/nyaa 的 size 都不读);pubDate/published/updated 用 `dateutil.parse` 后 `astimezone(None)`(不带时区的按宿主本地时区);`dc:creator` 输出为 nickname;不输出 guid 和 category。促销、HR、做种这些字段 RSS 路径都没有。
**差分**:15 份输入(testkit 2 份、手写 13 份),MP Python 解析出 36 条,逐字段比较标题、描述、链接、enclosure、大小、pubdate、作者,共 245 个字段,一致 234 个。登记 16 条(`packages/adapters/test/differential/rss.json#deviations`):
| 输入 | MP | 我们 | 分类 | 建议 |
| --- | --- | --- | --- | --- |
| 带 xmlns 的标准 Atom、RSS 1.0(RDF) | Python 0 条(Rust 有) | 3 条 / 1 条 | mp_bug | 保持 |
| 纯文本"RSS 已过期"提示 | 在"不以 `<` 开头"处就提前返回,过期判断走不到,续期永远不触发 | `FeedError('expired')` | mp_bug | 保持;续期逻辑我们自己做 |
| 条目内 `<source><title>` 排在前面 | 取到来源标题 | 取直接子元素 `title` | mp_bug | 保持 |
| enclosure 带 passkey | 原样 | `[REDACTED]` | intentional(SA5) | — |
| 带 DTD | `[]` | 整份拒绝 `forbidden_dtd` | intentional | — |
| 值里有换行缩进、`length=" 2048 "` | 保留空白,大小为 0 | trim 后大小为 2048 | intentional | 在 `rss/README` 补登记 |
| `&nbsp;` 实体 | 在此截断,标题变成 "Nbsp" | 保留字面量 | intentional | P3:补 HTML 命名实体解码表 |
| 未闭合标签 | 容错恢复 2 条 | 1 条 | intentional | — |
| pubDate `… CST`、纯日期 `2026-09-26` | 按宿主本地时区解释 | 按美国中部 -6 / UTC 午夜 | intentional(两边都可能错) | P2:不带时区的时间按站点 `timezone` 解释 |
RFC822、ISO、无秒、星期写错这几类两边一致;中文日期、"3 小时前"、纯数字两边都不解析。
## 5. parse_indexer_torrents_fast / parse_indexer_subtitles_fast(站点列表页)
**用途**:`SiteSpider.parse`(`modules/indexer/spider/__init__.py`)先走 Rust,返回 None 时回退 PyQuery 的 `get_info`。链路是 `IndexerModule.search_torrents`(关键词搜索)和 `refresh_torrents`(浏览 `browse.list/fields`);字幕分支 `search_type=="subtitles"` 走 `get_subtitle_info` 和 `__fill_subtitle_ids`,由 `/api/search` 字幕接口经 `SearchChain.search_subtitles_*` 调用。
**MP 字段语义**:size 用 `parse_size`(二进制单位,Python 银行家舍入);做种、下载、完成数取 `/` 前的部分,去掉逗号,不是纯数字时为 0;促销 case 按声明顺序取第一个命中(另有 selector 模式,用 `int()`);freedate 是原样文本;HR 配置了就输出 bool;category 投影成电影/电视剧/音乐/未知;pubdate 是站点墙上时间;imdbid 用 `re_search`;labels 是列表。
**能否独立调用**:`SiteSpider(indexer).parse(html)` 和 `RssHelper` 注入假 HTTP 端口后可以离线调用。**但 MP 的内置站点定义调不出来**:它们在 Cython `app/application/site/sites.*.so` 和加密的 `user.sites.v3.bin` 里,未认证时 `get_indexer()` 返回 `{}`,`SitesHelper()` 还依赖数据库事务执行器。所以双方共用我们的 `definitions/*.json`(与 MP 同一 schema,来自 MovieClaw)作为输入,**MP 真实站点的选择器只能静态对照**。
**差分**:72 个样本(21 个 NexusPHP 定义 × 3 个列表页、2 个登录页、3 个手写 DSL 配置、1 个字幕页),MP 解析出 127 行,比较 title、description、size、seeders、peers、grabs、促销系数、freedate、HR、分类、pubdate、链接、imdbid、labels 共 2108 个字段,一致 2069 个。登记 41 条(`indexer.json#deviations`):
| 输入 | MP | 我们 | 分类 | 建议 |
| --- | --- | --- | --- | --- |
| hdarea、hddolby、hdfans 等 9 个定义的列表选择器命中表头行 | Jinja 把 None 渲染成标题 "None" | 空串 | mp_bug ×32 | 两边都会产出垃圾行:**收紧这 9 个定义** |
| 相对时间 "2天"、"3小时前" | 用 dateparser 按当前时刻换算,结果随抓取时间漂移 | 不写 pubdate | mp_bug | 保持(生成时冻结了 now) |
| 促销 selector 取到 "0.5" | `int()` 报错,整行丢失 | 保留 | intentional(SA4) | — |
| `"2.5 B"` | 2(银行家舍入) | 3 | our_bug | P3:10 分钟 |
| 行元素自身带 `free_bg`/`sticky` class | PyQuery 查询包含行自身,得到 dl=0、hr=true、labels | `.find` 只查后代 | our_bug ×3 | **P1:先决定跟 Python 还是 Rust**(MP 的 Rust 和我们一样),再改 `spider/spider.ts` 或在 README 登记 |
| 字幕页 | 解析出 3 条 | 没有这个分支 | missing | P1:补字幕分支,期望数据已在 `indexer.json` |
**静态发现(差分之外)**:`table.torrents > tr:has(...)` 这种写法在显式带 `<tbody>` 的页面上,我们和 MP Python 都取到 0 行,MP Rust 能取到 5 行。受影响的定义有 agsvpt、chdbits、hdhome、keepfrds、ourbits、pter、pthome、ssd、tjupt。建议在 `scripts/mp-sync/movieclaw-overrides/` 统一补 `, table.torrents > tbody > tr` 这一支(audiences 已经这样写)。
## 6. parse_filter_rule_fast / filter_torrents(_with_trace)_fast(过滤规则)
**调用点**:`FilterModule.filter_torrents(rule_groups, torrents, mediainfo)`。先走 Rust,它返回 None 时回退 Python(规则解析在 `app/application/rules.py`,基于 pyparsing)。
- **搜索**:`chain/search/result.py`,按站点过滤。规则组用 `SearchFilterRuleGroups`。
- **订阅匹配 / RSS 刷新**:`chain/subscribe/match.py`,每个种子单独过滤。规则组依次取 `subscribe.filter_groups`、`BestVersionFilterRuleGroups`(洗版)、`SubscribeFilterRuleGroups`。洗版时 `pri_order <= current_priority` 的种子会被拒;`current_priority == 100` 表示已完成。
- **其他调用**:订阅搜索、工作流 filter 动作、规则测试接口、下载排序(把 pri_order 当排序键)。
- **规则组选取**:按 `media_type` 和 `category` 选组;多个组之间是 AND 关系。
**语法**:用 `>` 切出优先层,第 1 层 pri_order=100,依次递减。层内运算优先级为 `!` > `&` > `|`,可以加括号。规则名不存在时视为不匹配。单条规则的判定顺序是:tmdb 条件命中就直接放行;然后在 `title+description+labels` 上跑 include/exclude 正则;接着检查 `size_range`(单位 MB,**按每集算**,即总大小除以 MetaInfo 得到的 `total_episode`)、`seeders`、`downloadvolumefactor`、`publish_time`(延迟 N 分钟)。
**内置关键字 → Slot**:
| 能表达 ✔ | 近似 ~ | 不能 ✗ |
| --- | --- | --- |
| `4K/1080P/720P` → `hard.resolutions`;`BLU/REMUX/WEBDL` 及其取反 → `sources` 允许列表加 `unknownPolicy`;`BLURAY & !BLU` → `requireEncode` | `CNSUB` → `requiredSubtitles:["zh"]`(缺华语片由 tmdb 放行这条);体积区间用总量而非每集;`A > B` 用多个 Slot 或 `rankForSlot`(按来源等级优先,不按层级) | `UHD`、`H265/H264`(只有软偏好)、`HDR/DOLBY/SDR`(只有软偏好)、`ATMOS/60FPS/3D`、`SPECSUB`(特效字幕)、`CNVOI/HKVOI`(国配/粤配;我们把"国语"当音轨词剔掉)、`GZ`(官方标签)、`FREE`、做种数、`publish_time`、跨字段 OR、音乐的 13 条规则 |
**43 条规则样例的结论**:✔ 22、~ 6、✗ 15。完整表格见 `filter-cases.json#rules`,其中 `verdict` 和 `note` 是逐条结论。有代表性的几条:
- `4K & !REMUX > 1080P & WEBDL` ✔,用两层 Slot 表达。
- `SPECSUB & CNSUB & !BLU` ~,SPECSUB 那部分丢了。
- `!DOLBY & !REMUX` ~,只能表达 `!REMUX`。
- `CNSUB | 4K` ✗,属于跨字段 OR。
- `FREE`、`SEED5`、`GZ` ✗,这些是站点元数据。
**差异**(不一致共 61 处已登记,完整在 `filter-cases.json#deviations`):
| 输入 | MP | 我们 | 分类 | 建议 |
| --- | --- | --- | --- | --- |
| `2160p UHD Blu-ray HEVC … -BeyondHD`、`1080p Blu-ray AVC DTS-HD MA` | BLU(原盘) | BluRay 压制(isEncode) | our_bug ×12 | Blu-ray 加 AVC/HEVC/VC-1、且没有 x26x 时,判为 `BluRay-Disc` |
| 副标题 `CHS/ENG` | 中字 | 不识别 | our_bug ×2 | 字幕词元补 CHS/CHT/GB/BIG5 |
| `S03 全10集 60G` 配体积规则 | 按每集 6G 判 | 按总量 60G 判 | missing ×8 | Slot 加 `sizeBasis: per_episode` |
| 华语片 + CNSUB | tmdb 放行 18 个种子 | 拒绝 | missing | 允许条件依赖媒体上下文 |
| `2160p.HDR.HEVC-NOSRC`(没有来源) | 误命中 BLU | 来源未知 | mp_bug ×5 | 保持我们的做法 |
| 副标题只有 `国语` | 算作中字 | 当作音轨 | mp_bug ×2 | 保持;以后另加 audioLang 条件 |
| 单 Slot 的 `4K > 1080P > 720P` | 4K 总排在前面 | 先比来源等级,共 44 个逆序对 | intentional | 要复现 MP 的语义,就用多层 Slot 或优先级阶梯 |
**trace 对比**:MP 的 `filter_torrents_with_trace_fast` 返回 `([(index, pri_order)], [str])`,trace 是中文自由文本,只写进 debug 日志。例如 `种子 X 不包含任何项 [...]`、`大小 5G 不在范围 10000-50000MB`、`做种人数 3 小于 5`、`规则 X 不存在`。它不区分"字段未知"和"不满足";OR 分支里失败的原子也会被打印出来。我们的 `SlotEvaluation` 是结构化的:`violations[{field, code, expected, actual}]` 会列出全部违规,`unknown_field` 单独给出,accept 时带 `tier/softRank/reasons`。**缺两样**:命中的是第几层(对应 pri_order),以及逐层的失败原因。
## 7. zhconv_fast(繁简转换)
**用途**:`app/foundation/text.py convert()` 直接调用 `moviepilot_rust.zhconv_fast`,**没有 Python 回退**,原生扩展是硬依赖。调用点:
- TMDB/豆瓣/IMDb 识别时,把 `cn_name` 简体化后加入查询名(`themoviedb/__init__.py:285` 等)。
- TMDB 中文别名的"是否已是简体"判定(`tmdbapi.py:981`)。
- MetaAnime 分类标签判定。
- 音乐元数据简体化。
用的是 MediaWiki 词表,**有词组级转换**。
**差分**:69 条(动漫、港台片名、一对多字)中 58 条一致。
- missing 10 条:其中 6 条是字表缺字(迴、滾、魷、瑯、臥、駭);4 条是一对多字(乾杯、瞭解、頭髮、理髮),我们刻意不收,MP 靠词组表处理。
- mp_bug 1 条:`慶餘年` 转成了 `庆馀年`,官方简体片名是 `庆余年`。
**我们的替代**:`identity/zh-map.ts` 大约 930 对单字映射,只在 `normalizeTitle` 里用于召回比较。召回本来就会再经过 pg_trgm 相似度和别名表,一两个字不同不会直接失败。
**建议**:暂不引入 `opencc-js`(约 1 MB 词典,MIT 许可,需要按需加载)。先把字表补到覆盖 TMDB 中文标题的常用繁体字(把本差分的 missing 清零,约 0.5 小时)。等到要把"简体化后的查询名"作为 TMDB 查询词时(对应 MP `_prepare_search_names`)再评估 opencc。它能提供词组转换和台湾/香港用词转换(如 蜘蛛人 → 蜘蛛侠),代价是包体积和冷启动。
## 8. jieba_cut(中文分词)
**用途**:只有两处,都是"整理历史按标题模糊搜索":`application/history.py:712` 用 `"%".join(jieba_cut(title))` 生成 SQL LIKE 模式,另一处是 Agent 工具 `query_transfer_history`。**不参与识别、匹配和过滤。**同样没有 Python 回退。
**我们的替代**:资产和别名检索用 pg_trgm(`0004_content`、`0006_sources` 迁移)加 `normalizeTitle`。三元组相似度天然不依赖分词,对中文短标题的召回不比"分词后 LIKE"差。
**结论**:**不引入 `jieba-wasm`**。它的代价是 wasm 约 4 MB、词典加载 100 ms 级,而收益只落在历史搜索这一个次要功能上。以后如果要做全文检索,优先考虑 PG 的 `zhparser` 或 `pg_bigm`,而不是在应用层分词。
## 9. 补迁清单(按优先级)
| 优先级 | 项 | 涉及文件 | 工作量 | 消除的登记 |
| --- | --- | --- | --- | --- |
| P0 | 原盘判定:Blu-ray 加 AVC/HEVC/VC-1 且无 x26x → `BluRay-Disc`;`BDMV/BD25/50/66/100`、`COMPLETE BLURAY` | `release-parser/parse.ts` | 0.5 天 | filter our_bug ×12 |
| P0 | 动漫/字幕组命名:前缀标签剥离(B1)、`Name [NN]` 集号(B2)、开头方括号是片名还是组名(B3、B7)、`/` 多名称拆分(B8) | `release-parser/parse.ts`、`release-groups.ts` | 1.5 天 | meta our_bug 约 40 处 |
| P0 | `[点分隔发布名][…]` 去括号(B4);`Season 2017` 按年份处理(B5);开头日期和目录日期不当 airDate(B6) | `parse.ts`、`path.ts` | 0.5 天 | meta our_bug 约 15 处 |
| P1 | Slot 硬条件扩展:`excludeSources`、hdr require/exclude、codec 硬条件、`sizeBasis: per_episode`、`requireUhd` | `slot/types.ts`、`slot/evaluate.ts` | 1 天 | filter missing ×8,规则可表达数 22 → 约 30 |
| P1 | 字幕和音轨语言:CHS/CHT/GB/BIG5 识别为中字;国语、粤语作为 `audioLanguages`;特效字幕标记 | `parse.ts`、`types.ts`、Slot | 1 天 | filter our_bug ×2,SPECSUB/CNVOI 可表达 |
| P1 | 显式身份块 `{[tmdbid;type;s;e;g]}`,以及 bangumi/anilist | `parse.ts`(`explicitIds`) | 0.5 天 | meta missing ×2 |
| P1 | 优先级阶梯:`SlotSpec.tiers[]`,评估结果带 `level`,洗版时按 level 比较 | `slot/*`、server `acquisition.ts` | 1–1.5 天 | filter R05 intentional |
| P2 | 集号写法 `#13`、`[TV 04]`、`名称 12.mkv`、`S01 13-14`;裸 `[1080]`、`[5.1]`;`MA5.1`、`E13-14` | `parse.ts` | 0.5 天 | meta missing/our_bug 约 10 处 |
| P2 | 站点元数据条件:minSeeders、freeOnly、publishDelay、labels(需要改 `evaluateCandidate` 的签名) | `slot/*`、server | 1 天 | FREE/SEED/GZ 可表达 |
| P2 | 流媒体平台表补齐;`£` 多组分隔;"合集"作干扰词;拼音英文名从副标题取中文名 | `parse.ts`、`release-groups.ts` | 0.5 天 | meta missing 约 6 处 |
| P1 | 站点定义:9 个定义补 `tbody` 分支,9 个定义收紧表头 | `movieclaw-overrides/*.json` | 0.5 天 | indexer mp_bug ×32 的根因 |
| P1 | 选择器是否包含行元素自身的语义(case/hr/labels) | `sites/spider/spider.ts` | 0.5 天 | indexer our_bug ×3 |
| P1 | 字幕页解析分支(`get_subtitle_info`、`__fill_subtitle_ids`) | `spider/spider.ts`、`spider/schema.ts`、adapter 接口 | 1–1.5 天 | indexer missing ×1 |
| P2 | RSS 中不带时区的时间按站点时区解释 | `rss/parse.ts` | 0.5 天 | rss intentional ×2 |
| P3 | zh-map 补字(§7);`parseSizeMp` 改银行家舍入;RSS 补 HTML 命名实体 | `identity/zh-map.ts`、`sites/spider`、`rss/parse.ts` | 2 小时 | zhconv missing ×6、indexer our_bug ×1 |
**不迁**:MetaAnime 不输出来源和编码的行为(mp_bug);期号当集号、日期删除、未知组置空(与 README 偏离表一致);jieba。
@@ -0,0 +1,89 @@
/**
* MP 差分测试共用工具:读取 scripts/mp-sync/gen-differential.py 生成的期望 JSON,登记偏差集合与实际偏差集合比对。
* 偏差 key:`<sampleId>.status` / `<sampleId>.count` / `<sampleId>#<行号>.<字段>`。
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const HERE = dirname(fileURLToPath(import.meta.url));
export type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug";
export interface Deviation {
kind: DeviationKind;
note: string;
/** 仅在条件成立时才出现的偏差;local_tz_not_utc = 宿主本地时区偏移非 0 */
condition?: "local_tz_not_utc";
}
function conditionHolds(d: Deviation): boolean {
if (d.condition === "local_tz_not_utc") {
return new Date("2026-09-26T00:00:00").getTimezoneOffset() !== 0;
}
return true;
}
export interface DifferentialDoc<S> {
kind: string;
generatedFrom: Record<string, string>;
fields: string[];
samples: S[];
deviations: Record<string, Deviation>;
}
export function loadDoc<S>(name: string): DifferentialDoc<S> {
return JSON.parse(readFileSync(join(HERE, name), "utf8")) as DifferentialDoc<S>;
}
export function readInput(rel: string): string {
return readFileSync(join(HERE, rel), "utf8");
}
export interface Mismatch {
key: string;
mp: unknown;
ours: unknown;
}
/** null / undefined 视为同一"缺省";其余按 JSON 结构比较 */
export function sameValue(a: unknown, b: unknown): boolean {
if (a == null && b == null) return true;
return JSON.stringify(a) === JSON.stringify(b);
}
export class FieldCollector {
readonly mismatches: Mismatch[] = [];
compared = 0;
equal = 0;
check(key: string, mp: unknown, ours: unknown): void {
this.compared++;
if (sameValue(mp, ours)) this.equal++;
else this.mismatches.push({ key, mp, ours });
}
}
/** 实际不一致集合 vs 登记集合:返回两个方向的差集(均为空才通过)。 */
export function diffAgainstRegistry(
actual: Mismatch[],
registry: Record<string, Deviation>,
): { unregistered: Mismatch[]; stale: string[] } {
const actualKeys = new Set(actual.map((m) => m.key));
return {
unregistered: actual.filter((m) => !(m.key in registry)),
stale: Object.entries(registry)
.filter(([k, d]) => !actualKeys.has(k) && conditionHolds(d))
.map(([k]) => k),
};
}
/** 设置环境变量 DIFF_DUMP_DIR 时把实际不一致写成 <name>.mismatches.json(整理 deviations 用) */
export function dumpMismatches(name: string, ms: Mismatch[], stats?: { compared: number; equal: number }): void {
const dir = process.env.DIFF_DUMP_DIR;
if (dir) {
writeFileSync(join(dir, `${name}.mismatches.json`), `${JSON.stringify({ stats, mismatches: ms }, null, 2)}\n`);
}
}
export function formatMismatches(ms: Mismatch[]): string {
return ms.map((m) => `${m.key}\n mp: ${JSON.stringify(m.mp)}\n ours: ${JSON.stringify(m.ours)}`).join("\n");
}
@@ -0,0 +1,73 @@
/**
* 把 @umt/testkit 的 mock RSS / NexusPHP fixture 导出成文件,供 MP 差分生成脚本(scripts/mp-sync/gen-differential.py rss|indexer)读取。
* 运行:deno run -A packages/adapters/test/differential/export-inputs.ts
* 只写 `inputs/rss/tk-*.xml` 与 `inputs/indexer/tk-*.html.txt`;手写样本(hw-*)不受影响。
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
AUDIENCES_FIXTURE_RELEASES,
CHDBITS_LIST_PAGE,
CHDBITS_LOGIN_PAGE,
createMockNexusPhpSite,
HHANCLUB_LIST_PAGE,
type MockFeedItem,
renderAtom,
renderRss,
} from "@umt/testkit";
const here = dirname(fileURLToPath(import.meta.url));
const rssDir = join(here, "inputs", "rss");
const idxDir = join(here, "inputs", "indexer");
mkdirSync(rssDir, { recursive: true });
mkdirSync(idxDir, { recursive: true });
const items: MockFeedItem[] = [
{
guid: "audiences-812345",
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR",
link: "https://audiences.me/details.php?id=812345",
enclosureUrl: "https://audiences.me/download.php?id=812345&passkey=0123456789abcdef",
size: 84203386470,
pubDate: new Date(Date.UTC(2026, 8, 26, 13, 14, 3)),
categories: ["电影", "401"],
description: "沙丘2 / 沙丘:第二部 | <b>杜比视界</b> 国英双语 中字",
},
{
guid: "audiences-812346",
title: "Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV & Friends",
link: "https://audiences.me/details.php?id=812346",
enclosureUrl: "https://audiences.me/download.php?id=812346",
size: 5497558138,
pubDate: new Date(Date.UTC(2026, 8, 26, 12, 0, 0)),
},
{
guid: "audiences-812347",
title: "The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX",
link: "https://audiences.me/details.php?id=812347",
},
];
writeFileSync(join(rssDir, "tk-rss-basic.xml"), renderRss("观众 Torrents", items));
writeFileSync(join(rssDir, "tk-atom-basic.xml"), renderAtom("观众 Atom", items));
const site = await createMockNexusPhpSite({
releases: AUDIENCES_FIXTURE_RELEASES,
});
try {
const res = await fetch(`${site.baseUrl}/torrents.php?page=0`, {
headers: { cookie: site.cookie },
});
writeFileSync(join(idxDir, "tk-audiences-list.html.txt"), await res.text());
site.setLoggedIn(false);
const login = await fetch(`${site.baseUrl}/torrents.php`, {
headers: { cookie: site.cookie },
});
writeFileSync(join(idxDir, "tk-audiences-login.html.txt"), await login.text());
} finally {
await site.close();
}
writeFileSync(join(idxDir, "tk-hhanclub-list.html.txt"), HHANCLUB_LIST_PAGE);
writeFileSync(join(idxDir, "tk-chdbits-list.html.txt"), CHDBITS_LIST_PAGE);
writeFileSync(join(idxDir, "tk-chdbits-login.html.txt"), CHDBITS_LOGIN_PAGE);
console.log("exported to", here);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,123 @@
/**
* 站点索引解析差分:MP `SiteSpider.parse`(PyQuery 回退)vs 本项目 `SiteSpider.parse`。
* 同一 indexer 配置(本项目 definitions/*.json 或内联配置)+ 同一 HTML;期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(indexer.json)。
*/
import { describe, expect, it } from "vitest";
import { BUILTIN_SITE_DEFINITIONS, type IndexerDefinition, SiteSpider } from "../../src/sites/index.ts";
import {
diffAgainstRegistry,
dumpMismatches,
FieldCollector,
formatMismatches,
loadDoc,
readInput,
} from "./diff-util.ts";
interface IndexerSample {
id: string;
definition:
| { source: "definition"; id: string }
| {
source: "inline";
config: unknown;
};
input: { file?: string; inline?: string };
query: { keyword: string | null; search_type: "torrents" | "subtitles" };
mp: {
is_error?: boolean;
torrents?: Array<Record<string, unknown>>;
status?: string;
error?: string;
};
}
const doc = loadDoc<IndexerSample>("indexer.json");
function definitionOf(s: IndexerSample): IndexerDefinition {
if (s.definition.source === "definition") {
return BUILTIN_SITE_DEFINITIONS[s.definition.id] as IndexerDefinition;
}
return s.definition.config as IndexerDefinition;
}
/**
* 行对齐:条数相同按下标;条数不同(MP 整行丢弃等)且双方都有 page_url 时按 page_url 配对,
* 避免一处丢行导致后续所有行错位。行号 i 取 MP 侧下标。
*/
function alignRows(
mp: Array<Record<string, unknown>>,
ours: Array<Record<string, unknown>>,
): Array<[number, Record<string, unknown>, Record<string, unknown>]> {
const byUrl = mp.length !== ours.length && [...mp, ...ours].every((r) => typeof r.page_url === "string");
if (!byUrl) {
return mp.slice(0, Math.min(mp.length, ours.length)).map((m, i) => [i, m, ours[i]!]);
}
const index = new Map(ours.map((o) => [o.page_url as string, o]));
const out: Array<[number, Record<string, unknown>, Record<string, unknown>]> = [];
mp.forEach((m, i) => {
const o = index.get(m.page_url as string);
if (o) out.push([i, m, o]);
});
return out;
}
describe("站点索引差分(MP SiteSpider Python 回退)", () => {
const c = new FieldCollector();
for (const s of doc.samples) {
if (s.mp.status === "error") {
throw new Error(`${s.id}: MP 运行失败 ${s.mp.error}`);
}
if (s.query.search_type === "subtitles") {
// 本项目 SiteSpider 未移植字幕分支(parse_indexer_subtitles)
c.mismatches.push({
key: `${s.id}.status`,
mp: `ok:${s.mp.torrents?.length ?? 0}`,
ours: "unsupported",
});
continue;
}
const html = s.input.file ? readInput(s.input.file) : (s.input.inline ?? "");
const spider = new SiteSpider(definitionOf(s), s.query.keyword ? { keyword: s.query.keyword } : {});
const ours = spider.parse(html);
const mpRows = s.mp.torrents ?? [];
if (Boolean(s.mp.is_error) !== ours.isError) {
c.mismatches.push({
key: `${s.id}.status`,
mp: s.mp.is_error,
ours: ours.isError,
});
}
if (mpRows.length !== ours.torrents.length) {
c.mismatches.push({
key: `${s.id}.count`,
mp: mpRows.length,
ours: ours.torrents.length,
});
}
for (const [i, m, o] of alignRows(mpRows, ours.torrents as Array<Record<string, unknown>>)) {
for (const f of doc.fields) c.check(`${s.id}#${i}.${f}`, m[f], o[f]);
}
}
it("有样本且记录了生成来源", () => {
expect(doc.samples.length).toBeGreaterThan(10);
expect(doc.generatedFrom.commit).toMatch(/^[0-9a-f]{40}$/);
});
it("实际不一致集合 == 登记的 deviations", () => {
dumpMismatches("indexer", c.mismatches, {
compared: c.compared,
equal: c.equal,
});
const { unregistered, stale } = diffAgainstRegistry(c.mismatches, doc.deviations);
expect(formatMismatches(unregistered), "未登记的不一致").toBe("");
expect(stale.join("\n"), "登记了但已不再出现的偏差(请从 indexer.json deviations 删除)").toBe("");
});
it("登记项分类合法", () => {
for (const [key, d] of Object.entries(doc.deviations)) {
expect(["intentional", "missing", "our_bug", "mp_bug"], key).toContain(d.kind);
expect(d.note.length, key).toBeGreaterThan(0);
}
});
});
@@ -0,0 +1,49 @@
{
"id": "unit-case",
"name": "Unit(case)",
"domain": "https://pt.example.com/",
"search": { "paths": [{ "path": "torrents.php" }] },
"category": { "movie": [{ "id": "401" }], "tv": [{ "id": "402" }] },
"torrents": {
"list": { "selector": "table.torrents > tr:has(\"table.torrentname\")" },
"fields": {
"title_default": { "selector": "a[href*=\"details.php?id=\"]" },
"title_optional": { "selector": "a[title][href*=\"details.php?id=\"]", "attribute": "title" },
"title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
},
"details": { "selector": "a[href*=\"details.php?id=\"]", "attribute": "href" },
"download": { "selector": "a[href*=\"download.php?id=\"]", "attribute": "href" },
"imdbid": {
"selector": "a[href*=\"imdb.com/title/tt\"]",
"attribute": "href",
"filters": [{ "name": "re_search", "args": ["tt\\d+", 0] }]
},
"date_elapsed": { "selector": "td:nth-child(4) > span" },
"date_added": { "selector": "td:nth-child(4) > span", "attribute": "title" },
"date": {
"text": "{% if fields['date_elapsed'] or fields['date_added'] %}{{ fields['date_added'] if fields['date_added'] else fields['date_elapsed'] }}{% else %}now{% endif %}",
"filters": [{ "name": "dateparse", "args": "%Y-%m-%d %H:%M:%S" }]
},
"size": { "selector": "td:nth-child(5)" },
"seeders": { "selector": "td:nth-child(6)" },
"leechers": { "selector": "td:nth-child(7)" },
"grabs": { "selector": "td:nth-child(8)" },
"downloadvolumefactor": { "case": { "img.pro_free": 0, "img.pro_50pctdown2up": 0.5, "*": 1 } },
"uploadvolumefactor": { "case": { "img.pro_50pctdown2up": 2, "img.pro_2up": 2, "*": 1 } },
"description": { "selector": "font.subtitle", "remove": "span, a" },
"labels": { "selector": "span.label" },
"freedate": {
"selector": "img[class^=\"pro_\"]",
"attribute": "title",
"filters": [{ "name": "re_search", "args": ["\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}", 0] }]
},
"hr": { "selector": "img.hitandrun" },
"category": {
"selector": "a[href*=\"?cat=\"]",
"attribute": "href",
"filters": [{ "name": "querystring", "args": "cat" }]
}
}
}
}
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html><head><title>Unit :: 种子</title></head><body>
<a href="logout.php">退出</a>
<table class="torrents">
<tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td></tr>
<tr>
<td><a href="?cat=402">TV</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="details.php?id=100" title="Optional.Title">Default.Title</a>
<a href="download.php?id=100">DL</a>
<a href="https://www.imdb.com/title/tt1234567/">IMDb</a>
<font class="subtitle">Main description <span>remove</span><a>link</a></font>
<span class="label">FREE</span>
<img class="hitandrun" />
<img class="pro_free" title="Free until 2026-10-01 12:00:00" />
<span class="factor">x0.5</span><span class="upfactor">2.0X</span>
</td>
</tr>
</table>
</td>
<td></td>
<td><span title="2025-05-01 12:13:14">1 hour ago</span></td>
<td>1.5 GB</td>
<td>1,234</td>
<td>5/7</td>
<td>9</td>
</tr>
<tr>
<td><a href="?cat=401&amp;x=1">Movie</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="//pt.example.com/details.php?id=101"><b>No.Title.Attr.2026</b></a>
<a href="magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567">magnet-download.php?id=101</a>
<font class="subtitle">第一行<br/>第二行 多空格</font>
<span class="label">国语</span><span class="label"></span><span class="label">中字</span>
<img class="pro_50pctdown2up" title="2026-10-02 08:00:00" />
<span class="factor">50%</span>
</td>
</tr>
</table>
</td>
<td>0</td>
<td><span>3小时前</span></td>
<td>1,234.56 MiB</td>
<td>-</td>
<td>0</td>
<td></td>
</tr>
<tr>
<td><a href="?cat=499">Other</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="/details.php?id=102" title="Slash.Link.Title">Slash</a>
<a href="pt.example.com/download.php?id=102">DL</a>
<font class="subtitle"> 前后空格 </font>
<img class="pro_2up" />
</td>
</tr>
</table>
</td>
<td>2</td>
<td>2026/09/26 21:14</td>
<td>850 KB</td>
<td>12 / 30</td>
<td>abc</td>
<td>1,000</td>
</tr>
<tr>
<td><a href="?cat=401">Movie</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="details.php?id=103" title="Tiny.Size">Tiny</a>
<a href="/download.php?id=103&amp;passkey=abc">DL</a>
</td>
</tr>
</table>
</td>
<td>0</td>
<td>2026-09-26 21:14:03</td>
<td>2.5 B</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
</table>
</body></html>
@@ -0,0 +1,32 @@
{
"id": "unit-selector",
"name": "Unit(selector)",
"domain": "https://pt.example.com",
"media_type": "movie",
"search": { "paths": [{ "path": "torrents.php" }] },
"torrents": {
"list": { "selector": "table.torrents tr:has(table.torrentname)" },
"fields": {
"title": {
"selector": "a[href*=\"details.php\"]",
"filters": [{ "name": "replace", "args": [".", " "] }, { "name": "appendleft", "args": "[U] " }]
},
"description": { "selector": "font.subtitle", "contents": -1 },
"details": {
"selector": "a[href*=\"details.php\"]",
"attribute": "href",
"filters": [{ "name": "split", "args": ["?", -1] }, { "name": "appendleft", "args": "details.php?" }]
},
"download": { "selector": "a[href*=\"download.php\"]", "attribute": "href" },
"date_added": { "selector": "td:nth-child(4)" },
"size": { "selector": "td", "index": 4 },
"seeders": { "selector": "td:nth-child(6)", "filters": [{ "name": "re_search", "args": ["(\\d+)\\s*/", 1] }] },
"leechers": { "selector": "td:nth-child(7)" },
"grabs": { "selector": "td:nth-child(8)", "filters": [{ "name": "strip" }] },
"downloadvolumefactor": { "selector": "span.factor" },
"uploadvolumefactor": { "selector": "span.upfactor" },
"labels": { "selector": "span.label", "attribute": "class" },
"hr": { "selector": "img.nonexistent" }
}
}
}
@@ -0,0 +1,6 @@
<html><head><title>Unit :: 种子</title></head><body>
<table class="torrents">
<tr class="sticky free_bg"><td><a href="details.php?id=200" title="Row.Class.Free">Row</a></td><td>1 GB</td></tr>
<tr class="normal"><td><a href="details.php?id=201" title="Row.Plain">Row</a></td><td>2 GB</td></tr>
</table>
</body></html>
@@ -0,0 +1,17 @@
{
"id": "unit-self",
"name": "Unit(self)",
"domain": "https://pt.example.com/",
"search": { "paths": [{ "path": "torrents.php" }] },
"torrents": {
"list": { "selector": "table.torrents > tr" },
"fields": {
"title": { "selector": "a[title]", "attribute": "title" },
"details": { "selector": "a[title]", "attribute": "href" },
"size": { "selector": "td:nth-child(2)" },
"downloadvolumefactor": { "case": { "tr.free_bg": 0, "*": 1 } },
"hr": { "selector": "tr.sticky" },
"labels": { "selector": "tr.sticky", "attribute": "class" }
}
}
}
@@ -0,0 +1,75 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>观众 :: 种子</title></head>
<body><div id="info_block"><a href="userdetails.php?id=10001">mockuser</a>
<span class="site-userbar__compact-metric--uploaded">12.5 TB</span>
<span class="site-userbar__compact-metric--downloaded">3.2 TB</span>
<a href="logout.php">退出</a></div><div id="outer"><p align="center"><a href="?page=0"><b>1</b></a></p>
<table class="torrents torrents-table" cellspacing="0" cellpadding="5" width="100%"><tbody><tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">存活时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td><td class="colhead">发布者</td></tr><tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=401"><img class="c_401" src="pic/cattrans.gif" alt="电影" title="电影"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR" href="details.php?id=812345&amp;hit=1"><b>Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR</b></a> <img class="pro_free" src="pic/trans.gif" alt="Free" title="2026-10-01 12:00:00"><br><span class="torrent-subtitle-line">沙丘2 / 沙丘:第二部 | 杜比视界 国英双语 中字</span></td>
<td class="embedded" width="20"><a href="download.php?id=812345"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812345">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-26 21:14:03">3小时前</span></td>
<td class="rowfollow">78.42 GB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812345&amp;dllist=1#seeders">1,204</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812345&amp;dllist=1#leechers">17</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812345"><b>3,388</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">uploader1</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=403"><img class="c_403" src="pic/cattrans.gif" alt="综艺" title="综艺"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV" href="details.php?id=812346&amp;hit=1"><b>Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV</b></a> <img class="hitandrun" src="pic/trans.gif" alt="H&amp;R" title="H&amp;R"><br><span class="torrent-subtitle-line">奔跑吧 第十二季 第1期</span></td>
<td class="embedded" width="20"><a href="download.php?id=812346"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812346">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-26 20:00:00">2026-09-26 20:00:00</span></td>
<td class="rowfollow">5.12 GB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812346&amp;dllist=1#seeders">88</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812346&amp;dllist=1#leechers">2</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812346"><b>410</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=402"><img class="c_402" src="pic/cattrans.gif" alt="剧集" title="剧集"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX" href="details.php?id=812347&amp;hit=1"><b>The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX</b></a> <img class="pro_50pctdown2up" src="pic/trans.gif" alt="2X 50%" title="2026-09-29 08:30:00"><br><span class="torrent-subtitle-line">熊家餐馆 第三季</span></td>
<td class="embedded" width="20"><a href="download.php?id=812347"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812347">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-25 08:30:00">2026-09-25 08:30:00</span></td>
<td class="rowfollow">1,234.56 MiB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812347&amp;dllist=1#seeders">35</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812347&amp;dllist=1#leechers">0</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812347"><b>120</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=402"><img class="c_402" src="pic/cattrans.gif" alt="剧集" title="剧集"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a href="details.php?id=812348&amp;hit=1"><b>The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.D…</b></a></td>
<td class="embedded" width="20"><a href="download.php?id=812348"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812348">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-24 10:00:00">2026-09-24 10:00:00</span></td>
<td class="rowfollow">2.1 TB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812348&amp;dllist=1#seeders">12</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812348&amp;dllist=1#leechers">1</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812348"><b>44</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=406"><img class="c_406" src="pic/cattrans.gif" alt="纪录片" title="纪录片"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Planet.Earth.III.2023.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.5.1-GRP" href="details.php?id=812349&amp;hit=1"><b>Planet.Earth.III.2023.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.5.1-GRP</b></a> <img class="pro_2up" src="pic/trans.gif" alt="2X" title=""><br><span class="torrent-subtitle-line">地球脉动 第三季</span></td>
<td class="embedded" width="20"><a href="download.php?id=812349"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812349">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-23 10:00:00">2026-09-23 10:00:00</span></td>
<td class="rowfollow">850 KB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812349&amp;dllist=1#seeders">5</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812349&amp;dllist=1#leechers">0</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812349"><b>9</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr></tbody></table></div></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>观众 :: 登录</title></head>
<body><form method="post" action="takelogin.php"><p>你需要启用cookies才能登录</p>
<input type="text" name="username"><input type="password" name="password"><input type="submit" value="登录"></form></body></html>
@@ -0,0 +1,35 @@
<!DOCTYPE html><html><head><title>CHDBits :: 种子</title></head><body>
<table id="info_block"><tr><td><a href="userdetails.php?id=42" class="VIP_Name">carol</a> <a href="logout.php">退出</a>
<font class="color_uploaded">上传量:</font> 25.052 TB <font class="color_downloaded">下载量:</font> 1.2 TB</td></tr></table>
<table class="torrents" cellspacing="0" cellpadding="5" width="100%">
<tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td><td class="colhead">发布者</td></tr>
<tr>
<td class="rowfollow nowrap"><a href="?cat=402"><img class="c_tvseries" src="pic/cattrans.gif" alt="TV Series"/></a></td>
<td class="rowfollow"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="The.Last.of.Us.S02.2025.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD" href="details.php?id=555001&amp;hit=1"><b>The.Last.of.Us.S02.2025.1080p.BluRay...</b></a>
<img class="pro_free2up" src="pic/trans.gif" alt="2X Free"/> <span title="2026-09-30 12:00:00">[限时]</span><br/><font class="subtitle">最后生还者 第二季 <span class="tags">中字</span></font></td>
<td class="embedded" width="20"><a href="download.php?id=555001"><img class="download" alt="download"/></a></td>
</tr></table></td>
<td class="rowfollow">3</td>
<td class="rowfollow nowrap"><span title="2026-09-25 08:00:00">2天</span></td>
<td class="rowfollow">45.20<br/>GB</td>
<td class="rowfollow"><b><a href="details.php?id=555001#peers">42</a></b></td>
<td class="rowfollow">3</td>
<td class="rowfollow"><a href="viewsnatches.php?id=555001"><b>100</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=1">uploader</a></td>
</tr>
<tr>
<td class="rowfollow nowrap"><a href="?cat=401"><img class="c_movie" src="pic/cattrans.gif" alt="Movies"/></a></td>
<td class="rowfollow"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Oppenheimer.2023.2160p.UHD.BluRay.REMUX-CHD" href="details.php?id=555002&amp;hit=1"><b>Oppenheimer.2023...</b></a><br/><font class="subtitle">奥本海默</font></td>
<td class="embedded" width="20"><a href="download.php?id=555002"><img class="download" alt="download"/></a></td>
</tr></table></td>
<td class="rowfollow">0</td>
<td class="rowfollow nowrap"><span title="2026-09-01 10:00:00">26天</span></td>
<td class="rowfollow">80.00<br/>GB</td>
<td class="rowfollow">7</td>
<td class="rowfollow">0</td>
<td class="rowfollow">55</td>
<td class="rowfollow"><a href="userdetails.php?id=1">uploader</a></td>
</tr>
</table></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html><html><head><title>CHDBits :: 登录</title></head><body>
<form method="post" action="takelogin.php"><input name="username"/><input type="password" name="password"/></form>
<p>未登录!</p></body></html>
@@ -0,0 +1,33 @@
<!DOCTYPE html><html><head><title>HHCLUB :: 种子</title></head><body>
<div id="user-info-panel"><a href="userdetails.php?id=7" class="EliteUser_Name">bob</a> <a href="logout.php">退出</a></div>
<div class="torrent-table">
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=403"><img src="pic/cattrans.gif" alt="综艺"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=90001&amp;hit=1">Go.Fighting.S10E01.2026.2160p.WEB-DL.H265.AAC-HHWEB</a>
<span class="promotion-tag promotion-tag-free">免费</span></div>
<div class="torrent-info-text-small_name">极限挑战 第十季 第1期</div>
<div class="torrent-manage"><a href="download.php?id=90001">下载</a></div>
<div class="torrent-info-text-added"><span title="2026-09-20 20:30:00">6天23时</span></div>
<div class="torrent-info-text-size">6.12 GB</div>
<div class="torrent-info-text-seeders">88</div>
<div class="torrent-info-text-leechers">5</div>
<div class="torrent-info-text-finished">1,024</div>
<div class="torrent-info-text-author">anon</div>
<div class="torrent-info-hr">30</div>
</div>
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=401"><img src="pic/cattrans.gif" alt="电影"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=90002&amp;hit=1">Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-HHWEB</a>
<span class="promotion-tag promotion-tag-50pctdown2up">50%</span></div>
<div class="torrent-info-text-small_name">沙丘2</div>
<div class="torrent-manage"><a href="download.php?id=90002">下载</a></div>
<div class="torrent-info-text-added">2026-09-01 08:00:00</div>
<div class="torrent-info-text-size">78.3 GB</div>
<div class="torrent-info-text-seeders">12</div>
<div class="torrent-info-text-leechers">0</div>
<div class="torrent-info-text-finished">300</div>
<div class="torrent-info-text-author">anon</div>
</div>
</div>
<div class="pagination"><a href="?page=0">1-100</a><a href="?page=3">301-400</a></div>
</body></html>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<feed><title>Atom without namespace</title>
<entry><id>tag:e.x,2026:1</id><title>Atom.Entry.One</title><link rel="alternate" href="https://e.x/details?id=1"/><link rel="enclosure" href="https://e.x/dl?id=1" length="4096"/><updated>2026-09-26T13:14:03Z</updated><summary>atom summary</summary></entry>
<entry><id>tag:e.x,2026:2</id><title type="html">&lt;b&gt;Atom.Entry.Two&lt;/b&gt;</title><link href="https://e.x/details?id=2"/><published>2026-09-26T21:14:03+08:00</published><updated>2026-09-27T00:00:00Z</updated><content>content only</content></entry>
</feed>
@@ -0,0 +1 @@
RSS Link has expired, You need to get a new one!
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel rdf:about="https://e.x/"><title>RDF feed</title><link>https://e.x/</link></channel>
<item rdf:about="https://e.x/1"><title>RDF.Item.One</title><link>https://e.x/1</link><description>rdf desc</description><dc:date>2026-09-26T13:14:03Z</dc:date><dc:creator>alice</dc:creator></item>
</rdf:RDF>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>broken</title>
<item><title>Good.One</title><link>https://e.x/1</link></item>
<item><title>Unclosed<link>https://e.x/2</link></item>
</channel>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>CDATA feed</title>
<item><title><![CDATA[[BDMV] 奥本海默 Oppenheimer 2023 <UHD> & more]]></title><link>https://pt.example.com/details.php?id=1</link><description><![CDATA[<p>Size: 78.42 GB</p><img src="x.jpg"/>]]></description><enclosure url="https://pt.example.com/download.php?id=1&amp;passkey=abcdef0123456789" length="84203386470" type="application/x-bittorrent"/><pubDate>Sat, 26 Sep 2026 21:14:03 +0800</pubDate><dc:creator>豆瓣用户A</dc:creator><guid>pt-1</guid></item>
<item><title>Plain &amp; Escaped &lt;Title&gt; &#x4E2D;&#25991;</title><link>https://pt.example.com/details.php?id=2&amp;hit=1</link><description>desc &amp; more</description><enclosure url="https://pt.example.com/download.php?id=2" length="1024"/><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE rss [<!ENTITY brand "EvilCorp">]>
<rss version="2.0"><channel><title>dtd</title>
<item><title>&brand; Title</title><link>https://e.x/1</link></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>entities</title>
<item><title>Nbsp&nbsp;Title&copy;2026</title><link>https://e.x/1</link></item>
<item><title>Normal.After.Bad</title><link>https://e.x/2</link></item>
</channel></rss>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>fallback</title>
<item><title>Only.Link.Item</title><link>https://pt.example.com/download.php?id=10</link></item>
<item><title>No.Link.No.Enclosure</title><description>skip me</description></item>
<item><title></title><link>https://pt.example.com/details.php?id=11</link></item>
<item><link>https://pt.example.com/details.php?id=12</link></item>
<item><title>Enclosure.Only</title><enclosure url="magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&amp;dn=x" length="0"/></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>nested</title>
<item><source url="https://e.x/src"><title>Source.Title.First</title></source><title>Real.Title</title><link>https://e.x/1</link><media:title>Media.Title</media:title></item>
<item><title>Two.Enclosures</title><enclosure url="https://e.x/a.torrent" length="100"/><enclosure url="https://e.x/b.torrent" length="200"/></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:nyaa="https://nyaa.si/xmlns/nyaa" version="2.0"><channel><title>Nyaa-like</title>
<atom:link href="https://nyaa.example/?page=rss" rel="self" type="application/rss+xml"/>
<item><title>[Group] Show - 01 [1080p].mkv</title><link>https://nyaa.example/download/1.torrent</link><guid isPermaLink="true">https://nyaa.example/view/1</guid><pubDate>Sat, 26 Sep 2026 13:14:03 -0000</pubDate><nyaa:seeders>12</nyaa:seeders><nyaa:leechers>3</nyaa:leechers><nyaa:size>1.2 GiB</nyaa:size><nyaa:infoHash>0123456789abcdef0123456789abcdef01234567</nyaa:infoHash><description><![CDATA[<a href="https://nyaa.example/view/1">#1</a> | 1.2 GiB]]></description></item>
</channel></rss>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>pubdate formats</title>
<item><title>rfc822-gmt</title><link>https://e.x/1</link><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate></item>
<item><title>rfc822-offset</title><link>https://e.x/2</link><pubDate>Sat, 26 Sep 2026 21:14:03 +0800</pubDate></item>
<item><title>rfc822-no-seconds</title><link>https://e.x/3</link><pubDate>Sat, 26 Sep 2026 21:14 +0800</pubDate></item>
<item><title>rfc822-cst</title><link>https://e.x/4</link><pubDate>Sat, 26 Sep 2026 21:14:03 CST</pubDate></item>
<item><title>iso-z</title><link>https://e.x/5</link><pubDate>2026-09-26T13:14:03Z</pubDate></item>
<item><title>iso-offset</title><link>https://e.x/6</link><pubDate>2026-09-26T21:14:03+08:00</pubDate></item>
<item><title>naive-space</title><link>https://e.x/7</link><pubDate>2026-09-26 21:14:03</pubDate></item>
<item><title>date-only</title><link>https://e.x/8</link><pubDate>2026-09-26</pubDate></item>
<item><title>chinese</title><link>https://e.x/9</link><pubDate>2026年09月26日 21:14:03</pubDate></item>
<item><title>garbage</title><link>https://e.x/10</link><pubDate>3小时前</pubDate></item>
<item><title>no-weekday</title><link>https://e.x/11</link><pubDate>26 Sep 2026 13:14:03 +0000</pubDate></item>
<item><title>wrong-weekday</title><link>https://e.x/12</link><pubDate>Mon, 26 Sep 2026 13:14:03 GMT</pubDate></item>
<item><title>epoch-like</title><link>https://e.x/13</link><pubDate>1790428443</pubDate></item>
</channel></rss>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>size</title>
<item><title>Size.In.Description</title><link>https://pt.example.com/d?id=20</link><description>[Size: 1.50 GB] [Seeders: 12]</description></item>
<item><title>Length.NonDigit</title><enclosure url="https://pt.example.com/dl?id=21" length="1.5GB"/></item>
<item><title>Length.Empty</title><enclosure url="https://pt.example.com/dl?id=22" length=""/></item>
<item><title>Length.Missing</title><enclosure url="https://pt.example.com/dl?id=23"/></item>
<item><title>Length.Big</title><enclosure url="https://pt.example.com/dl?id=24" length="2199023255552"/></item>
<item><title>Length.Negative</title><enclosure url="https://pt.example.com/dl?id=25" length="-1"/></item>
</channel></rss>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>ws</title>
<item>
<title>
Spaced.Title.2026.1080p.WEB-DL
</title>
<link>
https://pt.example.com/details.php?id=3
</link>
<description>
line one
line two
</description>
<enclosure url=" https://pt.example.com/download.php?id=3 " length=" 2048 "/>
<pubDate>
Sat, 26 Sep 2026 13:14:03 GMT
</pubDate>
</item>
</channel></rss>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>观众 Atom</title>
<entry><id>audiences-812345</id><title>Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR</title><link rel="alternate" href="https://audiences.me/details.php?id=812345"/><link rel="enclosure" href="https://audiences.me/download.php?id=812345&amp;passkey=0123456789abcdef" length="84203386470"/><published>2026-09-26T13:14:03.000Z</published><category term="电影"/><category term="401"/><summary>沙丘2 / 沙丘:第二部 | &lt;b&gt;杜比视界&lt;/b&gt; 国英双语 中字</summary></entry>
<entry><id>audiences-812346</id><title>Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV &amp; Friends</title><link rel="alternate" href="https://audiences.me/details.php?id=812346"/><link rel="enclosure" href="https://audiences.me/download.php?id=812346" length="5497558138"/><published>2026-09-26T12:00:00.000Z</published></entry>
<entry><id>audiences-812347</id><title>The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX</title><link rel="alternate" href="https://audiences.me/details.php?id=812347"/></entry>
</feed>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>观众 Torrents</title>
<item><title>Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR</title><guid isPermaLink="false">audiences-812345</guid><link>https://audiences.me/details.php?id=812345</link><enclosure url="https://audiences.me/download.php?id=812345&amp;passkey=0123456789abcdef" length="84203386470" type="application/x-bittorrent"/><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate><category>电影</category><category>401</category><description><![CDATA[沙丘2 / 沙丘:第二部 | <b>杜比视界</b> 国英双语 中字]]></description></item>
<item><title>Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV &amp; Friends</title><guid isPermaLink="false">audiences-812346</guid><link>https://audiences.me/details.php?id=812346</link><enclosure url="https://audiences.me/download.php?id=812346" length="5497558138" type="application/x-bittorrent"/><pubDate>Sat, 26 Sep 2026 12:00:00 GMT</pubDate></item>
<item><title>The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX</title><guid isPermaLink="false">audiences-812347</guid><link>https://audiences.me/details.php?id=812347</link></item>
</channel></rss>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,121 @@
/**
* RSS 解析差分:MP `RssHelper._parse_impl`(lxml 回退)vs 本项目 `parseFeed`。
* 期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(rss.json);不一致必须登记在 rss.json `deviations`。
*/
import { describe, expect, it } from "vitest";
import { FeedError, type FeedItem, parseFeed } from "../../src/rss/index.ts";
import {
diffAgainstRegistry,
dumpMismatches,
FieldCollector,
formatMismatches,
loadDoc,
readInput,
} from "./diff-util.ts";
interface MpPubdate {
kind: "absent" | "unparsed" | "aware" | "naive";
text?: string;
wall?: string;
utc?: string;
}
interface MpRssItem {
title: string;
description: string;
link: string;
enclosure: string;
size: number;
nickname?: string;
pubdate: MpPubdate;
}
interface RssSample {
id: string;
input: { file: string; sha256: string };
mp: { status: "ok" | "false_error" | "none_expired"; items: MpRssItem[] };
}
const doc = loadDoc<RssSample>("rss.json");
/** MP 字段 → 可与本项目比较的值:空串即缺省;pubdate 统一为 UTC ISO */
function mpPubdate(p: MpPubdate): string | undefined {
if (p.kind === "aware") return p.utc;
// naive:MP astimezone(None) 以宿主本地时区解释墙上时间;JS 对无偏移的日期时间同样按本地解释
if (p.kind === "naive") return new Date(p.wall!).toISOString();
return undefined;
}
const blank = (s: string | null | undefined) => (s === "" || s == null ? undefined : s);
function runOurs(xml: string): { status: string; items: FeedItem[] } {
try {
return {
status: "ok",
items: parseFeed(xml, { fetchedAt: new Date("2026-09-27T00:00:00Z") }).items,
};
} catch (e) {
if (e instanceof FeedError) return { status: `error:${e.kind}`, items: [] };
throw e;
}
}
function statusEquivalent(mp: RssSample["mp"]["status"], ours: string): boolean {
if (mp === "ok") return ours === "ok";
if (mp === "none_expired") return ours === "error:expired";
return ours.startsWith("error:") && ours !== "error:expired";
}
describe("RSS 差分(MP RssHelper Python 回退)", () => {
const c = new FieldCollector();
for (const s of doc.samples) {
const ours = runOurs(readInput(s.input.file));
if (!statusEquivalent(s.mp.status, ours.status)) {
c.mismatches.push({
key: `${s.id}.status`,
mp: s.mp.status,
ours: ours.status,
});
}
if (s.mp.status !== "ok" || ours.status !== "ok") continue;
if (s.mp.items.length !== ours.items.length) {
c.mismatches.push({
key: `${s.id}.count`,
mp: s.mp.items.length,
ours: ours.items.length,
});
}
const n = Math.min(s.mp.items.length, ours.items.length);
for (let i = 0; i < n; i++) {
const m = s.mp.items[i]!;
const o = ours.items[i]!;
const k = (f: string) => `${s.id}#${i}.${f}`;
c.check(k("title"), m.title, o.title);
c.check(k("description"), blank(m.description), blank(o.description));
c.check(k("link"), blank(m.link), blank(o.link));
c.check(k("enclosure"), blank(m.enclosure), blank(o.enclosureUrl));
c.check(k("size"), m.size, o.size);
c.check(k("pubdate"), mpPubdate(m.pubdate), o.publishedAt);
c.check(k("nickname"), blank(m.nickname), blank(o.nickname));
}
}
it("有样本且记录了生成来源", () => {
expect(doc.samples.length).toBeGreaterThan(10);
expect(doc.generatedFrom.commit).toMatch(/^[0-9a-f]{40}$/);
});
it("实际不一致集合 == 登记的 deviations", () => {
dumpMismatches("rss", c.mismatches, {
compared: c.compared,
equal: c.equal,
});
const { unregistered, stale } = diffAgainstRegistry(c.mismatches, doc.deviations);
expect(formatMismatches(unregistered), "未登记的不一致").toBe("");
expect(stale.join("\n"), "登记了但已不再出现的偏差(请从 rss.json deviations 删除)").toBe("");
});
it("登记项分类合法", () => {
for (const [key, d] of Object.entries(doc.deviations)) {
expect(["intentional", "missing", "our_bug", "mp_bug"], key).toContain(d.kind);
expect(d.note.length, key).toBeGreaterThan(0);
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,207 @@
/**
* MoviePilot 过滤规则 vs Slot 差分测试。
*
* filter-cases.json 由 MP 的 Python 回退实现生成(见 generatedFrom)。这里把可表达的规则
* 手工翻译成 SlotSpec(每个 `>` 优先级层级一个 Slot),用 parseRelease + evaluateCandidate
* 求"首个 accept 的层级",与 MP 的 level 比对。
*
* 断言:不一致集合 == deviations 登记集合。补迁消除差异后测试会失败,提醒删登记。
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
evaluateCandidate,
type ParsedRelease,
parseRelease,
type ReleaseSource,
rankForSlot,
type SlotSpec,
} from "../../src/index.ts";
interface Torrent {
id: string;
title: string;
description: string;
size: number;
seeders: number;
}
interface Rule {
id: string;
rule: string;
verdict: "full" | "partial" | "none";
}
interface Case {
caseId: string;
ruleId: string;
torrentId: string;
mp: { matched: boolean; priOrder: number | null; level: number | null };
}
type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "not_expressible";
interface Deviation {
kind: DeviationKind;
note: string;
pairs?: string[];
}
interface Fixture {
generatedFrom: { commit: string; rustCrossCheck: { mismatches: unknown[] } };
torrents: Torrent[];
rules: Rule[];
cases: Case[];
deviations: Record<string, Deviation>;
}
const fx = JSON.parse(readFileSync(new URL("./filter-cases.json", import.meta.url), "utf8")) as Fixture;
// ---------------------------------------------------------------- MP 关键字 → Slot 片段
const MB = 1024 * 1024; // MP size_range 单位 MB = 1024*1024 字节
const ALL: ReleaseSource[] = ["REMUX", "BluRay", "BluRay-Disc", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVD", "DVDRip"];
const without = (...xs: ReleaseSource[]) => ALL.filter((s) => !xs.includes(s));
type Frag = Pick<SlotSpec, "hard"> & { unknown?: Partial<Record<string, "reject" | "accept">> };
const RES_4K: Frag = { hard: { resolutions: ["2160p"] }, unknown: { resolution: "reject" } };
const RES_1080: Frag = { hard: { resolutions: ["1080p", "1080i"] }, unknown: { resolution: "reject" } };
const RES_720: Frag = { hard: { resolutions: ["720p"] }, unknown: { resolution: "reject" } };
/** 正向来源条件:来源未知 = MP 正则不命中 = 拒绝 */
const srcIs = (...s: ReleaseSource[]): Frag => ({ hard: { sources: s }, unknown: { source: "reject" } });
/** 取反来源条件:来源未知 = MP 正则不命中 = !X 为真 = 放行 */
const srcNot = (...s: ReleaseSource[]): Frag => ({ hard: { sources: without(...s) }, unknown: { source: "accept" } });
const CNSUB: Frag = { hard: { requiredSubtitles: ["zh"] }, unknown: { subtitles: "reject" } };
const size = (min?: number, max?: number): Frag => ({
hard: {
minSizeBytes: min !== undefined ? min * MB : undefined,
maxSizeBytes: max !== undefined ? max * MB : undefined,
},
});
/** AND 合并片段(同字段取交集由调用方保证不冲突) */
function and(...fs: Frag[]): SlotSpec {
const hard: SlotSpec["hard"] = {};
const unknown: Record<string, "reject" | "accept"> = {};
for (const f of fs) {
for (const [k, v] of Object.entries(f.hard)) if (v !== undefined) (hard as Record<string, unknown>)[k] = v;
Object.assign(unknown, f.unknown);
}
return { name: "mp", hard, unknownPolicy: { ...unknown } };
}
/** 每条规则 → 各优先级层级的 SlotSpec(verdict=none 的规则不翻译) */
const TRANSLATE: Record<string, SlotSpec[]> = {
R01: [and(RES_4K)],
R02: [and(RES_1080)],
R03: [and(RES_720)],
R04: [and({ hard: { resolutions: ["2160p", "1080p", "1080i"] }, unknown: { resolution: "reject" } })],
R05: [and(RES_4K), and(RES_1080), and(RES_720)],
R06: [and(srcIs("BluRay-Disc"))],
R07: [and(srcNot("BluRay-Disc"))],
R08: [and(srcIs("REMUX"))],
R09: [and(srcNot("REMUX"))],
R10: [and(srcIs("WEB-DL", "WEBRip"))],
R11: [and(srcNot("BluRay-Disc", "REMUX"))],
R12: [and(RES_4K, srcNot("REMUX")), and(RES_1080, srcIs("WEB-DL", "WEBRip"))],
R13: [and(CNSUB)],
R14: [and(CNSUB, RES_4K, srcNot("BluRay-Disc"))],
R15: [and(CNSUB, RES_1080), and(RES_1080)],
R16: [and(size(2000, 30000))],
R17: [and(size(undefined, 8000))],
R18: [and(RES_4K, size(2000, 30000))],
R19: [and(srcNot("BluRay-Disc", "REMUX", "WEB-DL", "WEBRip"))],
R20: [and({ hard: { sources: ["BluRay"], requireEncode: true }, unknown: { source: "reject" } })],
R21: [and(RES_4K, srcNot("BluRay-Disc", "REMUX")), and(RES_1080, srcNot("BluRay-Disc", "REMUX"))],
R22: [
and(
{ hard: { resolutions: ["2160p", "1080p", "1080i"] }, unknown: { resolution: "reject" } },
srcIs("WEB-DL", "WEBRip"),
),
],
// partial:近似翻译
R23: [and(CNSUB)], // 无 TMDB 原语种放行
R24: [and(RES_4K)], // 无 !DOLBY
R25: [and(CNSUB, srcNot("BluRay-Disc"))], // 无 SPECSUB
R26: [and(size(20000))],
};
const parsed = new Map<string, ParsedRelease>(
fx.torrents.map((t) => [
t.id,
{ ...parseRelease(t.title, t.description ? { subtitle: t.description } : {}), sizeBytes: t.size },
]),
);
function ourLevel(ruleId: string, torrentId: string): number | null {
const levels = TRANSLATE[ruleId]!;
const r = parsed.get(torrentId)!;
for (let i = 0; i < levels.length; i++) if (evaluateCandidate(levels[i]!, r).decision === "accept") return i + 1;
return null;
}
// ---------------------------------------------------------------- 排序语义:单 Slot + rankForSlot vs MP 优先级
/** MP `4K > 1080P > 720P` 用单个 Slot(允许三种分辨率)+ rankForSlot 表达时的逆序对 */
const RANK_CHECKS: Record<string, { rule: string; spec: SlotSpec }> = {
"R05~rank": {
rule: "R05",
spec: and({ hard: { resolutions: ["2160p", "1080p", "1080i", "720p"] }, unknown: { resolution: "reject" } }),
},
};
function rankInversions(key: string): string[] {
const { rule, spec } = RANK_CHECKS[key]!;
const level = new Map(fx.cases.filter((c) => c.ruleId === rule).map((c) => [c.torrentId, c.mp.level]));
const ranked = rankForSlot(
spec,
fx.torrents.map((t) => ({ ...parsed.get(t.id)!, raw: t.id })),
).filter((e) => e.evaluation.decision === "accept");
const order = ranked.map((e) => e.candidate.raw);
const out: string[] = [];
for (let i = 0; i < order.length; i++)
for (let j = i + 1; j < order.length; j++) {
const a = level.get(order[i]!);
const b = level.get(order[j]!);
// 我们把 a 排在 b 前,MP 却认为 b 的层级更优
if (a != null && b != null && b < a) out.push(`${order[j]}>${order[i]}`);
}
return out.sort();
}
// ---------------------------------------------------------------- 断言
describe("MP 过滤规则差分(filter-cases.json)", () => {
it("fixture 完整:≥20 种子,Python/Rust 一致,每条可表达规则都有翻译", () => {
expect(fx.torrents.length).toBeGreaterThanOrEqual(20);
expect(fx.generatedFrom.rustCrossCheck.mismatches).toEqual([]);
const translatable = fx.rules.filter((r) => r.verdict !== "none").map((r) => r.id);
expect(Object.keys(TRANSLATE).sort()).toEqual(translatable.sort());
});
it("不一致集合 == deviations 登记集合", () => {
const actual: Record<string, { mp: number | null; ours: number | null }> = {};
for (const c of fx.cases) {
if (!TRANSLATE[c.ruleId]) continue;
const ours = ourLevel(c.ruleId, c.torrentId);
if (ours !== c.mp.level) actual[c.caseId] = { mp: c.mp.level, ours };
}
const registered = Object.keys(fx.deviations).filter((k) => !k.includes("~"));
expect(Object.keys(actual).sort()).toEqual(registered.sort());
for (const k of registered)
expect(["intentional", "missing", "our_bug", "mp_bug", "not_expressible"]).toContain(fx.deviations[k]!.kind);
});
it("单 Slot + rankForSlot 的排序逆序对 == 登记", () => {
for (const key of Object.keys(RANK_CHECKS)) {
const inv = rankInversions(key);
const dev = fx.deviations[key];
if (inv.length === 0) expect(dev).toBeUndefined();
else expect(dev?.pairs?.slice().sort()).toEqual(inv);
}
});
});
/** 生成/更新登记时用:UMT_FILTER_DUMP=1 deno task test 打印当前不一致 */
if (process.env.UMT_FILTER_DUMP) {
const rows: Record<string, unknown> = {};
for (const c of fx.cases) {
if (!TRANSLATE[c.ruleId]) continue;
const ours = ourLevel(c.ruleId, c.torrentId);
if (ours !== c.mp.level) rows[c.caseId] = { mp: c.mp.level, ours };
}
for (const key of Object.keys(RANK_CHECKS)) rows[key] = rankInversions(key);
console.log(`UMT_FILTER_DUMP ${JSON.stringify(rows)}`);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,233 @@
/**
* 标题识别差分:把 MP MetaInfo 输出与我们的 ParsedRelease 映射到同一"规范形",逐字段比较。
* 规范化只抹平表示差异(大小写、分隔符、同义写法),不抹平语义差异;语义差异登记在 meta-deviations.json。
*/
import { readFileSync } from "node:fs";
import { type ParsedRelease, parseRelease, parseReleasePath } from "../../src/release-parser/index.ts";
export interface MetaSample {
id: string;
kind: "title" | "path";
input: string;
subtitle?: string;
source: string;
ourNote?: string;
}
export type MpMeta = Record<string, string | number | null> & { error?: string };
export type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "representation";
export interface Deviation {
kind: DeviationKind;
mp: string | null;
ours: string | null;
note: string;
}
/** 参与比较的字段(media_id = find_metainfo 显式 ID 标签;edition = resource_type + resource_effect,由这两项覆盖;customization 双方均未配置) */
export const FIELDS = [
"name",
"cn_name",
"en_name",
"year",
"type",
"begin_season",
"end_season",
"begin_episode",
"end_episode",
"resource_pix",
"resource_type",
"resource_effect",
"video_encode",
"audio_encode",
"resource_team",
"part",
"web_source",
"media_id",
] as const;
export type Field = (typeof FIELDS)[number];
export type Canon = Record<Field, string | null>;
const readJson = <T>(name: string): T => JSON.parse(readFileSync(new URL(`./${name}`, import.meta.url), "utf8")) as T;
export function loadMeta() {
const samples = readJson<{ samples: MetaSample[] }>("meta-samples.json").samples;
const mp = readJson<{ python: Record<string, MpMeta>; rustMismatch: Record<string, unknown> }>("meta-mp.json");
const deviations = readJson<{ deviations: Record<string, Partial<Record<Field, Deviation>>> }>(
"meta-deviations.json",
).deviations;
return { samples, mp, deviations };
}
const str = (v: unknown): string | null => (v === null || v === undefined || v === "" ? null : String(v));
/** 名称:忽略大小写与标点/空白(MP 英文名做 Title Case,我们保留原大小写) */
const canonName = (v: unknown) => {
const s = str(v);
return s === null
? null
: s
.normalize("NFKC")
.toLowerCase()
.replace(/[\s._\-::·'’!!??,,&]+/g, "") || null;
};
function canonSource(v: string | null): string | null {
if (!v) return null;
const u = v.toUpperCase();
const cls = /REMUX/.test(u)
? "REMUX"
: /BLURAY|BDRIP|\bBD\b|BLU/.test(u)
? "BluRay"
: /WEB-?DL|^WEB$|\bWEB\b(?!RIP)/.test(u)
? "WEB-DL"
: /WEBRIP/.test(u)
? "WEBRip"
: /HDTV|UHDTV/.test(u)
? "HDTV"
: /HDRIP/.test(u)
? "HDRip"
: /DVD/.test(u)
? "DVD"
: /HDDVD/.test(u)
? "HDDVD"
: null;
const uhd = /\bUHD\b/.test(u.replace(/UHDTV/g, "")) ? "UHD " : "";
return `${uhd}${cls ?? `?${u}`}`.trim();
}
const HDR_CANON: Record<string, string> = {
HDR: "HDR10",
HDR10: "HDR10",
"HDR10+": "HDR10+",
HDR10PLUS: "HDR10+",
HDR10P: "HDR10+",
DV: "DV",
DOVI: "DV",
HLG: "HLG",
HDRVIVID: "HDR Vivid",
VIVID: "HDR Vivid",
"HDR VIVID": "HDR Vivid",
};
function canonHdrList(tokens: string[]): string | null {
const set = new Set<string>();
for (const t of tokens) {
const k = t.toUpperCase().replace(/\s+/g, "");
set.add(HDR_CANON[k] ?? HDR_CANON[t.toUpperCase()] ?? `?${t}`);
}
return set.size ? [...set].sort().join(",") : null;
}
function canonCodec(v: string | null): string | null {
if (!v) return null;
const u = v.toUpperCase().replace(/[\s.]/g, "");
if (/265|HEVC/.test(u)) return "H.265";
if (/264|AVC/.test(u)) return "H.264";
if (/AV1/.test(u)) return "AV1";
if (/VC-?1/.test(u)) return "VC-1";
if (/MPEG-?2/.test(u)) return "MPEG-2";
if (/XVID/.test(u)) return "XVID";
if (/DIVX/.test(u)) return "DIVX";
return `?${u}`;
}
const canonAudio = (v: string | null) =>
v
? v
.toUpperCase()
.replace(/DD\+/g, "DDP")
.replace(/[\s\-_.]/g, "") || null
: null;
const canonGroups = (v: string | null) =>
v
? v
.split(/[@&]/)
.map((g) => g.trim().toLowerCase())
.filter(Boolean)
.sort()
.join("@")
: null;
function canonPix(v: string | null): string | null {
if (!v) return null;
const u = v.toLowerCase();
return ({ "4k": "2160p", "8k": "4320p", "2k": "1440p" } as Record<string, string>)[u] ?? u;
}
const canonPart = (v: string | null) => (v ? v.toUpperCase().replace(/[\s._-]/g, "") : null);
const canonType = (v: string | null) =>
v === "电视剧" ? "tv" : v === "电影" ? "movie" : v === "未知" || v === null ? "unknown" : v;
export function canonMp(m: MpMeta): Canon {
const effect = str(m.resource_effect);
const hdrTokens = effect ? effect.split(/\s+/).filter((t) => /^(HDR|DV|DOVI|HLG|VIVID|HDRVIVID)/i.test(t)) : [];
return {
name: canonName(m.name),
cn_name: canonName(m.cn_name),
en_name: canonName(m.en_name),
year: str(m.year),
type: canonType(str(m.type)),
begin_season: str(m.begin_season),
end_season: str(m.end_season),
begin_episode: str(m.begin_episode),
end_episode: str(m.end_episode),
resource_pix: canonPix(str(m.resource_pix)),
resource_type: canonSource(str(m.resource_type)),
resource_effect: canonHdrList(hdrTokens),
video_encode: canonCodec(str(m.video_encode)),
audio_encode: canonAudio(str(m.audio_encode)),
resource_team: canonGroups(str(m.resource_team)),
part: canonPart(str(m.part)),
web_source: canonName(m.web_source),
media_id: m.media_id ? `${m.media_source}:${m.media_id}` : null,
};
}
const PROVIDER: Record<string, string> = { tmdb: "themoviedb", douban: "douban", imdb: "imdb", tvdb: "tvdb" };
export function oursParse(s: MetaSample): ParsedRelease {
return s.kind === "path"
? parseReleasePath(s.input)
: parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
}
export function canonOurs(r: ParsedRelease): Canon {
const src = r.source
? `${r.uhd ? "UHD " : ""}${r.source === "BluRay-Disc" ? "BluRay" : r.source === "DVDRip" ? "DVD" : r.source}`
: r.uhd
? "UHD ?"
: null;
const eps = r.episodes;
return {
name: canonName(r.title),
cn_name: canonName(r.titleCn),
en_name: canonName(r.titleEn),
year: str(r.year),
type: r.mediaTypeHint,
begin_season: str(r.season),
end_season: str(r.seasonEnd),
begin_episode: str(eps?.start),
end_episode: eps && eps.end !== eps.start ? String(eps.end) : null,
resource_pix: canonPix(str(r.resolution)),
resource_type: src,
resource_effect: r.hdr.length ? canonHdrList(r.hdr) : null,
video_encode: canonCodec(str(r.videoCodec)),
audio_encode: r.audio.length ? canonAudio(r.audio.join(" ")) : null,
resource_team: canonGroups(str(r.releaseGroup?.name)),
part: canonPart(str(r.part)),
web_source: canonName(r.webSource),
media_id: r.explicitIds[0] ? `${PROVIDER[r.explicitIds[0].provider]}:${r.explicitIds[0].id}` : null,
};
}
/** 逐字段差异(规范形不等) */
export function diffSample(
s: MetaSample,
mp: MpMeta,
): Partial<Record<Field, { mp: string | null; ours: string | null }>> {
const a = canonMp(mp);
const b = canonOurs(oursParse(s));
const out: Partial<Record<Field, { mp: string | null; ours: string | null }>> = {};
for (const f of FIELDS) if (a[f] !== b[f]) out[f] = { mp: a[f], ours: b[f] };
return out;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,35 @@
/**
* 标题识别差分测试:MP MetaInfo/MetaInfoPath(Python 参考实现)vs parseRelease/parseReleasePath。
* 期望输出由 `scripts/mp-sync/gen-differential.py meta` 生成(meta-mp.json),已知差异登记在 meta-deviations.json。
* 断言:实际差异集合 == 登记集合,且规范形取值与快照一致。补迁某条后测试会失败,提示删除对应登记。
*/
import { describe, expect, it } from "vitest";
import { diffSample, loadMeta } from "./meta-map.ts";
const { samples, mp, deviations } = loadMeta();
describe("标题识别差分(MP MetaInfo ↔ parseRelease)", () => {
it("样本 ≥ 150 且每条都有 MP 输出", () => {
expect(samples.length).toBeGreaterThanOrEqual(150);
for (const s of samples) {
expect(mp.python[s.id], s.id).toBeDefined();
expect(mp.python[s.id]?.error, s.id).toBeUndefined();
}
});
it("登记项都对应存在的样本", () => {
const ids = new Set(samples.map((s) => s.id));
for (const id of Object.keys(deviations)) expect(ids.has(id), id).toBe(true);
});
for (const s of samples) {
it(`${s.id} ${s.input}`, () => {
const actual = diffSample(s, mp.python[s.id]!);
const registered = deviations[s.id] ?? {};
const snapshot = Object.fromEntries(
Object.entries(registered).map(([f, d]) => [f, { mp: d!.mp, ours: d!.ours }]),
);
expect(actual, `${s.id} 差异与 meta-deviations.json 登记不一致`).toEqual(snapshot);
});
}
});
@@ -0,0 +1,21 @@
{
"note": "繁→简差分已知差异:key=输入。kind 同 meta-deviations.json。",
"deviations": {
"咒術迴戰": { "kind": "missing", "mp": "咒术回战", "ours": "咒术迴战", "note": "字表缺 迴→回" },
"孤獨搖滾": { "kind": "missing", "mp": "孤独摇滚", "ours": "孤独摇滾", "note": "字表缺 滾→滚" },
"魷魚遊戲": { "kind": "missing", "mp": "鱿鱼游戏", "ours": "魷鱼游戏", "note": "字表缺 魷→鱿" },
"瑯琊榜": { "kind": "missing", "mp": "琅琊榜", "ours": "瑯琊榜", "note": "字表缺 瑯→琅(异体)" },
"慶餘年": {
"kind": "mp_bug",
"mp": "庆馀年",
"ours": "庆余年",
"note": "MediaWiki 词表把 餘 转成 馀,官方简体片名为 庆余年"
},
"臥虎藏龍": { "kind": "missing", "mp": "卧虎藏龙", "ours": "臥虎藏龙", "note": "字表缺 臥→卧" },
"駭客任務": { "kind": "missing", "mp": "骇客任务", "ours": "駭客任务", "note": "字表缺 駭→骇" },
"乾杯": { "kind": "missing", "mp": "干杯", "ours": "乾杯", "note": "一对多字 乾 刻意不收;MP 有词组表" },
"瞭解": { "kind": "missing", "mp": "了解", "ours": "瞭解", "note": "一对多字 瞭 刻意不收;MP 有词组表" },
"頭髮": { "kind": "missing", "mp": "头发", "ours": "头髮", "note": "髮→发 未收(发/發 合并字)" },
"理髮": { "kind": "missing", "mp": "理发", "ours": "理髮", "note": "髮→发 未收" }
}
}
@@ -0,0 +1,78 @@
{
"generatedFrom": {
"repo": "MoviePilot",
"commit": "e6820a8",
"entry": "moviepilot_rust.zhconv_fast(text, 'zh-hans')"
},
"hans": {
"葬送的芙莉蓮": "葬送的芙莉莲",
"處刑少女的生存之道": "处刑少女的生存之道",
"夏日重現": "夏日重现",
"間諜家家酒": "间谍家家酒",
"進擊的巨人": "进击的巨人",
"鬼滅之刃": "鬼灭之刃",
"咒術迴戰": "咒术回战",
"我的英雄學院": "我的英雄学院",
"藥師少女的獨語": "药师少女的独语",
"無職轉生": "无职转生",
"關於我轉生變成史萊姆這檔事": "关于我转生变成史莱姆这档事",
"輝夜姬想讓人告白": "辉夜姬想让人告白",
"排球少年!!": "排球少年!!",
"鏈鋸人": "链锯人",
"孤獨搖滾": "孤独摇滚",
"黑暗榮耀": "黑暗荣耀",
"與神同行": "与神同行",
"魷魚遊戲": "鱿鱼游戏",
"機智醫生生活": "机智医生生活",
"請回答1988": "请回答1988",
"陽光先生": "阳光先生",
"鬼怪": "鬼怪",
"來自星星的你": "来自星星的你",
"後宮甄嬛傳": "后宫甄嬛传",
"瑯琊榜": "琅琊榜",
"慶餘年": "庆馀年",
"琅琊榜之風起長林": "琅琊榜之风起长林",
"甄嬛傳": "甄嬛传",
"雍正王朝": "雍正王朝",
"乾隆王朝": "乾隆王朝",
"康熙王朝": "康熙王朝",
"大明王朝1566": "大明王朝1566",
"頭文字D": "头文字D",
"無間道": "无间道",
"少林足球": "少林足球",
"功夫": "功夫",
"臥虎藏龍": "卧虎藏龙",
"英雄本色": "英雄本色",
"倩女幽魂": "倩女幽魂",
"喋血雙雄": "喋血双雄",
"阿飛正傳": "阿飞正传",
"花樣年華": "花样年华",
"重慶森林": "重庆森林",
"復仇者聯盟:終局之戰": "复仇者联盟:终局之战",
"蜘蛛人:無家日": "蜘蛛人:无家日",
"星際效應": "星际效应",
"全面啟動": "全面启动",
"駭客任務": "骇客任务",
"魔戒首部曲:魔戒現身": "魔戒首部曲:魔戒现身",
"神隱少女": "神隐少女",
"霍爾的移動城堡": "霍尔的移动城堡",
"乾杯": "干杯",
"著作": "著作",
"瞭解": "了解",
"頭髮": "头发",
"理髮": "理发",
"發現": "发现",
"後來": "后来",
"皇后": "皇后",
"範圍": "范围",
"姓范": "姓范",
"裡面": "里面",
"計畫": "计画",
"週末": "周末",
"臺灣": "台湾",
"台灣": "台湾",
"麵包": "面包",
"鬱金香": "郁金香",
"舞台劇": "舞台剧"
}
}
@@ -0,0 +1,74 @@
{
"note": "繁→简差分样本(影视标题/人名/常见一对多字)。期望由 MP moviepilot_rust.zhconv_fast(text, 'zh-hans') 生成",
"samples": [
"葬送的芙莉蓮",
"處刑少女的生存之道",
"夏日重現",
"間諜家家酒",
"進擊的巨人",
"鬼滅之刃",
"咒術迴戰",
"我的英雄學院",
"藥師少女的獨語",
"無職轉生",
"關於我轉生變成史萊姆這檔事",
"輝夜姬想讓人告白",
"排球少年!!",
"鏈鋸人",
"孤獨搖滾",
"黑暗榮耀",
"與神同行",
"魷魚遊戲",
"機智醫生生活",
"請回答1988",
"陽光先生",
"鬼怪",
"來自星星的你",
"後宮甄嬛傳",
"瑯琊榜",
"慶餘年",
"琅琊榜之風起長林",
"甄嬛傳",
"雍正王朝",
"乾隆王朝",
"康熙王朝",
"大明王朝1566",
"頭文字D",
"無間道",
"少林足球",
"功夫",
"臥虎藏龍",
"英雄本色",
"倩女幽魂",
"喋血雙雄",
"阿飛正傳",
"花樣年華",
"重慶森林",
"復仇者聯盟:終局之戰",
"蜘蛛人:無家日",
"星際效應",
"全面啟動",
"駭客任務",
"魔戒首部曲:魔戒現身",
"神隱少女",
"霍爾的移動城堡",
"乾杯",
"著作",
"瞭解",
"頭髮",
"理髮",
"發現",
"後來",
"皇后",
"範圍",
"姓范",
"裡面",
"計畫",
"週末",
"臺灣",
"台灣",
"麵包",
"鬱金香",
"舞台劇"
]
}
@@ -0,0 +1,22 @@
/**
* 繁→简差分:MP moviepilot_rust.zhconv_fast(zh-hans) vs identity/toSimplified。
* 期望由 `scripts/mp-sync/gen-differential.py zhconv` 生成;已知差异登记在 zhconv-deviations.json。
*/
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { toSimplified } from "../../src/identity/index.ts";
const read = (n: string) => JSON.parse(readFileSync(new URL(`./${n}`, import.meta.url), "utf8"));
const mp: Record<string, string> = read("zhconv-mp.json").hans;
const dev: Record<string, { mp: string; ours: string }> = read("zhconv-deviations.json").deviations;
it("toSimplified 与 MP zhconv 的差异集合 == 登记集合", () => {
const actual: Record<string, { mp: string; ours: string }> = {};
for (const [src, want] of Object.entries(mp)) {
const ours = toSimplified(src);
if (ours !== want) actual[src] = { mp: want, ours };
}
const snapshot = Object.fromEntries(Object.entries(dev).map(([k, d]) => [k, { mp: d.mp, ours: d.ours }]));
expect(actual).toEqual(snapshot);
expect(Object.keys(mp).length).toBeGreaterThanOrEqual(60);
});
+760
View File
@@ -0,0 +1,760 @@
#!/usr/bin/env python3
"""用 MoviePilot 的实现生成差分测试的期望输出(只读调用上游,不复制源码)。
用法(必须用 MP 自己的 venv 解释器,工作目录无要求):
MP_ROOT=/opt/moviepilot-plus/upstream/MoviePilot \
$MP_ROOT/.venv/bin/python scripts/mp-sync/gen-differential.py meta
... gen-differential.py filter # 过滤规则
... gen-differential.py rss # RSS / 站点索引解析
... gen-differential.py all
meta:读 packages/domain/test/differential/meta-samples.json,分别以
accelerator=None(Python 参考实现)与 accelerator=rust(moviepilot_rust 快路径)
调用 MetaInfo / MetaInfoPath,写 meta-mp.json。两条路径不一致的样本记在 rustMismatch。
生成后执行 `deno task fmt`(biome 统一 JSON 格式),再跑差分测试;新出现的差异需人工分类后
登记进对应 *-deviations.json / deviations 字段(kind 见 docs/porting/mp-rust-logic-parity.md)。
"""
from __future__ import annotations
import glob
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
REPO = Path(__file__).resolve().parents[2]
MP_ROOT = Path(os.environ.setdefault("MP_ROOT", "/opt/moviepilot-plus/upstream/MoviePilot"))
DOMAIN_DIFF = REPO / "packages/domain/test/differential"
ADAPTERS_DIFF = REPO / "packages/adapters/test/differential"
def _bootstrap_mp() -> str:
"""把 MP 放进 sys.path,隔离 CONFIG_DIR,返回上游提交号。"""
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="mp-diff-cfg-"))
if str(MP_ROOT) not in sys.path:
sys.path.insert(0, str(MP_ROOT))
os.chdir(MP_ROOT)
try:
return subprocess.check_output(["git", "-C", str(MP_ROOT), "rev-parse", "--short", "HEAD"], text=True).strip()
except Exception: # noqa: BLE001
return "unknown"
META_FIELDS = (
"name", "cn_name", "en_name", "year", "type", "begin_season", "end_season", "total_season",
"begin_episode", "end_episode", "total_episode", "resource_pix", "resource_type", "resource_effect",
"video_encode", "audio_encode", "resource_team", "edition", "part", "customization", "web_source",
"fps", "video_bit", "media_source", "media_id",
)
def _meta_to_dict(meta) -> dict:
out = {}
for key in META_FIELDS:
value = getattr(meta, key, None)
if key == "type":
value = value.value if value is not None else None
elif key == "media_source" and value is not None:
value = getattr(value, "value", str(value))
if value == "":
value = None
out[key] = value
out["class"] = type(meta).__name__
return out
def gen_meta() -> None:
commit = _bootstrap_mp()
from pathlib import Path as P
from app.adapters.system import rust
from app.domain.meta.runtime import configure_recognition_runtime
from app.domain.metainfo import MetaInfo, MetaInfoPath, clear_rust_parse_options_cache
from app.runtime.config import settings
samples = json.loads((DOMAIN_DIFF / "meta-samples.json").read_text("utf-8"))["samples"]
def run(accelerator) -> dict:
configure_recognition_runtime(
media_extensions_provider=lambda: (*settings.RMT_MEDIAEXT, *settings.RMT_SUBEXT, *settings.RMT_AUDIOEXT),
audio_extensions_provider=lambda: settings.RMT_AUDIOEXT,
accelerator=accelerator,
)
clear_rust_parse_options_cache()
res = {}
for s in samples:
try:
if s["kind"] == "path":
meta = MetaInfoPath(P(s["input"]))
else:
meta = MetaInfo(title=s["input"], subtitle=s.get("subtitle"))
res[s["id"]] = _meta_to_dict(meta)
except Exception as err: # noqa: BLE001
res[s["id"]] = {"error": f"{type(err).__name__}: {err}"}
return res
python_out = run(None)
rust_out = run(rust) if rust.is_available() else {}
mismatch = {}
for sid, py in python_out.items():
rs = rust_out.get(sid)
if rs is None:
continue
diff = {k: {"python": py.get(k), "rust": rs.get(k)} for k in py if py.get(k) != rs.get(k)}
if diff:
mismatch[sid] = diff
out = {
"generatedFrom": {"repo": "MoviePilot", "commit": commit, "entry": "app.domain.metainfo.MetaInfo/MetaInfoPath",
"rust": rust.status() if rust.is_available() else None},
"python": python_out,
"rustMismatch": mismatch,
}
(DOMAIN_DIFF / "meta-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", "utf-8")
print(f"meta: {len(python_out)} samples, rust≠python: {len(mismatch)}")
def gen_zhconv() -> None:
"""繁→简:moviepilot_rust.zhconv_fast 无 Python 回退(app/foundation/text.py 直接调用原生扩展)。"""
commit = _bootstrap_mp()
import moviepilot_rust
samples = json.loads((DOMAIN_DIFF / "zhconv-samples.json").read_text("utf-8"))["samples"]
out = {
"generatedFrom": {"repo": "MoviePilot", "commit": commit, "entry": "moviepilot_rust.zhconv_fast(text, 'zh-hans')"},
"hans": {s: moviepilot_rust.zhconv_fast(s, "zh-hans") for s in samples},
}
(DOMAIN_DIFF / "zhconv-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", "utf-8")
print(f"zhconv: {len(samples)} samples")
# ---------------------------------------------------------------- filter:过滤规则
# 用 MP 的 Python 回退实现(FilterModule,rust_accel 关闭)对 TORRENTS x RULES 求匹配与优先级,
# 并用 Rust filter_torrents_fast 交叉核对。无需数据库:规则组/规则集直接注入。
# 已知差异登记(deviations)不由本脚本生成,重新生成时从已有 filter-cases.json 保留。
GiB = 1024 ** 3
MiB = 1024 ** 2
# ---------------------------------------------------------------- 样本
# size 为站点报告的总大小(字节);labels 为站点标签
TORRENTS: list[dict[str, Any]] = [
{"id": "t01", "title": "Dune.Part.Two.2024.2160p.UHD.BluRay.Remux.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR", "description": "", "size": 70 * GiB, "seeders": 20},
{"id": "t02", "title": "Dune.Part.Two.2024.2160p.BluRay.x265.10bit.DV.HDR-CHD", "description": "沙丘2 中字 特效", "size": 25 * GiB, "seeders": 12},
{"id": "t03", "title": "Dune Part Two 2024 2160p UHD Blu-ray HEVC DV TrueHD 7.1-BeyondHD", "description": "", "size": 90 * GiB, "seeders": 4},
{"id": "t04", "title": "Dune.Part.Two.2024.1080p.WEB-DL.H264.DDP5.1-HHWEB", "description": "沙丘2 | 国语 中字", "size": 8 * GiB, "seeders": 50},
{"id": "t05", "title": "Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.5.1-WiKi", "description": "简繁英字幕", "size": 15 * GiB, "seeders": 9},
{"id": "t06", "title": "Dune.Part.Two.2024.720p.WEBRip.x264-GRP", "description": "", "size": 2 * GiB, "seeders": 1},
{"id": "t07", "title": "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX", "description": "", "size": 20 * GiB, "seeders": 30},
{"id": "t08", "title": "Movie.2022.2160p.HDR.HEVC-NOSRC", "description": "", "size": 18 * GiB, "seeders": 2},
{"id": "t09", "title": "The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.HDR.H.265-NTb", "description": "熊家餐馆 第三季 全10集 中字", "size": 60 * GiB, "seeders": 15},
{"id": "t10", "title": "The.Bear.S03E01.1080p.WEB-DL.H264-NTb", "description": "", "size": int(2.5 * GiB), "seeders": 8},
{"id": "t11", "title": "Movie.2023.1080p.BluRay.REMUX.AVC.DTS-HD.MA.5.1-EPSiLON", "description": "", "size": 30 * GiB, "seeders": 6},
{"id": "t12", "title": "Movie 2023 1080p Blu-ray AVC DTS-HD MA 5.1-AAA", "description": "", "size": 40 * GiB, "seeders": 3},
{"id": "t13", "title": "Movie.2023.COMPLETE.BLURAY-GRP", "description": "", "size": 45 * GiB, "seeders": 2},
{"id": "t14", "title": "Movie.2023.1080i.HDTV.H264-GRP", "description": "", "size": 10 * GiB, "seeders": 1},
{"id": "t15", "title": "[Nekomoe kissaten] Frieren - 01 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv", "description": "简繁日内封", "size": 1 * GiB, "seeders": 40},
{"id": "t16", "title": "The.Wandering.Earth.II.2023.2160p.WEB-DL.H265.HDR.DDP5.1-HDSWEB", "description": "流浪地球2 国语配音 中文字幕", "size": 12 * GiB, "seeders": 25},
{"id": "t17", "title": "Oppenheimer.2023.IMAX.2160p.BluRay.x265.10bit.HDR.DTS-HD.MA.5.1-SWTYBLZ", "description": "奥本海默 CHS/ENG", "size": 30 * GiB, "seeders": 7},
{"id": "t18", "title": "Movie.2022.3840x2160.WEB-DL.HDR10.HEVC-GRP", "description": "", "size": 15 * GiB, "seeders": 5},
{"id": "t19", "title": "Movie.2022.1920x1080.WEB-DL.H264-GRP", "description": "", "size": 6 * GiB, "seeders": 5},
{"id": "t20", "title": "Movie.2022.4K.WEB-DL.H265-GRP", "description": "", "size": 14 * GiB, "seeders": 5},
{"id": "t21", "title": "Movie.2022.1080p.WEB-DL.x264 [GB]", "description": "", "size": 4 * GiB, "seeders": 5},
{"id": "t22", "title": "Movie.2022.1080p.WEB-DL.H264.AAC 39.23 GB", "description": "", "size": 4 * GiB, "seeders": 5},
{"id": "t23", "title": "Some.Show.S01.1080p.BluRay.x264-GRP", "description": "全12集 简体中字", "size": 36 * GiB, "seeders": 3},
{"id": "t24", "title": "Movie.2021.DVDRip.x264-GRP", "description": "", "size": int(1.4 * GiB), "seeders": 2},
{"id": "t25", "title": "Movie 2021 1080p 3D BluRay Half-SBS x264-GRP", "description": "", "size": 8 * GiB, "seeders": 2},
{"id": "t26", "title": "Movie.2021.2160p.WEB-DL.60fps.HEVC-GRP", "description": "60帧 国语", "size": 10 * GiB, "seeders": 4},
{"id": "t27", "title": "Movie.2020.2160p.Remux.HEVC.DoVi-GRP", "description": "", "size": 50 * GiB, "seeders": 3},
{"id": "t28", "title": "Movie.2020.1080p.NF.WEB-DL.DDP5.1.H.264-CHDWEB", "description": "官方 中字", "labels": ["官方"], "size": 5 * GiB, "seeders": 10, "downloadvolumefactor": 0},
]
# 自定义规则(对应 MP「自定义规则」页:include/exclude 为单个正则字符串,size_range 单位 MB)
CUSTOM_RULES: dict[str, dict[str, Any]] = {
"SIZEMOVIE": {"size_range": "2000-30000"},
"SIZESMALL": {"size_range": "<8000"},
"SIZEBIG": {"size_range": ">20000"},
"SEED5": {"seeders": "5"},
}
# verdict: full=可完整表达 / partial=近似表达(测试按近似翻译比对,差异登记 not_expressible) / none=不可表达(只生成 MP 结果)
RULES: list[dict[str, Any]] = [
{"id": "R01", "rule": "4K", "verdict": "full"},
{"id": "R02", "rule": "1080P", "verdict": "full"},
{"id": "R03", "rule": "720P", "verdict": "full"},
{"id": "R04", "rule": "4K | 1080P", "verdict": "full"},
{"id": "R05", "rule": "4K > 1080P > 720P", "verdict": "full"},
{"id": "R06", "rule": "BLU", "verdict": "full"},
{"id": "R07", "rule": "!BLU", "verdict": "full"},
{"id": "R08", "rule": "REMUX", "verdict": "full"},
{"id": "R09", "rule": "!REMUX", "verdict": "full"},
{"id": "R10", "rule": "WEBDL", "verdict": "full"},
{"id": "R11", "rule": "!BLU & !REMUX", "verdict": "full"},
{"id": "R12", "rule": "4K & !REMUX > 1080P & WEBDL", "verdict": "full"},
{"id": "R13", "rule": "CNSUB", "verdict": "full"},
{"id": "R14", "rule": "CNSUB & 4K & !BLU", "verdict": "full"},
{"id": "R15", "rule": "CNSUB & 1080P > 1080P", "verdict": "full"},
{"id": "R16", "rule": "SIZEMOVIE", "verdict": "full"},
{"id": "R17", "rule": "SIZESMALL", "verdict": "full"},
{"id": "R18", "rule": "4K & SIZEMOVIE", "verdict": "full"},
{"id": "R19", "rule": "!BLU & !REMUX & !WEBDL", "verdict": "full"},
{"id": "R20", "rule": "BLURAY & !REMUX & !BLU", "verdict": "full"},
{"id": "R21", "rule": "4K & !BLU & !REMUX > 1080P & !BLU & !REMUX", "verdict": "full"},
{"id": "R22", "rule": "(4K | 1080P) & WEBDL", "verdict": "full"},
{"id": "R23", "rule": "CNSUB", "verdict": "partial", "mediainfo": {"original_language": "zh"},
"note": "tmdb.original_language=zh 时 CNSUB 对任何种子直接放行"},
{"id": "R24", "rule": "4K & !DOLBY", "verdict": "partial", "note": "硬条件无 HDR 维度,近似为 4K"},
{"id": "R25", "rule": "SPECSUB & CNSUB & !BLU", "verdict": "partial", "note": "无特效字幕维度,近似为 CNSUB & !BLU"},
{"id": "R26", "rule": "SIZEBIG", "verdict": "full"},
# ---- 以下不可表达,只记录 MP 行为供报告/将来补迁
{"id": "N01", "rule": "H265", "verdict": "none", "note": "编码只有软偏好 soft.codecs"},
{"id": "N02", "rule": "!DOLBY & !REMUX", "verdict": "none", "note": "HDR 只有软偏好"},
{"id": "N03", "rule": "HDR", "verdict": "none"},
{"id": "N04", "rule": "SPECSUB", "verdict": "none"},
{"id": "N05", "rule": "CNVOI", "verdict": "none", "note": "无音轨语言字段"},
{"id": "N06", "rule": "60FPS", "verdict": "none"},
{"id": "N07", "rule": "!3D", "verdict": "none"},
{"id": "N08", "rule": "ATMOS", "verdict": "none"},
{"id": "N09", "rule": "GZ", "verdict": "none", "note": "站点标签"},
{"id": "N10", "rule": "FREE", "verdict": "none", "note": "站点促销"},
{"id": "N11", "rule": "SEED5", "verdict": "none", "note": "做种数"},
{"id": "N12", "rule": "CNSUB | 4K", "verdict": "none", "note": "跨字段 OR"},
{"id": "N13", "rule": "!(4K & REMUX)", "verdict": "none", "note": "跨字段 NOT(AND) = OR"},
{"id": "N14", "rule": "UHD", "verdict": "none", "note": "ParsedRelease.uhd 有,但硬条件没有"},
{"id": "N15", "rule": "4K & H265 > 4K", "verdict": "none", "note": "level1 需编码硬条件"},
{"id": "N16", "rule": "NOSUCHRULE | 4K", "verdict": "none", "note": "未知规则名视为不匹配"},
]
def _filter_mp_root(mp_root: Optional[str]) -> Path:
root = mp_root or os.environ.get("MP_ROOT") or "/opt/moviepilot-plus/upstream/MoviePilot"
return Path(root).resolve()
def _filter_mp_commit(root: Path) -> str:
try:
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "--short", "HEAD"], text=True).strip()
except Exception: # noqa: BLE001
return "unknown"
def gen_filter(out_path: str, mp_root: Optional[str] = None) -> dict:
root = _filter_mp_root(mp_root)
if str(root) not in sys.path:
sys.path.insert(0, str(root))
old_cwd = os.getcwd()
os.chdir(root)
try:
from unittest.mock import patch
from app.adapters.system import rust as rust_accel
from app.application.rules import BUILTIN_RULE_SET
from app.domain.context import TorrentInfo
from app.modules.filter import FilterModule
finally:
os.chdir(old_cwd)
rule_set = {**BUILTIN_RULE_SET, **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),
)
def run(rule: dict, python: bool) -> list[Optional[int]]:
module = FilterModule()
module.rulehelper = _RuleHelper([SimpleNamespace(name=rule["id"], rule_string=rule["rule"])])
module.rule_set = rule_set
mi = SimpleNamespace(**rule["mediainfo"]) if rule.get("mediainfo") else None
out: list[Optional[int]] = []
for t in TORRENTS: # 逐条过滤,和订阅匹配链路(单种子列表)一致
ti = mk(t)
if python:
with patch("app.modules.filter.rust_accel.is_enabled", return_value=False):
res = module.filter_torrents(rule_groups=[rule["id"]], torrent_list=[ti], mediainfo=mi)
else:
res = module.filter_torrents(rule_groups=[rule["id"]], torrent_list=[ti], mediainfo=mi)
out.append(res[0].pri_order if res else None)
return out
rust_ok = rust_accel.is_enabled()
cases = []
rust_mismatch = []
for rule in RULES:
py = run(rule, python=True)
rs = run(rule, python=False) if rust_ok else py
for t, p, r in zip(TORRENTS, py, rs):
cid = f"{rule['id']}:{t['id']}"
if p != r:
rust_mismatch.append({"caseId": cid, "python": p, "rust": r})
cases.append({
"caseId": cid, "ruleId": rule["id"], "torrentId": t["id"],
"mp": {"matched": p is not None, "priOrder": p, "level": (101 - p) if p is not None else None},
})
out = Path(out_path)
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": _filter_mp_commit(root),
"engine": "python-fallback (app.modules.filter FilterModule, rust_accel disabled)",
"rustCrossCheck": {"available": rust_ok, "mismatches": rust_mismatch},
"generator": "scripts/mp-sync/gen-differential.py filter",
"semantics": "level = 1-based index of first matching '>' tier; priOrder = 101 - level (MP pri_order)",
},
"customRules": CUSTOM_RULES,
"torrents": TORRENTS,
"rules": RULES,
"cases": cases,
"deviations": deviations,
}
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(doc, ensure_ascii=False, indent=1) + "\n", "utf-8")
return doc
def gen_filter_cmd() -> None:
d = gen_filter(str(DOMAIN_DIFF / "filter-cases.json"), str(MP_ROOT))
print(f"filter: cases={len(d['cases'])} rust≠python={len(d['generatedFrom']['rustCrossCheck']['mismatches'])}")
# ---------------------------------------------------------------- rss / indexer:RSS 与站点列表页解析
# MoviePilot RSS / 站点索引解析 差分期望生成器。
#
# 用 MoviePilot(MP_ROOT,须用其 .venv 的 python 运行)的 **Python 回退路径** 解析本项目的 fixture,
# 把输入、MP 输出(同时附带 Rust 快路径输出作参考)写成 JSON,供
# `packages/adapters/test/differential/{rss,indexer}.test.ts` 与本项目解析器逐字段比对。
#
# - RSS:`app/application/rss.py RssHelper._parse_impl`(注入假 HTTP/解析端口,解析端口返回 None 强制走
# lxml 回退;`parse_rss_items_fast` 另行直接调用记录到 `mp_rust`)。
# - 站点索引:`app/modules/indexer/spider/__init__.py SiteSpider.parse`(把 `rust_accel.parse_indexer_torrents/
# parse_indexer_subtitles` 打桩为 None 强制走 PyQuery 回退;再恢复原函数跑一次记录 `mp_rust`)。
# MP 自带站点定义在加密资源 `user.sites.v3.bin`(Cython `SitesHelper`),未认证用户 `get_indexer()` 返回 {},
# `get_indexsites()` 只含 id/name/url,因此 indexer 配置改用本项目 `definitions/*.json`(同 MP schema)。
# 输入先由 `deno run -A packages/adapters/test/differential/export-inputs.ts` 导出 tk-* fixture。
UMT_ROOT = REPO
DEFAULT_OUT = UMT_ROOT / "packages/adapters/test/differential"
DEFINITIONS_DIR = UMT_ROOT / "packages/adapters/src/sites/definitions"
_BOOTSTRAPPED = False
def _mp_root() -> Path:
root = os.environ.get("MP_ROOT")
if not root:
raise SystemExit("需要环境变量 MP_ROOT(MoviePilot 源码根目录)")
return Path(root).resolve()
def _bootstrap() -> Path:
"""隔离 CONFIG_DIR 并建表(复用 MP 测试引导),固定进程时区为 UTC,返回 MP_ROOT。"""
global _BOOTSTRAPPED
root = _mp_root()
if _BOOTSTRAPPED:
return root
# 时区固定为 UTC:RSS 的 astimezone(None) 与站点 normalize_datetime 结果与宿主时区无关
os.environ["TZ"] = "UTC"
time.tzset()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
cwd = os.getcwd()
os.chdir(root)
try:
from app.testing.bootstrap import prepare_backend # noqa: WPS433
prepare_backend()
# 同 MP tests/conftest.py configure_plugin_system_services:装配类型化配置快照与 runtime setting
from app.application.configuration import (
RuntimeConfiguration,
RuntimeSettingsService,
configure_runtime_configuration,
configure_runtime_settings,
)
from app.runtime.config import settings
from app.runtime.settings import configure_runtime_setting_provider
from app.startup.composition.configuration import (
build_api_runtime_config,
build_chain_runtime_config,
build_scheduler_runtime_config,
)
configure_runtime_configuration(RuntimeConfiguration(
api=lambda: build_api_runtime_config(settings),
scheduler=lambda: build_scheduler_runtime_config(settings),
chain=lambda: build_chain_runtime_config(settings),
))
configure_runtime_settings(RuntimeSettingsService(settings))
configure_runtime_setting_provider(lambda key: getattr(settings, key))
_freeze_dateparser_now()
finally:
os.chdir(cwd)
_BOOTSTRAPPED = True
return root
# MP normalize_datetime 用 dateparser.parse(value) 无 settings,相对时间("3小时前")按当前时刻换算;
# 为使期望文件可复现,冻结其相对基准。
FROZEN_NOW = "2026-09-27T12:00:00"
def _freeze_dateparser_now() -> None:
import datetime as _dt
import dateparser as _real
from app.foundation import temporal
base = _dt.datetime.fromisoformat(FROZEN_NOW)
class _Shim:
@staticmethod
def parse(value, *args, **kwargs):
settings = dict(kwargs.pop("settings", None) or {})
settings.setdefault("RELATIVE_BASE", base)
return _real.parse(value, *args, settings=settings, **kwargs)
temporal.dateparser = _Shim()
def _mp_commit(root: Path) -> str:
try:
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
except Exception: # noqa: BLE001
return "unknown"
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _load_existing(path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text("utf-8"))
except Exception: # noqa: BLE001
return {}
return {}
def _jsonable(v: Any) -> Any:
import datetime as _dt
if isinstance(v, _dt.datetime):
return v.isoformat()
if isinstance(v, dict):
return {k: _jsonable(x) for k, x in v.items()}
if isinstance(v, (list, tuple)):
return [_jsonable(x) for x in v]
return v
# --------------------------------------------------------------------------- RSS
def _run_mp_rss_python(xml_text: str) -> dict:
"""走 RssHelper._parse_impl 的 lxml 回退分支;返回 {status, items}。"""
import datetime as _dt
from app.adapters.network.http import RequestUtils
from app.application.rss import RssHelper, configure_rss_ports, reset_rss_ports
raw = xml_text.encode("utf-8")
class _Http:
def get(self, **_kw):
return SimpleNamespace(status_code=200, content=raw, text=xml_text,
apparent_encoding="utf-8", encoding="utf-8", headers={})
def post(self, **_kw): # pragma: no cover
raise AssertionError("不应 POST")
@staticmethod
def decode_xml(response, **kw):
return RequestUtils.get_decoded_xml_content(response, **kw)
class _Browser:
@staticmethod
def render(**_kw): # pragma: no cover
raise AssertionError("不应访问浏览器")
class _Parser:
@staticmethod
def parse(_content, _max):
return None # 关闭 Rust,强制 Python 回退
calls: list = []
original = RssHelper._parse_publish_time
def _spy(value):
dt = original(value)
calls.append({"text": value, "naive": bool(dt is not None and dt.tzinfo is None),
"wall": dt.replace(tzinfo=None).isoformat() if dt is not None else None})
return dt
RssHelper._parse_publish_time = staticmethod(_spy)
configure_rss_ports(http=_Http(), browser=_Browser(), parser=_Parser())
try:
result = RssHelper().parse("https://differential.invalid/rss")
finally:
reset_rss_ports()
RssHelper._parse_publish_time = staticmethod(original)
if result is None:
return {"status": "none_expired", "items": []}
if result is False:
return {"status": "false_error", "items": []}
items = []
for it in result:
out = {k: it.get(k) for k in ("title", "description", "link", "enclosure", "size")}
if "nickname" in it:
out["nickname"] = it["nickname"]
pd = it.get("pubdate")
if pd == "":
out["pubdate"] = {"kind": "absent"}
else:
call = calls.pop(0) if calls else {"text": None, "naive": False, "wall": None}
if pd is None:
out["pubdate"] = {"kind": "unparsed", "text": call["text"]}
else:
utc = pd.astimezone(_dt.timezone.utc)
out["pubdate"] = {
"kind": "naive" if call["naive"] else "aware",
"text": call["text"],
# naive:MP 以宿主本地时区解释(astimezone(None));wall 为墙上时间
"wall": call["wall"],
"utc": utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{utc.microsecond // 1000:03d}Z",
}
items.append(out)
return {"status": "ok", "items": items}
def _run_mp_rss_rust(xml_text: str) -> Optional[dict]:
try:
import moviepilot_rust # type: ignore
except Exception as err: # noqa: BLE001
return {"status": "unavailable", "error": str(err)}
stripped = xml_text.strip()
if not stripped or not stripped.startswith("<"):
return {"status": "not_called", "note": "RssHelper.__parse_with_rust 对非 < 开头内容直接返回 None"}
try:
return {"status": "ok", "items": _jsonable(moviepilot_rust.parse_rss_items_fast(xml_text, 1001))}
except BaseException as err: # noqa: BLE001
return {"status": "error", "error": f"{type(err).__name__}: {err}"}
def gen_rss(out_dir: Optional[str] = None) -> Path:
"""生成 <out_dir>/rss.json;返回文件路径。"""
root = _bootstrap()
out = Path(out_dir).resolve() if out_dir else DEFAULT_OUT
inputs = sorted(glob.glob(str(out / "inputs/rss/*.xml")) + glob.glob(str(out / "inputs/rss/*.txt")))
target = out / "rss.json"
existing = _load_existing(target)
samples = []
for f in inputs:
p = Path(f)
data = p.read_bytes()
text = data.decode("utf-8")
samples.append({
"id": p.stem,
"input": {"file": f"inputs/rss/{p.name}", "sha256": _sha256(data)},
"mp": _run_mp_rss_python(text),
"mp_rust": _run_mp_rss_rust(text),
})
doc = {
"kind": "rss",
"generatedFrom": {
"repo": "MoviePilot", "commit": _mp_commit(root),
"function": "app/application/rss.py RssHelper._parse_impl (lxml fallback; parser port -> None)",
"rust": "moviepilot_rust.parse_rss_items_fast (reference only)",
"tz": "UTC", "generator": "gen-differential.py rss",
},
"fields": ["title", "description", "link", "enclosure", "size", "pubdate", "nickname"],
"samples": samples,
"deviations": existing.get("deviations", {}),
}
target.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", "utf-8")
return target
# --------------------------------------------------------------------------- 站点索引
def _run_mp_spider(indexer: dict, html: str, keyword: Optional[str], search_type: str, use_rust: bool) -> dict:
from app.adapters.system import rust as rust_accel
from app.modules.indexer.spider import SiteSpider
orig_t, orig_s = rust_accel.parse_indexer_torrents, rust_accel.parse_indexer_subtitles
if not use_rust:
rust_accel.parse_indexer_torrents = lambda **_kw: None
rust_accel.parse_indexer_subtitles = lambda **_kw: None
try:
spider = SiteSpider(indexer, keyword=keyword, search_type=search_type)
rows = spider.parse(html)
return {"is_error": bool(spider.is_error), "torrents": _jsonable(rows)}
except BaseException as err: # noqa: BLE001
return {"status": "error", "error": f"{type(err).__name__}: {err}"}
finally:
rust_accel.parse_indexer_torrents, rust_accel.parse_indexer_subtitles = orig_t, orig_s
SUBTITLE_INDEXER = {
# 取自 MP tests/test_rust_accel.py::test_rust_subtitle_parser_is_several_times_faster_than_python
"id": "hhanclub-subtitles", "name": "憨憨(字幕)", "domain": "https://hhanclub.net/", "public": False,
"subtitles": {
"list": {"selector": "#subtitles-table > div"},
"fields": {
"language_icon": {"selector": "div:nth-child(1) img", "attribute": "src"},
"title": {"selector": 'div:nth-child(2) a[href*="downloadsubs.php"]'},
"download": {"selector": 'div:nth-child(2) a[href*="downloadsubs.php"]', "attribute": "href"},
"size": {"selector": "div:nth-child(3)"},
"date_added": {"selector": "div:nth-child(4) span", "attribute": "title"},
"date_elapsed": {"selector": "div:nth-child(4) span"},
"grabs": {"defualt_value": 0},
"uploader": {"selector": 'div:nth-child(2) a[href*="userdetails.php"]'},
"report": {"selector": 'div:nth-child(5) a[href*="report.php"]', "attribute": "href"},
},
},
}
def _subtitle_html() -> str:
rows = []
for i in range(3):
rows.append(f"""<div class="grid"><div><img src="pic/flag/china.gif"></div><div>
<a href="downloadsubs.php?torrentid={i}&amp;subid={i + 1000}">Example Show S01E0{i + 1} 1080p WEB-DL CHS</a>
<a href="https://hhanclub.net/userdetails.php?id={i}"><b>tester{i}</b></a></div>
<div><div>111.99&nbsp;KB</div></div><div><span title="2026-04-21 20:54:37">1月18天</span></div>
<div><a href="report.php?subtitle={i + 1000}">举报</a></div></div>""")
return f'<div id="subtitles-table">{"".join(rows)}</div>'
def gen_indexer(out_dir: Optional[str] = None) -> Path:
"""生成 <out_dir>/indexer.json;返回文件路径。"""
root = _bootstrap()
out = Path(out_dir).resolve() if out_dir else DEFAULT_OUT
target = out / "indexer.json"
existing = _load_existing(target)
idx_dir = out / "inputs/indexer"
defs = {}
for f in sorted(DEFINITIONS_DIR.glob("*.json")):
d = json.loads(f.read_text("utf-8"))
if (d.get("umt") or {}).get("kind") == "nexusphp":
defs[d["id"]] = (f, d)
def page(name: str) -> tuple[str, dict]:
data = (idx_dir / name).read_bytes()
return data.decode("utf-8"), {"file": f"inputs/indexer/{name}", "sha256": _sha256(data)}
runs: list[tuple[str, dict, dict, str, dict, Optional[str], str]] = []
list_pages = ["tk-audiences-list.html.txt", "tk-hhanclub-list.html.txt", "tk-chdbits-list.html.txt"]
for site_id, (f, d) in defs.items():
for pg in list_pages:
sid = f"{site_id}@{pg.removesuffix(".html.txt")}"
runs.append((sid, d, {"source": "definition", "id": site_id,
"file": str(f.relative_to(UMT_ROOT)), "sha256": _sha256(f.read_bytes())},
pg, {}, "x", "torrents"))
for site_id, pg in (("audiences", "tk-audiences-login.html.txt"), ("chdbits", "tk-chdbits-login.html.txt")):
f, d = defs[site_id]
runs.append((f"{site_id}@{pg.removesuffix(".html.txt")}", d, {"source": "definition", "id": site_id,
"file": str(f.relative_to(UMT_ROOT)),
"sha256": _sha256(f.read_bytes())},
pg, {}, "x", "torrents"))
for cfg in sorted(idx_dir.glob("*.indexer.json")):
d = json.loads(cfg.read_text("utf-8"))
name = cfg.name.replace(".indexer.json", "")
# 同名 <name>.html.txt 优先,否则用共享的 hw-dsl-rows.html.txt(.txt 后缀避免 biome 按 HTML 做 a11y lint)
pg = f"{name}.html.txt" if (idx_dir / f"{name}.html.txt").exists() else "hw-dsl-rows.html.txt"
runs.append((f"{name}@{pg.removesuffix(".html.txt")}", d, {"source": "inline", "file": f"inputs/indexer/{cfg.name}",
"config": d}, pg, {}, None, "torrents"))
samples = []
for sid, indexer, def_meta, pg, _extra, keyword, stype in runs:
html, meta = page(pg)
samples.append({
"id": sid,
"definition": def_meta,
"input": meta,
"query": {"keyword": keyword, "search_type": stype},
"mp": _run_mp_spider(indexer, html, keyword, stype, use_rust=False),
"mp_rust": _run_mp_spider(indexer, html, keyword, stype, use_rust=True),
})
# 字幕:本项目未移植(静态登记),仍记录 MP 输出作为将来补迁的期望
sub_html = _subtitle_html()
samples.append({
"id": "hhanclub-subtitles@inline",
"definition": {"source": "inline", "config": SUBTITLE_INDEXER},
"input": {"inline": sub_html, "sha256": _sha256(sub_html.encode())},
"query": {"keyword": "Example Show", "search_type": "subtitles"},
"mp": _run_mp_spider(SUBTITLE_INDEXER, sub_html, "Example Show", "subtitles", use_rust=False),
"mp_rust": _run_mp_spider(SUBTITLE_INDEXER, sub_html, "Example Show", "subtitles", use_rust=True),
})
doc = {
"kind": "indexer",
"generatedFrom": {
"repo": "MoviePilot", "commit": _mp_commit(root),
"function": "app/modules/indexer/spider/__init__.py SiteSpider.parse (PyQuery fallback)",
"rust": "moviepilot_rust.parse_indexer_torrents_fast / parse_indexer_subtitles_fast (reference only)",
"definitions": "packages/adapters/src/sites/definitions/*.json (MP 内置定义加密且需认证,不可用)",
"tz": "UTC", "dateparserNow": FROZEN_NOW, "generator": "gen-differential.py indexer",
},
"fields": ["title", "description", "page_url", "enclosure", "size", "seeders", "peers", "grabs",
"downloadvolumefactor", "uploadvolumefactor", "freedate", "hit_and_run", "category",
"pubdate", "date_elapsed", "imdbid", "labels"],
"samples": samples,
"deviations": existing.get("deviations", {}),
}
target.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", "utf-8")
return target
def gen_rss_cmd() -> None:
print("rss: wrote", gen_rss(None))
def gen_indexer_cmd() -> None:
print("indexer: wrote", gen_indexer(None))
COMMANDS = {"meta": gen_meta, "zhconv": gen_zhconv, "filter": gen_filter_cmd, "rss": gen_rss_cmd, "indexer": gen_indexer_cmd}
def main(argv: list[str]) -> int:
cmd = argv[1] if len(argv) > 1 else "all"
if cmd == "all":
# MP 运行时配置是进程级全局状态,每个子命令独立进程执行
for name in COMMANDS:
subprocess.run([sys.executable, __file__, name], check=True)
return 0
if cmd not in COMMANDS:
print(f"unknown command {cmd}; choose from {', '.join(COMMANDS)} / all", file=sys.stderr)
return 2
COMMANDS[cmd]()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))