- sites/spiders: Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema (+mTorrent wrapper), specialized SiteAdapter, indirect download resolved at download time - sites/user-parsers: SiteParserBase + all 20 MP SiteSchema parsers (lxml XPath via xpath+xmldom), refreshSiteUserData with schema fallback - sites/definitions: zod SiteDefinition schemas, 23 MovieClaw v0.27.0 (MIT) definitions via scripts/mp-sync/movieclaw-import.mjs, registry getSiteDefinition/listSiteDefinitions, NOTICE - scripts/mp-sync: ledger.json (upstream sha256), check.mjs, build-ledger.mjs; docs/porting/mp-sync-ledger.md - license-and-provenance: add copied-mit kind, re-anchor MovieClaw rows to v0.27.0 Co-Authored-By: Claude Opus 5.5 <[email protected]>
197 lines
8.4 KiB
TypeScript
197 lines
8.4 KiB
TypeScript
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.ts";
|
|
|
|
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]);
|
|
});
|
|
});
|