feat(adapters): RSS and PT site adapters ported from MoviePilot v3 + mocks (W1-3b)

- rss: port of MP RssHelper._parse_impl/get_rss_link semantics with safe
  XML (entities off, DTD/ENTITY rejected, size/item caps), ETag/Last-Modified,
  backoff + Retry-After, circuit breaker with manual reset, passkey redaction,
  overlap-window GUID+digest dedupe
- sites/spider: TS port of MP SiteSpider (indexer schema, search URL builder,
  field DSL, filters, Jinja templates via nunjucks, login-page detection);
  audiences definition hand-written as JSON; raw category id preserved;
  shared per-site token bucket
- sites/mteam: port of MP MTorrentSpider (params, result mapping, promo
  factors, genDlToken flow) without embedding the API key in enclosures;
  business code/message checks
- testkit: mock RSS server, mock NexusPHP site, mock M-Team API
- docs: W1-3b migration mapping appended to porting ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:10:34 +08:00
1 parent ef2e504a94
commit a6d049db81
47 files changed
+5414

No files matched your search

@@ -0,0 +1,70 @@
import type { SiteCapabilities } from "../types";
/**
* 馒头站点定义:键名沿用 MP indexer dict(id/name/domain/parser/timeout),
* `umt` 为本项目扩展。API Key 不在定义里,经 SecretResolver 注入(MP 放在 indexer['apikey'])。
*/
export interface MteamDefinition {
id: string;
name: string;
/** 网页域(MP `_url`,详情页 = `${domain}detail/${id}`) */
domain: string;
parser: "mTorrent";
timeout: number;
umt: {
version: string;
kind: "mteam";
displayName: string;
/** API 基址;缺省为 `https://api.<根域>`(MP `_searchurl % _domain`) */
apiBaseUrl?: string;
timezone: string;
rateLimit: { minIntervalMs: number; burst: number };
capabilities: SiteCapabilities;
/** 原始分类 ID → 名称(自行编制,待核验) */
categoryNames: Record<string, string>;
};
}
export const mteamDefinition: MteamDefinition = {
id: "mteam",
name: "馒头",
domain: "https://tp.m-team.cc/",
parser: "mTorrent",
timeout: 15,
umt: {
version: "[email protected]",
kind: "mteam",
displayName: "馒头",
timezone: "+08:00",
rateLimit: { minIntervalMs: 1000, burst: 1 },
capabilities: { list: true, search: true, detail: true, download: true, rss: false },
categoryNames: {
"401": "电影",
"419": "电影",
"420": "电影",
"421": "电影",
"439": "电影",
"402": "剧集",
"403": "综艺",
"435": "剧集",
"438": "剧集",
"404": "纪录",
"405": "动画",
"407": "体育",
"406": "音乐",
"408": "音乐",
"434": "音乐",
},
},
};
/** MP `site_rules.extract_domain` 的简化:取主机名最后两级。 */
export function rootDomain(url: string): string {
const host = new URL(url).hostname;
const parts = host.split(".");
return parts.length >= 2 ? parts.slice(-2).join(".") : host;
}
export function mteamApiBase(def: MteamDefinition): string {
return (def.umt.apiBaseUrl ?? `https://api.${rootDomain(def.domain)}`).replace(/\/$/, "");
}