feat(browser): 插件真实坐标点击与页面内容读取(CF-2)
- umt 插件 POST /umt/tabs/{id}/mouse-click:page.mouse.click,跨域 iframe 的 Turnstile 也点得到;click 坐标优先走它,旧镜像退回 elementFromPoint
- 适配器 pageContent(地址 / 标题 / outerHTML / 正文 / navigator.userAgent);setEgress 返回插件出口模式
- mock camofox 增加 Cloudflare 模式(auto / interactive / captcha)与插件鼠标点击
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a11ef16e40
commit
6b14b0da45
7 files changed
+402
-10
No files matched your search
@@ -22,6 +22,7 @@ type Any = any;
|
|||||||
|
|
||||||
const ORIGIN = "https://audiences.example";
|
const ORIGIN = "https://audiences.example";
|
||||||
const ORIGIN_2FA = "https://secure.example";
|
const ORIGIN_2FA = "https://secure.example";
|
||||||
|
const ORIGIN_CF = "https://cf.example";
|
||||||
const USER = "fixture-user";
|
const USER = "fixture-user";
|
||||||
const PASSWORD = "e14-login-pw";
|
const PASSWORD = "e14-login-pw";
|
||||||
const ACCESS_KEY = "camofox-e14-key";
|
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 } };
|
const AT = { username: { x: 240, y: 64 }, password: { x: 240, y: 96 } };
|
||||||
|
|
||||||
let sid = "";
|
let sid = "";
|
||||||
|
let cfSid = "";
|
||||||
const childResults = new Map<string, FakeToolResult[]>();
|
const childResults = new Map<string, FakeToolResult[]>();
|
||||||
let mainResults: FakeToolResult[] = [];
|
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 typePassword = () => ({ sessionId: sid, ...AT.password, text: PASSWORD, pressEnter: true, allowSensitive: true });
|
||||||
|
|
||||||
const scripts: FakeScript[] = [
|
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",
|
name: "e14-main",
|
||||||
match: "E14 主会话登录",
|
match: "E14 主会话登录",
|
||||||
@@ -122,6 +134,7 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => {
|
|||||||
let e: E2E;
|
let e: E2E;
|
||||||
let mock: MockCamofox;
|
let mock: MockCamofox;
|
||||||
let mock2fa: MockCamofox;
|
let mock2fa: MockCamofox;
|
||||||
|
let mockCf: MockCamofox;
|
||||||
let evidenceDir: string;
|
let evidenceDir: string;
|
||||||
let svc: BrowserService;
|
let svc: BrowserService;
|
||||||
const ids = { site: "", site2fa: "", provider: "", provider2fa: "" };
|
const ids = { site: "", site2fa: "", provider: "", provider2fa: "" };
|
||||||
@@ -154,8 +167,16 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => {
|
|||||||
site: { ...site(ORIGIN_2FA), twoFactor: true },
|
site: { ...site(ORIGIN_2FA), twoFactor: true },
|
||||||
viewport: { width: 1600, height: 1000 },
|
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-"));
|
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)!;
|
svc = browserServiceFor(e.booted.ctx)!;
|
||||||
const ctx = e.booted.ctx;
|
const ctx = e.booted.ctx;
|
||||||
const keyRef = await ctx.secrets.create({ purpose: "other", plaintext: ACCESS_KEY });
|
const keyRef = await ctx.secrets.create({ purpose: "other", plaintext: ACCESS_KEY });
|
||||||
@@ -200,6 +221,7 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => {
|
|||||||
await e?.close();
|
await e?.close();
|
||||||
await mock?.close();
|
await mock?.close();
|
||||||
await mock2fa?.close();
|
await mock2fa?.close();
|
||||||
|
await mockCf?.close();
|
||||||
if (evidenceDir) await rm(evidenceDir, { recursive: true, force: true });
|
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)
|
// 坐标换算:截图 (240, 64) → 页面 (300, 80);(240, 96) → (300, 120)
|
||||||
expect(r[1]?.json).toMatchObject({ pageX: 300, pageY: 80 });
|
expect(r[1]?.json).toMatchObject({ pageX: 300, pageY: 80 });
|
||||||
expect(r[2]?.json).toMatchObject({ pageX: 300, pageY: 120 });
|
expect(r[2]?.json).toMatchObject({ pageX: 300, pageY: 120 });
|
||||||
const evals = mock.actions.filter((a) => a.action === "evaluate").map((a) => String(a.body.expression));
|
// CF-2:坐标点击走 umt 插件的真实鼠标事件(按页面 CSS 像素)
|
||||||
expect(evals.some((x) => x.includes("elementFromPoint(300, 80)"))).toBe(true);
|
const clicks = mock.actions.filter((a) => a.action === "mouse-click").map((a) => [a.body.x, a.body.y]);
|
||||||
expect(evals.some((x) => x.includes("elementFromPoint(300, 120)"))).toBe(true);
|
expect(clicks).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
[300, 80],
|
||||||
|
[300, 120],
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(mock.loggedIn.size).toBe(1);
|
expect(mock.loggedIn.size).toBe(1);
|
||||||
// 父会话事件流里有子流程
|
// 父会话事件流里有子流程
|
||||||
const events = await runEvents(e, runId);
|
const events = await runEvents(e, runId);
|
||||||
@@ -372,4 +399,72 @@ describe.skipIf(!hasPg)("E14 视觉自动化闭环", () => {
|
|||||||
expect(again).not.toBe(sid);
|
expect(again).not.toBe(sid);
|
||||||
await worker.close(again);
|
await worker.close(again);
|
||||||
}, 60_000);
|
}, 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);
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
* GET /umt/identity → 固定浏览器身份的公开部分:UA、OS、版本、生成时间(BR-4)
|
* GET /umt/identity → 固定浏览器身份的公开部分:UA、OS、版本、生成时间(BR-4)
|
||||||
* PUT /umt/sessions/:userId/proxy { proxy: {server, username?, password?} | null }
|
* PUT /umt/sessions/:userId/proxy { proxy: {server, username?, password?} | null }
|
||||||
* → 该 profile 的出口:站点开代理 = 与 HTTP 同一代理;null = 直连
|
* → 该 profile 的出口:站点开代理 = 与 HTTP 同一代理;null = 直连
|
||||||
|
* POST /umt/tabs/:tabId/mouse-click { userId, x, y } → 按视口坐标发真实鼠标事件(page.mouse.click,CF-2):
|
||||||
|
* 跨域 iframe 里的 Cloudflare Turnstile 复选框也点得到;
|
||||||
|
* 标签页不在回 422(与"插件没有这个端点"的 404 区分)
|
||||||
* Cookie 值只在请求/响应体里,不写日志(只记个数);代理只记主机,不记账号口令。
|
* Cookie 值只在请求/响应体里,不写日志(只记个数);代理只记主机,不记账号口令。
|
||||||
*
|
*
|
||||||
* BR-4 固定浏览器身份(docs/runbook/browser.md §11):`browser:launching` 钩子里,首次用 camoufox-js 生成一份 os=windows
|
* 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<sessionKey, Map<tabId, TabState>>) */
|
||||||
|
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"];
|
const FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"];
|
||||||
|
|
||||||
function parseUrls(raw) {
|
function parseUrls(raw) {
|
||||||
@@ -364,7 +377,30 @@ export async function register(app, ctx) {
|
|||||||
|
|
||||||
app.get("/umt/instance", ctx.auth(), (_req, res) => res.json(INSTANCE));
|
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,
|
identityMode,
|
||||||
proxyMode,
|
proxyMode,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ function realUpstream(handler: (c: Call) => Reply | undefined) {
|
|||||||
calls.push(call);
|
calls.push(call);
|
||||||
const r =
|
const r =
|
||||||
handler(call) ??
|
handler(call) ??
|
||||||
|
// 真实上游(不带 umt 插件)没有 /umt/* 路由
|
||||||
|
(call.path.startsWith("/umt/") ? { status: 404, json: { error: "Not found" } } : undefined) ??
|
||||||
(call.path === "/tabs" && call.method === "POST"
|
(call.path === "/tabs" && call.method === "POST"
|
||||||
? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } }
|
? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } }
|
||||||
: { json: { ok: true } });
|
: { json: { ok: true } });
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
type NavigationResult,
|
type NavigationResult,
|
||||||
type OpenSessionRequest,
|
type OpenSessionRequest,
|
||||||
type OpenSessionResult,
|
type OpenSessionResult,
|
||||||
|
type PageContent,
|
||||||
type PageMetrics,
|
type PageMetrics,
|
||||||
profileKeyFor,
|
profileKeyFor,
|
||||||
type ReopenSessionRequest,
|
type ReopenSessionRequest,
|
||||||
@@ -143,6 +144,14 @@ const PAGE_METRICS_EXPR = `/* umt-metrics */ (() => ({ w: innerWidth, h: innerHe
|
|||||||
pw: !!document.querySelector("input[type=password]"),
|
pw: !!document.querySelector("input[type=password]"),
|
||||||
text: String((document.body && document.body.innerText) || "").slice(0, 4000) }))()`;
|
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;
|
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
||||||
|
|
||||||
export class CamofoxBrowserProvider implements BrowserProvider {
|
export class CamofoxBrowserProvider implements BrowserProvider {
|
||||||
@@ -159,6 +168,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
|||||||
private cookieApi: "plugin" | "upstream" | "none" | null = null;
|
private cookieApi: "plugin" | "upstream" | "none" | null = null;
|
||||||
/** 实例标识接口:plugin = umt 插件 GET /umt/instance;health = 只能从 /health 里找;null = 未探测 */
|
/** 实例标识接口:plugin = umt 插件 GET /umt/instance;health = 只能从 /health 里找;null = 未探测 */
|
||||||
private instanceApi: "plugin" | "health" | null = 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) {
|
constructor(private readonly cfg: CamofoxConfig) {
|
||||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||||
@@ -419,7 +430,10 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
|||||||
* 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。
|
* 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。
|
||||||
* proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。
|
* 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 {
|
try {
|
||||||
const body = await this.json(
|
const body = await this.json(
|
||||||
await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, {
|
await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, {
|
||||||
@@ -428,7 +442,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
|||||||
}),
|
}),
|
||||||
"/umt/sessions/{userId}/proxy",
|
"/umt/sessions/{userId}/proxy",
|
||||||
);
|
);
|
||||||
return { restarted: body.restarted === true };
|
const mode = str(body.mode);
|
||||||
|
return { restarted: body.restarted === true, ...(mode ? { mode } : {}) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof BrowserError && e.httpStatus === 404)
|
if (e instanceof BrowserError && e.httpStatus === 404)
|
||||||
throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)");
|
throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)");
|
||||||
@@ -1047,6 +1062,22 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
|||||||
await this.withProfile(s, async () => {
|
await this.withProfile(s, async () => {
|
||||||
this.assertActor(s, ref.as);
|
this.assertActor(s, ref.as);
|
||||||
const tab = this.tabOf(s, ref);
|
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/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作,
|
||||||
// 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click
|
// 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click
|
||||||
if (typeof payload.x === "number" && typeof payload.y === "number") {
|
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<PageContent | null> {
|
||||||
|
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 */
|
/** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */
|
||||||
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
||||||
const nonce = this.newId()
|
const nonce = this.newId()
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export interface BrowserProvider {
|
|||||||
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
|
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
|
||||||
*/
|
*/
|
||||||
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
|
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
|
||||||
|
/**
|
||||||
|
* 当前页面内容(CF-2 挑战识别与浏览器代抓):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。
|
||||||
|
* 只读,不要求自动化控制权;上游不支持时返回 null。HTML 只给服务端解析器,不进模型与日志。
|
||||||
|
*/
|
||||||
|
pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number }): Promise<PageContent | null>;
|
||||||
|
|
||||||
// ── BR-2 凭据统一 ──
|
// ── BR-2 凭据统一 ──
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +113,7 @@ export interface BrowserProvider {
|
|||||||
* 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。
|
* 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。
|
||||||
* restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。
|
* restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。
|
||||||
*/
|
*/
|
||||||
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }>;
|
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean; mode?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReopenSessionRequest {
|
export interface ReopenSessionRequest {
|
||||||
@@ -124,6 +129,15 @@ export interface ReopenSessionResult {
|
|||||||
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
|
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 {
|
export interface PageMetrics {
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
|
|||||||
@@ -163,12 +163,67 @@ describe("camofox 适配器:身份与出口", () => {
|
|||||||
mock = await createMockCamofox({ accessKey: "k" });
|
mock = await createMockCamofox({ accessKey: "k" });
|
||||||
const p = provider(mock);
|
const p = provider(mock);
|
||||||
expect(await p.identity!()).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" });
|
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-a", { server: "http://127.0.0.1:7891" })).toEqual({
|
||||||
expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false });
|
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-a")).toEqual({ server: "http://127.0.0.1:7891" });
|
||||||
expect(mock.egress.get("site-b")).toBeNull();
|
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 () => {
|
it("旧插件(没有接口):identity() 为 null,setEgress() 报 not_implemented", async () => {
|
||||||
mock = await createMockCamofox({ accessKey: "k", cookieApi: "none" });
|
mock = await createMockCamofox({ accessKey: "k", cookieApi: "none" });
|
||||||
const p = provider(mock);
|
const p = provider(mock);
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export interface MockCamofoxSite {
|
|||||||
* 表单登录成功后把 name=fresh 写进该 profile 的 Cookie(读回时可见)。
|
* 表单登录成功后把 name=fresh 写进该 profile 的 Cookie(读回时可见)。
|
||||||
*/
|
*/
|
||||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
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 = 都没有 */
|
/** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */
|
||||||
@@ -128,6 +137,10 @@ export type MockCamofox = MockHttp & {
|
|||||||
readonly instanceId: string;
|
readonly instanceId: string;
|
||||||
/** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */
|
/** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */
|
||||||
egress: Map<string, { server: string; username?: string; password?: string } | null>;
|
egress: Map<string, { server: string; username?: string; password?: string } | null>;
|
||||||
|
/** 已通过 Cloudflare 挑战的 profile(CF-2) */
|
||||||
|
cfPassed: Set<string>;
|
||||||
|
/** 各 profile 读页面内容(umt-page)的次数 */
|
||||||
|
contentReads: Map<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */
|
/** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */
|
||||||
@@ -353,6 +366,23 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
|||||||
/** 重启前存在过的 tabId:之后访问返回 410 */
|
/** 重启前存在过的 tabId:之后访问返回 410 */
|
||||||
const restartedTabs = new Set<string>();
|
const restartedTabs = new Set<string>();
|
||||||
const site = opts.site;
|
const site = opts.site;
|
||||||
|
const cfPassed = new Set<string>();
|
||||||
|
const contentReads = new Map<string, number>();
|
||||||
|
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 putCookie = (userId: string, c: MockCamofoxCookie) => {
|
||||||
const jar = (cookieJars.get(userId) ?? []).filter(
|
const jar = (cookieJars.get(userId) ?? []).filter(
|
||||||
(x) => !(x.name === c.name && x.domain === c.domain && x.path === c.path),
|
(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[] => {
|
const pageLines = (t: MockCamofoxTab): Line[] => {
|
||||||
if (!site) return [];
|
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 u = new URL(t.url);
|
||||||
const passkey = site.passkey ?? CAMOFOX_FIXTURE_PASSKEY;
|
const passkey = site.passkey ?? CAMOFOX_FIXTURE_PASSKEY;
|
||||||
const releases = site.releases ?? [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }];
|
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
|
||||||
|
? `<html><head><title>Just a moment...</title></head><body><div id="challenge-spinner"></div><script>window._cf_chl_opt={cType: '${mode === "auto" ? "non-interactive" : "managed"}'}</script>${
|
||||||
|
mode === "captcha" ? '<div class="h-captcha"></div>' : ""
|
||||||
|
}</body></html>`
|
||||||
|
: site?.html
|
||||||
|
? site.html(t.url)
|
||||||
|
: `<html><head><title>${title}</title></head><body>${lines.map((l) => `<p>${l.text}</p>`).join("")}</body></html>`;
|
||||||
|
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 pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr);
|
||||||
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
|
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
|
||||||
if (!pt || !nonce) return { json: { ok: true, result: null } };
|
if (!pt || !nonce) return { json: { ok: true, result: null } };
|
||||||
@@ -811,6 +879,27 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
|||||||
return { json: { downloads: list } };
|
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\/instance$/, () => (cookieApi === "plugin" ? { json: { instanceId } } : err(404, "no route"))],
|
||||||
[
|
[
|
||||||
/^\/umt\/identity$/,
|
/^\/umt\/identity$/,
|
||||||
@@ -891,6 +980,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
|||||||
loggedIn,
|
loggedIn,
|
||||||
cookieJars,
|
cookieJars,
|
||||||
egress,
|
egress,
|
||||||
|
cfPassed,
|
||||||
|
contentReads,
|
||||||
expireLogin(userId?: string) {
|
expireLogin(userId?: string) {
|
||||||
if (userId) loggedIn.delete(userId);
|
if (userId) loggedIn.delete(userId);
|
||||||
else loggedIn.clear();
|
else loggedIn.clear();
|
||||||
@@ -905,6 +996,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
|||||||
if (o.loseProfiles) {
|
if (o.loseProfiles) {
|
||||||
loggedIn.clear();
|
loggedIn.clear();
|
||||||
cookieJars.clear();
|
cookieJars.clear();
|
||||||
|
cfPassed.clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user