- 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]>
309 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|