docs(porting): MoviePilot/MovieClaw porting ledger (W0-C)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
b22c440139
commit
8b90d396b2
4 files changed
+1533
No files matched your search
@@ -0,0 +1,547 @@
|
||||
# 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|None)` | Path 不存在时读 FileCache `region="torrents"`;磁力不解析 |
|
||||
| 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` | siteTorrentId `812345`,siteCategoryId `401`,sizeBytes ≈ 84202833838,seeders 1204,downloadFactor 0,uploadFactor 1,promoUntil `2026-10-01T04:00:00Z`(站点时区 +08),hitAndRun `unknown`(未配置),publishedAt `2026-09-26T13:14:03Z` |
|
||||
| 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 实例验证 |
|
||||
Reference in new issue
Block a user