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

+151
View File
@@ -0,0 +1,151 @@
/**
* M-Team API 解析。
*
* 迁移自 MoviePilot e6820a8 `app/modules/indexer/spider/mtorrent.py` `MTorrentSpider`:
* - `_movie_category/_tv_category/_music_category/_labels` → 同名常量
* - `__get_params` → `mteamSearchParams`
* - `__parse_result` → `parseMteamResult`
* - `__find_imdbid / __get_downloadvolumefactor / __get_uploadvolumefactor` → 同名函数
* - `_pageurl` → `page_url`
*
* 与 MP 的差异:
* 1. 不生成 `[base64(json 含 x-api-key)]genDlToken` 形式的 enclosure(凭据不得进入候选对象,迁移账本 MT3),
* 下载时由 adapter 现场调用 genDlToken(行为同 MP `_resolve_indirect_download_url`)。
* 2. 额外输出 `category_id`(原始分类 ID),`category` 仍是 MP 的媒体类型投影。
* 3. 额外保留 `status` 原文到 raw,便于核验。
*/
import type { MediaKind, MpMediaType, SpiderTorrent } from "../spider/spider";
export const MOVIE_CATEGORY = ["401", "419", "420", "421", "439", "405", "404"];
export const TV_CATEGORY = ["403", "402", "435", "438", "404", "405"];
export const MUSIC_CATEGORY = ["434", "406"];
export const LABELS: Record<string, string> = {
"0": "",
"1": "DIY",
"2": "国配",
"3": "DIY 国配",
"4": "中字",
"5": "DIY 中字",
"6": "国配 中字",
"7": "DIY 国配 中字",
};
export const PAGE_SIZE = 100;
/** M-Team 种子 JSON(字段名按公开 API,待真实账号核验)。 */
export interface MteamTorrentJson {
id: string | number;
name?: string | null;
smallDescr?: string | null;
category?: string | number | null;
size?: string | number | null;
createdDate?: string | null;
imdb?: string | null;
douban?: string | null;
labels?: string | number | null;
labelsNew?: string[] | null;
descr?: string | null;
status?: {
seeders?: string | number | null;
leechers?: string | number | null;
timesCompleted?: string | number | null;
discount?: string | null;
discountEndTime?: string | null;
promotionRule?: { discount?: string | null; endTime?: string | null } | null;
mallSingleFree?: { status?: string | null; endDate?: string | null } | null;
} | null;
[k: string]: unknown;
}
/** MP `__get_params`(page 为 0 起) */
export function mteamSearchParams(keyword: string | null | undefined, mtype: MediaKind | undefined, page = 0) {
let categories: string[];
if (!mtype) categories = [];
else if (mtype === "tv") categories = TV_CATEGORY;
else if (mtype === "music") categories = MUSIC_CATEGORY;
else categories = MOVIE_CATEGORY;
// mtorrent 搜索 imdb 需要输入完整 imdb 链接(MP 注释引 wiki.m-team.cc/zh-tw/imdbtosearch)
const kw = keyword?.startsWith("tt") ? `https://www.imdb.com/title/${keyword}` : (keyword ?? null);
return { keyword: kw, categories, pageNumber: Number(page) + 1, pageSize: PAGE_SIZE, visible: 1 };
}
/** MP `__find_imdbid` */
export function findImdbid(imdb: string | null | undefined): string {
return imdb ? (/tt\d+/.exec(imdb)?.[0] ?? "") : "";
}
/** MP `__get_downloadvolumefactor` */
export function getDownloadVolumeFactor(discount: string | null | undefined): number {
const map: Record<string, number> = { FREE: 0, PERCENT_50: 0.5, PERCENT_70: 0.3, _2X_FREE: 0, _2X_PERCENT_50: 0.5 };
return discount ? (map[discount] ?? 1) : 1;
}
/** MP `__get_uploadvolumefactor` */
export function getUploadVolumeFactor(discount: string | null | undefined): number {
const map: Record<string, number> = { _2X: 2.0, _2X_FREE: 2.0, _2X_PERCENT_50: 2.0 };
return discount ? (map[discount] ?? 1) : 1;
}
/** MP `time_tools.format_timestamp`:非数字字符串原样返回,Unix 秒转本地时间(此处按站点时区交给调用方)。 */
function formatTimestamp(v: string | null | undefined): string | undefined {
if (v == null) return undefined;
return String(v);
}
function toInt(v: unknown): number {
const n = Number.parseInt(String(v ?? "0") || "0", 10);
return Number.isFinite(n) ? n : 0;
}
/** MP `__parse_result` */
export function parseMteamResult(results: MteamTorrentJson[] | null | undefined, webUrl: string): SpiderTorrent[] {
const out: SpiderTorrent[] = [];
if (!results) return out;
const url = webUrl.endsWith("/") ? webUrl : `${webUrl}/`;
for (const result of results) {
const categoryValue = result.category == null ? undefined : String(result.category);
let category: MpMediaType;
if (categoryValue && MUSIC_CATEGORY.includes(categoryValue)) category = "音乐";
else if (categoryValue && TV_CATEGORY.includes(categoryValue) && !MOVIE_CATEGORY.includes(categoryValue))
category = "电视剧";
else if (categoryValue && MOVIE_CATEGORY.includes(categoryValue)) category = "电影";
else category = "未知";
let labels: string[] = [];
if (result.labelsNew && result.labelsNew.length > 0) labels = result.labelsNew.map(String);
else {
const v = LABELS[String(result.labels || "0")] ?? "";
if (v) labels = v.split(/\s+/);
}
const status = result.status ?? {};
const torrent: SpiderTorrent = {
title: result.name ?? undefined,
description: result.smallDescr ?? undefined,
size: toInt(result.size),
seeders: toInt(status.seeders),
peers: toInt(status.leechers),
grabs: toInt(status.timesCompleted),
downloadvolumefactor: getDownloadVolumeFactor(status.discount),
uploadvolumefactor: getUploadVolumeFactor(status.discount),
page_url: `${url}detail/${result.id}`,
imdbid: findImdbid(result.imdb),
labels,
category,
};
const pubdate = formatTimestamp(result.createdDate);
if (pubdate) torrent.pubdate = pubdate;
if (categoryValue) torrent.category_id = categoryValue;
if (status.discountEndTime) torrent.freedate = formatTimestamp(status.discountEndTime);
// 全站促销规则(当前馒头只有下载促销)
if (status.promotionRule) {
torrent.downloadvolumefactor = getDownloadVolumeFactor(status.promotionRule.discount ?? "NORMAL");
if (status.promotionRule.endTime) torrent.freedate = formatTimestamp(status.promotionRule.endTime);
}
if (status.mallSingleFree && status.mallSingleFree.status === "ONGOING") {
torrent.downloadvolumefactor = getDownloadVolumeFactor("FREE");
if (status.mallSingleFree.endDate) torrent.freedate = formatTimestamp(status.mallSingleFree.endDate);
}
out.push(torrent);
}
return out;
}