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
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user