# adapters/sites PT 站点适配器,按 MoviePilot v3(提交 e6820a8)迁移:通用配置驱动 spider(观众等 NexusPHP 站)与馒头 M-Team API。**仅对 `@umt/testkit` 的 mock 站点验证,未连接真实站点;选择器与 API 字段均待真实账号核验。** ## 模块与上游对照 | 本项目 | MoviePilot 上游 | 说明 | | --- | --- | --- | | `spider/schema.ts` `IndexerDefinition` | `SiteSpider.__init__` 读取的 indexer dict 键 + `app/application/site/sites.pyi` | 站点定义 schema(domain / search.paths / params / batch / browse / category / torrents.list / torrents.fields DSL);`umt` 为本项目扩展 | | `spider/spider.ts` `SiteSpider` | `app/modules/indexer/spider/__init__.py` `SiteSpider` | `getSearchUrl`(=`__get_search_url`)、`parse`/`getInfo`(=`parse`/`get_info` 全部字段)、`filterText`/`safeQuery`、`isLoginOrPermissionPage`、`selectMediaCategories`、`resolveCategoryMediaType`、`parseSizeMp`(=`foundation/size.parse_size`) | | `spider/template.ts` | jinja2.Template | nunjucks(Jinja 兼容)渲染 title/description/date 模板 | | `spider/adapter.ts` | `IndexerModule.search_torrents/refresh_torrents/__search_check/__clear_search_text`、`submission.download_torrent` | 请求、Cookie 注入、登录失效识别、下载校验 | | `spider/detail.ts` | —(MP 通用 spider 无详情页) | 本项目扩展,选择器在定义 `umt.detail` | | `definitions/audiences.json` | MP 站点定义在加密 `user.sites.v3.bin`,不可读 | 按 MP schema 手写;字段结构参考 MovieClaw `audiences.yaml`(data-ref,未复制);加站只需新增 JSON 并在 `registry.ts` 登记 | | `mteam/spider.ts` | `app/modules/indexer/spider/mtorrent.py` `MTorrentSpider` | `mteamSearchParams`(=`__get_params`)、`parseMteamResult`(=`__parse_result`)、系数表、标签表、分类组 | | `mteam/adapter.ts` | `MTorrentSpider.search`、`__get_download_url` + `submission._resolve_indirect_download_url` | genDlToken(id 走 query)→ 临时直链 → 不带 Cookie GET | | `mteam/client.ts` | `MTorrentSpider.__process_response` | 另加业务 code/message 检查(MP 只看 HTTP 200) | | `rate-limit.ts` | `SitesHelper.check(domain)`(闭源流控) | 站点级令牌桶,list/search/detail/download 共享 | ## 与 MP 的偏离(只限安全、数据保真与去重) 1. **原始分类**:spider/馒头结果保留 MP 的 `category`(电影/电视剧/音乐/未知 投影),另加 `category_id` 原值;`SiteRelease.category` 用原值 + 站点分类名(REQ §4.5,观众 403 综艺、馒头 404/405/407 不压成 tv)。 2. **凭据**:MP 候选携带 `site_cookie`,馒头 enclosure 为含 `x-api-key` 的 base64 JSON;本项目凭据只经 `SecretResolver` 在请求时注入,候选、raw、错误消息均不含 cookie/apikey/passkey/一次性直链(迁移账本 SA5/MT3)。 3. **登录失效**:MP 返回空列表并置 `is_error`;本项目抛 `SiteError('auth_expired')`,不把登录页当成 0 条正常结果(REQ §4.7.1)。3xx 不自动跟随,跳 login 视为失效。 4. **系数 selector 模式**用浮点(MP `int()` 对 0.5 抛异常丢整条,SA4)。 5. **H&R 三态**:定义里没有 `hr` 字段时 MP 缺省 False;本项目映射为 `'unknown'`(REQ §8.3 保守处理)。spider 层仍与 MP 一致:未配置不输出 `hit_and_run`。 6. **HTML 解析**用 htmlparser2 HTML 模式(与 lxml 一样不插入 ``);`:has("x")` 引号写法自动去引号(cheerio-select 不接受)。未移植 MP 的 Rust 加速与字幕搜索分支。 7. **馒头**:显式 `categoryIds` 覆盖 mtype 分类组;`freeOnly` 为本地过滤;业务 message 判定 `auth_invalid / rate_limited / not_found`。 ## 接口 `SiteAdapter{ id, kind, displayName, definitionVersion, capabilities, list(page, filters), search(query, filters), detail(id), downloadTarget(id), healthCheck() }`,页码对外 1 起(MP 内部 0 起)。`filters = { mtype?: 'movie'|'tv'|'music', categoryIds?, freeOnly?, page? }`。 `SiteRelease{ siteId, siteReleaseId, title, subtitle?, category{id,name}, sizeBytes, seeders, leechers, snatched, promotion{type, downloadFactor, uploadFactor, expiresAt}, hr, uploadedAt, detailUrl, downloadUrl?, infoHash?, imdbId?, doubanId?, tags[], raw(完整 MP 字段), observedAt }`。促销类型由 MP 系数组合推出(0/1 free、0/2 2xfree、0.5/1 50%、0.5/2 2x50%、0.3 30%、1/2 2x、1/1 none),系数字段未配置为 `unknown`;`expiresAt` 为 MP `freedate` 按 `umt.timezone` 转 UTC。 错误 `SiteError{kind}`:`auth_missing / auth_expired / auth_invalid`(`isAuthError`,停止重试)、`rate_limited`(`retryAfterMs`,已顺延限流器)、`parse_error`、`network`、`site_maintenance`、`not_found`、`unsupported`。 ## 观众定义待真实页面核验 行选择器、`td:nth-child(4..8)` 列号、`span.torrent-subtitle-line`、`?cat=` 分类、`img.pro_*` 促销 case 表、`freedate` 取促销图标 title、`search_area=4` 是否支持 IMDb、浏览 0 起分页、H&R 选择器(未配置 → unknown)、`span.tags` 标签、详情页 `h1#top`/`td.rowhead`/`#kdescr`。 ## 馒头字段待真实账号核验 1. 搜索体只发 MP 的 `{keyword, categories, pageNumber, pageSize, visible}`(无 `mode`);浏览时 `keyword: null` 是否被接受。 2. 数值字段为字符串还是数字(两者兼容);`totalPages` 字段名。 3. `status.discount` 全集(MP 表:FREE/PERCENT_50/PERCENT_70/_2X_FREE/_2X_PERCENT_50/_2X,其余按 1)、`discountEndTime` / `createdDate` 时区(按 +08:00;MP `format_timestamp` 对非数字字符串原样返回)。 4. `promotionRule{discount,endTime}` 与 `mallSingleFree{status,endDate}` 是否出现在 search 结果中。 5. `labelsNew` / `labels`(MP 表 "0"–"7")。 6. `/api/torrent/detail`(表单 id,MovieClaw 字段结构)是否可用、是否含 `descr`。 7. `genDlToken`(id 走 query,同 MP)直链有效期、次数与频率;返回空 `data` 的原因。 8. 错误 message 文案(停用/封禁/key 无效/频繁/不存在)与 HTTP 状态码。 9. `categoryNames` 为自行编制的粗分类名;默认网页域 `tp.m-team.cc` 需按账号实际域名配置。 10. H&R:API 未提供,恒为 `unknown`。 ## W1-3c:MP 开源站点框架全量移植 + MovieClaw 站点定义 跟随更新工具与对照总表见 `docs/porting/mp-sync-ledger.md`(`scripts/mp-sync/`)。每个移植文件头有 `SPDX-License-Identifier: GPL-3.0-only`、`@mp-source <上游路径> @ e6820a8` 和函数对照。 ### 专用 spider(`spiders/`,MP `app/modules/indexer/spider/*.py`) | parser 键(MP `SPIDER_PARSER_CLASSES`) | 文件 | 凭据 | 下载 | | --- | --- | --- | --- | | `Haidan` | `spiders/haidan.ts` | cookie | 列表直链(仅内存),raw 脱敏 | | `HDDolby` | `spiders/hddolby.ts` | apiKey(+cookie) | `download.php?id=&downhash=`(仅内存) | | `RousiPro` | `spiders/rousi.ts` | apiKey(Bearer) | 现场请求详情接口取 `data.download_url` | | `SunnyPT` | `spiders/sunnypt.ts` | apiKey | 现场 `download-token` 换直链 | | `TNodeSpider` | `spiders/tnode.ts` | cookie + 首页 CSRF(实例内缓存 24h) | 直链 | | `TorrentLeech` | `spiders/torrentleech.ts` | cookie | 直链;中文关键词按 MP 返回空 | | `Yema` | `spiders/yema.ts` | apiKey(Authorization) | 现场 `generateDownloadKey` 换 token | | `mTorrent` | `spiders/mtorrent.ts`(包装 `mteam/*`) | apiKey | genDlToken | `createSpecializedAdapterFor(def, deps)` 实现统一 `SiteAdapter`(list = 空关键词搜索;`detail` 为 `unsupported`);定义 `umt.kind = "api"`,schema 见 `definitions/schema.ts` `SpecializedSiteDefinitionSchema`。MP 的 `[base64(含 apikey 的请求配置)]url` 两段式 enclosure 不生成,下载时按同一配置现场换直链并校验 .torrent。`mteam/spider.ts` 已与 `mtorrent.py` 逐条核对(修正:纯数字时间戳按 +08:00 转换)。 ### 站点用户信息解析器(`user-parsers/`,MP `app/modules/indexer/parser/*.py`) 20 个 `SiteSchema` 全部移植:NexusPhp、NexusAudiences、NexusHhanclub、NexusProject、NexusRabbit、Gazelle、Unit3d、DiscuzX、IPTorrents、FileList、Small Horse、Zhixing、Bitpt、TNode、TorrentLeech、HDDolby、RousiPro、SunnyPT、Yema、MTorrent。 - 基类 `SiteParserBase`(`base.ts`)与 MP 相同的获取顺序:首页登录判定 → `_parseSitePage` → 基础信息 → 详情 → 未读消息(`fetchMessages`)→ 流量 → 做种分页(上限 50 页);cookie/apikey 两种请求模式;200/500/403 视为有内容;Cloudflare 挑战页返回空。 - XPath 用 `xpath` + `@xmldom/xmldom`(`dom.ts`,htmlparser2 解析,不插入 tbody),表达式与 MP 逐字一致。 - 入口 `refreshSiteUserData({name, url, schema, cookie|apikey|token, fetch?, limiter?})` → `SiteUserInfo{schema, domain, userId, username, userLevel, joinAt, upload, download, ratio, bonus, seeding, seedingSize, leeching, leechingSize, seedingInfo, messageUnread, messageUnreadContents, hrCount?, errMsg, raw}`;声明 schema 取不到 userid 时按 MP 顺序回退到其他 schema。 - 写操作:MTorrent / Rousi / NexusRabbit 读消息后标记已读,只在 `fetchMessages: true` 时发生。 ### 站点定义体系(`definitions/`) - 23 个站点 JSON 由 `scripts/mp-sync/movieclaw-import.mjs` 从 **MovieClaw v0.27.0(`c79bb1b0`,MIT)** 转换(见 `definitions/NOTICE-MovieClaw.md`),每个带 `provenance`;手工覆盖层 `scripts/mp-sync/movieclaw-overrides/*.json`(观众、馒头沿用 W1-3b 值,HHClub/CHD 补分类名)。**不要直接改 JSON**,改覆盖层后重跑脚本。 - 加载时 zod 校验(`definitions/schema.ts`:`IndexerDefinitionSchema` / `MteamDefinitionSchema` / `SpecializedSiteDefinitionSchema`,按 `umt.kind` 分派);注册表 `getSiteDefinition(id)`、`listSiteDefinitions({enabledOnly})`、`createSiteAdapter(def, deps)`。 - 新增字段:`enabled`、`schema`(MP 用户信息解析器)、`provenance`、`umt.detail.fields`(MovieClaw 逐字段详情选择器,支持 HHClub div 详情页)、`umt.profile`(MovieClaw 资料选择器,备用)、`umt.login`、`umt.authSupported`、`umt.customClass`。选择器扩展语法 `::attr(x)` / `::text` / `::next_sibling_text` 由 `spider/mc-selector.ts` 求值。 | 状态 | 站点 | | --- | --- | | 启用,**已实测 2026-09-29**(真实账号只读核对) | hhanclub 憨憨 | | 启用(主力,仍须真实账号核验) | audiences 观众、chdbits 彩虹岛、mteam 馒头 | | `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg | 未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。 ### 待真实站点核验(W1-3c 新增) 1. HHClub(2026-09-29 已实测,见下节):仍未见到的只有促销(`promotion-tag-*`)与 H&R(`div.torrent-info-hr`)命中的实例——当次第 1 页 100 条全部无促销、无 H&R,类名与 Jackett 现行定义一致,暂不改。 ### HHClub 实测记录(2026-09-29) 真实账号只读核对,对站点共发 1 次详情请求(列表、用户数据、健康检测用添加站点时产品自己抓的结果;下载令牌只核对列表里的下载链接形态,没有下载)。 | 项 | 结果 | | --- | --- | | 列表 `div.torrent-table-sub-info` 卡片、分类 `?cat[]=` 链接、大小 / 做种 / 下载 / 完成 | 正确,全为数字;分类 401–405/412 原始 ID 与名称都在 | | 标题 | **有误,已修**:标题链接文本末尾带新种标记「[新]」(100 条里 58 条;详情页标题没有),`title` 加 `replace` 过滤 | | 副标题 | 正确,纯文本,无 HTML 残片 | | 发布时间 | 正确:`span[title]` 绝对时间按 `+08:00` 转 UTC,相对时间只进 `date_elapsed` | | 详情页 | 标题 / 副标题取到;没有做种、完成、分类名、基本信息行,**刷新详情会用 null 冲掉列表值,已修**(服务端合并,见 `mergeDetailOverList`) | | 用户数据(`NexusHhanclub`) | 正确;等级「贵宾」之前 `vip` 为 null,**已加 `umt.vipLevels`** | | 健康检测 / 登录标志 `a[href='logout.php']` | 正确 | | 下载链接 | 列表给 `download.php?id=…&passkey=…`,入库时 passkey 脱敏 | | 标签(官方 / 中字 / 国语等) | 列表没有可用的标签选择器;界面按标题解析与副标题原文生成规格标签 | 访问默认(`umt.defaults`,与 `settings.sites` 取更保守的一方):快照每 60 分钟只抓第 1 页、健康检测 6 小时、用户数据 6 小时、实时搜索并发 1、详情缓存 24 小时;限流 3 秒 1 个令牌、突发 2。fixture:`@umt/testkit` 的 `HHANCLUB_LIST_PAGE_2026`(脱敏最小片段)。 2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。 3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。 4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。 5. 馒头 `mode=adult` 独立索引(MovieClaw custom/mteam.py)未实现。