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:
1 parent
ef2e504a94
commit
a6d049db81
47 files changed
+5414
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user