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
@@ -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 } });
|
||||
|
||||
@@ -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<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 */
|
||||
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
||||
const nonce = this.newId()
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface BrowserProvider {
|
||||
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 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 凭据统一 ──
|
||||
/**
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user