import { siteCloudflareState } from "@umt/contracts"; import { MOCK_CAMOFOX_IDENTITY, type MockCamofoxSite } from "@umt/testkit"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { browserServiceFor } from "../../src/modules/browser/index.ts"; import { type BrowserService, registerChallengeVision } from "../../src/modules/browser/service.ts"; import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts"; // CF-2 浏览器一侧(对 testkit mock camofox 的 Cloudflare 模式): // ① 自动放行(轮询页面)② 复选框 / Turnstile 派视觉子流程,用插件真实鼠标点击 ③ 选图验证码 / 点不过 → 会话转等待 + 待处理事项; // 人在原站视图里过完交还 → 读回 cf_clearance / UA 通知站点请求继续;浏览器代抓页面受 profile 锁与站点限流约束。 const up = await pgAvailable(); const GOOD = "cf2-session-value"; const CLEARANCE = "clr-from-browser"; const LIST_HTML = '种子
Oppenheimer
'; describe.skipIf(!up)("CF-2 浏览器过挑战 / 代抓", () => { let h: BrowserHarness; let svc: BrowserService; const cfCfg: NonNullable = { mode: "auto", autoPolls: 2, clearance: CLEARANCE }; const visionCalls: string[] = []; let visionClick = true; const url = `${ORIGIN}/torrents.php`; const sessions = async () => (await h.req("/browser/sessions")).body.data.items as Loose[]; const closeAll = async () => { for (const s of await sessions()) await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" }); }; const profileKey = () => `umt-site-${h.siteId}-acct-${h.accountId}`; beforeAll(async () => { h = await createBrowserHarness("cf2", { sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: GOOD }, cloudflare: cfCfg, html: (u) => u.includes("torrents.php") ? LIST_HTML : "首页", browserOptions: { challengePollMs: 5, challengeAutoWaitMs: 200, settleMs: 0 }, }); await sessions(); // 第一次请求时浏览器模块才为这个上下文建服务 svc = browserServiceFor(h.ctx)!; const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_uid=1; c_secure_pass=${GOOD}` }); await h.db .updateTable("site_account") .set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active" }) .where("id", "=", h.accountId) .execute(); // 视觉子流程(假):点一下复选框所在的位置(第 2 行) registerChallengeVision(h.ctx, async (req) => { visionCalls.push(req.sessionId); if (visionClick) await svc.visionAct( req.sessionId, { kind: "automation", holder: "system:cloudflare" }, (p, ref) => p.click(ref, { x: 100, y: 70 }), { wantImage: false, sensitiveAllowed: false }, ); return { status: visionClick ? "solved" : "needs_human", reason: "", childRunId: null }; }); }, 120_000); afterAll(async () => { await h?.close(); }); beforeEach(async () => { await closeAll(); h.mock.cfPassed.clear(); h.mock.contentReads.clear(); h.mock.actions.length = 0; for (const jar of h.mock.cookieJars.values()) jar.splice(0, jar.length, ...jar.filter((c) => c.name !== "cf_clearance")); cfCfg.mode = "auto"; visionCalls.length = 0; visionClick = true; await h.db .updateTable("site") .set({ config: JSON.stringify({}), enabled: true }) .where("id", "=", h.siteId) .execute(); await h.db .updateTable("settings") .set({ value: JSON.stringify({ enabled: true }) }) .where("key", "=", "automation") .execute(); }); it("① 自动放行:轮询到挑战页消失,读回 cf_clearance + navigator.userAgent + 出口", async () => { const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url }); expect(r).toEqual({ status: "passed", by: "auto", clearance: CLEARANCE, userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, browserEgress: "direct", }); expect(visionCalls).toEqual([]); // 会话仍归自动化,没有待处理事项 const [s] = await sessions(); expect(s.controller).toBe("automation"); }); it("② 复选框 / Turnstile:等不过 → 视觉子流程用插件真实鼠标点击 → 通过(by vision)", async () => { cfCfg.mode = "interactive"; const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url }); expect(r).toMatchObject({ status: "passed", by: "vision", clearance: CLEARANCE }); expect(visionCalls).toHaveLength(1); expect(h.mock.actions.some((a) => a.action === "mouse-click")).toBe(true); }); it("③ 视觉子流程点不过(最多 2 次)→ 会话转等待,待处理事项「观众需要你在原站视图里过一次验证」", async () => { cfCfg.mode = "interactive"; visionClick = false; const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url }); expect(r).toMatchObject({ status: "needs_human" }); expect(visionCalls).toHaveLength(2); const s = (await h.req(`/browser/sessions/${(r as Loose).sessionId}`)).body.data as Loose; expect(s.controller).toBe("waiting"); expect(s.waitingReason).toBe("cf_challenge"); const d = await h.db .selectFrom("decision") .selectAll() .where("id", "=", (r as Loose).decisionId) .executeTakeFirstOrThrow(); expect(d.title).toBe("观众需要你在原站视图里过一次验证"); expect(d.status).toBe("awaiting_user"); expect((d.evidence as Loose).cloudflare).toMatchObject({ siteId: h.siteId }); // 已在等人:再来一次不打扰,直接返回同一个等待 const again = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url }); expect(again).toMatchObject({ status: "needs_human", sessionId: (r as Loose).sessionId }); expect(visionCalls).toHaveLength(2); }); it("自动化总开关关闭:不派视觉子流程,直接转人工", async () => { cfCfg.mode = "interactive"; await h.db .insertInto("settings") .values({ key: "automation", value: JSON.stringify({ enabled: false }) }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ enabled: false }) })) .execute(); const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url }); expect(r).toMatchObject({ status: "needs_human" }); expect(visionCalls).toEqual([]); }); it("③ 选图 / 输码类:不点、不等,直接转人工;人过完交还 → 读回 cf_clearance,站点请求继续", async () => { cfCfg.mode = "captcha"; const r = (await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url })) as Loose; expect(r.status).toBe("needs_human"); expect(r.reason).toContain("选图"); expect(visionCalls).toEqual([]); // 站点请求一侧记下"等人处理"(级联在 sources-identity 里做,这里直接写状态) await h.db .updateTable("site") .set({ config: JSON.stringify({ cloudflare: { pending: "human", sessionId: r.sessionId } }) }) .where("id", "=", h.siteId) .execute(); // 人接管,在桌面里过了验证(桌面操作不经 API:直接改 mock 状态),交还 expect( (await h.req(`/browser/sessions/${r.sessionId}/control/acquire`, { body: { reason: "过验证" } })).status, ).toBe(200); h.mock.cfPassed.add(profileKey()); h.mock.cookieJars.get(profileKey())!.push({ name: "cf_clearance", value: "clr-human", domain: ".audiences.example", path: "/", expires: Math.floor(Date.now() / 1000) + 3600, httpOnly: true, secure: true, }); expect((await h.req(`/browser/sessions/${r.sessionId}/control/release`, { body: {} })).status).toBe(200); const site = await h.db.selectFrom("site").select("config").where("id", "=", h.siteId).executeTakeFirstOrThrow(); expect(siteCloudflareState(site.config)).toMatchObject({ pending: null, lastSolvedBy: "human" }); const acc = await h.db .selectFrom("site_account") .select("user_info") .where("id", "=", h.accountId) .executeTakeFirstOrThrow(); const info = (acc.user_info as Loose).cloudflare; expect(info).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, egress: "direct" }); expect(h.secrets.plain.get(info.secretRef)).toBe("clr-human"); // 待处理事项已关闭 const d = await h.db .selectFrom("decision") .select("status") .where("id", "=", r.decisionId) .executeTakeFirstOrThrow(); expect(d.status).toBe("resolved"); }); it("浏览器代抓:过了挑战的 profile 导航取 outerHTML;同一 profile 并发串行(只开一个会话);每次导航取站点令牌", async () => { h.mock.cfPassed.add(profileKey()); const [a, b] = await Promise.all([ svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }), svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }), ]); expect(a).toMatchObject({ status: "ok", html: LIST_HTML }); expect(b).toMatchObject({ status: "ok", html: LIST_HTML }); expect(await sessions()).toHaveLength(1); const navs = h.mock.calls.filter( (c) => c.path.endsWith("/navigate") || (c.path === "/tabs" && c.method === "POST"), ); expect(navs.length).toBeGreaterThanOrEqual(2); const count = await h.db .selectFrom("site_request_count") .select((eb) => eb.fn.sum("count").as("n")) .where("site_id", "=", h.siteId) .executeTakeFirst(); expect(Number(count?.n ?? 0)).toBeGreaterThanOrEqual(2); }); it("浏览器代抓:仍是挑战页 → challenge;会话在人工接管中 → 不打扰;站点停用 → 不可用", async () => { cfCfg.mode = "captcha"; expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({ status: "challenge", }); const [s] = await sessions(); await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "看看" } }); expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({ status: "unavailable", }); await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} }); await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute(); expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({ status: "unavailable", reason: "站点已停用", }); }); });