diff --git a/apps/server/test/e2e/e14-vision.test.ts b/apps/server/test/e2e/e14-vision.test.ts index 9678fea2..05c10704 100644 --- a/apps/server/test/e2e/e14-vision.test.ts +++ b/apps/server/test/e2e/e14-vision.test.ts @@ -22,6 +22,7 @@ type Any = any; const ORIGIN = "https://audiences.example"; const ORIGIN_2FA = "https://secure.example"; +const ORIGIN_CF = "https://cf.example"; const USER = "fixture-user"; const PASSWORD = "e14-login-pw"; const ACCESS_KEY = "camofox-e14-key"; @@ -30,6 +31,7 @@ const ACCESS_KEY = "camofox-e14-key"; const AT = { username: { x: 240, y: 64 }, password: { x: 240, y: 96 } }; let sid = ""; +let cfSid = ""; const childResults = new Map(); let mainResults: FakeToolResult[] = []; @@ -37,6 +39,16 @@ const typeUser = () => ({ sessionId: sid, ...AT.username, text: USER, allowSensi const typePassword = () => ({ sessionId: sid, ...AT.password, text: PASSWORD, pressEnter: true, allowSensitive: true }); const scripts: FakeScript[] = [ + { + // CF-2 系统发起的视觉子流程(没有父会话):截图 → 点 Turnstile 复选框(第 2 行,截图坐标 (240, 64)) + name: "e14-cf", + match: "Cloudflare 人机验证页", + steps: [ + { toolCalls: [{ capability: "browser.screenshot", input: () => ({ sessionId: cfSid }) }] }, + { toolCalls: [{ capability: "browser.click_at", input: () => ({ sessionId: cfSid, x: 240, y: 64 }) }] }, + ], + finalText: () => "已点复选框", + }, { name: "e14-main", match: "E14 主会话登录", @@ -122,6 +134,7 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => { let e: E2E; let mock: MockCamofox; let mock2fa: MockCamofox; + let mockCf: MockCamofox; let evidenceDir: string; let svc: BrowserService; const ids = { site: "", site2fa: "", provider: "", provider2fa: "" }; @@ -154,8 +167,16 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => { site: { ...site(ORIGIN_2FA), twoFactor: true }, viewport: { width: 1600, height: 1000 }, }); + mockCf = await createMockCamofox({ + accessKey: ACCESS_KEY, + site: { ...site(ORIGIN_CF), protectedPaths: /^$/, cloudflare: { mode: "interactive", clearance: "clr-e14" } }, + viewport: { width: 1600, height: 1000 }, + }); evidenceDir = await mkdtemp(join(tmpdir(), "umt-e14-evidence-")); - e = await createE2E("e14", { fakeScripts: scripts, browser: { evidenceDir, settleMs: 0 } }); + e = await createE2E("e14", { + fakeScripts: scripts, + browser: { evidenceDir, settleMs: 0, challengePollMs: 10, challengeAutoWaitMs: 200 }, + }); svc = browserServiceFor(e.booted.ctx)!; const ctx = e.booted.ctx; const keyRef = await ctx.secrets.create({ purpose: "other", plaintext: ACCESS_KEY }); @@ -200,6 +221,7 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => { await e?.close(); await mock?.close(); await mock2fa?.close(); + await mockCf?.close(); if (evidenceDir) await rm(evidenceDir, { recursive: true, force: true }); }); @@ -335,9 +357,14 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => { // 坐标换算:截图 (240, 64) → 页面 (300, 80);(240, 96) → (300, 120) expect(r[1]?.json).toMatchObject({ pageX: 300, pageY: 80 }); expect(r[2]?.json).toMatchObject({ pageX: 300, pageY: 120 }); - const evals = mock.actions.filter((a) => a.action === "evaluate").map((a) => String(a.body.expression)); - expect(evals.some((x) => x.includes("elementFromPoint(300, 80)"))).toBe(true); - expect(evals.some((x) => x.includes("elementFromPoint(300, 120)"))).toBe(true); + // CF-2:坐标点击走 umt 插件的真实鼠标事件(按页面 CSS 像素) + const clicks = mock.actions.filter((a) => a.action === "mouse-click").map((a) => [a.body.x, a.body.y]); + expect(clicks).toEqual( + expect.arrayContaining([ + [300, 80], + [300, 120], + ]), + ); expect(mock.loggedIn.size).toBe(1); // 父会话事件流里有子流程 const events = await runEvents(e, runId); @@ -372,4 +399,72 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => { expect(again).not.toBe(sid); await worker.close(again); }, 60_000); + + it("CF-2 站点请求遇 Turnstile:系统发起视觉子流程(没有父会话,预算取设置)点复选框,浏览器读回 cf_clearance", async () => { + // 只留 Cloudflare 这个浏览器服务(站点请求一侧不指定服务,取启用的那个) + await e.db.updateTable("browser_provider").set({ enabled: false }).execute(); + const providerCf = ( + await e.db + .insertInto("browser_provider") + .values({ + name: "camofox-e14-cf", + kind: "camofox", + base_url: mockCf.url, + secret_ref: ( + await e.db.selectFrom("browser_provider").select("secret_ref").executeTakeFirstOrThrow() + ).secret_ref, + enabled: true, + config: JSON.stringify({ navigationTimeoutMs: 2_000, maxRetries: 0 }), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const siteCf = ( + await e.db + .insertInto("site") + .values({ + key: "audiences", + name: "观众", + adapter: "nexusphp", + base_url: `${ORIGIN_CF}/`, + rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const ref = await e.booted.ctx.secrets.create({ purpose: "site_cookie", plaintext: "c_secure_pass=e14" }); + const accountId = ( + await e.db + .insertInto("site_account") + .values({ site_id: siteCf, label: "main", auth_kind: "cookie", secret_ref: ref, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + cfSid = `umt-site-${siteCf}-acct-${accountId}`; // 先占位,打开后换成会话 ID + const pending = svc.solveSiteChallenge({ siteId: siteCf, accountId, url: `${ORIGIN_CF}/torrents.php` }); + // 会话打开后脚本才知道 sessionId + await waitFor( + async () => { + const live = await svc.liveSessionFor(siteCf, accountId); + if (live) cfSid = live.id; + return !!live; + }, + 10_000, + "Cloudflare 会话打开", + 10, + ); + const out = await pending; + expect(out).toMatchObject({ status: "passed", by: "vision", clearance: "clr-e14" }); + expect(mockCf.actions.some((a) => a.action === "mouse-click" && a.body.x === 300 && a.body.y === 80)).toBe(true); + const child = await e.db + .selectFrom("agent_run") + .selectAll() + .where("spawn_reason", "=", "browser.solve_challenge") + .where("created_by", "=", "system:cloudflare") + .executeTakeFirstOrThrow(); + expect(child).toMatchObject({ trigger: "event", parent_run_id: null, status: "succeeded" }); + expect((child.budget as Any).maxToolCalls).toBe(4); + expect((child.budget as Any).maxDurationSec).toBeLessThanOrEqual(120); + expect(providerCf).toBeTruthy(); + }, 60_000); }); diff --git a/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js b/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js index a5ef8f9d..3f235804 100644 --- a/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js +++ b/docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/index.js @@ -10,6 +10,9 @@ * GET /umt/identity → 固定浏览器身份的公开部分:UA、OS、版本、生成时间(BR-4) * PUT /umt/sessions/:userId/proxy { proxy: {server, username?, password?} | null } * → 该 profile 的出口:站点开代理 = 与 HTTP 同一代理;null = 直连 + * POST /umt/tabs/:tabId/mouse-click { userId, x, y } → 按视口坐标发真实鼠标事件(page.mouse.click,CF-2): + * 跨域 iframe 里的 Cloudflare Turnstile 复选框也点得到; + * 标签页不在回 422(与"插件没有这个端点"的 404 区分) * Cookie 值只在请求/响应体里,不写日志(只记个数);代理只记主机,不记账号口令。 * * BR-4 固定浏览器身份(docs/runbook/browser.md §11):`browser:launching` 钩子里,首次用 camoufox-js 生成一份 os=windows @@ -168,6 +171,16 @@ async function windowsFontconfig(installDir, log) { } } +/** 上游会话 → 标签页状态(与 server.js findTab 相同的遍历:tabGroups: Map>) */ +function findTab(session, tabId) { + if (!session?.tabGroups) return null; + for (const group of session.tabGroups.values()) { + const t = group.get(tabId); + if (t?.page) return t; + } + return null; +} + const FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"]; function parseUrls(raw) { @@ -364,7 +377,30 @@ export async function register(app, ctx) { app.get("/umt/instance", ctx.auth(), (_req, res) => res.json(INSTANCE)); - log("info", "umt-cookies plugin: registered cookies / instance / identity / egress endpoints", { + // ── CF-2 真实坐标点击 ── + app.post("/umt/tabs/:tabId/mouse-click", ctx.auth(), express.json({ limit: "4kb" }), async (req, res) => { + try { + const userId = ctx.normalizeUserId(String(req.body?.userId ?? "")); + const x = Number(req.body?.x); + const y = Number(req.body?.y); + if (!userId) return res.status(400).json({ error: "userId is required" }); + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > 20000 || y > 20000) + return res.status(400).json({ error: "x / y must be viewport CSS pixels" }); + const tab = findTab(ctx.sessions.get(userId), req.params.tabId); + if (!tab) return res.status(422).json({ error: "tab not found" }); + const session = ctx.sessions.get(userId); + session.lastAccess = Date.now(); + tab.toolCalls = (tab.toolCalls ?? 0) + 1; + await ctx.withUserLimit(userId, () => tab.page.mouse.click(Math.round(x), Math.round(y), { delay: 60 })); + log("info", "umt mouse click", { reqId: req.reqId, userId, tabId: req.params.tabId }); + res.json({ ok: true, tabId: req.params.tabId, url: tab.page.url() }); + } catch (err) { + log("error", "umt mouse click failed", { reqId: req.reqId, error: err.message }); + res.status(500).json({ error: ctx.safeError(err) }); + } + }); + + log("info", "umt-cookies plugin: registered cookies / instance / identity / egress / mouse-click endpoints", { identityMode, proxyMode, }); diff --git a/packages/adapters/src/browser/camofox-real.test.ts b/packages/adapters/src/browser/camofox-real.test.ts index 29147700..9f3601f5 100644 --- a/packages/adapters/src/browser/camofox-real.test.ts +++ b/packages/adapters/src/browser/camofox-real.test.ts @@ -27,6 +27,8 @@ function realUpstream(handler: (c: Call) => Reply | undefined) { calls.push(call); const r = handler(call) ?? + // 真实上游(不带 umt 插件)没有 /umt/* 路由 + (call.path.startsWith("/umt/") ? { status: 404, json: { error: "Not found" } } : undefined) ?? (call.path === "/tabs" && call.method === "POST" ? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } } : { json: { ok: true } }); diff --git a/packages/adapters/src/browser/camofox/client.ts b/packages/adapters/src/browser/camofox/client.ts index dcc0e7e4..bb6ab67c 100644 --- a/packages/adapters/src/browser/camofox/client.ts +++ b/packages/adapters/src/browser/camofox/client.ts @@ -43,6 +43,7 @@ import { type NavigationResult, type OpenSessionRequest, type OpenSessionResult, + type PageContent, type PageMetrics, profileKeyFor, type ReopenSessionRequest, @@ -143,6 +144,14 @@ const PAGE_METRICS_EXPR = `/* umt-metrics */ (() => ({ w: innerWidth, h: innerHe pw: !!document.querySelector("input[type=password]"), text: String((document.body && document.body.innerText) || "").slice(0, 4000) }))()`; +/** 页面内容表达式(mock 按 umt-page 标记识别) */ +function pageContentExpr(maxChars: number): string { + return `/* umt-page */ (() => ({ url: location.href, title: document.title, + html: document.documentElement ? document.documentElement.outerHTML.slice(0, ${maxChars}) : "", + text: String((document.body && document.body.innerText) || "").slice(0, 20000), + ua: navigator.userAgent }))()`; +} + const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i; export class CamofoxBrowserProvider implements BrowserProvider { @@ -159,6 +168,8 @@ export class CamofoxBrowserProvider implements BrowserProvider { private cookieApi: "plugin" | "upstream" | "none" | null = null; /** 实例标识接口:plugin = umt 插件 GET /umt/instance;health = 只能从 /health 里找;null = 未探测 */ private instanceApi: "plugin" | "health" | null = null; + /** 真实鼠标坐标点击(CF-2):plugin = umt 插件 `POST /umt/tabs/{id}/mouse-click`;none = 插件没有,退回 elementFromPoint;null = 未探测 */ + private mouseApi: "plugin" | "none" | null = null; constructor(private readonly cfg: CamofoxConfig) { this.base = cfg.baseUrl.replace(/\/+$/, ""); @@ -419,7 +430,10 @@ export class CamofoxBrowserProvider implements BrowserProvider { * 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。 * proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。 */ - async setEgress(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }> { + async setEgress( + profileKey: string, + proxy: BrowserEgressProxy | null, + ): Promise<{ restarted: boolean; mode?: string }> { try { const body = await this.json( await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, { @@ -428,7 +442,8 @@ export class CamofoxBrowserProvider implements BrowserProvider { }), "/umt/sessions/{userId}/proxy", ); - return { restarted: body.restarted === true }; + const mode = str(body.mode); + return { restarted: body.restarted === true, ...(mode ? { mode } : {}) }; } catch (e) { if (e instanceof BrowserError && e.httpStatus === 404) throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)"); @@ -1047,6 +1062,22 @@ export class CamofoxBrowserProvider implements BrowserProvider { await this.withProfile(s, async () => { this.assertActor(s, ref.as); const tab = this.tabOf(s, ref); + // 坐标点击先走 umt 插件的真实鼠标事件(page.mouse.click):跨域 iframe 里的控件(Cloudflare Turnstile)与画布也点得到(CF-2) + if ( + action === "click" && + typeof payload.x === "number" && + typeof payload.y === "number" && + this.mouseApi !== "none" + ) { + const done = await this.mouseClick(s, tab, payload.x, payload.y); + if (done) { + this.touch(s, done.url ?? s.rawUrl); + if (done.url) tab.url = s.session.currentUrl; + s.currentTabId = tab.upstreamTabId; + after?.(s); + return; + } + } // 上游 click/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作, // 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click if (typeof payload.x === "number" && typeof payload.y === "number") { @@ -1068,6 +1099,73 @@ export class CamofoxBrowserProvider implements BrowserProvider { }); } + /** + * 插件的真实鼠标点击:`POST /umt/tabs/{id}/mouse-click {userId, x, y}`(视口 CSS 像素)。插件没有这个端点(404)时记下, + * 返回 null 让调用方退回 elementFromPoint。 + */ + private async mouseClick( + s: SessionState, + tab: BrowserTab, + x: number, + y: number, + ): Promise<{ url: string | undefined } | null> { + const path = `/umt/tabs/${encodeURIComponent(tab.upstreamTabId)}/mouse-click`; + try { + const res = await this.request("POST", path, { + body: { userId: s.session.profileKey, x, y }, + timeoutMs: s.budget.timeoutMs, + }); + const out = (await res.json().catch(() => ({}))) as Json; + this.mouseApi = "plugin"; + return { url: str(out.url) }; + } catch (e) { + // 404 = 插件没有这个端点(旧镜像);插件对"标签页不在"回 422,按标签页已不存在处理(BR-3 自愈) + if (e instanceof BrowserError && e.httpStatus === 404) { + this.mouseApi = "none"; + return null; + } + if (e instanceof BrowserError && e.httpStatus === 422) + throw new BrowserError("not_found", "标签页不存在或已关闭", { httpStatus: 404 }); + throw e; + } + } + + /** + * 当前页面内容(CF-2):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。只读,不要求自动化控制权; + * 上游不支持 evaluate 时返回 null。HTML 只给服务端解析器用,不进模型与日志。 + */ + async pageContent(ref: TabRef, opts: { maxHtmlBytes?: number } = {}): Promise { + const s = this.state(ref.sessionId); + const max = Math.max(1024, Math.min(opts.maxHtmlBytes ?? 8 * 1024 * 1024, 16 * 1024 * 1024)); + return this.withProfile(s, async () => { + const tab = this.tabOf(s, ref); + const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`; + let out: Json; + try { + const res = await this.request("POST", path, { + body: { userId: s.session.profileKey, expression: pageContentExpr(max) }, + timeoutMs: s.budget.timeoutMs, + }); + out = await this.json(res, path); + } catch (e) { + if (e instanceof BrowserError && e.kind === "not_found") throw e; + return null; + } + const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null; + if (!r || typeof r.html !== "string") return null; + const rawUrl = str(r.url) ?? s.rawUrl; + this.touch(s, rawUrl ?? s.rawUrl); + tab.url = s.session.currentUrl; + return { + url: rawUrl ? redactBrowserUrl(rawUrl) : null, + title: str(r.title) ?? "", + html: r.html, + text: str(r.text) ?? "", + userAgent: str(r.ua) ?? null, + }; + }); + } + /** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */ private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise { const nonce = this.newId() diff --git a/packages/adapters/src/browser/provider.ts b/packages/adapters/src/browser/provider.ts index 4734b743..f7c42448 100644 --- a/packages/adapters/src/browser/provider.ts +++ b/packages/adapters/src/browser/provider.ts @@ -68,6 +68,11 @@ export interface BrowserProvider { * 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。 */ pageMetrics(ref: TabRef): Promise; + /** + * 当前页面内容(CF-2 挑战识别与浏览器代抓):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。 + * 只读,不要求自动化控制权;上游不支持时返回 null。HTML 只给服务端解析器,不进模型与日志。 + */ + pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number }): Promise; // ── BR-2 凭据统一 ── /** @@ -108,7 +113,7 @@ export interface BrowserProvider { * 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。 * restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。 */ - setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }>; + setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean; mode?: string }>; } export interface ReopenSessionRequest { @@ -124,6 +129,15 @@ export interface ReopenSessionResult { cookies?: { status: "injected" | "unsupported" | "failed"; count: number }; } +export interface PageContent { + url: string | null; + title: string; + html: string; + /** document.body.innerText 前 2 万字 */ + text: string; + userAgent: string | null; +} + export interface PageMetrics { viewportWidth: number; viewportHeight: number; diff --git a/packages/adapters/src/browser/umt-plugin.test.ts b/packages/adapters/src/browser/umt-plugin.test.ts index d63d88af..37baaba1 100644 --- a/packages/adapters/src/browser/umt-plugin.test.ts +++ b/packages/adapters/src/browser/umt-plugin.test.ts @@ -163,12 +163,67 @@ describe("camofox 适配器:身份与出口", () => { mock = await createMockCamofox({ accessKey: "k" }); const p = provider(mock); expect(await p.identity!()).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" }); - expect(await p.setEgress!("site-a", { server: "http://127.0.0.1:7891" })).toEqual({ restarted: false }); - expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false }); + expect(await p.setEgress!("site-a", { server: "http://127.0.0.1:7891" })).toEqual({ + restarted: false, + mode: "site", + }); + expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false, mode: "site" }); expect(mock.egress.get("site-a")).toEqual({ server: "http://127.0.0.1:7891" }); expect(mock.egress.get("site-b")).toBeNull(); }); + const budget = { domainAllowlist: ["audiences.example"], timeoutMs: 5_000, maxRetries: 0, maxBytes: 1_000_000 }; + const openCf = async (m: MockCamofox) => { + const p = provider(m); + const { session } = await p.openSession({ + providerId: "11111111-1111-4111-8111-111111111111", + siteId: "s", + accountId: "a", + profileKey: "umt-site-s-acct-a", + initialUrl: "https://audiences.example/torrents.php", + budget, + }); + return { p, ref: { sessionId: session.id } }; + }; + const cfSite = (mode: "auto" | "interactive" | "captcha") => ({ + origin: "https://audiences.example", + username: "u", + password: "p", + protectedPaths: /^$/, + cloudflare: { mode, autoPolls: 2, clearance: "clr-1" }, + }); + + it("CF-2 pageContent():地址 / 标题 / HTML / UA;挑战页分类由调用方做", async () => { + mock = await createMockCamofox({ accessKey: "k", site: cfSite("auto") }); + const { p, ref } = await openCf(mock); + const c1 = await p.pageContent!(ref); + expect(c1).toMatchObject({ title: "Just a moment...", userAgent: MOCK_CAMOFOX_IDENTITY.userAgent }); + expect(c1?.html).toContain("_cf_chl_opt"); + // 第 2 次读页面时 mock 自动放行,并写 cf_clearance + const c2 = await p.pageContent!(ref); + expect(c2?.title).not.toBe("Just a moment..."); + const cookies = await p.getCookies!("umt-site-s-acct-a", ["https://audiences.example/"]); + expect(cookies.find((c) => c.name === "cf_clearance")?.value).toBe("clr-1"); + }); + + it("CF-2 坐标点击走插件真实鼠标事件(跨域 iframe 的 Turnstile 也点得到),不再 elementFromPoint", async () => { + mock = await createMockCamofox({ accessKey: "k", site: cfSite("interactive") }); + const { p, ref } = await openCf(mock); + // 第 2 行(y = 20 + 40 + 10)是复选框 + await p.click(ref, { x: 100, y: 70 }); + expect(mock.actions.some((a) => a.action === "mouse-click" && a.body.x === 100 && a.body.y === 70)).toBe(true); + expect(mock.actions.some((a) => a.action === "evaluate")).toBe(false); + expect(mock.cfPassed.has("umt-site-s-acct-a")).toBe(true); + }); + + it("CF-2 插件没有 mouse-click(旧镜像)→ 退回 elementFromPoint + 选择器点击", async () => { + mock = await createMockCamofox({ accessKey: "k", cookieApi: "none", site: cfSite("interactive") }); + const { p, ref } = await openCf(mock); + await p.click(ref, { x: 100, y: 70 }); + expect(mock.actions.some((a) => a.action === "evaluate")).toBe(true); + expect(mock.actions.some((a) => a.action === "click")).toBe(true); + }); + it("旧插件(没有接口):identity() 为 null,setEgress() 报 not_implemented", async () => { mock = await createMockCamofox({ accessKey: "k", cookieApi: "none" }); const p = provider(mock); diff --git a/packages/testkit/src/browser/mock-camofox.ts b/packages/testkit/src/browser/mock-camofox.ts index ccc99619..4c437352 100644 --- a/packages/testkit/src/browser/mock-camofox.ts +++ b/packages/testkit/src/browser/mock-camofox.ts @@ -39,6 +39,15 @@ export interface MockCamofoxSite { * 表单登录成功后把 name=fresh 写进该 profile 的 Cookie(读回时可见)。 */ sessionCookie?: { name: string; valid: string[]; fresh: string }; + /** + * Cloudflare 挑战(CF-2):没通过的 profile 打开站点任何页面都是挑战页(标题 Just a moment...)。 + * auto = 读页面内容(umt-page)autoPolls 次后自己通过;interactive = 第 1 行是「确认您是真人」复选框, + * 用插件真实鼠标点击(`POST /umt/tabs/{id}/mouse-click`)点中才通过;captcha = 选图验证码,自动化过不去。 + * 通过后该 profile 得到 cf_clearance(值 clearance)。 + */ + cloudflare?: { mode: "auto" | "interactive" | "captcha"; autoPolls?: number; clearance?: string }; + /** 页面 HTML(umt-page 读页面内容时返回);缺省按快照行拼一段简单 HTML */ + html?: (url: string) => string; } /** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */ @@ -128,6 +137,10 @@ export type MockCamofox = MockHttp & { readonly instanceId: string; /** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */ egress: Map; + /** 已通过 Cloudflare 挑战的 profile(CF-2) */ + cfPassed: Set; + /** 各 profile 读页面内容(umt-page)的次数 */ + contentReads: Map; }; /** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */ @@ -353,6 +366,23 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< /** 重启前存在过的 tabId:之后访问返回 410 */ const restartedTabs = new Set(); const site = opts.site; + const cfPassed = new Set(); + const contentReads = new Map(); + const passCf = (userId: string) => { + if (!site?.cloudflare || cfPassed.has(userId)) return; + cfPassed.add(userId); + putCookie(userId, { + name: "cf_clearance", + value: site.cloudflare.clearance ?? "cf-clearance-mock", + domain: `.${new URL(site.origin).hostname}`, + path: "/", + expires: Math.floor(Date.now() / 1000) + 365 * 86400, + httpOnly: true, + secure: true, + }); + }; + const underCf = (t: MockCamofoxTab) => + !!site?.cloudflare && !cfPassed.has(t.userId) && t.url.startsWith(new URL(site.origin).origin); const putCookie = (userId: string, c: MockCamofoxCookie) => { const jar = (cookieJars.get(userId) ?? []).filter( (x) => !(x.name === c.name && x.domain === c.domain && x.path === c.path), @@ -454,6 +484,14 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< const pageLines = (t: MockCamofoxTab): Line[] => { if (!site) return []; + if (underCf(t)) { + const mode = site.cloudflare!.mode; + const lines: Line[] = [{ ref: "e1", role: "heading", text: "Just a moment..." }]; + if (mode === "interactive") + lines.push({ ref: "e2", role: "checkbox", text: "确认您是真人", onClick: () => undefined }); + if (mode === "captcha") lines.push({ ref: "e2", role: "img", text: "请点击图中所有的红绿灯" }); + return lines; + } const u = new URL(t.url); const passkey = site.passkey ?? CAMOFOX_FIXTURE_PASSKEY; const releases = site.releases ?? [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }]; @@ -749,6 +787,36 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< }, }, }; + // 页面内容(CF-2):地址 / 标题 / HTML / 正文 / UA + if (expr.includes("umt-page")) { + const n = (contentReads.get(t.userId) ?? 0) + 1; + contentReads.set(t.userId, n); + if (underCf(t) && site?.cloudflare?.mode === "auto" && n >= (site.cloudflare.autoPolls ?? 2)) + passCf(t.userId); + const lines = pageLines(t); + const cf = underCf(t); + const mode = site?.cloudflare?.mode; + const title = cf ? "Just a moment..." : (lines[0]?.text ?? ""); + const html = cf + ? `Just a moment...
${ + mode === "captcha" ? '
' : "" + }` + : site?.html + ? site.html(t.url) + : `${title}${lines.map((l) => `

${l.text}

`).join("")}`; + return { + json: { + ok: true, + result: { + url: t.url, + title, + html, + text: lines.map((l) => l.text).join("\n"), + ua: MOCK_CAMOFOX_IDENTITY.userAgent, + }, + }, + }; + } const pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr); const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1]; if (!pt || !nonce) return { json: { ok: true, result: null } }; @@ -811,6 +879,27 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< return { json: { downloads: list } }; }, ], + [ + // 插件的真实鼠标点击(CF-2):坐标按行找元素并点击;挑战页的复选框点中即通过 + /^\/umt\/tabs\/([^/]+)\/mouse-click$/, + (m, q, req, b) => { + if (cookieApi !== "plugin" || req.method !== "POST") return err(404, "no route"); + const t = tabFor(m[1]!, b.userId as string | undefined); + if (!t) return err(422, "tab not found"); + actions.push({ action: "mouse-click", tabId: t.tabId, body: { ...b } }); + void q; + const y = Number(b.y); + const line = pageLines(t)[Math.floor((y - MOCK_ROW_Y0) / MOCK_ROW_H)]; + if (line) { + if (underCf(t) && site?.cloudflare?.mode === "interactive" && line.role === "checkbox") passCf(t.userId); + else { + if (line.role.startsWith("textbox")) t.focus = line.ref; + line.onClick?.(); + } + } + return { json: { ok: true, tabId: t.tabId, url: t.url } }; + }, + ], [/^\/umt\/instance$/, () => (cookieApi === "plugin" ? { json: { instanceId } } : err(404, "no route"))], [ /^\/umt\/identity$/, @@ -891,6 +980,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< loggedIn, cookieJars, egress, + cfPassed, + contentReads, expireLogin(userId?: string) { if (userId) loggedIn.delete(userId); else loggedIn.clear(); @@ -905,6 +996,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< if (o.loseProfiles) { loggedIn.clear(); cookieJars.clear(); + cfPassed.clear(); } }, });