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, kind: string): Promise { 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 = `t
h
Full.Title… 副标题 [置顶]x中字DIY
01天1.5 GB1,2345/79
No.Title.Attr
03 小时--x
`; 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("观众 :: 登录"); expect(r).toMatchObject({ isError: true, loginPage: true, torrents: [] }); const r2 = new SiteSpider(def).parse("

该页面必须在登录后才能访问

"); 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); }); });