Files
UHD_Media_Tool/docs/porting/moviepilot-downloader-and-sites.md
rootandClaude Opus 5.5 d9f2af2f3c feat(adapters): port MoviePilot open site framework (7 spiders, 20 user parsers) + MovieClaw v0.27.0 site definitions + mp-sync tools (W1-3c)
- sites/spiders: Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema (+mTorrent wrapper), specialized SiteAdapter, indirect download resolved at download time
- sites/user-parsers: SiteParserBase + all 20 MP SiteSchema parsers (lxml XPath via xpath+xmldom), refreshSiteUserData with schema fallback
- sites/definitions: zod SiteDefinition schemas, 23 MovieClaw v0.27.0 (MIT) definitions via scripts/mp-sync/movieclaw-import.mjs, registry getSiteDefinition/listSiteDefinitions, NOTICE
- scripts/mp-sync: ledger.json (upstream sha256), check.mjs, build-ledger.mjs; docs/porting/mp-sync-ledger.md
- license-and-provenance: add copied-mit kind, re-anchor MovieClaw rows to v0.27.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:39:27 +08:00

76 KiB
Raw Permalink Blame History

MoviePilot / MovieClaw 迁移对照:下载器(qBittorrent)与站点抓取底座

格式:REQ §14.1.2 迁移交付规范。只做行为对照与 TS 重写,不复制上游文件(许可边界见 license-and-provenance.md)。 本文件是研发对照记录,不表示对应 TS 模块已实现或已逐函数核验。

0. 来源与版本

项 值
MoviePilot 本地路径 /opt/moviepilot-plus/upstream/MoviePilot
MoviePilot 精确提交 e6820a83ab05e8a00eb0e14adf0f357faa965c7e(v3 分支,单提交浅克隆,提交时间 2026-08-30 13:32 +0800,version.py = v3.0.0)
与 REQ 附录快照关系 REQ [S16] 引用的是 33da9ce65d5020a8e087584a3321c1ff353c1921,本地克隆中不存在该对象(git cat-file 失败)。本文件所有 MP 行号均以 e6820a8 为准;若要对齐 [S16],需另行拉取该提交复核差异。
MoviePilot LICENSE 仓库根 LICENSE = GPLv3
MoviePilot 闭源部分 app/application/site/sites.cpython-314-x86_64-linux-gnu.so(SitesHelper,Cython 编译)+ user.sites.v3.bin(加密的站点索引资源)。站点 indexer 定义(各站选择器 YAML/JSON)不可读,只能看 sites.pyi 接口与 tests/ 中的样例配置。
MovieClaw 本机无源码(find /opt -maxdepth 3 -iname "*claw*" 无结果)。已通过 GitHub API 取得提交 fc9eb7248dd9c4f7cd8f3fbd704a357b0b766b90 的目录树(2039 个 blob,未截断)与下列原文:src/movieclaw_tracker/sites/configs/audiences.yaml、.../configs/mteam.yaml、.../sites/custom/mteam.py、src/movieclaw_tracker/selectors.py、src/movieclaw_tracker/frameworks/nexusphp.py、src/movieclaw_tracker/models.py、src/movieclaw_tracker/registry.py、LICENSE、pyproject.toml。
MovieClaw LICENSE(该提交) 修改版 Apache-2.0 + 非商业附加条件;LICENSE 第 44-45 行:"v0.27.0 及之前发布的版本以 MIT 许可证发布…本许可证适用于 v0.27.0 之后发布的版本"。pyproject.toml 第 7 行 version = "0.27.0",第 11 行 license = { text = "MovieClaw License (Apache-2.0 with non-commercial conditions)" }。main 提交不能按 MIT 处理。

路径约定:下文 "MP:" 前缀省略仓库根;"MC:" 指 MovieClaw 仓库根。行号为函数 def 所在行。


1. qBittorrent 适配器

1.1 上游文件/函数清单

# 上游文件 : 行 函数 输入 输出 职责
Q1 MP app/modules/qbittorrent/qbittorrent.py:22 Qbittorrent.__init__ host/port/username/password/apikey/category(bool)/sequentail/force_resume/first_last_piece/incomplete_files_ext 实例(self.qbc 可能为 None) 保存配置并立即登录
Q2 同上 :188 __login_qbittorrent — qbittorrentapi.Client 或 None 有 apikey 时 EXTRA_HEADERS={"Authorization": "Bearer <apikey>"} 并调 app_version() 验证;否则 auth_log_in();超时 (15, 60);VERIFY_WEBUI_CERTIFICATE=False;登录成功后同步 incomplete_files_ext
Q3 同上 :160 __sync_incomplete_file_suffix qbt, enabled — 读 app_preferences(),不一致才 app_set_preferences({"incomplete_files_ext": enabled})——会改写下载器全局偏好,目的是让未完成文件带 .!qB 后缀以免整理监控提前处理
Q4 同上 :174 / :182 is_inactive / reconnect — bool / — qbc is None 即需要重连;重连 = 重新登录
Q5 同上 :225 get_torrents(ids, status, tags) hash 列表/逗号串、qB status_filter、标签 (list, is_error) torrents_info(torrent_hashes, status_filter);标签过滤在客户端做:把 torrent.tags 按 , 拆分 strip 后判断 set(tags) ⊆ torrent_tags
Q6 同上 :255 get_completed_torrents ids, tags list 或 None(异常) 先 status="completed",再二次过滤:progress>=1 && amount_left<=0 && state ∉ {allocating, checkingdl, checkingup, downloading, error, forceddl, missingfiles, metadl, moving, queueddl, stalleddl, unknown}
Q7 同上 :292 get_downloading_torrents ids, tags list 或 None status="downloading"
Q8 同上 :305 delete_torrents_tag(ids, tag) ids 可为 None bool 先 torrents_remove_tags(有 ids 时),再 torrents_delete_tags 删除全局标签定义
Q9 同上 :323 / :338 remove_torrents_tag / set_torrents_tag ids, tags bool / — 单纯移除/添加标签
Q10 同上 :356 torrents_set_force_start ids — 仅当配置 force_resume 为真
Q11 同上 :385 get_torrent_id_by_tag(tags, status) 临时随机标签 hash 或 None 最多 9 次(range(1,10)),每次先 sleep(3) 再按标签查;查到即删临时标签;全部失败也删全局临时标签(避免残留随机标签)
Q12 同上 :408 add_torrent(content, is_paused, download_dir, tag, category, cookie, **kwargs) str=URL/磁力,bytes=.torrent 内容 (ok, added_ids) 见 1.2 分支表
Q13 同上 :99 __parse_add_torrent_response qB 返回 (ok, ids) 字符串含 Ok 即成功;新版 API(5.x)返回 {success_count, pending_count, added_torrent_ids},有 ids 用 ids,否则 count>0 视为成功
Q14 同上 :132 __sync_download_cookies url, cookie header bool Web API ≥ 2.11.3 时把站点 Cookie 同步到 qB Cookie 存储,成功则不再随 add 请求携带 cookie
Q15 同上 :484/:497 start_torrents / stop_torrents ids bool torrents_resume / torrents_pause
Q16 同上 :510 delete_torrents(delete_file, ids) — bool torrents_delete(delete_files=..., torrent_hashes=...),无任何路径引用检查
Q17 同上 :525 get_files(tid, retry, interval) hash 文件列表或 None 重试读 torrents_files;空列表也视为失败继续重试
Q18 同上 :550 set_files(torrent_hash, file_ids, priority) — bool torrents_file_priority;缺 hash 或 file_ids 直接 False
Q19 同上 :741 get_content_layout — Original/Subfolder/NoSubfolder 读全局偏好 torrent_content_layout
Q20 同上 :677/:693/:615 set_torrent_location / set_torrent_category / recheck_torrents — bool 迁移保存路径、改分类、重新校验
Q21 MP app/modules/qbittorrent/__init__.py:95 QbittorrentModule.download(content, download_dir, cookie, episodes, category, label, downloader) 种子内容/路径/磁力;需要的集号集合 (downloader_name, hash, layout, message) 业务级"添加 + 找回 hash + 选文件 + 启动",见 1.3
Q22 同上 :229 list_torrents(status, hashs, downloader, include_all_tags) 查询状态枚举 DownloaderTorrent[] 多实例遍历,默认只查带 TORRENT_TAG(默认 "MOVIEPILOT",app/runtime/config.py:458)的任务
Q23 同上 :358 __normalize_torrent_state qB state paused/downloading/completed 见 1.4
Q24 同上 :369 transfer_completed(hashs) — — 打 已整理 标签
Q25 同上 :381 remove_torrents(hashs, delete_file=True) — bool 默认 delete_file=True
Q26 MP app/modules/_base/downloader.py:60 _get_torrent_info Path/str/bytes `(Torrent None, bytes
Q27 MP app/modules/__init__.py:363 / :381 normalize_path / normalize_return_path 路径, 下载器名 str 按下载器配置 path_mapping: [(storage_path, download_path)] 做前缀替换,首条命中即停;并去掉 local:/rclone: 等存储协议前缀

1.2 add_torrent(Q12)关键分支

条件 行为
qbc is None 或 content 空 返回 (False, [])
content 为 str 作为 urls 提交(URL 或磁力);bytes 作为 torrent_files
ignore_category_check=True(默认) use_auto_torrent_management = self._category(配置开关),category 原样传
ignore_category_check=False(download() 走这条) 只有 category && self._category 时才启用自动管理并传 category;否则 is_auto=False, category=None
URL 且有 cookie 且非磁力 尝试 __sync_download_cookies;成功则 add 请求不带 cookie
其余固定参数 save_path、is_paused、tags、is_sequential_download=sequentail、is_first_last_piece_priority、cookie、category
抛异常 记日志,返回 (False, [])——无法区分"qB 未收到"与"qB 已收到但响应丢失"

1.3 QbittorrentModule.download(Q21)流程与状态

步骤 上游行为(__init__.py:95-227) 失败返回
1 _get_torrent_info 读种子;既不是可解析种子也不是磁力 → 失败 (None,None,None,"添加种子任务失败:无法读取种子文件")
2 取实例 get_instance(downloader);不存在 直接 return None(注意:不是四元组,调用方需兼容)
3 生成 10 位随机标签 tag;label 存在则 label.split(',') + [tag],否则 [tag, TORRENT_TAG] 或 [tag] —
4 is_paused = bool(episodes)——需要选文件时先暂停添加 —
5 add_torrent(..., download_dir=normalize_path(...), ignore_category_check=False) —
6a 添加失败:get_torrents() 全量查询;按 name 相等且 total_size 相等 判定"已存在同种子" → 删除临时标签;若带 已整理 标签则移除;若缺 TORRENT_TAG 则补打;返回 hash 与 layout,消息 "下载任务已存在" 查询异常:"无法连接qbittorrent下载器";未匹配:"添加种子任务失败:<content>"
6b 添加成功:优先用 added_torrent_ids[0](并删临时标签),否则 get_torrent_id_by_tag(tag) 轮询 找不到 hash:"下载任务添加成功,但获取Qbittorrent任务信息失败"
7 layout = get_content_layout()(全局偏好,不是该任务实际布局) —
8 is_paused 时:get_files(hash, retry=5, interval=1);对每个文件 MetaInfo(file_name),文件无集号或集号不是 episodes 子集 → 列入不下载 读文件失败:返回 hash 但消息 "获取种子文件失败,下载任务可能在暂停状态"(任务停留暂停)
9 只有 sucess_epidised(拼写如此)非空 且 有要排除的文件时才 set_files(priority=0);一个都没匹配上时不排除任何文件、整种下载 —
10 force_resume → torrents_set_force_start,否则 start_torrents 返回消息 "添加下载成功,已选择集数:[...]"
11 未暂停添加:仅在 force_resume 时强制开始 "添加下载成功"

1.4 状态映射(Q23 + Q6)

qB 原始 state(小写) list_torrents 归一(Q23) get_completed_torrents(Q6)是否算完成 本项目建议 download_task 观测态
paused/pauseddl/stoppeddl paused 否(status_filter=completed 不会返回;若返回 progress<1 也排除) paused(未完成)
pausedup/stoppedup paused 是(progress=1) completed_paused
allocating/checkingdl/downloading/forceddl/metadl/queueddl/stalleddl downloading 否 downloading / stalled(stalleddl 单列,供 §8.4.2 无进展判定)
uploading/stalledup/forcedup/queuedup completed 是 seeding
checkingup completed 否 checking
moving completed 否 moving
error completed(!) 否 error
missingfiles completed(!) 否 missing_files
unknown / 空 completed 否 unknown

上游缺陷:Q23 用"不在 paused/downloading 集合 ⇒ completed"的兜底,error、missingfiles、unknown 在列表视图会显示为完成。完成判定只能用 Q6 的严格条件。本项目禁止兜底为完成,未识别状态一律 unknown。

1.5 list_torrents 查询分支(Q22)

查询 行为
指定 hashs 各实例 get_torrents(ids=hashs, tags=query_tags)——即便按 hash 查也受 TORRENT_TAG 过滤,未打标签的任务查不到(include_all_tags=True 除外)
TRANSFER get_completed_torrents(tags) 后跳过 tags 字符串中含 已整理 的(注意是对逗号串做 in 子串判断)
DOWNLOADING get_downloading_torrents
ALL/COMPLETED/PAUSED 全量取回后按 Q23 归一值过滤
路径 content_path 优先,否则 save_path/name;再 normalize_return_path 反向映射
返回字段 downloader、title、name/year/season_episode(由 MetaInfo(name) 解析)、path、save_path、content_path、hash、size、tags、category、限速、ratio_limit、seeding_time_limit、progress(×100)、state、速度、剩余时间

1.6 删除与"已整理"标签的上游条件

上游位置 条件 动作
MP app/chain/transfer/settlement.py:620 _TransferChain__mark_torrent_completed_if_done 作业视图中该 hash 所有整理任务结束 且 __is_torrent_download_completed(:650,list_torrents(hashs) 全部 progress≥100;查不到/异常视为未完成) 且 复查无新登记任务 transfer_completed 打 已整理。注释引用 issue #6009:多集种子部分文件先完成时提前打标签会让剩余文件永远不被整理
MP app/chain/transfer/settlement.py:469-500 仅 transfer_type == "move",且该作业全部成功,且 jobview.is_torrent_success(hash)(跨作业该种子所有任务成功),且 _can_delete_torrent 为真 remove_torrents(hash),delete_file 默认 True
MP app/chain/transfer/history.py:305 _can_delete_torrent 查不到种子 → False;progress<100 → False;取不到文件列表 → False;遍历文件,save_path = torrents[0].path.parent,若存在 后缀在媒体扩展名内、未命中整理屏蔽词、且物理存在 的文件 → False;否则 True 判定 move 后源是否已搬空
MP app/chain/download/tasks.py:102 _download_file_deleted 收到"下载源文件被删除"事件且按 hash 查得到任务 remove_torrents(delete_file=False) 并发 DownloadDeleted 事件
MP app/agent/tools/impl/delete_download_tasks.py:63 Agent 工具参数 delete_files 直接调用,无额外引用检查

上游没有:共享保存根检查、同一 save_path 被其他任务引用检查、H&R/做种时长判断、qB 失联时的保守分支(失联 → 查询返回 None → 不删,属于隐式保守)。

1.7 对应 TS 模块(建议)

包:packages/adapters/src/qbittorrent/;纯判定逻辑放 packages/domain/src/download/。

TS 路径 函数签名 对应上游
packages/adapters/src/qbittorrent/client.ts class QbClient { constructor(cfg: QbInstanceConfig, http: HttpPort); login(): Promise<QbAuthResult>; ensureSession(): Promise<void>; appWebApiVersion(): Promise<string> } Q1-Q4
同上 torrentsInfo(q: { hashes?: string[]; filter?: QbStatusFilter; category?: string; tag?: string }): Promise<QbTorrent[]> Q5(用 qB 原生 tag 参数,不在客户端过滤全量)
同上 torrentsAdd(req: QbAddRequest): Promise<QbAddResult>,QbAddRequest = { source: {kind:'file'; bytes: Uint8Array; filename: string} | {kind:'url'; url: string}; savepath: string; category?: string; tags: string[]; paused: boolean; autoTMM: false; contentLayout?: 'Original'|'Subfolder'|'NoSubfolder'; sequential?: boolean; firstLastPiecePrio?: boolean },QbAddResult = { accepted: boolean; addedIds: string[]; raw: unknown } Q12/Q13
同上 torrentFiles(hash: string, opts?: { retries: number; intervalMs: number }): Promise<QbFile[] | null>;setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<void> Q17/Q18
同上 addTags(hashes: string[], tags: string[])、removeTags(hashes: string[], tags: string[])、deleteTagDefinitions(tags: string[])、pause(hashes)、resume(hashes)、setLocation(hash, path)、recheck(hashes) Q8-Q10/Q15/Q20
同上 deleteTorrents(hashes: string[], deleteFiles: boolean): Promise<void>——adapter 层不暴露裸 deleteFiles=true,由 packages/tasks 的 cleanupDownloadCache 在通过 assertSafeToDeleteData 后传入 Q16/Q25
packages/adapters/src/qbittorrent/submit.ts submitTorrent(inst: QbClient, req: SubmitRequest): Promise<SubmitOutcome>;SubmitOutcome = { kind: 'added'; hash: string } | { kind: 'already_exists'; hash: string; existingSavePath: string; existingTags: string[] } | { kind: 'accepted_hash_unknown'; probeTag: string } | { kind: 'rejected'; reason: string } | { kind: 'unreachable' } Q21 步骤 1-7
packages/adapters/src/qbittorrent/state.ts mapQbState(raw: string): QbObservedState;isDownloadComplete(t: QbTorrent): boolean Q6/Q23
packages/adapters/src/qbittorrent/paths.ts toDownloaderPath(p: string, map: PathMapping[]): string;fromDownloaderPath(p: string, map: PathMapping[]): string Q27
packages/domain/src/download/file-selection.ts selectTorrentFiles(files: TorrentFileEntry[], want: EpisodeSelection, parse: (name: string) => ParsedRelease): FileSelectionPlan,FileSelectionPlan = { include: number[]; exclude: number[]; coveredEpisodes: number[]; unmatched: string[]; decision: 'select_subset' | 'download_all' | 'no_match' } Q21 步骤 8-9 + get_torrent_episodes
packages/domain/src/download/delete-guard.ts assertSafeToDeleteData(input: DeleteGuardInput): DeleteGuardVerdict(见 §6 保留/调整) 1.6
packages/testkit/src/mock-qb/ createMockQb(opts: { version: '4.6.7' | '5.1.0'; apiKey?: string; addLatencyMs?: number; dropAddResponse?: boolean }): MockQbServer 验证样本

1.8 保留行为

编号 保留的上游行为 依据
QK1 需要选文件时以 paused 方式添加,选完再 resume Q21 步骤 4、10
QK2 添加后 hash 的获取顺序:① 本地解析 .torrent 算 infohash(本项目新增,最可靠)② 5.x 返回的 added_torrent_ids ③ 按唯一探针标签轮询(3s×9);探针标签用完即删,并删除全局标签定义 Q11/Q13
QK3 添加失败时先查询 qB 判断是否已存在同种子,走"已存在"分支而非直接报失败 Q21 步骤 6a;与 REQ §8.4"收单超时先查询 qB 实际任务" 一致
QK4 完成判定需同时满足 progress>=1 && amount_left<=0 && state ∉ 非终态集合 Q6
QK5 多集种子必须整种下载完成后才能打"已整理"/结束下载侧管理(#6009) settlement.py:620
QK6 move 删种前逐文件检查"仍存在未屏蔽的媒体文件 ⇒ 不删" history.py:305
QK7 下载器路径 ↔ 本地存储路径双向前缀映射,首条命中 Q27
QK8 支持 API Key(Bearer)与用户名密码两种认证;401/403/LoginFailed 归类为凭据错误 Q2
QK9 Web API ≥ 2.11.3 才使用 Cookie 同步 API Q14

1.9 调整项与原因

编号 上游行为 本项目调整 原因
QA1 已存在判定用 name + total_size 用 infohash(v1,且记录 v2) 精确比对;名称+大小只作提示 REQ §8.4:业务关联用实例 ID + torrent 标识并保留 v1/v2 hash;同名同大小不同种子会误判
QA2 已存在时移除 已整理、补打 MP 标签 发现已存在 → 走接管与冲突检查:不覆盖其 save_path、其他标签、文件选择;是否打管理标签取决于接管授权 REQ §8.4"不覆盖其保存路径、其他标签或文件选择"
QA3 list_torrents 默认只看带 TORRENT_TAG 的任务,按 hash 查也受限 管理标签限定日常自动化范围;按 hash 查询、用户委托调查不受标签限制 REQ §8.4"标签范围与用户主动委托"
QA4 标签在客户端对全量任务过滤 使用 qB API tag= 参数;大实例避免全量拉取 性能;REQ §9.4
QA5 __normalize_torrent_state 兜底为 completed 未识别/error/missingFiles 独立状态,绝不当完成 防止误触发入库/清理
QA6 登录时改写全局 incomplete_files_ext 不改写下载器全局偏好;读取当前值记录到实例能力快照;入库只处理 Q6 判定为完成的任务的文件,并排除 .!qB 后缀 下载器可能被其他程序共用;全局设置属于用户资产
QA7 layout 取全局 torrent_content_layout 提交时显式传 contentLayout(默认 Original);入库时以 content_path + 文件列表实际计算,不依赖全局偏好 全局偏好可能在任务创建后被改;接管任务的布局未知
QA8 add_torrent 异常即失败 异常/超时 → accepted_unknown 状态,由 submitDownload 任务先按 infohash 查询再决定重试(幂等键 = 实例ID+infohash) REQ §8.4、§11.2 幂等
QA9 选文件:一个集都没匹配上则整种下载 返回 no_match 决策;在 acquisition_plan 中显式记录"整种下载"并需计划允许(或进入待处理) 避免把错误识别的整季包整包拉下占满缓存;REQ §8.4.1
QA10 选文件时非视频文件(字幕、nfo)因无集号全被排除 附属文件按"随行规则"(同目录/同 stem 的字幕、音轨)保留下载;纯样本/截图排除 字幕随行是入库需求(REQ §14.1.3 附属文件)
QA11 remove_torrents 默认 delete_file=True,只检查媒体文件是否仍存在 删数据必须通过 assertSafeToDeleteData:①下载器可达且 hash 存在;②该任务所有文件均有终态 organization_file_result(入库已验证或明确跳过);③save_path/content_path 不是共享保存根(内容路径必须是种子专属目录或单文件);④没有其他 qB 任务(含非纳管)的 content_path 与之重叠;⑤没有媒体库资产直接指向该路径(媒体库直接做种);⑥H&R/保留时长/分享率要求已满足或不存在;任一条件未知 → 拒绝 REQ §8.3 全段
QA12 move 模式整理成功即删种(含数据) Move:入库验证 + 数据库登记完成后,仅删除 qB 任务记录(deleteFiles=false 或由我们自己的文件层清理已验证路径);有 H&R 义务时不 Move REQ §8.3 Move 段
QA13 随机 10 位标签 探针标签带前缀 uhd-probe-<ulid>,并在 asset_operation 账本记录,崩溃恢复时可清理残留 可追溯、可恢复
QA14 get_instance 失败返回裸 None 统一返回判别联合 unreachable TS 类型安全

1.10 qB 验证样本

样本 输入 期望输出
QV1 5.x 正常添加 .torrent 单文件 Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC.DTS-HD.MA.5.1-FGT.mkv,mock 返回 {"success_count":1,"added_torrent_ids":["3f5a…c9"]} added,hash 与本地计算 infohash 一致,探针标签不需要
QV2 4.x 正常添加 同上,mock 返回纯文本 Ok. 本地 infohash 命中 → added;不轮询
QV3 磁力 + 4.x magnet:?xt=urn:btih:… 返回 Ok.,前 2 次 torrents/info?tag=uhd-probe-… 为空,第 3 次返回 added,轮询 3 次,探针标签被 remove + deleteTags
QV4 响应丢失 mock dropAddResponse=true(qB 已加入但连接中断) accepted_unknown → 按 infohash 查询命中 → added,不重复提交
QV5 已存在 已有同 infohash 任务,save_path=/downloads/pt,tags=BT,keep,文件优先级已部分关闭 already_exists,不修改 save_path/tags/文件选择,进入接管冲突检查
QV6 同名同大小不同种 名称 The.Bear.S03.1080p.DSNP.WEB-DL.DDP5.1.H.264-NTb、大小相同但 infohash 不同 不视为已存在(上游会误判)
QV7 整季包选集 文件:The.Bear.S03E01.1080p….mkv … S03E10、The.Bear.S03E01.1080p….chs.ass、Sample/sample.mkv;需要集 {4,5} include = E04/E05 视频 + 同 stem 字幕;exclude 其余;paused 添加 → setFilePriority(0) → resume
QV8 无匹配 需要 {11},种子只有 E01-E10 no_match,不整种下载,计划进入待处理
QV9 状态映射 stalledDL/pausedUP/error/missingFiles/checkingUP/moving/forcedUP stalled/completed_paused/error/missing_files/checking/moving/seeding;只有 pausedUP、forcedUP(且 progress=1、amount_left=0)判完成
QV10 部分完成多集种 种子 E01-E10,E01-E03 文件已完成,progress=0.3 可对已完成文件排入整理(文件级),但不得结束下载侧管理/打完成标记
QV11 共享保存根 单文件种 content_path=/downloads/pt/Movie.mkv,同目录还有其他任务的文件 删数据守卫:只能删该文件,不能递归删 /downloads/pt
QV12 凭据错误 mock 对 /api/v2/auth/login 返回 Fails. / API Key 返回 401 QbAuthResult.kind = 'bad_credentials',实例健康置为验证失败,不重试风暴

2. 下载链:整季包与多文件选择

2.1 上游调用链

# 上游文件 : 行 函数 作用
D1 MP app/chain/download/batch.py:32 DownloadBatchOwner.batch_download(contexts, no_exists, save_path, …) 公开入口,委托 _execute_batch_download
D2 同上 :65 _execute_batch_download 三段式组合择优:整季 → 季内单集 → 整季包中挑集
D3 MP app/application/download/selection.py:16/41/72/90/106/123/132 update_no_exists_seasons / update_no_exists_episodes / get_season_episodes / get_no_exist_media / get_required_episodes / requires_complete_coverage / apply_allowed_episodes 缺集账本与覆盖判定(纯函数)
D4 MP app/application/torrent/download.py:432 TorrentHelper.get_torrent_episodes(files, custom_words) 只对视频扩展名文件、只用文件名(不含目录)做 MetaInfo,有 begin_episode 才计入,返回集号并集
D5 MP app/chain/download/submission.py:155 download_torrent(torrent) 取 .torrent:磁力原样返回;[base64]url 间接下载先换票(不带 cookie);返回 (content, 目录名, 文件清单)
D6 同上 :65 _resolve_indirect_download_url 解码 [base64(json)]url,按 json 中 method/header/params/proxy/result 调接口取临时直链,结果 host 约束到 result_base_url
D7 同上 :261/:295 download_single / _execute_download_single 解析下载目录与分类、调下载器 download()、登记 DownloadHistory、失败通知
D8 MP app/chain/download/existence.py:25 get_no_exists_info(meta, mediainfo, no_exists, totals) 查媒体库已有季集,生成 no_exists 缺失账本

2.2 _execute_batch_download 分支(D2)

阶段 行(约) 候选条件 下载前核验 成功后账本更新
0 电影/音乐 ~170-190 非剧集按 media key 去重下载 — 加入 downloaded_list
1 整季匹配 ~191-330 TV;no_exists 中 episodes 为空的季(全季缺失);种子无集号(meta.episode_list 空);种子季集 ⊆ 需要季;无季号默认第 1 季 若种子只标一季:下载 .torrent 解析文件集数(D4);磁力无法核验 → 跳过;require_complete_coverage 时必须覆盖目标集合每一集,否则按数量:len(episodes) < 总集数 → 放弃("不是完整合集") ;多季包直接下载不核验 update_no_exists_seasons 移除已满足季;confirmed_full_coverage
2 季内单集 ~333-420 单季且季号一致、有集号的种子;effective_need = need ∩ allowed_episodes 完整覆盖任务:候选集 ⊇ 目标集合(允许多余集);普通:候选集 ⊆ 需要集 update_no_exists_episodes 扣减
3 整季包挑集 ~421-540 仍缺集且非完整覆盖任务;单季、季号一致;种子无集号或与需要集有交集 下载 .torrent 解析文件集号;selected = torrent_episodes ∩ effective_need,空则跳过;磁力跳过 调 download_single(episodes=selected) → qB 暂停添加+选文件(§1.3);扣减缺集
通用 — 失败冷却:__is_context_in_failure_cooldown / __remember_context_failure 同一候选失败后本轮不再尝试 — 返回 (downloaded_list, no_exists 剩余)

2.3 保留 / 调整

类别 内容 原因
保留 下载前打开 .torrent 用文件名核验"声称整季"的实际集数;磁力无法核验时不作为整季候选 防止命名错误的整季包
保留 完整覆盖语义:按集合覆盖判断,不按数量;允许包含范围外额外集 selection.py:106/123
保留 三段优先级:整季 > 单集 > 整季包挑集 与上游择优一致
保留 集号识别只用文件名,不用目录名 D4;目录常为整季名,会污染
调整 上游"只标一季 + 集数 ≥ TMDB 总集数"才视为完整;TMDB 缺集时会误判 REQ §14.1.3 "TMDB 缺集" 场景:总集数来源需带置信度,本地集账本优先(REQ §5.4)
调整 上游阶段 1 多季包不核验直接下载 多季包同样核验文件清单(可取得 .torrent 时)
调整 综艺期号/日期集号:上游 MetaInfo 解析 20240315 类文件名基本得不到 begin_episode,阶段 3 会跳过 v0.1 不做综艺分段(任务书 §3.2);selectTorrentFiles 对无法解析集号的视频返回 unmatched,不静默排除
调整 选片决策属于 evaluateAcquisition(确定性)+ acquisition_slot;下载链只执行计划 任务书 §3.1 第 5 条;Slot 多目标
调整 [base64]url 把 API Key 放进 enclosure 字符串(见 §4.4) 本项目 enclosure 只存站点+资源 ID,下载时由 adapter 按 secret_ref 取凭据

TS 建议:packages/domain/src/acquisition/season-pack.ts

  • evaluateSeasonPackCandidate(input: { claimed: { seasons: number[]; episodes: number[] }; fileEpisodes: number[] | null /* null=磁力无法核验 */; need: SeasonNeed; }): SeasonPackVerdict
  • type SeasonNeed = { season: number; requiredEpisodes: Set<number>; totalEpisodes: { value: number; confidence: 'local_ledger' | 'tmdb' | 'unknown' }; requireCompleteCoverage: boolean; allowedEpisodes?: Set<number> }
  • type SeasonPackVerdict = { kind: 'full_season' } | { kind: 'select_episodes'; episodes: number[] } | { kind: 'reject'; reason: 'unverifiable_magnet' | 'coverage_gap' | 'fewer_than_total' | 'no_needed_episode'; missing?: number[] }

验证样本:

样本 输入 期望
DV1 标题 Severance.S02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX,文件 E01-E10,缺失整季 S02(TMDB 10 集) full_season
DV2 标题 Severance.S02.1080p…(无集号)但文件仅 E01-E06 reject: fewer_than_total(非完整覆盖任务)/coverage_gap missing=[7..10](完整覆盖任务)
DV3 缺 S02E07-E10,候选为 S02 整季包 E01-E10 阶段 3:select_episodes [7,8,9,10]
DV4 磁力 magnet:?xt=urn:btih:…,标题 Some.Show.S01.1080p.WEB-DL reject: unverifiable_magnet
DV5 多季包 Friends.S01-S10.1080p.BluRay.x265-RARBG 需下载 .torrent 核验每季文件集号(上游直接下)
DV6 综艺 奔跑吧.第十二季.20240426.2160p.WEB-DL.H265.AAC-LelveTV,文件名只有日期 v0.1:unmatched 列出,不选择;标记待 M2 综艺账本

3. 站点抓取底座(MoviePilot)

3.1 上游结构总览

# 上游文件 : 行 符号 说明
S1 MP app/application/site/sites.pyi SitesHelper.get_indexers() / get_indexer(domain) / check(domain) / get_authsites() Cython 闭源:站点 indexer 配置由加密资源 user.sites.v3.bin 提供;check(domain) 是站点流控
S2 MP app/modules/indexer/__init__.py:376 IndexerModule.search_torrents(site, keywords, mtype, cat, page) 搜索入口:__create_search_request → __execute_search → __parse_result → 健康统计
S3 同上 :150 __search_check(site, word) site.language == "en" 且关键词含中文 → 不搜;SitesHelper().check(domain) 流控命中 → 不搜
S4 同上 :171 __clear_search_text 去标点(替换为空格)
S5 同上 :29 / :40 SPIDER_PARSER_CLASSES / _SPECIALIZED_SEARCH_ARGUMENTS site.parser 取值 mTorrent/TNodeSpider/TorrentLeech/Yema/Haidan/HDDolby/RousiPro/SunnyPT 走专用 API 类;否则走通用 SiteSpider
S6 同上 :183 __indexer_statistic 成功记耗时、失败计数 → site_health_service
S7 同上 :211 __parse_result dict → TorrentInfo;imdbid 转 media_source/media_id;附带 site id/name/cookie/ua/proxy/pri/downloader
S8 同上 :626 refresh_torrents(site, keyword, cat, page) RSS 式浏览刷新(无关键词)
S9 同上 :662 refresh_userdata(site) 用户数据(上传/下载/分享率/做种)→ parser/*
S10 MP app/modules/indexer/spider/__init__.py:62 class SiteSpider 通用配置驱动 HTML 爬取
S11 MP app/domain/context.py class TorrentInfo 字段见 3.4 统一候选结构

3.2 SiteSpider indexer 配置结构(由 __init__:84 与 tests/test_rust_accel.py:426-480 推出)

配置键 类型/示例 用途
id / name / domain "unit" / "Unit" / "https://example.com/" 身份与基址(domain 自动补 /)
search.paths[] [{path: "torrents.php", type: "all"|"movie"|"tv"|"music"}] 按媒体类型选路径,无专用则回退 all(__get_search_url:171)
search.params {search: "{keyword}", search_area: 4} 额外查询参数模板;search_area 仅在关键词是 tt\d+ 时保留(IMDb 搜索)
batch {delimiter: " ", space_replace: "_"} 多关键词批量搜索(search_mode=1 "或")
browse {path: "torrents.php?page={page}", start: 0, list, fields} 无关键词浏览;start 为起始页偏移
category {movie: [{id: 401, cat, desc}], tv: [...], field?: "cat", delimiter?: ",", param?: "cat[]"} 分类参数三种写法:field 拼接、param 重复参数(如 HHClub cat[])、默认 cat<ID>=1
torrents.list.selector 'table.torrents > tr:has("table.torrentname")' 行选择器
torrents.fields.<name> {selector, attribute?, index?, contents?, remove?, filters?[], text?(Jinja), case?{css: value}} 字段提取 DSL
result_num / timeout / ua / proxy / cookie / language / parser / media_type — 每页上限、超时(默认 15s)等
默认查询参数 search_mode(0 与/1 或)、search_area(0 标题)、page、notnewword=1 NexusPHP 标准

3.3 字段提取 DSL(SiteSpider 私有方法)

字段 方法 : 行 规则
title __get_title:405 selector 直接取;或 text Jinja 模板,可引用 title_default/title_optional(NexusPHP 典型:优先 a[title] 的 title 属性,避免链接文字被截断)
description(副标题) __get_description:427 selector/selectors,或 text 模板引用 tags/subject/description_free_forever/description_normal;remove: "span,a" 去掉内嵌标签元素
details → page_url __get_detail:461 相对链接补全:// 开头补协议,/ 开头拼 domain
download → enclosure __get_download:479 同上,磁力原样
grabs / leechers(→peers)/ seeders :577 / :549 / :563 取 / 前部分、去 ,、过滤后非纯数字则 0
size __get_size:535 去换行后 size_tools.parse_size(如 1.50 GB → 字节);空 → 0
imdbid __get_imdbid:527 通常 re_search tt\d+
downloadvolumefactor / uploadvolumefactor :703 / :727 有配置则先置 1;case: {css: 值} 按声明顺序首个命中生效(支持 "*" 兜底);或 selector 抽数字——上游用 int() 转换,0.5 会抛异常被 get_info 吞掉导致整条记录丢失
date_added / date / date_elapsed __get_pubdate:591 / :695 date_added 优先(通常为 span[title] 的精确时间),否则 date 模板 + dateparse
freedate __get_free_date:773 促销截止时间文本
labels __get_labels:751 多值列表
hr → hit_and_run __get_hit_and_run:781 选择器命中 → True;配置了但未命中 → False;未配置 → 字段缺省 False(TorrentInfo 默认值)
category __get_category:796 取站点分类值后 resolve_category_media_type 投影为 电影/电视剧/音乐/未知;未配置用站点 media_type
过滤器 __filter_text:975 re_search [pattern, group]、split [sep, idx]、replace [a, b]、dateparse fmt、strip、appendleft s、querystring key
取值通用 _safe_query:872 / __attribute_or_text:1024 / __index:1039 attribute 取属性否则文本;index/contents 选第 N 个
登录/权限页检测 __is_login_or_permission_page:1059 <title> 为"登录"或含":: 登录";正文前 1000 字含"未登录"/"登录 / 注册"/"必须在登录后才能访问"/"你需要启用cookies才能登录" → is_error=True,返回空
解析入口 parse:1077 优先 Rust 扩展 rust_accel.parse_indexer_torrents,不可用回退 PyQuery;最多 result_num 条

3.4 TorrentInfo 候选字段(MP app/domain/context.py)

site, site_name, site_cookie, site_ua, site_proxy, site_order, site_downloader, title, description, media_source, media_id, enclosure, page_url, size, seeders, peers, grabs, pubdate, date_elapsed, freedate, uploadvolumefactor, downloadvolumefactor, hit_and_run(默认 False), labels, pri_order, category

注意:MP 候选对象携带 site_cookie,会随上下文流转。本项目 source_release 不得持有凭据,只保存 site_account_id。

3.5 NexusPHP 通用选择器对照(MP 测试样例 vs MovieClaw 默认)

字段 MP 样例配置(tests/test_rust_accel.py:426) MC 默认(MC:src/movieclaw_tracker/selectors.py:39 NexusPHPSelectors) 本项目建议默认
行 table.torrents > tr:has("table.torrentname") table.torrents > tr:not(:first-child) MP 写法(排除表头与嵌套行更稳)
标题 a[title][href*="details.php?id="]@title 优先,回退链接文字 a[href*='details.php'] 的 title 属性优先,回退文字 同
副标题 font.subtitle(remove span,a) td.embedded > span 站点覆盖(观众:span.torrent-subtitle-line)
详情/ID a[href*="details.php?id="]@href 同,正则取 id 同,保存 site_torrent_id
下载 a[href*="download.php?id="]@href a[href*='download.php']@href 同
分类 a[href*="?cat="]@href + querystring cat td:nth-child(1) img@class 保留原始分类 ID(REQ §4.5),不投影
发布时间 td:nth-child(4) > span@title(精确),回退 span 文本(相对时间) 同(torrent_time_css/_fallback_css,fmt %Y-%m-%d %H:%M:%S) 同;按站点时区转 UTC(MC 有 timezone 配置,默认站点时区)
大小 td:nth-child(5) 同 同
做种/下载/完成 td:nth-child(6/7/8) 同 同(列号站点可覆盖)
发布者 — td:nth-child(9) a 同 MC
下载系数 case {img.free: 0, "*": 1} 有序表 img.pro_free→0, img.pro_free2up→0, img.pro_50pctdown→0.5, img.pro_50pctdown2up→0.5, img.pro_30pctdown→0.3 MC 有序表 + 支持小数
上传系数 case {"*": 1} img.pro_free2up→2, img.pro_50pctdown2up→2, img.pro_2up→2 同 MC
促销截止 freedate 字段(站点配置) torrent_promo_deadline_css + title 属性 + 正则 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},仅在 dl<1 时解析 同 MC
H&R img.hitandrun → bool torrent_hr_css 默认空 ⇒ None(未知) 三态,同 MC
登录失效 登录/权限页文本检测 login_success_css: a[href*='logout'] 两者都用
总页数 — 扫描 a[href*='page='] 取最大 page 同 MC,并标记"估计值"

3.6 MP 用户数据解析(app/modules/indexer/parser/*,站点健康/账号面板参考)

上游 函数 : 行 行为
parser/__init__.py:43 SiteParserBase.parse:186 抓首页 → 基础/详情/流量页 → 做种分页 → 未读消息
同上 :136-163 默认页 userdetails.php?id=、index.php、messages.php?action=viewmailbox&box=1&unread=yes、getusertorrentlistajax.php?userid=
parser/nexus_php.py:96 _parse_user_traffic_info 正则抓"上传量/下载量/分享率/下载中",分享率页面值优先,否则计算
同上 :170 _parse_user_torrent_seeding_info 按表头定位大小/做种列,累加做种体积,翻页
parser/nexus_audiences.py:16 NexusAudiencesSiteUserInfo 观众新版顶栏 userbar:__parse_userbar_info:349、__parse_compact_metric:412;未读私信第一页不能带 page 参数(__build_unread_mailbox_page:318)
parser/mtorrent.py:37 _parse_site_page M-Team 用户数据走 API:api/member/profile、api/msg/search、api/member/getUserTorrentList、api/tracker/myPeerStatus,header x-api-key

v0.1 只需要"登录有效性 + 基本上传/下载/分享率"用于来源健康卡片;私信读取不做。

3.7 站点底座调整项

编号 上游 本项目 原因
SA1 站点定义在闭源加密资源中,随资源包更新 站点定义随产品发布的 Adapter 注册表(TS 常量 + 可覆盖 JSON/YAML),版本号入 site_snapshot REQ §4.5;可审计;闭源资源无法对照
SA2 分类投影成 电影/电视剧 保存 site_category_id 原值 + 站点分类名;应用级分类只作检索辅助 REQ §4.5"不能将综艺等不可逆地压成 tv"
SA3 H&R 缺省 False 三态 true/false/unknown;unknown 在清理/Move 判定中按"有义务"保守处理 REQ §8.3
SA4 促销 selector 模式 int() 截断 parseFloat,解析失败该字段置 null,不丢整条 数据完整性
SA5 候选携带 cookie 凭据只在 adapter 内部通过 secret_ref 取;日志/快照/模型上下文不含 cookie、passkey、apikey 任务书约定"含 passkey 的 URL 不得进入日志"
SA6 SitesHelper.check 闭源流控 packages/adapters/src/sites/rate-limit.ts 站点级令牌桶 + 最小间隔(MC min_request_interval,默认 3s,M-Team 1s) REQ §4.3/§4.6
SA7 登录页检测失败只记 is_error 区分 auth_expired(登录页/权限页)、parse_error(结构变化)、network_error;auth 类立即置账号验证失败,parse 类累计多次再熔断(MC _AUTH_ERROR_KEYWORDS 思路) REQ §4.3 退避/熔断
SA8 英文站不搜中文 保留(site.language) —

4. M-Team API 模式

4.1 MP app/modules/indexer/spider/mtorrent.py

项 行 上游行为
端点 :29-33 POST https://api.<domain>/api/torrent/search、POST …/api/torrent/genDlToken、字幕 …/api/subtitle/list、…/api/subtitle/genlink、…/api/subtitle/dlV2?credential=
认证 search:182 header x-api-key: <apikey>、Content-Type: application/json、User-Agent;无 apikey 直接返回 (True, [])(错误)
搜索参数 __get_params:85 {keyword, categories, pageNumber: page+1, pageSize: 100, visible: 1};关键词 tt… 改写为完整 IMDb URL https://www.imdb.com/title/tt…
分类 :38-42 movie 401,419,420,421,439,405,404;tv 403,402,435,438,404,405;music 434,406(404 纪录、405 动画同时属于 movie 与 tv)
结果映射 __parse_result:108 name→title、smallDescr→description、size、status.seeders/leechers/timesCompleted、createdDate→pubdate、imdb→tt、labelsNew(新)或 labels 位图(旧,_labels:47:1 DIY/2 国配/4 中字 组合)、page_url = <domain>detail/<id>
促销 :242 / :258 下载:FREE 0, PERCENT_50 0.5, PERCENT_70 0.3, _2X_FREE 0, _2X_PERCENT_50 0.5;上传:_2X/_2X_FREE/_2X_PERCENT_50 → 2;discountEndTime → freedate;全站 promotionRule.discount/endTime 覆盖下载系数;mallSingleFree.status == "ONGOING" → 下载系数 0 并用 endDate
响应判定 __process_response:171 仅看 HTTP 200,取 data.data;不检查业务 code/message
下载链接 __get_download_url:271 生成 "[" + base64(json{method:post, cookie:false, params:{id}, header:{UA, Accept, x-api-key}, proxy, result:"data"}) + "]" + genDlToken URL,由 submission.py:65 _resolve_indirect_download_url 解码后 POST(id 作为 query params),取 data 字段为临时直链,再 GET .torrent
H&R — 搜索结果未映射 H&R

4.2 MovieClaw src/movieclaw_tracker/sites/custom/mteam.py(提交 fc9eb72)

项 行 行为
类 :116 MTeamSite(BaseSite) 由 mteam.yaml 的 custom_class 装配
统一调用 _post_api:148 POST;响应 {"code":"0","message":"SUCCESS","data":…};非 JSON → TrackerParseError;message ∉ {SUCCESS, OK, 0, ""} → 命中 _AUTH_ERROR_KEYWORDS(停用/封禁/禁用/banned…)抛 TrackerAuthError,否则 TrackerParseError;返回 data
促销表 _DISCOUNT_MAP(文件头) NORMAL 1, FREE 0, PERCENT_70 0.3, PERCENT_50 0.5, _2X_FREE 0, _2X_PERCENT_50 0.5, _2X 1;上传 _UPLOAD_2X_DISCOUNTS = {_2X,_2X_FREE,_2X_PERCENT_50} → 2.0
商城单种免费 _mall_free_override:196 status.mallSingleFree.status == "ONGOING" → 下载系数 0,截止取 endDate,否则保留 discountEndTime;注释:discount 字段此时保持原值(如 PERCENT_50)
行映射 _parse_torrent:224 torrent_id=id、title=name、subtitle=smallDescr、site_category_id=category、size_bytes、seeders/leechers/snatched、upload_time=createdDate(格式 %Y-%m-%d %H:%M:%S,站点时区)、imageList、free、free_deadline、download_url=torrent_id(只存 ID)、detail_url = web_base_url/detail/<id>;单条失败跳过不中断整页
单次搜索 _search_once:278 POST /api/torrent/search JSON {categories, keyword, pageNumber: page(1 起), pageSize: 100, visible: 1},可加 mode: "adult"、discount: "FREE";返回 data.data 与 totalPages
拆分搜索 _search:315 普通与成人(mode=adult)两个索引分别请求再合并;若请求覆盖全部非成人分类则 categories=[](不过滤)
列表/搜索 list_torrents:363 / search:378 未指定分类时默认全部(含成人)
详情 get_torrent_detail:386 从 URL/ID 取数字 ID;POST /api/torrent/detail 表单 id;额外 descr、imdb
下载 download_torrent:445 POST /api/torrent/genDlToken 表单 {id} → data 为一次性直链 → GET 字节;无 data → "可能未认证或达到下载频率限制"
用户 get_user_profile:460 POST /api/member/profile(memberCount.uploaded/downloaded/shareRate/bonus、id/username/role),POST /api/tracker/myPeerStatus(seeder/leecher)

4.3 MC:src/movieclaw_tracker/sites/configs/mteam.yaml 字段结构

键 值
site_id / display_name mteam / M-Team
base_url https://api.m-team.cc(API 域)
web_base_url https://tp.m-team.cc(用户可打开的详情页域)
framework / custom_class api / movieclaw_tracker.sites.custom.mteam.MTeamSite
min_request_interval 1.0(默认 3s)
auth.supported [apikey]
categories movie [401,419,420,421,439];tv [403,402,435,438,407](407 体育);documentary [404];anime [405];music [406,408,434];game [423];other [422,427,409];av [410,429,424,430,426,437,431,432,436,425,433,411,412,413]

4.4 M-Team 对照结论

编号 决定 依据
MT1 认证只用 API Key,header x-api-key;凭据经 secret_ref MP/MC 一致;REQ §4.5"优先 API Key"
MT2 业务状态必须检查 message/code,并把凭据类错误与解析类错误分开 MC _post_api:148;MP 未检查(缺陷)
MT3 下载:候选只保存 site_torrent_id;下载时现场 genDlToken 换一次性直链再 GET;直链不入库不入日志 MC download_torrent:445;MP 把 apikey 编进 enclosure(__get_download_url:271),必须偏离
MT4 分类用 MC 的细分映射并保存原始 ID;404/405 不重复计入 movie+tv MC mteam.yaml;REQ §4.5
MT5 促销:基础 discount 表 + 全站 promotionRule(MP)+ mallSingleFree(MP/MC 都有);三者优先级:mallSingleFree ONGOING > promotionRule > discount 合并 MP :155-167 与 MC :196
MT6 成人索引 mode=adult 默认不请求(本项目无此需求),保留参数位 MC _search:315
MT7 分页:对外 1 起;MP 内部 0 起 +1 MP :85、MC :278
MT8 IMDb 搜索时关键词改写为完整 IMDb URL MP __get_params:85(引 wiki)
MT9 H&R:API 搜索未提供 → unknown 两者均未映射

5. MovieClaw 观众(Audiences)配置

5.1 MC:src/movieclaw_tracker/sites/configs/audiences.yaml 字段结构(全文 36 行)

键 值 说明
site_id audiences
display_name 观众
base_url https://audiences.me
framework nexusphp 继承 NexusPHPSelectors 默认值,只覆盖差异
selectors.torrent_row_css table.torrents > tr:has(table.torrentname) 表类为 torrents torrents-table
selectors.torrent_category_css a[href^='?cat=']::attr(href) 分类 ID 来自 ?cat=XXX
selectors.torrent_subtitle_css span.torrent-subtitle-line 副标题专用类
selectors.page_offset -1 分页 0 起,对外 page=1 发送 0
selectors.profile_uid_css a[href*='userdetails.php?id=']::attr(href) 顶栏取 uid
selectors.profile_username_css a[href*='userdetails.php?id=']
selectors.profile_uploaded_css span.site-userbar__compact-metric--uploaded 新版 userbar
selectors.profile_downloaded_css span.site-userbar__compact-metric--downloaded
categories movie [401];tv [402, 403](剧集/综艺);documentary [406] 403 综艺被并入 tv——本项目需保留 403 原值
未配置 torrent_hr_css、promo_*、torrent_promo_deadline_css 使用默认 img.pro_* 促销规则;H&R 为 unknown

MC 注册表 MC:src/movieclaw_tracker/registry.py:172 _load_site_yaml 读取的全部顶层键:site_id(必填)、display_name、base_url、web_base_url、framework、custom_class、timezone、auth.supported、categories、selectors、http2、timeout(30)、max_retries(3)、min_request_interval、hr_seed_hours。

5.2 MC NexusPHP 实现要点(MC:src/movieclaw_tracker/frameworks/nexusphp.py)

函数 : 行 行为
list_torrents:48 GET torrent_list_path,page = page + page_offset,分类参数 cat<ID>=1(_apply_category_params:358)
search:67 GET search_path,参数 search_keyword_param(默认 search)
get_torrent_detail:87 detail_url 由网页域换回请求域;取标题、副标题、描述、大小、下载链接、IMDb、豆瓣、文件列表(td.rowfollow > ul > li)
_parse_promo:199 有序规则表首个命中;截止时间仅在 dl<1 且配置了选择器时解析,转 UTC
_parse_torrent_row:284 无详情链接的行跳过;title 属性优先;site_category_id 原值;H&R 三态;free = dl < 1.0
_parse_total_pages:343 扫描分页链接最大 page

5.3 观众适配建议(本项目)

项 取值 来源
列表路径 torrents.php,page 0 起 MC page_offset:-1
行 table.torrents > tr:has(table.torrentname) MC/MP 一致
副标题 span.torrent-subtitle-line MC
分类 a[href^='?cat=']@href → cat 参数原值(401/402/403/406 …) MC;不投影
促销 默认 NexusPHP img.pro_*;需用真实页面核对,未核对前允许 unknown MC 未覆盖
H&R 待核对选择器;默认 unknown MC 未配置
登录有效 a[href*='logout'] 存在 + MP 登录页文本检测 MC login_success_css + MP :1059
用户数据 顶栏 span.site-userbar__compact-metric--uploaded/--downloaded MC + MP nexus_audiences.py:349/412
私信 不做(第一页不能带 page 参数的坑留档) MP nexus_audiences.py:318

6. TS 模块建议(站点)

TS 路径 函数签名 对应上游
packages/adapters/src/sites/types.ts interface SiteAdapter { readonly siteKey: string; listTorrents(q: ListQuery, ctx: SiteCallCtx): Promise<SitePage<SiteTorrentRow>>; search(q: SearchQuery, ctx: SiteCallCtx): Promise<SitePage<SiteTorrentRow>>; getDetail(siteTorrentId: string, ctx: SiteCallCtx): Promise<SiteTorrentDetail>; fetchTorrentFile(siteTorrentId: string, ctx: SiteCallCtx): Promise<Uint8Array>; checkAuth(ctx: SiteCallCtx): Promise<SiteAuthStatus>; fetchUserStats?(ctx: SiteCallCtx): Promise<SiteUserStats> } MC BaseSite 契约 + MP IndexerModule
同上 type SiteTorrentRow = { siteTorrentId: string; title: string; subtitle: string; siteCategoryId: string | null; siteCategoryName: string | null; sizeBytes: number | null; seeders: number | null; leechers: number | null; snatched: number | null; publishedAt: string | null /* UTC ISO */; uploader: string | null; downloadFactor: number | null; uploadFactor: number | null; promoUntil: string | null; hitAndRun: 'yes' | 'no' | 'unknown'; labels: string[]; imdbId: string | null; doubanId: string | null; detailUrl: string /* 无凭据 */; raw: Record<string, string> } MP TorrentInfo + MC TorrentListItem
同上 type SiteCallCtx = { account: { id: string; secretRef: string }; signal: AbortSignal; limiter: SiteRateLimiter; http: HttpPort } —
同上 type SiteError = { kind: 'auth_expired' | 'auth_invalid' | 'rate_limited' | 'parse_error' | 'network' | 'site_maintenance'; message: string; httpStatus?: number } MC 异常分层 + MP 登录页检测
packages/adapters/src/sites/nexusphp/selectors.ts type NexusPhpSelectors = { rowCss: string; titleCss: string; subtitleCss: string; categoryCss: string; categoryParam: 'cat' ; sizeCss: string; seedersCss: string; leechersCss: string; snatchedCss: string; timeCss: string; timeFallbackCss: string; timeFormat: string; timezone: string; promoDownloadRules: Array<[css: string, factor: number]>; promoUploadRules: Array<[string, number]>; promoDeadline?: { css: string; attr: string; re: string }; hrCss?: string; pageOffset: 0 | -1; listPath: string; searchPath: string; searchParam: string; loginSuccessCss: string };const NEXUSPHP_DEFAULTS: NexusPhpSelectors MC selectors.py:39 + MP 测试样例
packages/adapters/src/sites/nexusphp/parse.ts parseTorrentList(html: string, sel: NexusPhpSelectors, base: URL): { rows: SiteTorrentRow[]; totalPages: number | null; pageKind: 'list' | 'login' | 'permission_denied' | 'unknown' } MP parse:1077/__is_login_or_permission_page:1059、MC _parse_torrent_row:284
同上 parseSize(text: string): number | null(1.50 GB/1,234.5 MiB/850 KB);parseCount(text: string): number | null(取 / 前、去逗号) MP __get_size:535/__get_seeders:563
packages/adapters/src/sites/nexusphp/adapter.ts createNexusPhpAdapter(def: SiteDefinition): SiteAdapter MC NexusPHPSite
packages/adapters/src/sites/registry.ts const SITE_DEFINITIONS: Record<'audiences' | 'mteam', SiteDefinition>;getSiteAdapter(siteKey: string): SiteAdapter MC registry.py:172
packages/adapters/src/sites/audiences.ts export const audiencesDefinition: SiteDefinition §5.3
packages/adapters/src/sites/mteam/client.ts mteamPost<T>(path: '/api/torrent/search' | '/api/torrent/detail' | '/api/torrent/genDlToken' | '/api/member/profile' | '/api/tracker/myPeerStatus', body: { json?: unknown; form?: Record<string, string> }, ctx: SiteCallCtx): Promise<T>(检查 message,凭据类错误映射 auth_invalid) MC _post_api:148
packages/adapters/src/sites/mteam/map.ts mapMteamTorrent(t: MteamTorrentJson, webBase: string): SiteTorrentRow;resolveMteamPromo(status: MteamStatus, now: Date): { downloadFactor: number; uploadFactor: number; promoUntil: string | null } §4.4 MT5
packages/adapters/src/sites/mteam/adapter.ts createMteamAdapter(def: SiteDefinition): SiteAdapter(fetchTorrentFile = genDlToken + GET) MC download_torrent:445
packages/adapters/src/sites/rate-limit.ts createSiteRateLimiter(opts: { minIntervalMs: number; burst: number }): SiteRateLimiter SA6
packages/testkit/src/mock-nexusphp/ createMockNexusPhp(opts: { flavor: 'audiences' | 'generic'; pages: FixturePage[]; loggedIn: boolean }): MockServer(fixture:列表页 HTML、详情页、登录页、download.php?id= 返回 .torrent) —
packages/testkit/src/mock-mteam/ createMockMteam(opts: { apiKey: string; torrents: MteamTorrentJson[]; failMode?: 'banned_key' | 'rate_limit' | 'non_json' }): MockServer —

7. 验证样本(站点)

7.1 NexusPHP(观众风格)列表行 fixture 要点

样本 行内容 期望 SiteTorrentRow
SV1 免费 + 截止 a[href="details.php?id=812345"][title="Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR"];span.torrent-subtitle-line=`沙丘2 / 沙丘:第二部 杜比视界 国英双语 中字;a[href="?cat=401"];img.pro_free[title="2026-10-01 12:00:00"];时间 span[title="2026-09-26 21:14:03"]=3小时前;大小 78.42 GB;做种 1,204;下载 17;完成 3,388`
SV2 综艺原分类 标题 Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV,副标题 奔跑吧 第十二季 第1期,?cat=403 siteCategoryId 403(不得折叠为 tv)
SV3 50% + 2x img.pro_50pctdown2up downloadFactor 0.5,uploadFactor 2
SV4 H&R 已配置未命中 站点定义 hrCss: "img.hitandrun",行无该元素 hitAndRun no
SV5 截断标题 a 文本 The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.D…,无 title 属性 回退文字(并记录 raw.titleTruncated=1)
SV6 登录失效 返回页 <title>观众 :: 登录</title> pageKind='login' → SiteError auth_expired,不产生 0 条"正常结果"
SV7 大小格式 1,234.56 MiB / 850 KB / 2.1 TB 1294529986 / 870400 / 2308974418329
SV8 分页 请求 page=1 发出 torrents.php?page=0(观众 0 起)

7.2 M-Team API fixture

样本 status / 响应 期望
MV1 {"code":"0","message":"SUCCESS","data":{"data":[{"id":"912233","name":"Shogun.2024.S01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX","smallDescr":"幕府将军 第一季 全10集 杜比视界","category":"402","size":"61234567890","createdDate":"2026-09-20 08:15:00","imdb":"https://www.imdb.com/title/tt2788316/","status":{"seeders":"532","leechers":"4","timesCompleted":"2011","discount":"PERCENT_50","discountEndTime":"2026-09-28 08:15:00"}}],"totalPages":"3"}} downloadFactor 0.5,promoUntil 对应 +08 转 UTC,siteCategoryId 402,imdbId tt2788316,detailUrl https://tp.m-team.cc/detail/912233,不含 apikey
MV2 商城单种免费 discount: "PERCENT_50",mallSingleFree: {"status":"ONGOING","endDate":"2026-09-27 20:00:00"} downloadFactor 0,promoUntil = endDate
MV3 全站促销 discount: "NORMAL",promotionRule: {"discount":"FREE","endTime":"2026-10-07 23:59:59"} downloadFactor 0
MV4 2x 免费 _2X_FREE 0 / 2
MV5 Key 失效 HTTP 200 {"code":"1","message":"帳號已停用"} auth_invalid,账号验证失败,停止重试
MV6 非 JSON HTTP 200 HTML 维护页 site_maintenance/parse_error,退避
MV7 下载 genDlToken 表单 id=912233 → {"code":"0","message":"SUCCESS","data":"https://api.m-team.cc/api/rss/dlv2?sign=…&t=…&tid=912233"} → GET 返回 .torrent fetchTorrentFile 返回字节;直链不写日志/DB
MV8 分类 category 405(动画)、404(纪录)、407(体育) 原值保存;应用分类 anime/documentary/tv
MV9 IMDb 搜索 keyword tt15239678 请求体 keyword = https://www.imdb.com/title/tt15239678

8. 给实现者的行为提醒(汇总)

# 提醒 位置
1 qB 状态兜底不能是"完成";完成只按 progress>=1 && amount_left<=0 && state∉非终态。error/missingFiles/moving/checkingUP 均非完成 §1.4
2 提交下载必须幂等:本地算 infohash → 超时/异常先按 hash 查询 qB 再决定重试;已存在走接管冲突检查,不改其路径/标签/文件选择 §1.3、QA1/QA2/QA8
3 删数据守卫:共享保存根、其他任务重叠、媒体库直接做种、部分文件未终态、H&R 未知 任一成立都禁止 deleteFiles=true;上游 move 后默认删数据不可照搬 §1.6、QA11/QA12
4 多集种子整种完成前不结束下载侧管理(MP #6009) QK5
5 站点原始分类 ID 原样保存(观众 403 综艺、馒头 404/405/407);H&R 三态,unknown 视为有义务 SA2/SA3、§5.1
6 凭据(cookie/passkey/apikey)不进入候选对象、enclosure、日志、快照;M-Team 下载现场 genDlToken SA5、MT3
7 M-Team 检查业务 message,区分凭据死亡与解析异常 MT2
8 整季包声称完整时先解析 .torrent 文件名核验集数;磁力无法核验不当整季 §2.2

9. 未取得 / 待核验

项 状态
MP 站点 indexer 真实配置(观众、馒头、HHClub、CHD 选择器) 未取得:在闭源 SitesHelper .so + 加密 user.sites.v3.bin 中;本文只用测试样例与 MC 配置对照
MP 提交 33da9ce…(REQ [S16]) 本地缺失,未对照
观众促销/H&R 真实类名 MC 未配置,需用真实页面(或用户提供的脱敏 HTML)核对后写入 audiences.ts
M-Team genDlToken 返回直链的有效期与频率限制 两项目均未写明;mock 中按一次性处理
qB 5.x API Key 认证与 added_torrent_ids 字段的最小版本 MP 以运行时探测兼容;需在 spike 中以 qB 4.6.x / 5.1.x 实例验证

W1-3a 实际迁移对照(qB / TMDB / Emby / 文件系统)

上游:/opt/moviepilot-plus/upstream/MoviePilot,提交 e6820a8。只做行为对照与 TS 重写,不复制代码。

qBittorrent(packages/adapters/src/qbittorrent/)

上游函数 TS 保留 / 调整
Qbittorrent.__init__ / __login_qbittorrent / is_inactive / reconnect QbClient 构造 + login();401/403 时自动重登一次 保留 API Key(Bearer)与账号密码两种认证;不调用 __sync_incomplete_file_suffix(QA6,只读 preferences())
__parse_add_torrent_response add() 响应解析 保留:文本 Ok. 或 5.x JSON(success_count/pending_count/added_torrent_ids)
add_torrent add() 保留 savepath/category/tags/paused(同时发 stopped)/rename/sequential/firstLastPiece(实例默认);调整:autoTMM 固定 false、显式 contentLayout(QA7);Cookie 同步(Q14)未迁移——.torrent 由站点适配器下载后以字节提交
get_torrents list({hashes, tag, tags, category, filter}) 调整:首个标签走 qB 原生 tag 参数,其余在客户端做子集过滤(QA4);按 hash 查询不受管理标签限制(QA3)
get_completed_torrents getCompleted() + isDownloadComplete() 保留严格完成条件与非终态集合(QK4);未知 state 也不算完成
get_downloading_torrents getDownloading() 保留
__normalize_torrent_state(__init__.py) mapQbState() 调整:error/missingFiles/unknown 独立状态,不兜底为 completed(QA5)
delete_torrents_tag / remove_torrents_tag / set_torrents_tag deleteTorrentsTag() / removeTags() / addTags();deleteTagDefinitions() 保留
torrents_set_force_start setForceStart() 保留;是否调用由上层按实例 force_resume 配置决定
get_torrent_id_by_tag / __get_last_add_torrentid_by_tag submit() 内探针标签轮询 保留 9 次 × 3s 默认(首查不等待)、用后删标签与全局定义;调整:探针前缀 uhd-probe-(QA13),hash 已知时不用探针(QK2)
start_torrents / stop_torrents resume() / pause() 5.x 端点改名(stop/start)时自动切换
delete_torrents delete(hashes, { deleteFiles }) 调整:deleteFiles 必须显式,删数据前的引用检查在上层(QA11/QA12)
get_files files(hash, { retries, intervalMs }) 保留:空列表视为未就绪继续重试
set_files setFilePriority() 保留:无 hash/无文件 ID 返回 false 不发请求
recheck_torrents / set_torrent_location / set_torrent_category / change_torrent recheck() / setLocation() / setCategory() / changeTorrent() 保留 KB/s→B/s 换算、仅做种时长时 ratio=-2
get_content_layout / transfer_info contentLayout() / transferInfo() 只读
QbittorrentModule.download 步骤 1-7 submit() 调整:本地 infohash 预查已存在 → already_exists 不改其路径/标签(QA1/QA2);add 超时/断连 → 按 hash 回查(QA8),返回判别联合(QA14)。选集(步骤 8-9)属 domain selectTorrentFiles,本适配器只提供 files/setFilePriority
_get_torrent_info(_base/downloader.py) parseTorrent() / parseMagnet() 新增 v1/v2 infohash(原始 info 字节切片求哈希)
normalize_path / normalize_return_path toDownloaderPath() / fromDownloaderPath() 保留首条命中

TMDB(packages/adapters/src/tmdb/)

上游函数 TS 保留 / 调整
search_movies / search_tvs / search_multiis search()(多语言合并)/ searchRaw()(首选语言)/ searchMulti() 电影年份参数 year,剧集 first_air_date_year;multi 只保留 movie/tv
__compare_names + foundation/text.py remove_punctuation compareNames() / normalizeTitleForCompare() 保留同一标点集、去零宽字符与空白、大写后完全相等
_generate_year_range yearRange() 保留 [year, year+1, year-1]
match / _build_match_plan / _match_resolution matchTmdb() 保留步骤顺序(季年份优先,再名称+年份容差)与首个命中短路;调整:查询异常抛 TmdbError,不当作"未找到"继续
__search_movie_by_name / __search_tv_by_name / _build_name_candidate_plan / _name_resolution / _alias_matches / __get_names matchTmdb() 内 searchByName 保留:按日期降序、年份前缀过滤、标题/原标题直达,否则逐个拉详情用别名(alternative_titles)+ 译名(translations)比较
__search_tv_by_season / _season_candidate_matches / _season_year_matches matchTmdb() 内 searchBySeason 保留;剧集组 group_seasons 未迁移
match_multi / _sort_multi_results matchTmdbMulti() 保留电影优先、日期降序
__get_movie_detail / __get_tv_detail details() 附带 alternative_titles,external_ids,translations;输出归一化 MetadataCandidate
get_tv_season_detail season() 保留
tmdbv3api/tmdb.py request 的 @cached TtlLruCache + 并发合并 保留 maxsize 1024 / TTL 24h(可配)/ 空结果 30min / 失败不缓存;新增并发合并与 429 Retry-After 退避
cache.py TmdbCache(识别结果持久缓存) 未迁移 识别结果持久化归 db/domain(external_identity)

Emby / Jellyfin(packages/adapters/src/emby/)

上游函数 TS 保留 / 调整
Emby.get_user resolveUserId() 保留:用户名匹配 → 管理员回退
__get_emby_series_id_by_name findSeriesIdByName() 保留 SearchTerm + 名称完全相等 + 年份;调整:连不通 → unknown,未找到 → ok(null)
get_movies getMovies() 保留名称完全相等 + 年份 + 身份兼容
get_tv_episodes getTvEpisodes() 保留 IsMissing=false、按 ParentIndexNumber/IndexNumber 汇总、缓存 itemId 失效时按标题重查;新增 Virtual 过滤与多集文件(IndexNumberEnd)展开
get_iteminfo getItem() 单条目 404 → ok(null)(ID 失效),其他失败 → unknown
MediaServerIdentityHelper.from_provider_ids / are_compatible providerIdentity() / identitiesCompatible() 保留来源优先级与"同来源不同 ID 才冲突"
(新增) searchItems() / findByProviderId() / seasons() / episodes() / normalizeMediaSource() MediaSources 版本信息;查询截断时不给"不存在"
jellyfin.py 认证 flavor: "jellyfin" 保留 Authorization: MediaBrowser Token=、无 /emby 前缀;调整:Emby 也用请求头 X-Emby-Token,不把 api_key 放进 URL

文件系统(packages/adapters/src/fs/)

对照 docs/porting/moviepilot-transfer.md §2.6:_write_atomically/_partial_path/_cleanup_stale_partials → copyWithVerify/partialPathFor/sweepStalePartials(新增 fsync、校验、no-clobber);LocalStorage.move → moveSafe(EXDEV 不降级)+ crossDeviceMove(三步可重入)。statSafe/scanIncremental/ffprobe/diskUsage 为本项目新增。

10. W1-3b 实际迁移对照(RSS / 站点)

按用户指示"照抄 MoviePilot v3 实现",以 MP 提交 e6820a8 为蓝本迁移为 TS。只有安全、数据保真与去重相关的偏离,逐条列在"调整"列。MovieClaw 仍只参考字段结构(data-ref)。

10.1 通用站点 spider(packages/adapters/src/sites/spider/)

上游函数 TS 函数 保留 / 调整
spider/__init__.py select_media_categories selectMediaCategories 保留
resolve_category_media_type resolveCategoryMediaType 保留(投影值仍写 category)
SiteSpider.__init__ 读取的 indexer 键 IndexerDefinition(schema.ts) 保留全部键;新增 umt 扩展(版本、时区、限流、登录标志、详情页、分类名)
SiteSpider.__get_search_url / __format_search_word SiteSpider.getSearchUrl / formatSearchWord 保留(默认参数、search_area 仅 IMDb 保留、batch、typed path、category field/param/catN、browse.start、result_media_type)
UrlUtils.combine_url combineUrl 保留(doseq)
__can_search canSearch 保留
get_info + __get_title … __get_category getInfo + 同名私有方法 保留字段顺序与缺省值(size/seeders/peers/grabs 缺失为 0,相对时间不写 pubdate,case 顺序首个命中);调整:系数 selector 模式浮点(SA4);__get_category 额外输出 category_id 原值
_safe_query / __remove / __attribute_or_text / __index / __filter_text 同名方法 保留(re_search/split(含负下标)/replace/dateparse/strip/appendleft/querystring,单个过滤器失败跳过)
__is_login_or_permission_page SiteSpider.isLoginOrPermissionPage 保留判定;调整:adapter 抛 auth_expired 而不是返回空列表
parse(PyQuery 分支) SiteSpider.parse 保留;htmlparser2 HTML 模式对齐 lxml(不插 tbody);:has("x") 去引号;未移植 Rust 加速与字幕分支
jinja2 模板 template.ts(nunjucks) 保留 Jinja 语法
foundation/size.parse_size parseSizeMp 保留(round)
IndexerModule.__search_check / __clear_search_text search 前检查 / clearSearchText 保留(英文站不搜中文、去标点)
SitesHelper.check(domain)(闭源) createSiteRateLimiter 以令牌桶实现(SA6)
submission.download_torrent downloadTarget 保留(Cookie GET、磁力原样);调整:不跟随 3xx、校验 bencode 并计算 infohash、登录页识别
— detail.ts parseDetailPage 新增(MP 通用 spider 无详情页)
MP 站点定义(加密 user.sites.v3.bin) definitions/audiences.json 按 MP schema 手写,待真实页面核验

10.2 M-Team(packages/adapters/src/sites/mteam/)

上游函数 TS 函数 保留 / 调整
mtorrent.py _movie_category/_tv_category/_music_category/_labels MOVIE_CATEGORY/TV_CATEGORY/MUSIC_CATEGORY/LABELS 保留
MTorrentSpider.__get_params mteamSearchParams 保留(tt 前缀改写 IMDb URL、pageNumber=page+1、pageSize 100、visible 1、无 mode);调整:显式 categoryIds 覆盖 mtype 分类组
__parse_result parseMteamResult 保留字段映射、promotionRule 覆盖下载系数、mallSingleFree ONGOING → 0、freedate 取值顺序;调整:不生成含 apikey 的 base64 enclosure(MT3);输出 category_id
__find_imdbid / __get_downloadvolumefactor / __get_uploadvolumefactor findImdbid / getDownloadVolumeFactor / getUploadVolumeFactor 保留
search / __process_response mteamPost + adapter list/search 保留 header(x-api-key、UA、JSON);调整:检查业务 code/message 区分凭据/限流/不存在(MT2)
__get_download_url + submission._resolve_indirect_download_url adapter downloadTarget 保留(POST genDlToken,id 走 query,取 data,不带 Cookie GET);令牌不入库不入日志
parser/mtorrent.py(api/member/profile) healthCheck 只用作登录有效性
— detail(/api/torrent/detail) 新增,字段结构参考 MovieClaw(data-ref)

10.3 RSS(packages/adapters/src/rss/)

上游函数 TS 函数 保留 / 调整
RssHelper.MAX_RSS_SIZE / MAX_RSS_ITEMS fetcher maxBytes / parser maxItems 默认值 保留 50MB / 1000;大小按解压后字节流式限制
RssHelper._parse_impl 条目循环 parseFeed / itemFields 保留字段语义(见 rss/README);调整:fast-xml-parser 关闭实体处理并预检拒绝 ENTITY/内部 DTD;新增 guid/categories/rawDigest
_rss_expired_msg RSS_EXPIRED_MESSAGES → FeedError('expired') 保留识别,返回值改为错误类型
RssHelper.parse(GET,非 200 失败) createRssFetcher().fetchFeed 保留;新增 ETag/Last-Modified、退避 + Retry-After、熔断 + 手动恢复、passkey 脱敏
rss_link_conf / _get_site_domain / get_rss_link RSS_LINK_CONF / rssSiteDomain / buildRssLinkRequest + extractRssLink 只迁移 default 与 audiences.me;xpath 改等价 CSS
— applyFeedItems / FeedCursor 新增重叠窗口去重(REQ §4.2/§4.3)

10.4 许可提示

本节模块是对 MP(GPL-3.0)实现的逐函数 TS 移植,性质上比 behavior-ref 更接近衍生作品。项目目前为 internal-only 使用;若将来对外分发,需按 license-and-provenance.md §2 重新评估(GPL-3.0 同许可与源码提供义务)。

11. W1-3c 实际迁移对照(MP 开源站点框架全量 + MovieClaw 站点定义)

逐文件对照总表与跟随更新流程见 mp-sync-ledger.md(scripts/mp-sync/ledger.json 由源码 @mp-source 标签生成)。本节只记结构性决定。

11.1 专用 spider(packages/adapters/src/sites/spiders/)

MP 本项目 说明
IndexerModule.SPIDER_PARSER_CLASSES / _SPECIALIZED_SEARCH_ARGUMENTS spiders/index.ts SPECIALIZED_SPIDERS、SpecializedSpider.search(ctx, {keyword, mtype, cat, page}) 键名与 MP 相同(Haidan/HDDolby/RousiPro/SunnyPT/TNodeSpider/TorrentLeech/Yema/mTorrent)
各 spider __get_params / __parse_result / __process_response / 系数函数 spiders/<name>.ts 同名纯函数 常量表逐字保留;结果为 MP 字段(SpiderTorrent)+ category_id、site_release_id、*_utc 扩展
(True, []) 错误返回 SiteError 401/403 → auth_invalid,缺凭据 → auth_missing,业务错误按文案分类
[base64 请求配置]url enclosure + submission._resolve_indirect_download_url spiders/common.ts resolveIndirectDownloadUrl / downloadTorrentFile 下载时现场换直链,结果约束到可信 API 基址,不带凭据下载并校验 .torrent
TNodeSpider.__get_token(@cached 24h)、SunnyPTSpider._category_cache(1h) 适配器实例内 TTL 缓存

11.2 站点用户信息解析器(packages/adapters/src/sites/user-parsers/)

MP 本项目
parser/__init__.py SiteSchema / SiteParserBase types.ts SiteSchema / base.ts SiteParserBase(parse 流程、_get_page_content 请求模式、_parse_logged_in、to_dict)
parser/<name>.py 20 个类 user-parsers/<kebab-name>.ts 同名类
IndexerModule.refresh_userdata(schema 回退) registry.ts refreshSiteUserData
SiteUserData SiteUserInfo(camelCase,raw = to_dict(),不含凭据)
lxml XPath / BeautifulSoup dom.ts(xpath + xmldom)/ cheerio
SiteUtils.is_logged_in、under_challenge、parse_int/parse_float、normalize_datetime、format_timestamp、extract_domain common.ts 同名函数

偏离:翻页上限 50;网络/HTTP 失败另记 http_errors;写操作(标记已读)受 fetchMessages 门控;normalize_datetime 只处理绝对时间。

11.3 站点定义

  • MP 的站点定义不可得(加密),定义 schema 按 sites.pyi 与 spider 读取的键整理为 zod(definitions/schema.ts),加载即校验。
  • 选择器值:用户 2026-09-27 确认复制 MovieClaw v0.27.0(MIT)资产,23 个站点经 scripts/mp-sync/movieclaw-import.mjs 转换(ledger CL-012/CL-013,copied-mit,NOTICE 见 definitions/NOTICE-MovieClaw.md)。映射规则按 movieclaw-assessment.md §4;page_offset -1 → browse.start 0;promo_*_rules → case 表(补 "*": 1);X::attr(a) → {selector, attribute};分类取 \d+;MovieClaw 的 documentary/anime/game/av/other 只进 categoryNames(原始 ID 保留)。
  • 观众/馒头保留 W1-3b 手写值(覆盖层),HHClub/CHD 覆盖层补站点分类名;4 个主力站启用,其余 19 站 enabled:false(未实测)。