Files
UHD_Media_Tool/packages/adapters/src/sites/spider/spider.test.ts
T
rootandClaude Opus 5.5 d9f2af2f3c feat(adapters): port MoviePilot open site framework (7 spiders, 20 user parsers) + MovieClaw v0.27.0 site definitions + mp-sync tools (W1-3c)
- 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]>
2026-09-27 18:39:27 +08:00

309 lines
13 KiB
TypeScript

import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
audiencesDefinition,
createSiteRateLimiter,
createSpiderSiteAdapter,
type IndexerDefinition,
type SiteAdapter,
SiteError,
SiteSpider,
staticSecretResolver,
} from "../index.ts";
let site: MockNexusPhpSite | undefined;
afterEach(async () => {
await site?.close();
site = undefined;
});
function adapterFor(s: MockNexusPhpSite, def = audiencesDefinition, cookie: string | null = s.cookie): SiteAdapter {
return createSpiderSiteAdapter(def, {
baseUrl: `${s.baseUrl}/`,
secrets: staticSecretResolver(cookie ? { [def.id]: { cookie } } : {}),
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("SiteSpider(MP spider 迁移)", () => {
const def: IndexerDefinition = {
...audiencesDefinition,
domain: "https://example.com/",
category: {
movie: [{ id: "401" }],
tv: [{ id: "402" }, { id: "403" }],
},
};
it("__get_search_url:默认参数、search_area 仅 IMDb 保留、mtype 选分类组、cat 白名单", () => {
const u1 = new URL(new SiteSpider(def, { keyword: "Dune", page: 2 }).getSearchUrl());
expect(u1.pathname).toBe("/torrents.php");
expect(Object.fromEntries(u1.searchParams)).toEqual({
search_mode: "0",
search_area: "0",
page: "2",
notnewword: "1",
search: "Dune",
cat401: "1",
cat402: "1",
cat403: "1",
});
const u2 = new URL(new SiteSpider(def, { keyword: "tt15239678" }).getSearchUrl());
expect(u2.searchParams.get("search_area")).toBe("4");
const u3 = new URL(new SiteSpider(def, { keyword: "x", mtype: "tv", cat: "403" }).getSearchUrl());
expect([...u3.searchParams.keys()].filter((k) => k.startsWith("cat"))).toEqual(["cat403"]);
const u4 = new URL(new SiteSpider(def, { keyword: ["A B", "C"] }).getSearchUrl());
expect(u4.searchParams.get("search")).toBe("A_B C");
expect(u4.searchParams.get("search_mode")).toBe("1");
// 浏览:browse.path + start
expect(new SiteSpider(def, { page: 3 }).getSearchUrl()).toBe("https://example.com/torrents.php?page=3");
});
it("分类参数 param(cat[] 重复)与 field 拼接两种写法", () => {
const withParam: IndexerDefinition = { ...def, category: { param: "cat[]", tv: [{ id: "402" }, { id: "403" }] } };
const u = new URL(new SiteSpider(withParam, { keyword: "x" }).getSearchUrl());
expect(u.searchParams.getAll("cat[]")).toEqual(["402", "403"]);
const withField: IndexerDefinition = { ...def, category: { field: "cat", delimiter: ",", tv: [{ id: "402" }] } };
expect(new URL(new SiteSpider(withField, { keyword: "x" }).getSearchUrl()).searchParams.get("cat")).toBe(",402");
});
it("get_info 字段 DSL:模板标题、remove、dateparse、case 系数、querystring、hr 配置后两态", () => {
const html = `<html><head><title>t</title></head><body><table class="torrents"><tr><td>h</td></tr>
<tr><td><a href="?cat=402"><img></a></td><td><table class="torrentname"><tr><td class="embedded">
<a href="details.php?id=100" title="Full.Title.2025.2160p">Full.Title…</a> <img class="free">
<font class="subtitle">副标题 <span>[置顶]</span><a>x</a></font><span class="label">中字</span><span class="label">DIY</span>
</td></tr></table></td><td>0</td><td><span title="2025-05-01 12:13:14">1天</span></td><td>1.5 GB</td><td>1,234</td><td>5/7</td><td>9</td></tr>
<tr><td><a href="?cat=401"></a></td><td><table class="torrentname"><tr><td class="embedded">
<a href="details.php?id=101">No.Title.Attr</a> <img class="hitandrun">
</td></tr></table></td><td>0</td><td><span>3 小时</span></td><td>--</td><td>x</td><td></td><td></td></tr></table></body></html>`;
const d: IndexerDefinition = {
...def,
torrents: {
list: { selector: 'table.torrents > tr:has("table.torrentname")' },
fields: {
...def.torrents.fields,
description: { selector: "font.subtitle", remove: "span,a" },
labels: { selector: "span.label" },
hr: { selector: "img.hitandrun" },
downloadvolumefactor: { case: { "img.free": 0, "*": 1 } },
},
},
};
const { torrents } = new SiteSpider(d).parse(html);
expect(torrents[0]).toMatchObject({
title: "Full.Title.2025.2160p",
description: "副标题",
page_url: "https://example.com/details.php?id=100",
size: 1610612736,
seeders: 1234,
peers: 5,
grabs: 9,
downloadvolumefactor: 0,
uploadvolumefactor: 1,
pubdate: "2025-05-01 12:13:14",
labels: ["中字", "DIY"],
hit_and_run: false,
category: "电视剧",
category_id: "402",
});
expect(torrents[1]).toMatchObject({
title: "No.Title.Attr",
hit_and_run: true,
size: 0,
seeders: 0,
category: "电影",
});
expect(torrents[1]!.pubdate).toBeUndefined(); // 相对时间不写入 pubdate(MP __is_invalid_pubdate_text)
});
it("登录/权限页判定(MP __is_login_or_permission_page)", () => {
const r = new SiteSpider(def).parse("<html><head><title>观众 :: 登录</title></head><body></body></html>");
expect(r).toMatchObject({ isError: true, loginPage: true, torrents: [] });
const r2 = new SiteSpider(def).parse("<html><body><p>该页面必须在登录后才能访问</p></body></html>");
expect(r2.loginPage).toBe(true);
});
});
describe("观众适配器(spider + mock NexusPHP)", () => {
it("列表:page=1 发送 page=0;免费+截止、时间、原始分类、副标题;H&R 未配置为 unknown", async () => {
site = await createMockNexusPhpSite();
const page = await adapterFor(site).list(1);
const req = site.requests[0]!;
expect(req.path).toBe("/torrents.php?page=0");
expect(req.headers.cookie).toBe(site.cookie);
expect(page.items).toHaveLength(5);
expect(page.totalPages).toBe(1);
const dune = page.items[0]!;
expect(dune).toMatchObject({
siteId: "audiences",
siteReleaseId: "812345",
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR",
subtitle: "沙丘2 / 沙丘:第二部 | 杜比视界 国英双语 中字",
category: { id: "401", name: "电影" },
sizeBytes: 84202833838,
seeders: 1204,
leechers: 17,
snatched: 3388,
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-10-01T04:00:00.000Z" },
hr: "unknown",
uploadedAt: "2026-09-26T13:14:03.000Z",
});
expect(dune.raw).toMatchObject({ category: "电影", category_id: "401", pubdate: "2026-09-26 21:14:03" });
expect(dune.detailUrl).toBe(`${site.baseUrl}/details.php?id=812345&hit=1`);
expect(JSON.stringify(page)).not.toContain("mock-session-secret");
});
it("列表:综艺保留 403、50%+2x、2x、截断标题、TB/KB", async () => {
site = await createMockNexusPhpSite();
const { items } = await adapterFor(site).list(1);
const byId = Object.fromEntries(items.map((i) => [i.siteReleaseId, i]));
expect(byId["812346"]!.category).toEqual({ id: "403", name: "综艺" });
expect(byId["812346"]!.raw.category).toBe("电视剧");
expect(byId["812346"]!.promotion.type).toBe("none");
expect(byId["812347"]!.promotion).toMatchObject({ type: "2x50%", downloadFactor: 0.5, uploadFactor: 2 });
expect(byId["812347"]!.promotion.expiresAt).toBe("2026-09-29T00:30:00.000Z");
expect(byId["812347"]!.sizeBytes).toBe(1294529987);
expect(byId["812348"]!.title).toBe("The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.D…");
expect(byId["812348"]!.sizeBytes).toBe(2308974418330);
expect(byId["812349"]!.promotion).toMatchObject({
type: "2x",
downloadFactor: 1,
uploadFactor: 2,
expiresAt: null,
});
expect(byId["812349"]!.category).toEqual({ id: "406", name: "纪录片" });
});
it("配置 hr 字段后 H&R 为 true/false", async () => {
site = await createMockNexusPhpSite();
const def: IndexerDefinition = {
...audiencesDefinition,
torrents: {
...audiencesDefinition.torrents,
fields: { ...audiencesDefinition.torrents.fields, hr: { selector: "img.hitandrun" } },
},
};
const { items } = await adapterFor(site, def).list(1);
expect(items.find((i) => i.siteReleaseId === "812346")!.hr).toBe(true);
expect(items.find((i) => i.siteReleaseId === "812345")!.hr).toBe(false);
});
it("搜索:关键词清洗与分类下推;无结果返回空页", async () => {
site = await createMockNexusPhpSite();
const a = adapterFor(site);
const r = await a.search("奔跑吧!", { categoryIds: ["403"] });
const u = new URL(site.requests[0]!.path, site.baseUrl);
expect(u.searchParams.get("search")).toBe("奔跑吧");
expect(u.searchParams.get("cat403")).toBe("1");
expect(u.searchParams.get("cat401")).toBeNull();
expect(r.items.map((i) => i.siteReleaseId)).toEqual(["812346"]);
expect((await a.search("不存在的片名")).items).toEqual([]);
});
it("分页:pageSize=2 时 page=2 发送 page=1,总页数由分页链接估计", async () => {
site = await createMockNexusPhpSite({ pageSize: 2 });
const p2 = await adapterFor(site).list(2);
expect(site.requests[0]!.path).toBe("/torrents.php?page=1");
expect(p2).toMatchObject({ totalPages: 3, totalPagesEstimated: true });
expect(p2.items.map((i) => i.siteReleaseId)).toEqual(["812347", "812348"]);
});
it("详情:标题/副标题/体积/同伴/促销/IMDb/豆瓣;不存在 → not_found", async () => {
site = await createMockNexusPhpSite();
const a = adapterFor(site);
const d = await a.detail("812345");
expect(d).toMatchObject({
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR",
subtitle: "沙丘2 / 沙丘:第二部 | 杜比视界 国英双语 中字",
sizeBytes: 84202833838,
seeders: 1204,
leechers: 17,
imdbId: "tt15239678",
doubanId: "35575567",
uploadedAt: "2026-09-26T13:14:03.000Z",
category: { id: "401", name: "电影" },
promotion: { type: "free", expiresAt: "2026-10-01T04:00:00.000Z" },
});
await expectSiteError(a.detail("999999"), "not_found");
});
it("下载目标:带 Cookie 取 .torrent 并校验 infohash", async () => {
site = await createMockNexusPhpSite();
const t = await adapterFor(site).downloadTarget("812345");
expect(t.kind).toBe("torrent");
if (t.kind !== "torrent") return;
expect(t.infoHash).toBe(site.infoHashOf("812345"));
expect(t.name).toContain("Dune.Part.Two");
expect(t.fileName).toContain(".torrent");
});
it("登录失效识别:列表/详情/下载抛 auth_expired,健康检查报告", async () => {
site = await createMockNexusPhpSite();
const a = adapterFor(site);
expect((await a.healthCheck()).status).toBe("ok");
site.setLoggedIn(false);
const err = await expectSiteError(a.list(1), "auth_expired");
expect(err.isAuthError).toBe(true);
await expectSiteError(a.detail("812345"), "auth_expired");
await expectSiteError(a.downloadTarget("812345"), "auth_expired");
expect(await a.healthCheck()).toMatchObject({ ok: false, status: "auth_expired" });
});
it("登录失效(302 login.php)与缺少 Cookie", async () => {
site = await createMockNexusPhpSite({ loginMode: "redirect" });
await expectSiteError(
adapterFor(site, audiencesDefinition, "c_secure_uid=1; c_secure_pass=wrong").list(1),
"auth_expired",
);
const before = site.requests.length;
await expectSiteError(adapterFor(site, audiencesDefinition, null).list(1), "auth_missing");
expect(site.requests.length).toBe(before);
});
it("页面改版 → parse_error;429 + Retry-After → rate_limited", async () => {
site = await createMockNexusPhpSite();
const a = adapterFor(site);
site.setBrokenLayout(true);
await expectSiteError(a.list(1), "parse_error");
site.setBrokenLayout(false);
site.failNext(1, 429, "7");
expect((await expectSiteError(a.list(1), "rate_limited")).retryAfterMs).toBe(7000);
});
it("站点级限流:列表/搜索/详情/下载共享同一令牌桶", async () => {
site = await createMockNexusPhpSite();
let clock = 0;
const sleeps: number[] = [];
const limiter = createSiteRateLimiter({
minIntervalMs: 3000,
now: () => clock,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
});
const a = createSpiderSiteAdapter(audiencesDefinition, {
baseUrl: `${site.baseUrl}/`,
secrets: staticSecretResolver({ audiences: { cookie: site.cookie } }),
limiter,
});
await a.list(1);
await a.search("Dune");
await a.detail("812345");
await a.downloadTarget("812345");
expect(sleeps).toEqual([3000, 3000, 3000]);
expect(limiter.stats.granted).toBe(4);
});
});