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,211 @@
|
||||
/**
|
||||
* 馒头(M-Team)API Key 适配器。
|
||||
*
|
||||
* 对应 MoviePilot e6820a8:
|
||||
* - `MTorrentSpider.search`(POST /api/torrent/search,x-api-key + UA + JSON)→ `list` / `search`
|
||||
* - `MTorrentSpider.__process_response` → `mteamPost`(另加业务 code/message 检查,迁移账本 MT2)
|
||||
* - `MTorrentSpider.__get_download_url` + `submission.py _resolve_indirect_download_url` + `download_torrent`
|
||||
* (POST genDlToken?id= → data 为临时直链 → 不带 cookie GET)→ `downloadTarget`
|
||||
* - `app/modules/indexer/parser/mtorrent.py`(api/member/profile)→ `healthCheck`
|
||||
* 详情 `/api/torrent/detail` 为本项目扩展(MP spider 无详情调用,字段结构参考 MovieClaw mteam.py,data-ref)。
|
||||
*/
|
||||
import { redactUrl } from "../../rss/redact";
|
||||
import { buildHttpContext, type SiteAdapterDeps } from "../deps";
|
||||
import { readBody, siteFetch } from "../http";
|
||||
import { healthFromError } from "../spider/adapter";
|
||||
import { promotionFromFactors } from "../spider/map";
|
||||
import type { SpiderTorrent } from "../spider/spider";
|
||||
import {
|
||||
type DownloadTarget,
|
||||
type SiteAdapter,
|
||||
SiteError,
|
||||
type SiteHealth,
|
||||
type SiteListFilters,
|
||||
type SitePage,
|
||||
type SiteRelease,
|
||||
type SiteReleaseDetail,
|
||||
} from "../types";
|
||||
import { extractDoubanId, inspectTorrent, parseSiteTime } from "../util";
|
||||
import { mteamPost } from "./client";
|
||||
import { type MteamDefinition, mteamApiBase } from "./definition";
|
||||
import { type MteamTorrentJson, mteamSearchParams, parseMteamResult } from "./spider";
|
||||
|
||||
interface MteamSearchData {
|
||||
data?: MteamTorrentJson[] | null;
|
||||
totalPages?: string | number | null;
|
||||
total?: string | number | null;
|
||||
}
|
||||
|
||||
export function mteamTorrentToRelease(
|
||||
t: SpiderTorrent,
|
||||
src: MteamTorrentJson,
|
||||
def: MteamDefinition,
|
||||
observedAt: string,
|
||||
): SiteRelease {
|
||||
const tz = def.umt.timezone;
|
||||
const id = String(src.id);
|
||||
const release: SiteRelease = {
|
||||
siteId: def.id,
|
||||
siteReleaseId: id,
|
||||
title: (t.title ?? "").trim(),
|
||||
category: {
|
||||
id: t.category_id ?? null,
|
||||
name: t.category_id ? (def.umt.categoryNames[t.category_id] ?? null) : null,
|
||||
},
|
||||
sizeBytes: t.size ?? null,
|
||||
seeders: t.seeders ?? null,
|
||||
leechers: t.peers ?? null,
|
||||
snatched: t.grabs ?? null,
|
||||
promotion: promotionFromFactors(t.downloadvolumefactor, t.uploadvolumefactor, parseSiteTime(t.freedate, tz)),
|
||||
// API 搜索结果无 H&R 字段(MP 未映射)→ 三态 unknown
|
||||
hr: "unknown",
|
||||
uploadedAt: parseSiteTime(t.pubdate, tz),
|
||||
detailUrl: t.page_url ?? `${def.domain.replace(/\/$/, "")}/detail/${id}`,
|
||||
tags: t.labels ?? [],
|
||||
raw: { ...t, status: src.status ?? null, labelsRaw: src.labels ?? null },
|
||||
observedAt,
|
||||
};
|
||||
if (t.description) release.subtitle = String(t.description).trim();
|
||||
if (t.imdbid) release.imdbId = t.imdbid;
|
||||
const douban =
|
||||
extractDoubanId(src.douban ?? null) ??
|
||||
(src.douban && /^\d+$/.test(String(src.douban)) ? String(src.douban) : undefined);
|
||||
if (douban) release.doubanId = douban;
|
||||
return release;
|
||||
}
|
||||
|
||||
export function createMteamAdapter(input: MteamDefinition, deps: SiteAdapterDeps): SiteAdapter {
|
||||
const def: MteamDefinition = deps.baseUrl ? { ...input, umt: { ...input.umt, apiBaseUrl: deps.baseUrl } } : input;
|
||||
const http = buildHttpContext(def.id, { timeoutMs: def.timeout * 1000, ...deps }, def.umt.rateLimit);
|
||||
const api = mteamApiBase(def);
|
||||
|
||||
async function apiKey(): Promise<string> {
|
||||
const k = await deps.secrets.resolve({ siteId: def.id, kind: "apiKey" });
|
||||
// MP:无 apikey 直接返回错误
|
||||
if (!k) throw new SiteError(def.id, "auth_missing", "未配置 M-Team API Key");
|
||||
return k;
|
||||
}
|
||||
|
||||
async function doSearch(
|
||||
page: number,
|
||||
filters: SiteListFilters,
|
||||
keyword: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SitePage> {
|
||||
const params = mteamSearchParams(keyword, filters.mtype, Math.max(1, page) - 1);
|
||||
// 本项目扩展:显式分类 ID 优先于 mtype 分类组
|
||||
if (filters.categoryIds?.length) params.categories = filters.categoryIds;
|
||||
const data = await mteamPost<MteamSearchData>(
|
||||
http,
|
||||
api,
|
||||
await apiKey(),
|
||||
"/api/torrent/search",
|
||||
{ json: params },
|
||||
signal,
|
||||
);
|
||||
const observedAt = http.now().toISOString();
|
||||
const src = data?.data ?? [];
|
||||
const rows = parseMteamResult(src, def.domain);
|
||||
let items = rows.map((t, i) => mteamTorrentToRelease(t, src[i]!, def, observedAt));
|
||||
if (filters.freeOnly) items = items.filter((r) => r.promotion.downloadFactor === 0);
|
||||
const tp = Number(data?.totalPages);
|
||||
return {
|
||||
items,
|
||||
page: Math.max(1, page),
|
||||
totalPages: Number.isFinite(tp) && tp >= 0 ? tp : null,
|
||||
totalPagesEstimated: false,
|
||||
fetchedAt: observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function checkId(id: string): string {
|
||||
// 兼容详情页 URL(MP get_subtitle_links 从 page_url 取末段)
|
||||
const m = /(\d+)\/?$/.exec(id.trim());
|
||||
if (!m) throw new SiteError(def.id, "not_found", "资源 ID 非法");
|
||||
return m[1]!;
|
||||
}
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
kind: "mteam",
|
||||
displayName: def.umt.displayName,
|
||||
capabilities: def.umt.capabilities,
|
||||
definitionVersion: def.umt.version,
|
||||
|
||||
list(page, filters = {}, opts = {}) {
|
||||
return doSearch(page, filters, null, opts.signal);
|
||||
},
|
||||
|
||||
search(query, filters = {}, opts = {}) {
|
||||
return doSearch(filters.page ?? 1, filters, query.trim() || null, opts.signal);
|
||||
},
|
||||
|
||||
async detail(siteReleaseId, opts = {}): Promise<SiteReleaseDetail> {
|
||||
const id = checkId(siteReleaseId);
|
||||
const data = await mteamPost<MteamTorrentJson | null>(
|
||||
http,
|
||||
api,
|
||||
await apiKey(),
|
||||
"/api/torrent/detail",
|
||||
{ form: { id } },
|
||||
opts.signal,
|
||||
);
|
||||
if (!data || data.id == null) throw new SiteError(def.id, "not_found", `详情 ${id}:无数据`);
|
||||
const [row] = parseMteamResult([data], def.domain);
|
||||
const r: SiteReleaseDetail = mteamTorrentToRelease(row!, data, def, http.now().toISOString());
|
||||
if (data.descr) r.descriptionText = String(data.descr).slice(0, 20_000);
|
||||
return r;
|
||||
},
|
||||
|
||||
async downloadTarget(siteReleaseId, opts = {}): Promise<DownloadTarget> {
|
||||
const id = checkId(siteReleaseId);
|
||||
// MP __get_download_url: method=post, params={id}(query), header 含 x-api-key, result="data"
|
||||
const url = await mteamPost<string | null>(
|
||||
http,
|
||||
api,
|
||||
await apiKey(),
|
||||
"/api/torrent/genDlToken",
|
||||
{ query: { id } },
|
||||
opts.signal,
|
||||
);
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new SiteError(def.id, "rate_limited", `genDlToken ${id}:未返回下载链接(可能未认证或达到下载频率限制)`);
|
||||
}
|
||||
// MP:涉及解析地址的不使用 Cookie 下载;直链含一次性签名,只在内存中使用
|
||||
const res = await siteFetch(http, url, { method: "GET", ...(opts.signal ? { signal: opts.signal } : {}) });
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(
|
||||
def.id,
|
||||
res.status === 404 ? "not_found" : "network",
|
||||
`下载 ${redactUrl(url)}:HTTP ${res.status}`,
|
||||
{
|
||||
httpStatus: res.status,
|
||||
},
|
||||
);
|
||||
}
|
||||
const bytes = await readBody(http, res);
|
||||
const info = inspectTorrent(bytes);
|
||||
if (!info) throw new SiteError(def.id, "parse_error", `下载 ${id}:返回内容不是 .torrent`);
|
||||
return {
|
||||
kind: "torrent",
|
||||
bytes,
|
||||
infoHash: info.infoHash,
|
||||
name: info.name,
|
||||
fileName: null,
|
||||
verifiedAt: http.now().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async healthCheck(opts = {}): Promise<SiteHealth> {
|
||||
const started = Date.now();
|
||||
const checkedAt = http.now().toISOString();
|
||||
try {
|
||||
await mteamPost<unknown>(http, api, await apiKey(), "/api/member/profile", { form: {} }, opts.signal);
|
||||
return { siteId: def.id, status: "ok", ok: true, checkedAt, latencyMs: Date.now() - started };
|
||||
} catch (e) {
|
||||
return healthFromError(def.id, e, checkedAt);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* M-Team API 调用。对应 MP `MTorrentSpider.search/__process_response`(MP 只看 HTTP 200);
|
||||
* 业务 code/message 检查与凭据/解析错误分层参考 MovieClaw `_post_api`(behavior-ref,迁移账本 MT2)。
|
||||
*/
|
||||
import { redactText } from "../../rss/redact";
|
||||
import { readText, type SiteHttpContext, siteFetch } from "../http";
|
||||
import { SiteError } from "../types";
|
||||
|
||||
export type MteamApiPath =
|
||||
| "/api/torrent/search"
|
||||
| "/api/torrent/detail"
|
||||
| "/api/torrent/genDlToken"
|
||||
| "/api/member/profile"
|
||||
| "/api/tracker/myPeerStatus";
|
||||
|
||||
const SUCCESS_MESSAGES = new Set(["SUCCESS", "OK", "0", ""]);
|
||||
const AUTH_KEYWORDS = [
|
||||
"停用",
|
||||
"封禁",
|
||||
"禁用",
|
||||
"banned",
|
||||
"disabled",
|
||||
"無效",
|
||||
"无效",
|
||||
"invalid",
|
||||
"unauthorized",
|
||||
"未授權",
|
||||
"未授权",
|
||||
"api key",
|
||||
"apikey",
|
||||
"key無效",
|
||||
"key无效",
|
||||
];
|
||||
const NOT_FOUND_KEYWORDS = ["不存在", "not found", "not exist"];
|
||||
const RATE_KEYWORDS = ["頻繁", "频繁", "too many", "rate limit"];
|
||||
|
||||
/**
|
||||
* M-Team API 调用:x-api-key 头 + 业务 code/message 检查(MT2)。
|
||||
* 凭据类错误 → auth_invalid(停止重试);非 JSON → site_maintenance/parse_error。
|
||||
*/
|
||||
export async function mteamPost<T>(
|
||||
http: SiteHttpContext,
|
||||
apiBase: string,
|
||||
apiKey: string,
|
||||
path: MteamApiPath,
|
||||
body: { json?: unknown; form?: Record<string, string>; query?: Record<string, string> },
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { "x-api-key": apiKey, accept: "application/json, text/plain, */*" };
|
||||
let payload: string | undefined;
|
||||
if (body.json !== undefined) {
|
||||
headers["content-type"] = "application/json";
|
||||
payload = JSON.stringify(body.json);
|
||||
} else if (body.form) {
|
||||
headers["content-type"] = "application/x-www-form-urlencoded";
|
||||
payload = new URLSearchParams(body.form).toString();
|
||||
}
|
||||
const qs = body.query ? `?${new URLSearchParams(body.query).toString()}` : "";
|
||||
const url = `${apiBase.replace(/\/$/, "")}${path}${qs}`;
|
||||
const res = await siteFetch(http, url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
...(payload !== undefined ? { body: payload } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(http.siteId, "auth_invalid", `${path}:HTTP ${res.status},API Key 无效或无权限`, {
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(http.siteId, res.status === 404 ? "not_found" : "network", `${path}:HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
const text = await readText(http, res);
|
||||
let json: { code?: unknown; message?: unknown; data?: unknown };
|
||||
try {
|
||||
json = JSON.parse(text) as typeof json;
|
||||
} catch {
|
||||
const maintenance = /維護|维护|maintenance/i.test(text);
|
||||
throw new SiteError(
|
||||
http.siteId,
|
||||
maintenance ? "site_maintenance" : "parse_error",
|
||||
`${path}:响应不是 JSON${maintenance ? "(站点维护)" : ""}`,
|
||||
{ httpStatus: res.status },
|
||||
);
|
||||
}
|
||||
const message = json.message == null ? "" : String(json.message);
|
||||
const code = json.code == null ? "0" : String(json.code);
|
||||
if (code !== "0" || !SUCCESS_MESSAGES.has(message.toUpperCase())) {
|
||||
const lower = message.toLowerCase();
|
||||
const kind = RATE_KEYWORDS.some((k) => lower.includes(k))
|
||||
? "rate_limited"
|
||||
: NOT_FOUND_KEYWORDS.some((k) => lower.includes(k))
|
||||
? "not_found"
|
||||
: AUTH_KEYWORDS.some((k) => lower.includes(k.toLowerCase()))
|
||||
? "auth_invalid"
|
||||
: "parse_error";
|
||||
throw new SiteError(
|
||||
http.siteId,
|
||||
kind,
|
||||
redactText(`${path}:业务错误 code=${code} message=${message.slice(0, 200)}`),
|
||||
);
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
@@ -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(/\/$/, "");
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createMockMteamApi, type MockMteamApi } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMteamAdapter,
|
||||
createSiteRateLimiter,
|
||||
getDownloadVolumeFactor,
|
||||
mteamDefinition,
|
||||
type SiteAdapter,
|
||||
SiteError,
|
||||
type SiteRateLimiter,
|
||||
staticSecretResolver,
|
||||
} from "../index";
|
||||
|
||||
let api: MockMteamApi | undefined;
|
||||
afterEach(async () => {
|
||||
await api?.close();
|
||||
api = undefined;
|
||||
});
|
||||
|
||||
function adapterFor(m: MockMteamApi, key: string | null = m.apiKey, limiter?: SiteRateLimiter): SiteAdapter {
|
||||
return createMteamAdapter(mteamDefinition, {
|
||||
baseUrl: m.baseUrl,
|
||||
secrets: staticSecretResolver(key ? { mteam: { apiKey: key } } : {}),
|
||||
limiter: limiter ?? createSiteRateLimiter({ minIntervalMs: 0 }),
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSiteError(p: Promise<unknown>, kind: string): Promise<SiteError> {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SiteError);
|
||||
expect((e as SiteError).kind).toBe(kind);
|
||||
return e as SiteError;
|
||||
}
|
||||
throw new Error(`expected SiteError ${kind}`);
|
||||
}
|
||||
|
||||
describe("M-Team API 适配器(mock)", () => {
|
||||
it("列表:x-api-key 头 + 搜索体,映射 MV1 字段,结果不含 API Key", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const page = await adapterFor(api).list(1);
|
||||
const req = api.requests[0]!;
|
||||
expect(req.path).toBe("/api/torrent/search");
|
||||
expect(req.headers["x-api-key"]).toBe(api.apiKey);
|
||||
expect(api.lastSearchBody).toEqual({ keyword: null, categories: [], pageNumber: 1, pageSize: 100, visible: 1 });
|
||||
expect(page).toMatchObject({ page: 1, totalPages: 1, totalPagesEstimated: false });
|
||||
const shogun = page.items.find((i) => i.siteReleaseId === "912233")!;
|
||||
expect(shogun).toMatchObject({
|
||||
siteId: "mteam",
|
||||
title: "Shogun.2024.S01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
|
||||
subtitle: "幕府将军 第一季 全10集 杜比视界",
|
||||
category: { id: "402", name: "剧集" },
|
||||
raw: expect.objectContaining({ category: "电视剧", category_id: "402" }),
|
||||
sizeBytes: 61234567890,
|
||||
seeders: 532,
|
||||
leechers: 4,
|
||||
snatched: 2011,
|
||||
promotion: { type: "50%", downloadFactor: 0.5, uploadFactor: 1, expiresAt: "2026-09-28T00:15:00.000Z" },
|
||||
hr: "unknown",
|
||||
uploadedAt: "2026-09-20T00:15:00.000Z",
|
||||
detailUrl: "https://tp.m-team.cc/detail/912233",
|
||||
imdbId: "tt2788316",
|
||||
tags: ["中字", "DV"],
|
||||
});
|
||||
expect(shogun.downloadUrl).toBeUndefined();
|
||||
expect(JSON.stringify(page)).not.toContain(api.apiKey);
|
||||
});
|
||||
|
||||
it("促销优先级:商城单种免费 > 全站促销 > 基础折扣;2x 免费;标签位图", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const { items } = await adapterFor(api).list(1);
|
||||
const by = Object.fromEntries(items.map((i) => [i.siteReleaseId, i]));
|
||||
expect(by["912234"]!.promotion).toEqual({
|
||||
type: "free",
|
||||
downloadFactor: 0,
|
||||
uploadFactor: 1,
|
||||
expiresAt: "2026-09-27T12:00:00.000Z",
|
||||
});
|
||||
expect(by["912234"]!.tags).toEqual(["国配", "中字"]);
|
||||
expect(by["912235"]!.promotion).toMatchObject({
|
||||
type: "free",
|
||||
downloadFactor: 0,
|
||||
expiresAt: "2026-10-07T15:59:59.000Z",
|
||||
});
|
||||
expect(by["912236"]!.promotion).toMatchObject({ type: "2xfree", downloadFactor: 0, uploadFactor: 2 });
|
||||
expect(by["912237"]!.promotion).toEqual({ type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null });
|
||||
// MP __get_downloadvolumefactor:未知折扣代码按 1 处理
|
||||
expect(getDownloadVolumeFactor("SOMETHING_NEW")).toBe(1);
|
||||
});
|
||||
|
||||
it("分类原始 ID 保留(404 纪录 / 405 动画 / 407 体育)", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const { items } = await adapterFor(api).list(1);
|
||||
const cats = Object.fromEntries(items.map((i) => [i.siteReleaseId, i.category]));
|
||||
expect(cats["912235"]).toEqual({ id: "405", name: "动画" });
|
||||
expect(cats["912236"]).toEqual({ id: "404", name: "纪录" });
|
||||
expect(cats["912237"]).toEqual({ id: "407", name: "体育" });
|
||||
});
|
||||
|
||||
it("搜索:IMDb 关键词改写为完整 URL;分类与免费筛选下推", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const a = adapterFor(api);
|
||||
const r = await a.search("tt15398776");
|
||||
expect(api.lastSearchBody!.keyword).toBe("https://www.imdb.com/title/tt15398776");
|
||||
expect(r.items.map((i) => i.siteReleaseId)).toEqual(["912234"]);
|
||||
const free = await a.search("", { freeOnly: true, categoryIds: ["404", "405"] });
|
||||
expect(api.lastSearchBody).toMatchObject({ keyword: null, categories: ["404", "405"] });
|
||||
const tv = await a.search("Shogun", { mtype: "tv" });
|
||||
expect(api.lastSearchBody!.categories).toEqual(["403", "402", "435", "438", "404", "405"]);
|
||||
expect(tv.items.map((i) => i.siteReleaseId)).toEqual(["912233"]);
|
||||
// 912235 基础 NORMAL 但全站促销 FREE → 下载系数 0
|
||||
expect(free.items.map((i) => i.siteReleaseId)).toEqual(["912235", "912236"]);
|
||||
const kw = await a.search("芙莉莲");
|
||||
expect(kw.items.map((i) => i.siteReleaseId)).toEqual(["912235"]);
|
||||
});
|
||||
|
||||
it("详情:表单 id,带简介;不存在 → not_found", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const a = adapterFor(api);
|
||||
const d = await a.detail("912233");
|
||||
expect(api.requests[0]!.body).toBe("id=912233");
|
||||
expect(d.descriptionText).toContain("幕府将军");
|
||||
expect(d.imdbId).toBe("tt2788316");
|
||||
const byUrl = await a.detail("https://tp.m-team.cc/detail/912233");
|
||||
expect(byUrl.siteReleaseId).toBe("912233");
|
||||
await expectSiteError(a.detail("1"), "not_found");
|
||||
});
|
||||
|
||||
it("下载令牌:现场 genDlToken → 一次性直链 → .torrent,infohash 校验", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const a = adapterFor(api);
|
||||
const t = await a.downloadTarget("912233");
|
||||
expect(api.issuedTokens).toBe(1);
|
||||
expect(api.requests.map((r) => r.path.split("?")[0])).toEqual(["/api/torrent/genDlToken", "/api/rss/dlv2"]);
|
||||
// MP:genDlToken 的 id 走 query 参数;直链下载不带 x-api-key
|
||||
expect(api.requests[0]!.path).toBe("/api/torrent/genDlToken?id=912233");
|
||||
expect(api.requests[1]!.headers["x-api-key"]).toBeUndefined();
|
||||
expect(t.kind).toBe("torrent");
|
||||
if (t.kind === "torrent") expect(t.infoHash).toBe(api.infoHashOf("912233"));
|
||||
// 每次下载都重新签发令牌
|
||||
await a.downloadTarget("912233");
|
||||
expect(api.issuedTokens).toBe(2);
|
||||
api.setFailMode("no_dl_token");
|
||||
await expectSiteError(a.downloadTarget("912233"), "rate_limited");
|
||||
});
|
||||
|
||||
it("Key 失效/停用 → auth_invalid(停止重试),健康检查报告;缺 Key → auth_missing", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const wrong = await expectSiteError(adapterFor(api, "wrong-key").list(1), "auth_invalid");
|
||||
expect(wrong.isAuthError).toBe(true);
|
||||
api.setFailMode("banned_key");
|
||||
const banned = await expectSiteError(adapterFor(api).search("x"), "auth_invalid");
|
||||
expect(banned.message).not.toContain(api.apiKey);
|
||||
expect(await adapterFor(api).healthCheck()).toMatchObject({ ok: false, status: "auth_invalid" });
|
||||
api.setFailMode(null);
|
||||
expect((await adapterFor(api).healthCheck()).status).toBe("ok");
|
||||
const before = api.requests.length;
|
||||
await expectSiteError(adapterFor(api, null).list(1), "auth_missing");
|
||||
expect(api.requests.length).toBe(before);
|
||||
});
|
||||
|
||||
it("非 JSON 维护页 → site_maintenance;业务限流/HTTP 429 → rate_limited 并顺延限流器", async () => {
|
||||
api = await createMockMteamApi();
|
||||
const limiter = createSiteRateLimiter({ minIntervalMs: 0 });
|
||||
const a = adapterFor(api, api.apiKey, limiter);
|
||||
api.setFailMode("non_json");
|
||||
await expectSiteError(a.list(1), "site_maintenance");
|
||||
api.setFailMode("rate_limit");
|
||||
await expectSiteError(a.list(1), "rate_limited");
|
||||
api.setFailMode("http_429");
|
||||
const e = await expectSiteError(a.list(1), "rate_limited");
|
||||
expect(e.retryAfterMs).toBe(5000);
|
||||
expect(limiter.tryAcquire()).toBe(false);
|
||||
});
|
||||
|
||||
it("站点级限流 1s:列表/搜索/详情/下载共享", async () => {
|
||||
api = await createMockMteamApi();
|
||||
let clock = 0;
|
||||
const sleeps: number[] = [];
|
||||
const limiter = createSiteRateLimiter({
|
||||
minIntervalMs: mteamDefinition.umt.rateLimit.minIntervalMs,
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
clock += ms;
|
||||
},
|
||||
});
|
||||
const a = adapterFor(api, api.apiKey, limiter);
|
||||
await a.list(1);
|
||||
await a.search("Shogun");
|
||||
await a.detail("912233");
|
||||
await a.downloadTarget("912233"); // genDlToken + GET 直链 = 2 次
|
||||
expect(sleeps).toEqual([1000, 1000, 1000, 1000]);
|
||||
});
|
||||
});
|
||||
@@ -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