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

+60
View File
@@ -0,0 +1,60 @@
/**
* 站点 RSS 地址获取。迁移自 MP e6820a8 `app/application/rss.py`:
* `RssHelper.rss_link_conf`(仅 default 与 audiences.me 两项,v0.1 范围)、`_get_site_domain`、`get_rss_link`。
* MP 用 lxml xpath `//a[@class='faqlink']/@href` 取最后一个;这里用等价 CSS `a.faqlink` + href。
* 请求本身由调用方(站点适配器)带 Cookie 发送并走站点限流;本模块只构造请求与解析结果。
*/
import { load } from "cheerio";
export interface RssLinkConf {
/** 等价于 MP xpath 的 CSS 选择器与属性 */
selector: string;
attribute: string;
url: string;
params: Record<string, string | number>;
}
export const RSS_LINK_CONF: Record<string, RssLinkConf> = {
default: {
selector: "a.faqlink",
attribute: "href",
url: "getrss.php",
params: { inclbookmarked: 0, itemsmalldescr: 1, showrows: 50, search_mode: 1 },
},
"audiences.me": {
selector: "a.faqlink",
attribute: "href",
url: "getrss.php",
params: { inclbookmarked: 0, itemsmalldescr: 1, showrows: 50, search_mode: 1, torrent_type: 1, exp: 180 },
},
};
/** MP `_get_site_domain`:匹配配置域名,否则回退最后两级域名。 */
export function rssSiteDomain(url: string): string {
const host = new URL(url).hostname.toLowerCase();
for (const d of Object.keys(RSS_LINK_CONF)) {
if (d !== "default" && (host === d || host.endsWith(`.${d}`))) return d;
}
const parts = host.split(".");
return parts.length >= 2 ? parts.slice(-2).join(".") : host;
}
/** 构造 getrss.php 的 POST 请求(表单参数) */
export function buildRssLinkRequest(siteUrl: string): { url: string; form: Record<string, string> } {
const conf = RSS_LINK_CONF[rssSiteDomain(siteUrl)] ?? RSS_LINK_CONF.default!;
return {
url: new URL(conf.url, siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`).toString(),
form: Object.fromEntries(Object.entries(conf.params).map(([k, v]) => [k, String(v)])),
};
}
/** 从 getrss.php 返回页提取 RSS 地址(MP 取匹配结果的最后一个)。返回值含 passkey,调用方须当作凭据保存。 */
export function extractRssLink(siteUrl: string, html: string): string | null {
const conf = RSS_LINK_CONF[rssSiteDomain(siteUrl)] ?? RSS_LINK_CONF.default!;
const $ = load(html);
const links = $(conf.selector)
.map((_, el) => $(el).attr(conf.attribute))
.get()
.filter((x): x is string => Boolean(x));
return links.at(-1) ?? null;
}