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,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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user