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:
rootandClaude Opus 5.5 committed 2026-09-30 21:26:53 +08:00
1 parent a11ef16e40
commit 6b14b0da45
7 files changed
+402 -10

No files matched your search

@@ -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<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) */
@@ -353,6 +366,23 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
/** 重启前存在过的 tabId:之后访问返回 410 */
const restartedTabs = new Set<string>();
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 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
? `<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 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();
}
},
});