@trusty-squire/mcp 0.9.17-rc.3 → 0.9.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot/agent.d.ts +61 -10
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +2577 -1626
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +34 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1313 -229
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/captcha-solver-2captcha.d.ts +21 -0
- package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
- package/dist/bot/captcha-solver-2captcha.js +77 -0
- package/dist/bot/captcha-solver-2captcha.js.map +1 -1
- package/dist/bot/credential-extraction-flow.d.ts +66 -0
- package/dist/bot/credential-extraction-flow.d.ts.map +1 -0
- package/dist/bot/credential-extraction-flow.js +422 -0
- package/dist/bot/credential-extraction-flow.js.map +1 -0
- package/dist/bot/failure-stage.d.ts.map +1 -1
- package/dist/bot/failure-stage.js +8 -3
- package/dist/bot/failure-stage.js.map +1 -1
- package/dist/bot/flow-engine.d.ts +7 -0
- package/dist/bot/flow-engine.d.ts.map +1 -0
- package/dist/bot/flow-engine.js +13 -0
- package/dist/bot/flow-engine.js.map +1 -0
- package/dist/bot/form-fill.d.ts +31 -4
- package/dist/bot/form-fill.d.ts.map +1 -1
- package/dist/bot/form-fill.js +109 -8
- package/dist/bot/form-fill.js.map +1 -1
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +10 -2
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +9 -33
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/nav-search.d.ts +34 -0
- package/dist/bot/nav-search.d.ts.map +1 -1
- package/dist/bot/nav-search.js +283 -31
- package/dist/bot/nav-search.js.map +1 -1
- package/dist/bot/observation-frame.d.ts +16 -0
- package/dist/bot/observation-frame.d.ts.map +1 -0
- package/dist/bot/observation-frame.js +22 -0
- package/dist/bot/observation-frame.js.map +1 -0
- package/dist/bot/onboarding-capture.d.ts +10 -2
- package/dist/bot/onboarding-capture.d.ts.map +1 -1
- package/dist/bot/onboarding-capture.js +131 -22
- package/dist/bot/onboarding-capture.js.map +1 -1
- package/dist/bot/post-signup-action-executor.d.ts +53 -0
- package/dist/bot/post-signup-action-executor.d.ts.map +1 -0
- package/dist/bot/post-signup-action-executor.js +104 -0
- package/dist/bot/post-signup-action-executor.js.map +1 -0
- package/dist/bot/post-signup-flow.d.ts +51 -0
- package/dist/bot/post-signup-flow.d.ts.map +1 -0
- package/dist/bot/post-signup-flow.js +134 -0
- package/dist/bot/post-signup-flow.js.map +1 -0
- package/dist/bot/post-signup-login-flow.d.ts +28 -0
- package/dist/bot/post-signup-login-flow.d.ts.map +1 -0
- package/dist/bot/post-signup-login-flow.js +35 -0
- package/dist/bot/post-signup-login-flow.js.map +1 -0
- package/dist/bot/post-signup-recovery-state.d.ts +145 -0
- package/dist/bot/post-signup-recovery-state.d.ts.map +1 -0
- package/dist/bot/post-signup-recovery-state.js +239 -0
- package/dist/bot/post-signup-recovery-state.js.map +1 -0
- package/dist/bot/post-signup-synthetic-capture.d.ts +43 -0
- package/dist/bot/post-signup-synthetic-capture.d.ts.map +1 -0
- package/dist/bot/post-signup-synthetic-capture.js +84 -0
- package/dist/bot/post-signup-synthetic-capture.js.map +1 -0
- package/dist/bot/promote-to-skill.d.ts +18 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +310 -23
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/read-otp.d.ts +1 -0
- package/dist/bot/read-otp.d.ts.map +1 -1
- package/dist/bot/read-otp.js +70 -18
- package/dist/bot/read-otp.js.map +1 -1
- package/dist/bot/redact.d.ts.map +1 -1
- package/dist/bot/redact.js +1 -0
- package/dist/bot/redact.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +18 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +1290 -71
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/bot/semantic-transition.d.ts +66 -0
- package/dist/bot/semantic-transition.d.ts.map +1 -0
- package/dist/bot/semantic-transition.js +361 -0
- package/dist/bot/semantic-transition.js.map +1 -0
- package/dist/bot/signup-oauth-flow.d.ts +16 -0
- package/dist/bot/signup-oauth-flow.d.ts.map +1 -0
- package/dist/bot/signup-oauth-flow.js +31 -0
- package/dist/bot/signup-oauth-flow.js.map +1 -0
- package/dist/bot/state-classifier.d.ts +18 -0
- package/dist/bot/state-classifier.d.ts.map +1 -0
- package/dist/bot/state-classifier.js +127 -0
- package/dist/bot/state-classifier.js.map +1 -0
- package/dist/bot/terminal-gate.d.ts +20 -0
- package/dist/bot/terminal-gate.d.ts.map +1 -0
- package/dist/bot/terminal-gate.js +165 -0
- package/dist/bot/terminal-gate.js.map +1 -0
- package/dist/bot/xvfb.d.ts +3 -0
- package/dist/bot/xvfb.d.ts.map +1 -1
- package/dist/bot/xvfb.js +106 -40
- package/dist/bot/xvfb.js.map +1 -1
- package/dist/provision-gate.d.ts +16 -0
- package/dist/provision-gate.d.ts.map +1 -0
- package/dist/provision-gate.js +66 -0
- package/dist/provision-gate.js.map +1 -0
- package/dist/provision-run.d.ts +45 -0
- package/dist/provision-run.d.ts.map +1 -0
- package/dist/provision-run.js +99 -0
- package/dist/provision-run.js.map +1 -0
- package/dist/skill-registry-client.d.ts +9 -0
- package/dist/skill-registry-client.d.ts.map +1 -1
- package/dist/skill-registry-client.js +31 -6
- package/dist/skill-registry-client.js.map +1 -1
- package/dist/tools/provision-any.d.ts +21 -0
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +127 -36
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +1 -1
package/dist/bot/browser.js
CHANGED
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
import { chromium as baseChromium } from "playwright";
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
26
|
import { Socket, createServer } from "node:net";
|
|
27
|
-
import { existsSync } from "node:fs";
|
|
28
|
-
import { spawn } from "node:child_process";
|
|
27
|
+
import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
|
|
28
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
29
29
|
import { detectAsn } from "./asn.js";
|
|
30
|
-
import { CHROME_PROFILE_DIR, launchWithProfileGate, ProfileBusyError, reapLeakedProfileHolder, waitForProfileFree } from "./profile.js";
|
|
30
|
+
import { CHROME_PROFILE_DIR, clearStaleSingletonLock, launchWithProfileGate, ProfileBusyError, reapLeakedProfileHolder, waitForProfileFree, } from "./profile.js";
|
|
31
31
|
import { startXvfb, xvfbAvailable } from "./xvfb.js";
|
|
32
32
|
// Lazy registration: installing the plugin mutates the chromium singleton
|
|
33
33
|
// from playwright-extra so we only do it once per process. We require()
|
|
@@ -109,6 +109,69 @@ function getChromium() {
|
|
|
109
109
|
}
|
|
110
110
|
return cachedChromium;
|
|
111
111
|
}
|
|
112
|
+
const HCAPTCHA_UUID_RE = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
113
|
+
export function extractHcaptchaSitekeyFromHtml(html) {
|
|
114
|
+
if (!/hcaptcha\.com|h-captcha|hcaptcha/i.test(html))
|
|
115
|
+
return null;
|
|
116
|
+
const normalized = html
|
|
117
|
+
.replace(/"/g, '"')
|
|
118
|
+
.replace(/"/g, '"')
|
|
119
|
+
.replace(/&/g, "&");
|
|
120
|
+
for (const src of normalized.matchAll(/<iframe[^>]+src=["']([^"']*hcaptcha[^"']*)["']/gi)) {
|
|
121
|
+
const raw = src[1];
|
|
122
|
+
if (raw === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(raw, "https://example.invalid");
|
|
126
|
+
const direct = url.searchParams.get("sitekey");
|
|
127
|
+
if (direct !== null && direct.length > 10)
|
|
128
|
+
return direct;
|
|
129
|
+
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
|
130
|
+
const fromHash = new URLSearchParams(hash).get("sitekey");
|
|
131
|
+
if (fromHash !== null && fromHash.length > 10)
|
|
132
|
+
return fromHash;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
const match = raw.match(new RegExp(`[?#&]sitekey=(${HCAPTCHA_UUID_RE})`, "i"));
|
|
136
|
+
if (match?.[1] !== undefined)
|
|
137
|
+
return match[1];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const patterns = [
|
|
141
|
+
// Standard hCaptcha/SDK naming.
|
|
142
|
+
new RegExp(`(?:sitekey|site_key|site-key|hcaptcha_key|captchaApiKey|data-(?:hcaptcha-)?sitekey)["'\\s]*[:=]\\s*["'](${HCAPTCHA_UUID_RE})["']`, "i"),
|
|
143
|
+
// Stripe and similar app config JSON often names keys
|
|
144
|
+
// `express_hcaptcha_site_key` or `hcaptcha_login_main_site_key`.
|
|
145
|
+
new RegExp(`(?:hcaptcha[^"'<>]{0,80}site[_-]?key|express_hcaptcha_site_key)["'\\s]*[:=]\\s*["'](${HCAPTCHA_UUID_RE})["']`, "i"),
|
|
146
|
+
];
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
const match = normalized.match(pattern);
|
|
149
|
+
if (match?.[1] !== undefined)
|
|
150
|
+
return match[1];
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
export function extractHcaptchaResponseKeyFromToken(token) {
|
|
155
|
+
const parts = token.split(".");
|
|
156
|
+
if (parts.length < 2)
|
|
157
|
+
return null;
|
|
158
|
+
const payload = parts[1];
|
|
159
|
+
if (payload === undefined || payload.length === 0)
|
|
160
|
+
return null;
|
|
161
|
+
try {
|
|
162
|
+
const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
163
|
+
const parsed = JSON.parse(json);
|
|
164
|
+
for (const key of ["ekey", "eKey", "respKey", "responseKey", "key", "kr"]) {
|
|
165
|
+
const value = parsed[key];
|
|
166
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
112
175
|
// Map a cookie jar to the OAuth providers that have a LIVE logged-in session.
|
|
113
176
|
// The auth cookies that mean "signed in": GitHub → `user_session`; Google →
|
|
114
177
|
// any of the *SID session cookies (NID / CONSENT / 1P_JAR are set even when
|
|
@@ -141,6 +204,17 @@ function isCaptchaVariant(v) {
|
|
|
141
204
|
v === "hcaptcha" ||
|
|
142
205
|
v === "unknown");
|
|
143
206
|
}
|
|
207
|
+
function pngDimensions(buf) {
|
|
208
|
+
if (buf.length < 24)
|
|
209
|
+
return null;
|
|
210
|
+
if (buf[0] !== 0x89 ||
|
|
211
|
+
buf[1] !== 0x50 ||
|
|
212
|
+
buf[2] !== 0x4e ||
|
|
213
|
+
buf[3] !== 0x47) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
217
|
+
}
|
|
144
218
|
// Real-Chromium-family browser channels we'll prefer over the bundled
|
|
145
219
|
// Chromium binary when available. Chromium ships without Widevine,
|
|
146
220
|
// without proprietary codecs, with an empty navigator.plugins array,
|
|
@@ -309,6 +383,123 @@ async function waitForDevtools(port, deadlineMs) {
|
|
|
309
383
|
}
|
|
310
384
|
throw new Error(`Chrome DevTools endpoint never came up on ${base} (${lastErr})`);
|
|
311
385
|
}
|
|
386
|
+
async function withChromeStartupLock(fn) {
|
|
387
|
+
const lockDir = "/tmp/trusty-squire-chrome-start.lock";
|
|
388
|
+
const deadline = Date.now() + 60_000;
|
|
389
|
+
for (;;) {
|
|
390
|
+
try {
|
|
391
|
+
mkdirSync(lockDir);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
try {
|
|
396
|
+
const ageMs = Date.now() - statSync(lockDir).mtimeMs;
|
|
397
|
+
if (ageMs > 120_000) {
|
|
398
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (Date.now() >= deadline) {
|
|
407
|
+
throw new Error(`Timed out waiting for Chrome startup lock at ${lockDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
408
|
+
}
|
|
409
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
return await fn();
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const selfManagedChromePids = new Set();
|
|
420
|
+
let selfManagedCleanupInstalled = false;
|
|
421
|
+
let orphanVerifyReapRan = false;
|
|
422
|
+
function killPid(pid, signal) {
|
|
423
|
+
try {
|
|
424
|
+
process.kill(pid, signal);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// already gone / not ours
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function cleanupSelfManagedChromes() {
|
|
431
|
+
for (const pid of selfManagedChromePids)
|
|
432
|
+
killPid(pid, "SIGKILL");
|
|
433
|
+
selfManagedChromePids.clear();
|
|
434
|
+
}
|
|
435
|
+
function installSelfManagedChromeCleanup() {
|
|
436
|
+
if (selfManagedCleanupInstalled)
|
|
437
|
+
return;
|
|
438
|
+
selfManagedCleanupInstalled = true;
|
|
439
|
+
process.once("exit", cleanupSelfManagedChromes);
|
|
440
|
+
const exitForSignal = (code) => {
|
|
441
|
+
cleanupSelfManagedChromes();
|
|
442
|
+
process.exit(128 + code);
|
|
443
|
+
};
|
|
444
|
+
process.once("SIGINT", () => exitForSignal(2));
|
|
445
|
+
process.once("SIGTERM", () => exitForSignal(15));
|
|
446
|
+
process.once("SIGHUP", () => exitForSignal(1));
|
|
447
|
+
}
|
|
448
|
+
function registerSelfManagedChrome(child) {
|
|
449
|
+
installSelfManagedChromeCleanup();
|
|
450
|
+
if (child.pid !== undefined)
|
|
451
|
+
selfManagedChromePids.add(child.pid);
|
|
452
|
+
child.once("exit", () => {
|
|
453
|
+
if (child.pid !== undefined)
|
|
454
|
+
selfManagedChromePids.delete(child.pid);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// Stale verifier browsers are the expensive leak mode: if the MCP process dies
|
|
458
|
+
// mid-verify, self-launched Chrome survives as PPID=1 with a
|
|
459
|
+
// ~/.trusty-squire/profiles/verify-* user-data-dir. It keeps the profile lock,
|
|
460
|
+
// burns memory, and may leave defunct helper children. A live verifier should
|
|
461
|
+
// never be parented to init, so these are safe to reap at the next browser
|
|
462
|
+
// startup. Best-effort and Linux-only; failure must not block signups.
|
|
463
|
+
function reapOrphanedVerifyBrowsersOnce() {
|
|
464
|
+
if (orphanVerifyReapRan)
|
|
465
|
+
return;
|
|
466
|
+
orphanVerifyReapRan = true;
|
|
467
|
+
if (process.platform !== "linux")
|
|
468
|
+
return;
|
|
469
|
+
let rows = "";
|
|
470
|
+
try {
|
|
471
|
+
rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
|
|
472
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
473
|
+
}).toString("utf8");
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const pids = [];
|
|
479
|
+
for (const line of rows.split("\n")) {
|
|
480
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
481
|
+
if (match === null)
|
|
482
|
+
continue;
|
|
483
|
+
const pid = Number(match[1]);
|
|
484
|
+
const ppid = Number(match[2]);
|
|
485
|
+
const args = match[3] ?? "";
|
|
486
|
+
if (Number.isFinite(pid) &&
|
|
487
|
+
ppid === 1 &&
|
|
488
|
+
/(?:chrome|chromium)/i.test(args) &&
|
|
489
|
+
/--user-data-dir=.*\.trusty-squire\/profiles\/verify-[^/\s]+/.test(args)) {
|
|
490
|
+
pids.push(pid);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (pids.length === 0)
|
|
494
|
+
return;
|
|
495
|
+
console.error(`[universal-bot] reaping ${pids.length} orphaned verify Chrome process(es)`);
|
|
496
|
+
for (const pid of pids)
|
|
497
|
+
killPid(pid, "SIGTERM");
|
|
498
|
+
setTimeout(() => {
|
|
499
|
+
for (const pid of pids)
|
|
500
|
+
killPid(pid, "SIGKILL");
|
|
501
|
+
}, 2_000).unref();
|
|
502
|
+
}
|
|
312
503
|
// Classify an anti-bot interstitial page from its (title + body) text.
|
|
313
504
|
// `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
|
|
314
505
|
// `verificationPassed` is the signal the challenge SUCCEEDED — but
|
|
@@ -462,38 +653,82 @@ export class BrowserController {
|
|
|
462
653
|
// an emulated viewport on the connected context)
|
|
463
654
|
// • locale/geo/permissions → applied post-connect by start()
|
|
464
655
|
async launchSelfManagedContext(params) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
656
|
+
// Remote-CDP attach: BOT_CDP_ENDPOINT points at a Chrome already running on
|
|
657
|
+
// another host (e.g. a real-GPU Mac), reachable over Tailscale. We do NOT
|
|
658
|
+
// spawn or own the binary — the remote host launched it with its own
|
|
659
|
+
// profile, real GPU, and (residential) egress. Just attach over CDP. This
|
|
660
|
+
// is the real-GPU path: software-WebGL output (llvmpipe) is what
|
|
661
|
+
// hCaptcha-Enterprise-class anti-bot scores, and only real hardware fixes
|
|
662
|
+
// the rendered-pixel fingerprint that JS spoofing can't.
|
|
663
|
+
const remoteEndpoint = (process.env.BOT_CDP_ENDPOINT ?? "").trim();
|
|
664
|
+
if (remoteEndpoint.length > 0) {
|
|
665
|
+
const launcher = getChromium();
|
|
666
|
+
const browser = await launcher.connectOverCDP(remoteEndpoint);
|
|
667
|
+
this.cdpBrowser = browser;
|
|
668
|
+
this.launchedMode = "remote";
|
|
669
|
+
const ctx = browser.contexts()[0];
|
|
670
|
+
if (ctx === undefined) {
|
|
671
|
+
throw new Error(`remote Chrome (BOT_CDP_ENDPOINT=${remoteEndpoint}) exposed no default browser context`);
|
|
672
|
+
}
|
|
673
|
+
return ctx;
|
|
674
|
+
}
|
|
675
|
+
const endpoint = await withChromeStartupLock(async () => {
|
|
676
|
+
const port = await findFreePort();
|
|
677
|
+
clearStaleSingletonLock(this.profileDir);
|
|
678
|
+
const argv = [
|
|
679
|
+
`--remote-debugging-port=${port}`,
|
|
680
|
+
"--remote-debugging-address=127.0.0.1",
|
|
681
|
+
`--user-data-dir=${this.profileDir}`,
|
|
682
|
+
"--no-first-run",
|
|
683
|
+
"--no-default-browser-check",
|
|
684
|
+
"--password-store=basic",
|
|
685
|
+
"--window-position=0,0",
|
|
686
|
+
`--window-size=${params.window.width},${params.window.height}`,
|
|
687
|
+
"--lang=en-US",
|
|
688
|
+
...params.args,
|
|
689
|
+
...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
|
|
690
|
+
...(params.headless ? ["--headless=new"] : []),
|
|
691
|
+
"about:blank",
|
|
692
|
+
];
|
|
693
|
+
const child = spawn(params.binary, argv, { env: params.env, stdio: ["ignore", "ignore", "pipe"] });
|
|
694
|
+
this.childChrome = child;
|
|
695
|
+
registerSelfManagedChrome(child);
|
|
696
|
+
let chromeStderr = "";
|
|
697
|
+
let chromeExit = "";
|
|
698
|
+
child.stderr?.on("data", (chunk) => {
|
|
699
|
+
chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
|
|
700
|
+
});
|
|
701
|
+
child.on("exit", (code, signal) => {
|
|
702
|
+
chromeExit = ` exit=${code ?? "null"} signal=${signal ?? "none"}`;
|
|
703
|
+
});
|
|
488
704
|
try {
|
|
489
|
-
|
|
705
|
+
return await waitForDevtools(port, 30_000);
|
|
490
706
|
}
|
|
491
|
-
catch {
|
|
492
|
-
|
|
707
|
+
catch (err) {
|
|
708
|
+
const alive = child.pid !== undefined
|
|
709
|
+
? (() => {
|
|
710
|
+
try {
|
|
711
|
+
process.kill(child.pid, 0);
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
})()
|
|
718
|
+
: false;
|
|
719
|
+
try {
|
|
720
|
+
child.kill("SIGKILL");
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
/* already gone */
|
|
724
|
+
}
|
|
725
|
+
reapLeakedProfileHolder(this.profileDir);
|
|
726
|
+
this.childChrome = null;
|
|
727
|
+
const detail = chromeStderr.trim();
|
|
728
|
+
throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
|
|
729
|
+
`${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
493
730
|
}
|
|
494
|
-
|
|
495
|
-
throw err;
|
|
496
|
-
}
|
|
731
|
+
});
|
|
497
732
|
// Use the patchright launcher's connectOverCDP — it's the exact path the
|
|
498
733
|
// falsification experiment validated (its connect avoids Runtime.enable,
|
|
499
734
|
// which a plain attach would emit). The anti-detection that matters here
|
|
@@ -562,6 +797,7 @@ export class BrowserController {
|
|
|
562
797
|
console.error("[universal-bot] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
563
798
|
}
|
|
564
799
|
async start() {
|
|
800
|
+
reapOrphanedVerifyBrowsersOnce();
|
|
565
801
|
const channel = await detectChromiumChannel();
|
|
566
802
|
this.launchedChannel = channel;
|
|
567
803
|
const proxy = await this.resolveProxy();
|
|
@@ -570,12 +806,27 @@ export class BrowserController {
|
|
|
570
806
|
// module's existing logging convention).
|
|
571
807
|
console.error(`[universal-bot] launching browser channel=${channel ?? "bundled-chromium"} ` +
|
|
572
808
|
`proxy=${proxy?.server ?? "direct"}`);
|
|
809
|
+
// Remote-CDP mode (BOT_CDP_ENDPOINT): the browser runs on a REMOTE host
|
|
810
|
+
// (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
|
|
811
|
+
// across Tailscale. The remote machine IS a real device, so we spoof
|
|
812
|
+
// NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
|
|
813
|
+
// real Apple-GPU output would be its own mismatch tell), no local Xvfb, no
|
|
814
|
+
// egress-geo override (the remote host's real timezone + residential IP are
|
|
815
|
+
// authentic). software-WebGL output is exactly what the toughest anti-bot
|
|
816
|
+
// (hCaptcha Enterprise) scores; only real hardware fixes the pixel
|
|
817
|
+
// fingerprint, which is the whole point of this path.
|
|
818
|
+
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
819
|
+
if (remoteMode) {
|
|
820
|
+
console.error(`[universal-bot] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
|
|
821
|
+
`(real-host GPU + egress; local fingerprint spoof + Xvfb DISABLED)`);
|
|
822
|
+
}
|
|
573
823
|
// T3.1: probe where this run's traffic actually exits so the
|
|
574
824
|
// browser's declared timezone matches its egress IP (a US-timezone
|
|
575
825
|
// browser on a foreign proxy IP is itself an anti-bot signal).
|
|
576
826
|
// Done before the real launch: launchPersistentContext bakes the
|
|
577
|
-
// timezone in at creation, with no way to set it afterward.
|
|
578
|
-
|
|
827
|
+
// timezone in at creation, with no way to set it afterward. Skipped in
|
|
828
|
+
// remote mode — the remote host's own clock/IP are the authentic truth.
|
|
829
|
+
const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy);
|
|
579
830
|
if (geo !== null) {
|
|
580
831
|
console.error(`[universal-bot] egress geo: timezone=${geo.timezoneId}` +
|
|
581
832
|
(geo.geolocation !== undefined
|
|
@@ -666,11 +917,22 @@ export class BrowserController {
|
|
|
666
917
|
// have released within the 120s wait — so by here, reaping the LOCAL
|
|
667
918
|
// holder (SIGKILL + clear singletons; no-ops on a remote-host holder)
|
|
668
919
|
// and retrying once is safe and recovers the run instead of failing it.
|
|
669
|
-
|
|
920
|
+
//
|
|
921
|
+
// That assumption is false for verifier/discovery concurrency: a live
|
|
922
|
+
// holder can be another legitimate slot still closing the same robot
|
|
923
|
+
// profile. In concurrent mode, never SIGKILL the holder; surface
|
|
924
|
+
// ProfileBusyError so the orchestrator can retry later without corrupting
|
|
925
|
+
// another run.
|
|
926
|
+
const concurrency = Number.parseInt(process.env.HOUSEKEEPER_CONCURRENCY ?? "1", 10) || 1;
|
|
927
|
+
const allowLiveReap = concurrency <= 1;
|
|
928
|
+
const reaped = allowLiveReap ? reapLeakedProfileHolder(this.profileDir) : false;
|
|
670
929
|
if (reaped) {
|
|
671
930
|
console.error("[universal-bot] reaped a leaked Chrome holding the profile (orphan from an externally-killed run) — retrying");
|
|
672
931
|
free = await waitForProfileFree(this.profileDir, { deadlineMs: 10_000 });
|
|
673
932
|
}
|
|
933
|
+
else if (!allowLiveReap) {
|
|
934
|
+
console.error("[universal-bot] profile still held after wait; not reaping because HOUSEKEEPER_CONCURRENCY>1");
|
|
935
|
+
}
|
|
674
936
|
if (!free) {
|
|
675
937
|
throw new ProfileBusyError("bot Chrome profile is held by another run (a login or signup); retry shortly");
|
|
676
938
|
}
|
|
@@ -777,6 +1039,15 @@ export class BrowserController {
|
|
|
777
1039
|
this.launchedContext = true;
|
|
778
1040
|
// Speed: optionally abort heavy/irrelevant requests before any navigation.
|
|
779
1041
|
await this.installResourceBlocking();
|
|
1042
|
+
// Dev-runtime guard: when the bot is run through `tsx`, esbuild may inject
|
|
1043
|
+
// calls to its `__name(fn, "name")` helper into functions passed to
|
|
1044
|
+
// page.evaluate/addInitScript. Those functions execute in the browser page,
|
|
1045
|
+
// where Node's helper does not exist, causing an immediate
|
|
1046
|
+
// `ReferenceError: __name is not defined` before the real signup even
|
|
1047
|
+
// starts. Define the same no-op helper in every document. Built `dist`
|
|
1048
|
+
// should not emit these calls, but the helper is harmless there too.
|
|
1049
|
+
const evaluateNameShimScript = 'Object.defineProperty(globalThis, "__name", { value: (fn) => fn, configurable: true });';
|
|
1050
|
+
await context.addInitScript({ content: evaluateNameShimScript });
|
|
780
1051
|
// Patch navigator.webdriver — BASELINE ONLY. Measured against the
|
|
781
1052
|
// rebrowser bot-detector, this manual `defineProperty` is
|
|
782
1053
|
// COUNTERPRODUCTIVE under patchright: it re-adds `webdriver` as an own
|
|
@@ -803,103 +1074,168 @@ export class BrowserController {
|
|
|
803
1074
|
// at 1.0. Idempotent via a marker so the per-nav re-apply is cheap, and
|
|
804
1075
|
// getParameter.toString() is masked to the original native source so
|
|
805
1076
|
// the patch itself isn't a tell. Only strings change, not rendering.
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
return "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)";
|
|
823
|
-
}
|
|
824
|
-
return orig.call(this, p);
|
|
825
|
-
};
|
|
826
|
-
Object.defineProperty(proto.getParameter, "toString", {
|
|
827
|
-
value: () => native,
|
|
828
|
-
configurable: true,
|
|
829
|
-
writable: true,
|
|
830
|
-
});
|
|
831
|
-
marked.__tsWebglPatched = true;
|
|
832
|
-
};
|
|
833
|
-
if (typeof WebGLRenderingContext !== "undefined") {
|
|
834
|
-
spoof(WebGLRenderingContext.prototype);
|
|
835
|
-
}
|
|
836
|
-
if (typeof WebGL2RenderingContext !== "undefined") {
|
|
837
|
-
spoof(WebGL2RenderingContext.prototype);
|
|
838
|
-
}
|
|
839
|
-
// Device-tell normalization. The headless harvester box reports 20
|
|
840
|
-
// logical cores (navigator.hardwareConcurrency) — a consumer residential
|
|
841
|
-
// device is 4-16. A 20-core Linux machine behind a "residential" IP is
|
|
842
|
-
// an internal inconsistency Cloudflare Turnstile scores against
|
|
843
|
-
// (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
|
|
844
|
-
// clean-fingerprint click; hwConcurrency=20 + Linux is the standout
|
|
845
|
-
// anomaly). Normalize to a common consumer profile. Same per-nav main-
|
|
846
|
-
// world application as the WebGL spoof — patchright denies init-world
|
|
847
|
-
// reach, and Turnstile reads these after the challenge script loads
|
|
848
|
-
// (seconds in), so the framenavigated re-apply wins the race. Defined on
|
|
849
|
-
// Navigator.prototype (where the native getters live) so there's no own-
|
|
850
|
-
// property tell on the instance.
|
|
851
|
-
const navProto = Navigator.prototype;
|
|
852
|
-
if (navProto.__tsDevicePatched !== true) {
|
|
853
|
-
try {
|
|
854
|
-
Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
|
|
855
|
-
get: () => 8,
|
|
856
|
-
configurable: true,
|
|
857
|
-
});
|
|
858
|
-
Object.defineProperty(Navigator.prototype, "deviceMemory", {
|
|
859
|
-
get: () => 8,
|
|
860
|
-
configurable: true,
|
|
861
|
-
});
|
|
862
|
-
// Screen availHeight tell: a headless Xvfb screen reports
|
|
863
|
-
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
864
|
-
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
865
|
-
// availWidth = width). Reinstate that gap so the screen reads like
|
|
866
|
-
// an ordinary desktop, not a bare framebuffer. Guarded so it only
|
|
867
|
-
// applies when the two are currently equal (i.e. headless).
|
|
868
|
-
try {
|
|
869
|
-
if (screen.availHeight === screen.height) {
|
|
870
|
-
Object.defineProperty(Screen.prototype, "availHeight", {
|
|
871
|
-
get: () => screen.height - 40,
|
|
872
|
-
configurable: true,
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
catch {
|
|
877
|
-
// leave it
|
|
878
|
-
}
|
|
879
|
-
navProto.__tsDevicePatched = true;
|
|
880
|
-
}
|
|
881
|
-
catch {
|
|
882
|
-
// descriptor already locked by something else — leave it.
|
|
883
|
-
}
|
|
884
|
-
}
|
|
1077
|
+
const installWebglSpoofScript = String.raw `(() => {
|
|
1078
|
+
const VENDOR_WEBGL = 0x9245; // UNMASKED_VENDOR_WEBGL
|
|
1079
|
+
const RENDERER_WEBGL = 0x9246; // UNMASKED_RENDERER_WEBGL
|
|
1080
|
+
const spoof = (proto) => {
|
|
1081
|
+
// The marker lives on the prototype so re-application is a no-op; the
|
|
1082
|
+
// cast is the one typed-alternative-exhausted spot (adding an ad-hoc
|
|
1083
|
+
// brand to a DOM prototype).
|
|
1084
|
+
if (proto.__tsWebglPatched === true) return;
|
|
1085
|
+
const orig = proto.getParameter;
|
|
1086
|
+
const native = orig.toString();
|
|
1087
|
+
proto.getParameter = function (p) {
|
|
1088
|
+
if (p === VENDOR_WEBGL) return "Google Inc. (Intel)";
|
|
1089
|
+
if (p === RENDERER_WEBGL) {
|
|
1090
|
+
return "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)";
|
|
1091
|
+
}
|
|
1092
|
+
return orig.call(this, p);
|
|
885
1093
|
};
|
|
886
|
-
|
|
1094
|
+
Object.defineProperty(proto.getParameter, "toString", {
|
|
1095
|
+
value: () => native,
|
|
1096
|
+
configurable: true,
|
|
1097
|
+
writable: true,
|
|
1098
|
+
});
|
|
1099
|
+
proto.__tsWebglPatched = true;
|
|
1100
|
+
};
|
|
1101
|
+
if (typeof WebGLRenderingContext !== "undefined") {
|
|
1102
|
+
spoof(WebGLRenderingContext.prototype);
|
|
1103
|
+
}
|
|
1104
|
+
if (typeof WebGL2RenderingContext !== "undefined") {
|
|
1105
|
+
spoof(WebGL2RenderingContext.prototype);
|
|
1106
|
+
}
|
|
1107
|
+
// Device-tell normalization. The headless harvester box reports 20
|
|
1108
|
+
// logical cores (navigator.hardwareConcurrency) — a consumer residential
|
|
1109
|
+
// device is 4-16. A 20-core Linux machine behind a "residential" IP is
|
|
1110
|
+
// an internal inconsistency Cloudflare Turnstile scores against
|
|
1111
|
+
// (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
|
|
1112
|
+
// clean-fingerprint click; hwConcurrency=20 + Linux is the standout
|
|
1113
|
+
// anomaly). Normalize to a common consumer profile. Same per-nav main-
|
|
1114
|
+
// world application as the WebGL spoof — patchright denies init-world
|
|
1115
|
+
// reach, and Turnstile reads these after the challenge script loads
|
|
1116
|
+
// (seconds in), so the framenavigated re-apply wins the race. Defined on
|
|
1117
|
+
// Navigator.prototype (where the native getters live) so there's no own-
|
|
1118
|
+
// property tell on the instance.
|
|
1119
|
+
const navProto = Navigator.prototype;
|
|
1120
|
+
if (navProto.__tsDevicePatched !== true) {
|
|
1121
|
+
try {
|
|
1122
|
+
Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
|
|
1123
|
+
get: () => 8,
|
|
1124
|
+
configurable: true,
|
|
1125
|
+
});
|
|
1126
|
+
Object.defineProperty(Navigator.prototype, "deviceMemory", {
|
|
1127
|
+
get: () => 8,
|
|
1128
|
+
configurable: true,
|
|
1129
|
+
});
|
|
1130
|
+
// Screen availHeight tell: a headless Xvfb screen reports
|
|
1131
|
+
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
1132
|
+
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
1133
|
+
// availWidth = width). Reinstate that gap so the screen reads like
|
|
1134
|
+
// an ordinary desktop, not a bare framebuffer. Guarded so it only
|
|
1135
|
+
// applies when the two are currently equal (i.e. headless).
|
|
1136
|
+
try {
|
|
1137
|
+
if (screen.availHeight === screen.height) {
|
|
1138
|
+
Object.defineProperty(Screen.prototype, "availHeight", {
|
|
1139
|
+
get: () => screen.height - 40,
|
|
1140
|
+
configurable: true,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
} catch {
|
|
1144
|
+
// leave it
|
|
1145
|
+
}
|
|
1146
|
+
navProto.__tsDevicePatched = true;
|
|
1147
|
+
} catch {
|
|
1148
|
+
// descriptor already locked by something else — leave it.
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
})();`;
|
|
1152
|
+
if (!remoteMode)
|
|
1153
|
+
await context.addInitScript({ content: installWebglSpoofScript });
|
|
887
1154
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
1155
|
+
// addInitScript covers document-start page JS, but Playwright's
|
|
1156
|
+
// page.evaluate utility execution can run in a separate realm. Install the
|
|
1157
|
+
// same no-op helper there with a STRING evaluate (tsx cannot wrap strings
|
|
1158
|
+
// with __name). This prevents dev-runtime source runs from crashing before
|
|
1159
|
+
// replay reaches the service page.
|
|
1160
|
+
await this.page.evaluate(evaluateNameShimScript).catch(() => undefined);
|
|
888
1161
|
// Re-apply on every navigation — the main-world reach patchright's isolated
|
|
889
1162
|
// init world denies us. framenavigated fires at navigation-commit (before
|
|
890
1163
|
// most page JS), so a late WebGL query (reCAPTCHA scores seconds in) sees
|
|
891
1164
|
// the spoofed strings; a document-start fingerprinter could still race it.
|
|
892
1165
|
const reapplyWebglSpoof = () => {
|
|
1166
|
+
if (remoteMode)
|
|
1167
|
+
return; // real-GPU remote host: spoof nothing
|
|
893
1168
|
const pg = this.page;
|
|
894
1169
|
if (pg === null)
|
|
895
1170
|
return;
|
|
896
|
-
void
|
|
897
|
-
|
|
898
|
-
|
|
1171
|
+
void (async () => {
|
|
1172
|
+
await pg.evaluate(evaluateNameShimScript).catch(() => undefined);
|
|
1173
|
+
await pg.evaluate(installWebglSpoofScript).catch(() => {
|
|
1174
|
+
// mid-navigation / closed page — the next navigation re-applies.
|
|
1175
|
+
});
|
|
1176
|
+
})();
|
|
899
1177
|
};
|
|
1178
|
+
// A CROSS-ORIGIN captcha iframe (hCaptcha / Turnstile / reCAPTCHA) is its own
|
|
1179
|
+
// realm: the main-frame page.evaluate above never reaches it, so the captcha's
|
|
1180
|
+
// OWN fingerprint read sees the real software-WebGL renderer (llvmpipe /
|
|
1181
|
+
// SwiftShader) + 20-core / high-memory / no-taskbar Linux profile — a
|
|
1182
|
+
// headless/VM tell. MEASURED 2026-06-23: Stripe's invisible hCaptcha
|
|
1183
|
+
// Enterprise flags the session before any token, identically on a datacenter
|
|
1184
|
+
// AND a residential exit IP (IP falsified) — the discriminator is this
|
|
1185
|
+
// unspoofed in-iframe fingerprint. Patch the iframe's own main world too.
|
|
1186
|
+
// frame.evaluate reaches a cross-origin frame's main world at the driver
|
|
1187
|
+
// level (same path that wins the main-frame race), re-applied at
|
|
1188
|
+
// navigation-commit before the captcha's scoring JS queries WebGL.
|
|
1189
|
+
const CAPTCHA_FRAME_RE = /(hcaptcha\.com|challenges\.cloudflare\.com|google\.com\/recaptcha|recaptcha\.net|arkoselabs\.com|funcaptcha\.com)/i;
|
|
1190
|
+
// String probe (no compiled-fn __name shim needed): the UNMASKED renderer
|
|
1191
|
+
// a captcha would read. Logged only under CAPTCHA_TRACE to prove the fix.
|
|
1192
|
+
const RENDERER_PROBE = String.raw `(() => { try { const c = document.createElement("canvas"); const gl = c.getContext("webgl") || c.getContext("webgl2"); if (!gl) return "no-gl"; const e = gl.getExtension("WEBGL_debug_renderer_info"); return e ? String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL)) : "no-ext"; } catch (err) { return "err:" + (err && err.message); } })()`;
|
|
1193
|
+
const trace = process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1";
|
|
900
1194
|
this.page.on("framenavigated", (frame) => {
|
|
901
|
-
if (
|
|
1195
|
+
if (remoteMode)
|
|
1196
|
+
return; // real-GPU remote host: no in-iframe spoof
|
|
1197
|
+
if (this.page === null)
|
|
1198
|
+
return;
|
|
1199
|
+
if (frame === this.page.mainFrame()) {
|
|
902
1200
|
reapplyWebglSpoof();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (!CAPTCHA_FRAME_RE.test(frame.url()))
|
|
1204
|
+
return;
|
|
1205
|
+
const cfHost = (() => {
|
|
1206
|
+
try {
|
|
1207
|
+
return new URL(frame.url()).host;
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
return "captcha-frame";
|
|
1211
|
+
}
|
|
1212
|
+
})();
|
|
1213
|
+
void (async () => {
|
|
1214
|
+
if (trace) {
|
|
1215
|
+
const before = await frame.evaluate(RENDERER_PROBE).catch(() => "eval-fail");
|
|
1216
|
+
// eslint-disable-next-line no-console
|
|
1217
|
+
console.error(`[captcha-fp] ${cfHost} renderer BEFORE spoof: ${before}`);
|
|
1218
|
+
}
|
|
1219
|
+
// Retry until the spoof STICKS. The first framenavigated commonly
|
|
1220
|
+
// eval-fails (frame mid-commit, or a throwaway about:blank hCaptcha
|
|
1221
|
+
// replaces), and hCaptcha reads the fingerprint during its widget
|
|
1222
|
+
// lifecycle — a single best-effort apply loses the race. Re-apply on a
|
|
1223
|
+
// ~3s budget until the iframe's renderer reads Intel, so the spoof is in
|
|
1224
|
+
// place before the scoring read.
|
|
1225
|
+
let landed = false;
|
|
1226
|
+
for (let i = 0; i < 20 && !landed; i++) {
|
|
1227
|
+
await frame.evaluate(installWebglSpoofScript).catch(() => undefined);
|
|
1228
|
+
const r = await frame.evaluate(RENDERER_PROBE).catch(() => "eval-fail");
|
|
1229
|
+
if (typeof r === "string" && r.includes("Intel"))
|
|
1230
|
+
landed = true;
|
|
1231
|
+
else
|
|
1232
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
1233
|
+
}
|
|
1234
|
+
if (trace) {
|
|
1235
|
+
// eslint-disable-next-line no-console
|
|
1236
|
+
console.error(`[captcha-fp] ${cfHost} renderer AFTER spoof: ${landed ? "Intel (landed)" : "FAILED to land in budget"}`);
|
|
1237
|
+
}
|
|
1238
|
+
})();
|
|
903
1239
|
});
|
|
904
1240
|
this.page.on("load", reapplyWebglSpoof);
|
|
905
1241
|
// rc.33 — captcha tracing. When UNIVERSAL_BOT_CAPTCHA_TRACE=1 is
|
|
@@ -913,13 +1249,14 @@ export class BrowserController {
|
|
|
913
1249
|
if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
|
|
914
1250
|
this.page.on("response", async (resp) => {
|
|
915
1251
|
const url = resp.url();
|
|
916
|
-
if (!/challenges\.cloudflare\.com|google\.com\/recaptcha/.test(url)) {
|
|
1252
|
+
if (!/challenges\.cloudflare\.com|google\.com\/recaptcha|hcaptcha\.com|newassets\.hcaptcha\.com/.test(url)) {
|
|
917
1253
|
return;
|
|
918
1254
|
}
|
|
919
1255
|
const status = resp.status();
|
|
920
1256
|
const ct = resp.headers()["content-type"] ?? "";
|
|
921
1257
|
let bodyPreview = "";
|
|
922
|
-
if (/json|javascript|html|plain/.test(ct)
|
|
1258
|
+
if (/json|javascript|html|plain/.test(ct) ||
|
|
1259
|
+
/api\.hcaptcha\.com\/(?:checksiteconfig|getcaptcha|checkcaptcha)/.test(url)) {
|
|
923
1260
|
try {
|
|
924
1261
|
const body = await resp.text();
|
|
925
1262
|
bodyPreview =
|
|
@@ -945,6 +1282,19 @@ export class BrowserController {
|
|
|
945
1282
|
// same channel + proxy so it reports the real egress. Best-effort —
|
|
946
1283
|
// any failure returns null and start() keeps a default timezone.
|
|
947
1284
|
async probeEgressGeo(channel, proxy) {
|
|
1285
|
+
if (proxy === null) {
|
|
1286
|
+
try {
|
|
1287
|
+
const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
|
|
1288
|
+
if (!resp.ok)
|
|
1289
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
1290
|
+
return parseEgressGeo(await resp.text());
|
|
1291
|
+
}
|
|
1292
|
+
catch (err) {
|
|
1293
|
+
console.error(`[universal-bot] egress geo probe failed — using default ` +
|
|
1294
|
+
`timezone: ${err instanceof Error ? err.message : String(err)}`);
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
948
1298
|
let probe;
|
|
949
1299
|
try {
|
|
950
1300
|
probe = await getChromium().launch({
|
|
@@ -1058,6 +1408,28 @@ export class BrowserController {
|
|
|
1058
1408
|
// settled page. Distinct from ERR_CONNECTION_ABORTED (a dropped socket).
|
|
1059
1409
|
const TRANSIENT_NET = /ERR_SOCKS_CONNECTION_FAILED|ERR_CONNECTION_(?:RESET|CLOSED|FAILED|ABORTED)|ERR_NETWORK_CHANGED|ERR_TIMED_OUT|ERR_NAME_NOT_RESOLVED|net::ERR_EMPTY_RESPONSE|net::ERR_ABORTED/i;
|
|
1060
1410
|
const MAX_GOTO_ATTEMPTS = 3;
|
|
1411
|
+
const sameOriginPathAndSearch = (a, b) => {
|
|
1412
|
+
try {
|
|
1413
|
+
const left = new URL(a);
|
|
1414
|
+
const right = new URL(b);
|
|
1415
|
+
return left.origin === right.origin && left.pathname === right.pathname && left.search === right.search;
|
|
1416
|
+
}
|
|
1417
|
+
catch {
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
const landedAuthGateForTarget = (landedRaw, targetRaw) => {
|
|
1422
|
+
try {
|
|
1423
|
+
const landed = new URL(landedRaw);
|
|
1424
|
+
const target = new URL(targetRaw);
|
|
1425
|
+
if (landed.origin !== target.origin)
|
|
1426
|
+
return false;
|
|
1427
|
+
return /\/(?:sign[_-]?in|login|log[_-]?in|auth)(?:\/|$)/i.test(landed.pathname);
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
return false;
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1061
1433
|
for (let attempt = 1;; attempt++) {
|
|
1062
1434
|
try {
|
|
1063
1435
|
await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
@@ -1081,6 +1453,25 @@ export class BrowserController {
|
|
|
1081
1453
|
}
|
|
1082
1454
|
catch (err) {
|
|
1083
1455
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1456
|
+
// Some client-routed apps commit the address bar to the requested SPA
|
|
1457
|
+
// route but never fire the lifecycle event Playwright is waiting for.
|
|
1458
|
+
// Treat that as a successful navigation: callers immediately inspect
|
|
1459
|
+
// the DOM and have their own element-level waits.
|
|
1460
|
+
if (/Timeout \d+ms exceeded/i.test(msg)) {
|
|
1461
|
+
await this.sleep(500);
|
|
1462
|
+
if (sameOriginPathAndSearch(this.page.url(), url))
|
|
1463
|
+
break;
|
|
1464
|
+
if (landedAuthGateForTarget(this.page.url(), url))
|
|
1465
|
+
break;
|
|
1466
|
+
await this.page
|
|
1467
|
+
.waitForURL((landed) => sameOriginPathAndSearch(landed.toString(), url), { timeout: 5000 })
|
|
1468
|
+
.then(() => undefined)
|
|
1469
|
+
.catch(() => undefined);
|
|
1470
|
+
if (sameOriginPathAndSearch(this.page.url(), url))
|
|
1471
|
+
break;
|
|
1472
|
+
if (landedAuthGateForTarget(this.page.url(), url))
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1084
1475
|
if (attempt >= MAX_GOTO_ATTEMPTS || !TRANSIENT_NET.test(msg))
|
|
1085
1476
|
throw err;
|
|
1086
1477
|
// Linear backoff — give the tunnel a moment to recover a slot.
|
|
@@ -1483,6 +1874,13 @@ export class BrowserController {
|
|
|
1483
1874
|
}
|
|
1484
1875
|
await this.humanClick(selector);
|
|
1485
1876
|
}
|
|
1877
|
+
async clickNth(selector, index) {
|
|
1878
|
+
if (!this.page)
|
|
1879
|
+
throw new Error("Browser not started");
|
|
1880
|
+
const safeIndex = Math.max(0, Math.floor(index));
|
|
1881
|
+
const locator = this.page.locator(selector).nth(safeIndex);
|
|
1882
|
+
await locator.click({ timeout: 8000 });
|
|
1883
|
+
}
|
|
1486
1884
|
// Click a link/button by its visible text. Used for one-off
|
|
1487
1885
|
// dismissibles where the bot knows the literal label text and
|
|
1488
1886
|
// doesn't need full inventory ranking (e.g. GitHub's "skip 2FA
|
|
@@ -1608,38 +2006,52 @@ export class BrowserController {
|
|
|
1608
2006
|
.scrollIntoViewIfNeeded({ timeout: 5000 })
|
|
1609
2007
|
.catch(() => { });
|
|
1610
2008
|
if (!this.humanize) {
|
|
1611
|
-
await this.page.check(selector, { force: true });
|
|
1612
|
-
|
|
2009
|
+
await this.page.check(selector, { force: true }).catch(() => undefined);
|
|
2010
|
+
if (await this.ensureChecked(selector))
|
|
2011
|
+
return;
|
|
2012
|
+
throw new Error(`Unable to check selector "${selector}" after label and DOM fallbacks`);
|
|
1613
2013
|
}
|
|
1614
2014
|
// For visible checkboxes, move the mouse to it first (a real user
|
|
1615
2015
|
// would). For force-checked invisible ones, fall back to the
|
|
1616
2016
|
// Playwright API so we don't try to mouse-click an offscreen element.
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
2017
|
+
await this.humanClick(selector).catch(() => undefined);
|
|
2018
|
+
await this.page.check(selector, { force: true }).catch(() => undefined);
|
|
2019
|
+
if (await this.ensureChecked(selector))
|
|
2020
|
+
return;
|
|
2021
|
+
throw new Error(`Unable to check selector "${selector}" after click, label, and DOM fallbacks`);
|
|
2022
|
+
}
|
|
2023
|
+
async ensureChecked(selector) {
|
|
2024
|
+
if (!this.page)
|
|
2025
|
+
return false;
|
|
2026
|
+
if (await this.page.locator(selector).isChecked().catch(() => false))
|
|
2027
|
+
return true;
|
|
2028
|
+
await this.clickAssociatedLabel(selector).catch(() => false);
|
|
2029
|
+
if (await this.page.locator(selector).isChecked().catch(() => false))
|
|
2030
|
+
return true;
|
|
2031
|
+
const domChecked = await this.page
|
|
2032
|
+
.locator(selector)
|
|
2033
|
+
.first()
|
|
2034
|
+
.evaluate((el) => {
|
|
2035
|
+
if (!(el instanceof HTMLInputElement))
|
|
2036
|
+
return false;
|
|
2037
|
+
if (el.type !== "checkbox" && el.type !== "radio")
|
|
2038
|
+
return false;
|
|
2039
|
+
if (!el.checked) {
|
|
2040
|
+
el.click();
|
|
1626
2041
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
// LABEL fires the real onChange the library listens for. Best-effort.
|
|
1634
|
-
if (!isChecked) {
|
|
1635
|
-
const labelClicked = await this.clickAssociatedLabel(selector);
|
|
1636
|
-
if (!labelClicked)
|
|
1637
|
-
await this.page.check(selector, { force: true });
|
|
2042
|
+
if (!el.checked) {
|
|
2043
|
+
el.checked = true;
|
|
2044
|
+
el.setAttribute("checked", "");
|
|
2045
|
+
el.setAttribute("aria-checked", "true");
|
|
2046
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2047
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1638
2048
|
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2049
|
+
return el.checked;
|
|
2050
|
+
})
|
|
2051
|
+
.catch(() => false);
|
|
2052
|
+
if (!domChecked)
|
|
2053
|
+
return false;
|
|
2054
|
+
return await this.page.locator(selector).isChecked().catch(() => false);
|
|
1643
2055
|
}
|
|
1644
2056
|
// Click the <label> associated with a checkbox/radio input — either a
|
|
1645
2057
|
// `<label for="<id>">` or the wrapping `<label>` ancestor. Mantine/Radix
|
|
@@ -1669,6 +2081,31 @@ export class BrowserController {
|
|
|
1669
2081
|
await wrapping.click({ timeout: 4000 });
|
|
1670
2082
|
return true;
|
|
1671
2083
|
}
|
|
2084
|
+
// Some Radix/shadcn-style controls render the hidden input as a sibling
|
|
2085
|
+
// of the visible agreement label, with no `for=` and no wrapping label
|
|
2086
|
+
// (Mistral's terms checkbox). At this point direct check has already
|
|
2087
|
+
// failed/not toggled, so clicking the nearest agreement-shaped label in
|
|
2088
|
+
// the same form is the safest remaining human-equivalent action.
|
|
2089
|
+
const clickedAgreement = await this.page
|
|
2090
|
+
.locator(selector)
|
|
2091
|
+
.first()
|
|
2092
|
+
.evaluate((el) => {
|
|
2093
|
+
const agreementRe = /terms|tos\b|privacy|policy|i accept|i agree|agree to/i;
|
|
2094
|
+
const form = el.closest("form");
|
|
2095
|
+
const labels = [
|
|
2096
|
+
...(form ? Array.from(form.querySelectorAll("label")) : []),
|
|
2097
|
+
...Array.from(document.querySelectorAll("label")),
|
|
2098
|
+
];
|
|
2099
|
+
const label = labels.find((candidate) => agreementRe.test(candidate.textContent ?? ""));
|
|
2100
|
+
if (!(label instanceof HTMLElement))
|
|
2101
|
+
return false;
|
|
2102
|
+
label.click();
|
|
2103
|
+
return true;
|
|
2104
|
+
})
|
|
2105
|
+
.catch(() => false);
|
|
2106
|
+
if (clickedAgreement) {
|
|
2107
|
+
return true;
|
|
2108
|
+
}
|
|
1672
2109
|
}
|
|
1673
2110
|
catch {
|
|
1674
2111
|
// best-effort
|
|
@@ -1699,7 +2136,7 @@ export class BrowserController {
|
|
|
1699
2136
|
// These two regexes MUST stay byte-identical with
|
|
1700
2137
|
// AGREEMENT_TEXT_RE / MARKETING_TEXT_RE in this module — the
|
|
1701
2138
|
// page realm can't import, so they're inlined here.
|
|
1702
|
-
const agreementRe = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr/i;
|
|
2139
|
+
const agreementRe = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr|age|18\+|18 years|certif/i;
|
|
1703
2140
|
const marketingRe = /newsletter|updates|offers|product tips|marketing|promotional|receive emails|opt[- ]?in to|subscribe/i;
|
|
1704
2141
|
const checked = [];
|
|
1705
2142
|
const boxes = Array.from(document.querySelectorAll('input[type="checkbox"]'));
|
|
@@ -1707,7 +2144,11 @@ export class BrowserController {
|
|
|
1707
2144
|
if (box.checked || box.disabled)
|
|
1708
2145
|
continue;
|
|
1709
2146
|
const rect = box.getBoundingClientRect();
|
|
1710
|
-
|
|
2147
|
+
const ancestorLabel = box.closest("label");
|
|
2148
|
+
const labelRect = ancestorLabel?.getBoundingClientRect();
|
|
2149
|
+
const visible = (rect.width > 0 && rect.height > 0) ||
|
|
2150
|
+
(labelRect !== undefined && labelRect.width > 0 && labelRect.height > 0);
|
|
2151
|
+
if (!visible)
|
|
1711
2152
|
continue;
|
|
1712
2153
|
// Associated text = attributes + a label[for=id] + nearest
|
|
1713
2154
|
// ancestor <label> + the immediately following sibling text.
|
|
@@ -1722,7 +2163,6 @@ export class BrowserController {
|
|
|
1722
2163
|
if (forLabel)
|
|
1723
2164
|
parts.push(forLabel.textContent ?? "");
|
|
1724
2165
|
}
|
|
1725
|
-
const ancestorLabel = box.closest("label");
|
|
1726
2166
|
if (ancestorLabel)
|
|
1727
2167
|
parts.push(ancestorLabel.textContent ?? "");
|
|
1728
2168
|
const sibling = box.nextSibling;
|
|
@@ -1736,12 +2176,15 @@ export class BrowserController {
|
|
|
1736
2176
|
continue;
|
|
1737
2177
|
// React/Vue controlled inputs ignore a bare `.checked = true`:
|
|
1738
2178
|
// their state lives in the framework, updated only by the real
|
|
1739
|
-
// event flow.
|
|
1740
|
-
//
|
|
1741
|
-
|
|
2179
|
+
// event flow. Click first (while unchecked) so the framework sees the
|
|
2180
|
+
// same transition a user would make, then force-ensure checked and
|
|
2181
|
+
// dispatch input/change for styled/hidden inputs whose click target
|
|
2182
|
+
// does not toggle the underlying control.
|
|
2183
|
+
box.click();
|
|
2184
|
+
if (!box.checked)
|
|
2185
|
+
box.checked = true;
|
|
1742
2186
|
box.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1743
2187
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1744
|
-
box.click();
|
|
1745
2188
|
const label = box.getAttribute("data-testid") ||
|
|
1746
2189
|
box.getAttribute("name") ||
|
|
1747
2190
|
box.id ||
|
|
@@ -1756,6 +2199,111 @@ export class BrowserController {
|
|
|
1756
2199
|
return [];
|
|
1757
2200
|
}
|
|
1758
2201
|
}
|
|
2202
|
+
// Deterministic pre-submit guard for required signup category choices.
|
|
2203
|
+
//
|
|
2204
|
+
// Paddle-class forms ask a required "What do you sell?" question where one
|
|
2205
|
+
// product category must be selected before account creation, but the submit
|
|
2206
|
+
// button remains enabled. The planner can satisfy the agreement checkbox and
|
|
2207
|
+
// still skip the category, producing a rejected submit + no verification mail.
|
|
2208
|
+
//
|
|
2209
|
+
// Keep this conservative: only fire when the page text explicitly says a
|
|
2210
|
+
// product/category choice is required, never touch agreement/marketing boxes,
|
|
2211
|
+
// and prefer low-risk SaaS/software labels over restricted categories.
|
|
2212
|
+
async checkRequiredSignupChoiceBoxes() {
|
|
2213
|
+
if (!this.page)
|
|
2214
|
+
throw new Error("Browser not started");
|
|
2215
|
+
try {
|
|
2216
|
+
return await this.page.evaluate(() => {
|
|
2217
|
+
const choiceGateRe = /what do you sell|categories we support|select which types? of products|choose (?:a|your) (?:category|product|business type)|product category|business category/i;
|
|
2218
|
+
const safeChoiceRe = /digital products?|saas|software|developer tools?|apis?|mobile apps?|data|analytics/i;
|
|
2219
|
+
const riskyChoiceRe = /gambling|financial services?|physical products?|marketplace|human services?|adult|weapons?|medical|restricted|crypto|payments?|banking/i;
|
|
2220
|
+
const agreementRe = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr/i;
|
|
2221
|
+
const marketingRe = /newsletter|updates|offers|product tips|marketing|promotional|receive emails|opt[- ]?in to|subscribe/i;
|
|
2222
|
+
const bodyText = document.body?.innerText ?? "";
|
|
2223
|
+
if (!choiceGateRe.test(bodyText))
|
|
2224
|
+
return [];
|
|
2225
|
+
const associatedText = (box) => {
|
|
2226
|
+
const parts = [
|
|
2227
|
+
box.getAttribute("data-testid") ?? "",
|
|
2228
|
+
box.getAttribute("name") ?? "",
|
|
2229
|
+
box.id,
|
|
2230
|
+
box.getAttribute("aria-label") ?? "",
|
|
2231
|
+
];
|
|
2232
|
+
if (box.id) {
|
|
2233
|
+
const forLabel = document.querySelector(`label[for="${CSS.escape(box.id)}"]`);
|
|
2234
|
+
if (forLabel)
|
|
2235
|
+
parts.push(forLabel.textContent ?? "");
|
|
2236
|
+
}
|
|
2237
|
+
const ancestorLabel = box.closest("label");
|
|
2238
|
+
if (ancestorLabel)
|
|
2239
|
+
parts.push(ancestorLabel.textContent ?? "");
|
|
2240
|
+
if (box.nextElementSibling) {
|
|
2241
|
+
parts.push(box.nextElementSibling.textContent ?? "");
|
|
2242
|
+
}
|
|
2243
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
2244
|
+
};
|
|
2245
|
+
const boxes = Array.from(document.querySelectorAll('input[type="checkbox"], input[type="radio"]'));
|
|
2246
|
+
const visibleBoxes = boxes.filter((box) => {
|
|
2247
|
+
if (box.disabled)
|
|
2248
|
+
return false;
|
|
2249
|
+
const rect = box.getBoundingClientRect();
|
|
2250
|
+
return rect.width > 0 && rect.height > 0;
|
|
2251
|
+
});
|
|
2252
|
+
const alreadyChoseCategory = visibleBoxes.some((box) => {
|
|
2253
|
+
if (!box.checked)
|
|
2254
|
+
return false;
|
|
2255
|
+
const text = associatedText(box);
|
|
2256
|
+
return (!agreementRe.test(text) &&
|
|
2257
|
+
!marketingRe.test(text) &&
|
|
2258
|
+
!riskyChoiceRe.test(text));
|
|
2259
|
+
});
|
|
2260
|
+
if (alreadyChoseCategory)
|
|
2261
|
+
return [];
|
|
2262
|
+
const candidates = visibleBoxes
|
|
2263
|
+
.filter((box) => !box.checked)
|
|
2264
|
+
.map((box) => ({ box, text: associatedText(box) }))
|
|
2265
|
+
.filter(({ text }) => {
|
|
2266
|
+
if (!text)
|
|
2267
|
+
return false;
|
|
2268
|
+
if (agreementRe.test(text) || marketingRe.test(text))
|
|
2269
|
+
return false;
|
|
2270
|
+
if (riskyChoiceRe.test(text))
|
|
2271
|
+
return false;
|
|
2272
|
+
return safeChoiceRe.test(text);
|
|
2273
|
+
})
|
|
2274
|
+
.sort((a, b) => {
|
|
2275
|
+
const score = (text) => {
|
|
2276
|
+
if (/digital products?|saas|software/i.test(text))
|
|
2277
|
+
return 3;
|
|
2278
|
+
if (/developer tools?|apis?|data|analytics/i.test(text))
|
|
2279
|
+
return 2;
|
|
2280
|
+
if (/mobile apps?/i.test(text))
|
|
2281
|
+
return 1;
|
|
2282
|
+
return 0;
|
|
2283
|
+
};
|
|
2284
|
+
return score(b.text) - score(a.text);
|
|
2285
|
+
});
|
|
2286
|
+
const choice = candidates[0];
|
|
2287
|
+
if (!choice)
|
|
2288
|
+
return [];
|
|
2289
|
+
choice.box.checked = true;
|
|
2290
|
+
choice.box.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2291
|
+
choice.box.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2292
|
+
choice.box.click();
|
|
2293
|
+
return [
|
|
2294
|
+
choice.box.getAttribute("data-testid") ||
|
|
2295
|
+
choice.box.getAttribute("name") ||
|
|
2296
|
+
choice.box.id ||
|
|
2297
|
+
choice.box.getAttribute("aria-label") ||
|
|
2298
|
+
choice.text ||
|
|
2299
|
+
"signup-choice",
|
|
2300
|
+
];
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
catch {
|
|
2304
|
+
return [];
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
1759
2307
|
// Scroll a Terms-of-Service style modal to the bottom so the gated
|
|
1760
2308
|
// "Accept" button enables. Railway's signup is the canonical case:
|
|
1761
2309
|
// a modal with a virtualized ToS list watches real `scroll` /
|
|
@@ -1979,6 +2527,39 @@ export class BrowserController {
|
|
|
1979
2527
|
tagName = "select";
|
|
1980
2528
|
}
|
|
1981
2529
|
}
|
|
2530
|
+
else {
|
|
2531
|
+
const rowControl = await this.page
|
|
2532
|
+
.locator(activeSelector)
|
|
2533
|
+
.first()
|
|
2534
|
+
.evaluate((label) => {
|
|
2535
|
+
const root = label.closest(".n-form-group__row") ??
|
|
2536
|
+
label.closest("label")?.parentElement ??
|
|
2537
|
+
label.parentElement;
|
|
2538
|
+
const control = root?.querySelector('select,button[role="combobox"],input[role="combobox"],[role="combobox"]');
|
|
2539
|
+
if (control === null || control === undefined)
|
|
2540
|
+
return null;
|
|
2541
|
+
const id = control.getAttribute("id");
|
|
2542
|
+
if (id !== null && id.length > 0)
|
|
2543
|
+
return `#${CSS.escape(id)}`;
|
|
2544
|
+
const testId = control.getAttribute("data-qa") ??
|
|
2545
|
+
control.getAttribute("data-testid") ??
|
|
2546
|
+
control.getAttribute("data-test") ??
|
|
2547
|
+
control.getAttribute("data-cy");
|
|
2548
|
+
if (testId !== null && testId.length > 0) {
|
|
2549
|
+
return `[data-qa="${CSS.escape(testId)}"],[data-testid="${CSS.escape(testId)}"],[data-test="${CSS.escape(testId)}"],[data-cy="${CSS.escape(testId)}"]`;
|
|
2550
|
+
}
|
|
2551
|
+
return null;
|
|
2552
|
+
})
|
|
2553
|
+
.catch(() => null);
|
|
2554
|
+
if (rowControl !== null) {
|
|
2555
|
+
activeSelector = rowControl;
|
|
2556
|
+
tagName = await this.page
|
|
2557
|
+
.locator(activeSelector)
|
|
2558
|
+
.first()
|
|
2559
|
+
.evaluate((node) => node.tagName.toLowerCase())
|
|
2560
|
+
.catch(() => tagName);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
1982
2563
|
}
|
|
1983
2564
|
if (tagName === "select") {
|
|
1984
2565
|
// Native path. rc.15 — keep value="" options selectable. The
|
|
@@ -2094,6 +2675,8 @@ export class BrowserController {
|
|
|
2094
2675
|
'[role="option"]:visible',
|
|
2095
2676
|
'[role="menuitem"]:visible',
|
|
2096
2677
|
'[role="menuitemradio"]:visible',
|
|
2678
|
+
'mat-option:visible',
|
|
2679
|
+
'.mat-mdc-option:visible',
|
|
2097
2680
|
'[id^="react-select-"][role*="menu"]:visible',
|
|
2098
2681
|
'[role="listbox"]:visible li:visible',
|
|
2099
2682
|
];
|
|
@@ -2392,7 +2975,7 @@ export class BrowserController {
|
|
|
2392
2975
|
// misreads "URL unchanged, not on provider" as "OAuth completed"
|
|
2393
2976
|
// and the run falls apart.
|
|
2394
2977
|
//
|
|
2395
|
-
// Poll for up to
|
|
2978
|
+
// Poll for up to 15s for the disabled state to clear. Both the
|
|
2396
2979
|
// HTML `disabled` attribute AND `aria-disabled="true"` are
|
|
2397
2980
|
// honored — the latter covers ARIA-styled buttons (Radix, Headless
|
|
2398
2981
|
// UI) that visually appear interactive but reject input.
|
|
@@ -2411,7 +2994,7 @@ export class BrowserController {
|
|
|
2411
2994
|
// next round's reason includes "click failed: target is
|
|
2412
2995
|
// aria-disabled" and the planner pivots to checking other fields.
|
|
2413
2996
|
{
|
|
2414
|
-
const deadline = Date.now() +
|
|
2997
|
+
const deadline = Date.now() + 15_000;
|
|
2415
2998
|
let isDisabled = false;
|
|
2416
2999
|
while (Date.now() < deadline) {
|
|
2417
3000
|
isDisabled = await locator
|
|
@@ -2435,7 +3018,7 @@ export class BrowserController {
|
|
|
2435
3018
|
// (meilisearch/zilliz: planner clicked a disabled Next 4+ times because
|
|
2436
3019
|
// the generic hint didn't say WHICH field blocked it). Feedback only.
|
|
2437
3020
|
const hint = await this.unfilledRequiredHint();
|
|
2438
|
-
throw new Error("target is disabled (HTML disabled or aria-disabled=true) after
|
|
3021
|
+
throw new Error("target is disabled (HTML disabled or aria-disabled=true) after 15s — " +
|
|
2439
3022
|
"the click would no-op. A required precondition is unmet: an empty " +
|
|
2440
3023
|
"input, an unselected dropdown, an unchecked agreement checkbox, or " +
|
|
2441
3024
|
"a missing preset/permission choice. Do NOT retry this click — pick a " +
|
|
@@ -2677,6 +3260,11 @@ export class BrowserController {
|
|
|
2677
3260
|
return false;
|
|
2678
3261
|
});
|
|
2679
3262
|
if (solved) {
|
|
3263
|
+
if (widget.kind === "hcaptcha") {
|
|
3264
|
+
const settled = await this.waitForCaptchaChallengeToSettle(15_000, 10_000);
|
|
3265
|
+
if (!settled)
|
|
3266
|
+
return { found: true, solved: false, kind: widget.kind };
|
|
3267
|
+
}
|
|
2680
3268
|
return { found: true, solved: true, kind: widget.kind };
|
|
2681
3269
|
}
|
|
2682
3270
|
}
|
|
@@ -2968,6 +3556,14 @@ export class BrowserController {
|
|
|
2968
3556
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2969
3557
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2970
3558
|
}
|
|
3559
|
+
for (const el of Array.from(document.querySelectorAll("[data-hcaptcha-widget-id], .h-captcha"))) {
|
|
3560
|
+
el.setAttribute("data-hcaptcha-response", tok);
|
|
3561
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3562
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
3563
|
+
}
|
|
3564
|
+
const form = inputs[0]?.closest("form");
|
|
3565
|
+
form?.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3566
|
+
form?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2971
3567
|
// 2. Fire the widget's onSuccess callback if registered. The
|
|
2972
3568
|
// callbacks are stored on `___grecaptcha_cfg.clients`; the
|
|
2973
3569
|
// exact tree is undocumented and shifts across versions
|
|
@@ -3180,7 +3776,7 @@ export class BrowserController {
|
|
|
3180
3776
|
if (!this.page)
|
|
3181
3777
|
throw new Error("Browser not started");
|
|
3182
3778
|
try {
|
|
3183
|
-
|
|
3779
|
+
const fromDom = await this.page.evaluate(() => {
|
|
3184
3780
|
const div = document.querySelector(".h-captcha[data-sitekey], [data-hcaptcha-sitekey]");
|
|
3185
3781
|
if (div !== null) {
|
|
3186
3782
|
const k = div.getAttribute("data-sitekey") ??
|
|
@@ -3190,69 +3786,361 @@ export class BrowserController {
|
|
|
3190
3786
|
}
|
|
3191
3787
|
const iframe = document.querySelector('iframe[src*="hcaptcha.com"]');
|
|
3192
3788
|
if (iframe !== null) {
|
|
3193
|
-
const
|
|
3789
|
+
const url = new URL(iframe.src);
|
|
3790
|
+
const k = url.searchParams.get("sitekey") ??
|
|
3791
|
+
new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash).get("sitekey");
|
|
3194
3792
|
if (k !== null && k.length > 10)
|
|
3195
3793
|
return k;
|
|
3196
3794
|
}
|
|
3197
|
-
// INVISIBLE hCaptcha (huggingface, 2026-06-17): no .h-captcha div, no
|
|
3198
|
-
// rendered iframe — the sitekey lives in the page's JS config
|
|
3199
|
-
// (`captchaApiKey:"<uuid>"`, `sitekey:"<uuid>"`). Scan the HTML for a
|
|
3200
|
-
// UUID-shaped key next to a sitekey/captcha hint, but ONLY when an
|
|
3201
|
-
// hCaptcha script is present (so an unrelated config UUID can't match).
|
|
3202
|
-
// UUID shape excludes reCAPTCHA `6L…` and Turnstile `0x…` keys.
|
|
3203
|
-
const html = document.documentElement.outerHTML;
|
|
3204
|
-
if (/hcaptcha\.com|h-captcha|hcaptcha/i.test(html)) {
|
|
3205
|
-
const m = html.match(/(?:sitekey|captchaApiKey|data-(?:hcaptcha-)?sitekey)["'\s]*[:=]\s*["']([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})["']/i);
|
|
3206
|
-
if (m !== null && m[1] !== undefined)
|
|
3207
|
-
return m[1];
|
|
3208
|
-
}
|
|
3209
3795
|
return null;
|
|
3210
3796
|
});
|
|
3797
|
+
if (fromDom !== null)
|
|
3798
|
+
return fromDom;
|
|
3799
|
+
// INVISIBLE hCaptcha (Hugging Face, Stripe): no .h-captcha div, no
|
|
3800
|
+
// iframe `?sitekey=` param — the sitekey lives in the page's JS/JSON
|
|
3801
|
+
// config (`captchaApiKey`, `express_hcaptcha_site_key`,
|
|
3802
|
+
// `hcaptcha_login_main_site_key`, etc.). Scan the HTML for a UUID-shaped
|
|
3803
|
+
// key next to a sitekey/captcha hint, but only when an hCaptcha marker is
|
|
3804
|
+
// present so an unrelated config UUID cannot match.
|
|
3805
|
+
const html = await this.page.evaluate(() => document.documentElement.outerHTML);
|
|
3806
|
+
return extractHcaptchaSitekeyFromHtml(html);
|
|
3807
|
+
}
|
|
3808
|
+
catch {
|
|
3809
|
+
return null;
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
async getBrowserUserAgent() {
|
|
3813
|
+
if (!this.page)
|
|
3814
|
+
throw new Error("Browser not started");
|
|
3815
|
+
try {
|
|
3816
|
+
return await this.page.evaluate(() => navigator.userAgent);
|
|
3211
3817
|
}
|
|
3212
3818
|
catch {
|
|
3213
3819
|
return null;
|
|
3214
3820
|
}
|
|
3215
3821
|
}
|
|
3822
|
+
async getHcaptchaSolveContext() {
|
|
3823
|
+
if (!this.page)
|
|
3824
|
+
throw new Error("Browser not started");
|
|
3825
|
+
try {
|
|
3826
|
+
return await this.page.evaluate(() => {
|
|
3827
|
+
let invisible = false;
|
|
3828
|
+
let rqdata = null;
|
|
3829
|
+
const useRqdata = (value) => {
|
|
3830
|
+
if (rqdata === null && value !== null && value.trim().length > 0)
|
|
3831
|
+
rqdata = value;
|
|
3832
|
+
};
|
|
3833
|
+
for (const el of Array.from(document.querySelectorAll(".h-captcha, [data-hcaptcha-widget-id]"))) {
|
|
3834
|
+
const size = el.getAttribute("data-size") ?? el.getAttribute("size");
|
|
3835
|
+
if (size?.toLowerCase() === "invisible")
|
|
3836
|
+
invisible = true;
|
|
3837
|
+
useRqdata(el.getAttribute("data-rqdata"));
|
|
3838
|
+
}
|
|
3839
|
+
for (const iframe of Array.from(document.querySelectorAll('iframe[src*="hcaptcha.com"]'))) {
|
|
3840
|
+
try {
|
|
3841
|
+
const url = new URL(iframe.src);
|
|
3842
|
+
const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
|
3843
|
+
const size = url.searchParams.get("size") ?? hashParams.get("size");
|
|
3844
|
+
const frame = url.searchParams.get("frame") ?? hashParams.get("frame");
|
|
3845
|
+
useRqdata(url.searchParams.get("rqdata") ?? hashParams.get("rqdata"));
|
|
3846
|
+
const clientOptions = url.searchParams.get("clientOptions") ?? hashParams.get("clientOptions");
|
|
3847
|
+
if (clientOptions !== null) {
|
|
3848
|
+
try {
|
|
3849
|
+
const parsed = JSON.parse(clientOptions);
|
|
3850
|
+
if (typeof parsed.rqdata === "string")
|
|
3851
|
+
useRqdata(parsed.rqdata);
|
|
3852
|
+
}
|
|
3853
|
+
catch {
|
|
3854
|
+
// ignore non-JSON client options
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
if (size?.toLowerCase() === "invisible" ||
|
|
3858
|
+
frame?.toLowerCase() === "checkbox-invisible") {
|
|
3859
|
+
invisible = true;
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
catch {
|
|
3863
|
+
// ignore malformed extension/proxy iframe URLs
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
return { invisible, userAgent: navigator.userAgent, rqdata };
|
|
3867
|
+
});
|
|
3868
|
+
}
|
|
3869
|
+
catch {
|
|
3870
|
+
return {
|
|
3871
|
+
invisible: false,
|
|
3872
|
+
userAgent: await this.getBrowserUserAgent().catch(() => null),
|
|
3873
|
+
rqdata: null,
|
|
3874
|
+
};
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3216
3877
|
// Inject a 2Captcha-resolved hCaptcha token into the page's
|
|
3217
|
-
// h-captcha-response textarea(s)
|
|
3218
|
-
//
|
|
3219
|
-
// also mirrors the response token into a g-recaptcha-response
|
|
3220
|
-
// on some compat installs, so populate both names if present.
|
|
3878
|
+
// h-captcha-response textarea(s), update hCaptcha runtime response
|
|
3879
|
+
// accessors, and fire registered callbacks. Mirrors injectRecaptchaToken;
|
|
3880
|
+
// hCaptcha also mirrors the response token into a g-recaptcha-response
|
|
3881
|
+
// textarea on some compat installs, so populate both names if present.
|
|
3221
3882
|
async injectHcaptchaToken(token) {
|
|
3222
3883
|
if (!this.page)
|
|
3223
3884
|
throw new Error("Browser not started");
|
|
3224
3885
|
try {
|
|
3225
|
-
|
|
3886
|
+
const responseKey = extractHcaptchaResponseKeyFromToken(token);
|
|
3887
|
+
return await this.page.evaluate(({ tok, key }) => {
|
|
3888
|
+
const widgetIds = new Set();
|
|
3226
3889
|
const inputs = Array.from(document.querySelectorAll('textarea[name="h-captcha-response"], textarea[id^="h-captcha-response"], textarea[name="g-recaptcha-response"]'));
|
|
3227
|
-
if (inputs.length === 0)
|
|
3228
|
-
return false;
|
|
3229
3890
|
for (const input of inputs) {
|
|
3230
3891
|
input.value = tok;
|
|
3231
3892
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3232
3893
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
3233
3894
|
}
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3895
|
+
for (const host of Array.from(document.querySelectorAll(".h-captcha, [data-hcaptcha-widget-id], [data-hcaptcha-response]"))) {
|
|
3896
|
+
host.setAttribute("data-hcaptcha-response", tok);
|
|
3897
|
+
const id = host.getAttribute("data-hcaptcha-widget-id") ??
|
|
3898
|
+
host.getAttribute("data-hcaptcha-widget-id".toLowerCase());
|
|
3899
|
+
if (id !== null && id.length > 0)
|
|
3900
|
+
widgetIds.add(id);
|
|
3901
|
+
host.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3902
|
+
host.dispatchEvent(new Event("change", { bubbles: true }));
|
|
3903
|
+
}
|
|
3904
|
+
for (const iframe of Array.from(document.querySelectorAll('iframe[src*="hcaptcha.com"]'))) {
|
|
3905
|
+
try {
|
|
3906
|
+
const url = new URL(iframe.src);
|
|
3907
|
+
const params = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
|
3908
|
+
const id = params.get("id");
|
|
3909
|
+
if (id !== null && id.length > 0)
|
|
3910
|
+
widgetIds.add(id);
|
|
3911
|
+
}
|
|
3912
|
+
catch {
|
|
3913
|
+
// ignore malformed extension/proxy iframe URLs
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
const win = window;
|
|
3917
|
+
const hcaptcha = win.hcaptcha;
|
|
3918
|
+
if (hcaptcha !== undefined) {
|
|
3919
|
+
const originalGetResponse = hcaptcha.getResponse?.bind(hcaptcha);
|
|
3920
|
+
const originalGetRespKey = hcaptcha.getRespKey?.bind(hcaptcha);
|
|
3921
|
+
hcaptcha.getResponse = (id) => {
|
|
3922
|
+
if (id === undefined || widgetIds.size === 0 || widgetIds.has(String(id)))
|
|
3923
|
+
return tok;
|
|
3924
|
+
return originalGetResponse?.(id) ?? tok;
|
|
3925
|
+
};
|
|
3926
|
+
hcaptcha.getRespKey = (id) => {
|
|
3927
|
+
if (id === undefined || widgetIds.size === 0 || widgetIds.has(String(id)))
|
|
3928
|
+
return key ?? "";
|
|
3929
|
+
return originalGetRespKey?.(id) ?? key ?? "";
|
|
3930
|
+
};
|
|
3931
|
+
}
|
|
3932
|
+
let callbackFired = false;
|
|
3933
|
+
const fire = (fn) => {
|
|
3934
|
+
if (typeof fn !== "function")
|
|
3935
|
+
return;
|
|
3936
|
+
callbackFired = true;
|
|
3937
|
+
try {
|
|
3938
|
+
fn(tok, key ?? undefined);
|
|
3939
|
+
}
|
|
3940
|
+
catch {
|
|
3941
|
+
// A page callback can be stale after React remounts a widget.
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3944
|
+
// Fire callbacks registered by markup, e.g. data-callback="onSubmit".
|
|
3237
3945
|
try {
|
|
3238
|
-
const host
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
if (typeof fn === "function")
|
|
3243
|
-
fn(tok);
|
|
3946
|
+
for (const host of Array.from(document.querySelectorAll(".h-captcha[data-callback]"))) {
|
|
3947
|
+
const name = host.getAttribute("data-callback");
|
|
3948
|
+
if (name !== null && name !== undefined)
|
|
3949
|
+
fire(win[name]);
|
|
3244
3950
|
}
|
|
3245
3951
|
}
|
|
3246
3952
|
catch {
|
|
3247
|
-
// no named callback
|
|
3953
|
+
// no named callback, continue to runtime config scan.
|
|
3248
3954
|
}
|
|
3249
|
-
|
|
3250
|
-
|
|
3955
|
+
// Programmatic hCaptcha integrations pass function callbacks to
|
|
3956
|
+
// hcaptcha.render(). The SDK keeps them in ___hcaptcha_cfg; crawl it
|
|
3957
|
+
// generically so React/Vue wrappers are handled like plain forms.
|
|
3958
|
+
const seen = new Set();
|
|
3959
|
+
const scan = (value, depth) => {
|
|
3960
|
+
if (value === null || value === undefined || depth > 7 || seen.has(value))
|
|
3961
|
+
return;
|
|
3962
|
+
seen.add(value);
|
|
3963
|
+
if (typeof value === "function")
|
|
3964
|
+
return;
|
|
3965
|
+
if (typeof value !== "object")
|
|
3966
|
+
return;
|
|
3967
|
+
for (const [key, child] of Object.entries(value)) {
|
|
3968
|
+
const normalized = key.toLowerCase();
|
|
3969
|
+
if (typeof child === "function" &&
|
|
3970
|
+
(normalized === "callback" ||
|
|
3971
|
+
normalized === "success-callback" ||
|
|
3972
|
+
normalized === "verify-callback" ||
|
|
3973
|
+
normalized === "onverify" ||
|
|
3974
|
+
normalized === "onsuccess")) {
|
|
3975
|
+
fire(child);
|
|
3976
|
+
continue;
|
|
3977
|
+
}
|
|
3978
|
+
if (typeof child === "object" && child !== null)
|
|
3979
|
+
scan(child, depth + 1);
|
|
3980
|
+
}
|
|
3981
|
+
};
|
|
3982
|
+
scan(win.___hcaptcha_cfg, 0);
|
|
3983
|
+
scan(win.hcaptcha, 0);
|
|
3984
|
+
return inputs.length > 0 || widgetIds.size > 0 || callbackFired;
|
|
3985
|
+
}, { tok: token, key: responseKey });
|
|
3251
3986
|
}
|
|
3252
3987
|
catch {
|
|
3253
3988
|
return false;
|
|
3254
3989
|
}
|
|
3255
3990
|
}
|
|
3991
|
+
async solveVisibleHcaptchaChallengeWithCoordinates(solve) {
|
|
3992
|
+
if (!this.page)
|
|
3993
|
+
throw new Error("Browser not started");
|
|
3994
|
+
const challenge = await this.findVisibleHcaptchaChallengeFrame();
|
|
3995
|
+
if (challenge === null) {
|
|
3996
|
+
return { found: false, solved: false, reason: "no_visible_challenge" };
|
|
3997
|
+
}
|
|
3998
|
+
let shot;
|
|
3999
|
+
try {
|
|
4000
|
+
shot = await challenge.locator.screenshot({ type: "png", timeout: 8_000 });
|
|
4001
|
+
}
|
|
4002
|
+
catch (err) {
|
|
4003
|
+
return {
|
|
4004
|
+
found: true,
|
|
4005
|
+
solved: false,
|
|
4006
|
+
reason: `screenshot_failed:${err instanceof Error ? err.message : String(err)}`,
|
|
4007
|
+
clicks: 0,
|
|
4008
|
+
};
|
|
4009
|
+
}
|
|
4010
|
+
const dims = pngDimensions(shot);
|
|
4011
|
+
if (dims === null || dims.width <= 0 || dims.height <= 0) {
|
|
4012
|
+
return {
|
|
4013
|
+
found: true,
|
|
4014
|
+
solved: false,
|
|
4015
|
+
reason: "invalid_challenge_screenshot",
|
|
4016
|
+
clicks: 0,
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
const solveRes = await solve({
|
|
4020
|
+
imageBase64: shot.toString("base64"),
|
|
4021
|
+
comment: "hCaptcha challenge screenshot. Click all matching image targets requested by the prompt. If a Verify or Submit button is visible, click it after selecting targets.",
|
|
4022
|
+
minClicks: 1,
|
|
4023
|
+
maxClicks: 12,
|
|
4024
|
+
});
|
|
4025
|
+
if (solveRes.kind !== "ok") {
|
|
4026
|
+
return {
|
|
4027
|
+
found: true,
|
|
4028
|
+
solved: false,
|
|
4029
|
+
reason: `2captcha_${solveRes.kind}` +
|
|
4030
|
+
("reason" in solveRes ? `:${solveRes.reason}` : ""),
|
|
4031
|
+
clicks: 0,
|
|
4032
|
+
...("durationMs" in solveRes ? { durationMs: solveRes.durationMs } : {}),
|
|
4033
|
+
};
|
|
4034
|
+
}
|
|
4035
|
+
let clicks = 0;
|
|
4036
|
+
for (const point of solveRes.coordinates) {
|
|
4037
|
+
const box = await challenge.locator.boundingBox({ timeout: 1_500 }).catch(() => null);
|
|
4038
|
+
if (box === null || box.width <= 0 || box.height <= 0)
|
|
4039
|
+
break;
|
|
4040
|
+
const x = box.x + (point.x / dims.width) * box.width;
|
|
4041
|
+
const y = box.y + (point.y / dims.height) * box.height;
|
|
4042
|
+
await this.bezierMouseTo(x, y);
|
|
4043
|
+
await this.sleep(rand(100, 260));
|
|
4044
|
+
await this.page.mouse.click(x, y);
|
|
4045
|
+
this.mouseX = x;
|
|
4046
|
+
this.mouseY = y;
|
|
4047
|
+
clicks += 1;
|
|
4048
|
+
}
|
|
4049
|
+
await this.sleep(650);
|
|
4050
|
+
let settled = await this.waitForCaptchaChallengeToSettle(2_500).catch(() => false);
|
|
4051
|
+
if (!settled && clicks > 0) {
|
|
4052
|
+
const box = await challenge.locator.boundingBox({ timeout: 1_500 }).catch(() => null);
|
|
4053
|
+
if (box !== null && box.width > 0 && box.height > 0) {
|
|
4054
|
+
const verifyX = box.x + Math.min(box.width - 32, Math.max(32, box.width * 0.84));
|
|
4055
|
+
const verifyY = box.y + Math.min(box.height - 24, Math.max(24, box.height * 0.92));
|
|
4056
|
+
await this.bezierMouseTo(verifyX, verifyY);
|
|
4057
|
+
await this.sleep(rand(120, 320));
|
|
4058
|
+
await this.page.mouse.click(verifyX, verifyY);
|
|
4059
|
+
this.mouseX = verifyX;
|
|
4060
|
+
this.mouseY = verifyY;
|
|
4061
|
+
}
|
|
4062
|
+
settled = await this.waitForCaptchaChallengeToSettle(10_000).catch(() => false);
|
|
4063
|
+
}
|
|
4064
|
+
const responsePresent = await this.page
|
|
4065
|
+
.evaluate(() => {
|
|
4066
|
+
const ta = document.querySelector('textarea[name="h-captcha-response"], textarea[id^="h-captcha-response"]');
|
|
4067
|
+
return ta !== null && ta.value.length > 0;
|
|
4068
|
+
})
|
|
4069
|
+
.catch(() => false);
|
|
4070
|
+
const out = {
|
|
4071
|
+
found: true,
|
|
4072
|
+
solved: settled || responsePresent,
|
|
4073
|
+
clicks,
|
|
4074
|
+
durationMs: solveRes.durationMs,
|
|
4075
|
+
};
|
|
4076
|
+
if (!out.solved)
|
|
4077
|
+
out.reason = "challenge_still_visible";
|
|
4078
|
+
return out;
|
|
4079
|
+
}
|
|
4080
|
+
async findVisibleHcaptchaChallengeFrame() {
|
|
4081
|
+
if (!this.page)
|
|
4082
|
+
throw new Error("Browser not started");
|
|
4083
|
+
const selectors = [
|
|
4084
|
+
'iframe[src*="hcaptcha.com"][src*="frame=challenge"]',
|
|
4085
|
+
'iframe[src*="newassets.hcaptcha.com"][src*="frame=challenge"]',
|
|
4086
|
+
'iframe[src*="hcaptcha.com"][src*="/challenge"]',
|
|
4087
|
+
'iframe[src*="newassets.hcaptcha.com"][src*="/challenge"]',
|
|
4088
|
+
];
|
|
4089
|
+
const deadline = Date.now() + 5_000;
|
|
4090
|
+
while (Date.now() < deadline) {
|
|
4091
|
+
for (const selector of selectors) {
|
|
4092
|
+
const locator = this.page.locator(selector);
|
|
4093
|
+
const count = await locator.count().catch(() => 0);
|
|
4094
|
+
for (let i = 0; i < count; i++) {
|
|
4095
|
+
const el = locator.nth(i);
|
|
4096
|
+
const box = await el.boundingBox({ timeout: 1_000 }).catch(() => null);
|
|
4097
|
+
if (box === null)
|
|
4098
|
+
continue;
|
|
4099
|
+
if (box.width < 180 || box.height < 160)
|
|
4100
|
+
continue;
|
|
4101
|
+
return { locator: el, box };
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
await this.sleep(250);
|
|
4105
|
+
}
|
|
4106
|
+
return null;
|
|
4107
|
+
}
|
|
4108
|
+
async waitForCaptchaChallengeToSettle(timeoutMs = 4000, stableClearMs = 2_500) {
|
|
4109
|
+
if (!this.page)
|
|
4110
|
+
throw new Error("Browser not started");
|
|
4111
|
+
const hasVisibleChallenge = async () => await this.page.evaluate(() => {
|
|
4112
|
+
const visible = (el) => {
|
|
4113
|
+
const style = window.getComputedStyle(el);
|
|
4114
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
|
|
4115
|
+
return false;
|
|
4116
|
+
}
|
|
4117
|
+
const r = el.getBoundingClientRect();
|
|
4118
|
+
return r.width > 8 && r.height > 8;
|
|
4119
|
+
};
|
|
4120
|
+
const selectors = [
|
|
4121
|
+
'iframe[src*="hcaptcha.com"][src*="frame=challenge"]',
|
|
4122
|
+
'iframe[src*="newassets.hcaptcha.com"][src*="frame=challenge"]',
|
|
4123
|
+
'iframe[src*="recaptcha/api2/bframe"]',
|
|
4124
|
+
'iframe[src*="challenges.cloudflare.com"]',
|
|
4125
|
+
];
|
|
4126
|
+
return selectors.some((sel) => Array.from(document.querySelectorAll(sel)).some((el) => visible(el)));
|
|
4127
|
+
});
|
|
4128
|
+
const deadline = Date.now() + timeoutMs;
|
|
4129
|
+
let clearSince = null;
|
|
4130
|
+
while (Date.now() < deadline) {
|
|
4131
|
+
const visible = await hasVisibleChallenge().catch(() => false);
|
|
4132
|
+
if (!visible) {
|
|
4133
|
+
clearSince ??= Date.now();
|
|
4134
|
+
if (Date.now() - clearSince >= stableClearMs)
|
|
4135
|
+
return true;
|
|
4136
|
+
}
|
|
4137
|
+
else {
|
|
4138
|
+
clearSince = null;
|
|
4139
|
+
}
|
|
4140
|
+
await this.sleep(250);
|
|
4141
|
+
}
|
|
4142
|
+
return false;
|
|
4143
|
+
}
|
|
3256
4144
|
// Small mouse wiggle near the current position. Used during prewarm
|
|
3257
4145
|
// so the page sees pointer events before we navigate away.
|
|
3258
4146
|
async jitterMouse() {
|
|
@@ -3286,6 +4174,7 @@ export class BrowserController {
|
|
|
3286
4174
|
fullPage: false,
|
|
3287
4175
|
type: "jpeg",
|
|
3288
4176
|
quality: 70,
|
|
4177
|
+
timeout: 8_000,
|
|
3289
4178
|
});
|
|
3290
4179
|
return buffer.toString("base64");
|
|
3291
4180
|
}
|
|
@@ -3313,7 +4202,7 @@ export class BrowserController {
|
|
|
3313
4202
|
url: this.page.url(),
|
|
3314
4203
|
title: await this.page.title(),
|
|
3315
4204
|
html: await this.page.content(),
|
|
3316
|
-
screenshot: await this.screenshot(),
|
|
4205
|
+
screenshot: await this.screenshot().catch(() => ""),
|
|
3317
4206
|
};
|
|
3318
4207
|
}
|
|
3319
4208
|
async extractText() {
|
|
@@ -3336,6 +4225,117 @@ export class BrowserController {
|
|
|
3336
4225
|
throw new Error("Browser not started");
|
|
3337
4226
|
return await this.page.evaluate(() => document.body?.innerText ?? "");
|
|
3338
4227
|
}
|
|
4228
|
+
async extractScopedRouteCandidates(prefix) {
|
|
4229
|
+
if (!this.page)
|
|
4230
|
+
throw new Error("Browser not started");
|
|
4231
|
+
return await this.page.evaluate(async (rawPrefix) => {
|
|
4232
|
+
const prefix = String(rawPrefix ?? "").replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
4233
|
+
const candidates = [];
|
|
4234
|
+
const seen = new Set();
|
|
4235
|
+
const add = (value) => {
|
|
4236
|
+
if (typeof value !== "string")
|
|
4237
|
+
return;
|
|
4238
|
+
const trimmed = value.trim();
|
|
4239
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(trimmed))
|
|
4240
|
+
return;
|
|
4241
|
+
if (seen.has(trimmed))
|
|
4242
|
+
return;
|
|
4243
|
+
seen.add(trimmed);
|
|
4244
|
+
candidates.push(trimmed);
|
|
4245
|
+
};
|
|
4246
|
+
const pathSegments = (href) => {
|
|
4247
|
+
try {
|
|
4248
|
+
return new URL(href, location.origin).pathname.split("/").filter(Boolean);
|
|
4249
|
+
}
|
|
4250
|
+
catch {
|
|
4251
|
+
return [];
|
|
4252
|
+
}
|
|
4253
|
+
};
|
|
4254
|
+
for (const anchor of Array.from(document.querySelectorAll("a[href]"))) {
|
|
4255
|
+
const segs = pathSegments(anchor.getAttribute("href") ?? "");
|
|
4256
|
+
if ((segs[0] ?? "").toLowerCase() === prefix)
|
|
4257
|
+
add(segs[1]);
|
|
4258
|
+
}
|
|
4259
|
+
const walk = (value) => {
|
|
4260
|
+
if (Array.isArray(value)) {
|
|
4261
|
+
for (const item of value)
|
|
4262
|
+
walk(item);
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
if (value === null || typeof value !== "object")
|
|
4266
|
+
return;
|
|
4267
|
+
const record = value;
|
|
4268
|
+
const preferredKeys = prefix === "p" || prefix.startsWith("project")
|
|
4269
|
+
? ["slug", "projectSlug", "currentProjectSlug", "lastViewedProjectSlug", "id"]
|
|
4270
|
+
: prefix.startsWith("org") || prefix.startsWith("organization")
|
|
4271
|
+
? ["slug", "orgSlug", "organizationSlug", "id"]
|
|
4272
|
+
: prefix.startsWith("workspace")
|
|
4273
|
+
? ["slug", "workspaceSlug", "id"]
|
|
4274
|
+
: ["slug", "id"];
|
|
4275
|
+
for (const key of preferredKeys)
|
|
4276
|
+
add(record[key]);
|
|
4277
|
+
for (const item of Object.values(record))
|
|
4278
|
+
walk(item);
|
|
4279
|
+
};
|
|
4280
|
+
const inspectJsonText = (text) => {
|
|
4281
|
+
try {
|
|
4282
|
+
walk(JSON.parse(text));
|
|
4283
|
+
}
|
|
4284
|
+
catch {
|
|
4285
|
+
// Ignore non-JSON storage/API payloads.
|
|
4286
|
+
}
|
|
4287
|
+
};
|
|
4288
|
+
try {
|
|
4289
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
4290
|
+
const key = localStorage.key(i);
|
|
4291
|
+
if (key !== null)
|
|
4292
|
+
inspectJsonText(localStorage.getItem(key) ?? "");
|
|
4293
|
+
}
|
|
4294
|
+
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
4295
|
+
const key = sessionStorage.key(i);
|
|
4296
|
+
if (key !== null)
|
|
4297
|
+
inspectJsonText(sessionStorage.getItem(key) ?? "");
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
catch {
|
|
4301
|
+
// Storage can be blocked in hardened contexts; DOM/API probes are enough.
|
|
4302
|
+
}
|
|
4303
|
+
const likelyListApi = (url) => {
|
|
4304
|
+
const lower = url.toLowerCase();
|
|
4305
|
+
if (!lower.includes("api"))
|
|
4306
|
+
return false;
|
|
4307
|
+
if (prefix === "p" || prefix.startsWith("project"))
|
|
4308
|
+
return /projects?[\w.-]*list|list[\w.-]*projects?/.test(lower);
|
|
4309
|
+
if (prefix.startsWith("org") || prefix.startsWith("organization"))
|
|
4310
|
+
return /organi[sz]ations?[\w.-]*list|orgs?[\w.-]*list|list[\w.-]*(orgs?|organi[sz]ations?)/.test(lower);
|
|
4311
|
+
if (prefix.startsWith("workspace"))
|
|
4312
|
+
return /workspaces?[\w.-]*list|list[\w.-]*workspaces?/.test(lower);
|
|
4313
|
+
return /list/.test(lower);
|
|
4314
|
+
};
|
|
4315
|
+
const urls = Array.from(new Set(performance
|
|
4316
|
+
.getEntriesByType("resource")
|
|
4317
|
+
.map((entry) => entry.name)
|
|
4318
|
+
.filter(likelyListApi))).slice(-8);
|
|
4319
|
+
for (const url of urls) {
|
|
4320
|
+
try {
|
|
4321
|
+
const controller = new AbortController();
|
|
4322
|
+
const timeout = window.setTimeout(() => controller.abort(), 1_500);
|
|
4323
|
+
const res = await fetch(url, {
|
|
4324
|
+
credentials: "include",
|
|
4325
|
+
signal: controller.signal,
|
|
4326
|
+
});
|
|
4327
|
+
window.clearTimeout(timeout);
|
|
4328
|
+
if (!res.ok)
|
|
4329
|
+
continue;
|
|
4330
|
+
inspectJsonText(await res.text());
|
|
4331
|
+
}
|
|
4332
|
+
catch {
|
|
4333
|
+
// Best-effort only; resolver falls back to text/href matching.
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
return candidates.slice(0, 20);
|
|
4337
|
+
}, prefix);
|
|
4338
|
+
}
|
|
3339
4339
|
// Discrete strings an API key might occupy — for credential
|
|
3340
4340
|
// extraction. Gathered so a key is read WHOLE and un-glued from its
|
|
3341
4341
|
// neighbours: extractText() concatenates the whole <body>, which
|
|
@@ -3984,31 +4984,39 @@ export class BrowserController {
|
|
|
3984
4984
|
async waitForAuthWidgetHydration(timeoutMs = 8_000) {
|
|
3985
4985
|
if (!this.page)
|
|
3986
4986
|
return;
|
|
4987
|
+
const authWidgetHydrationProbe = String.raw `(() => {
|
|
4988
|
+
const vis = (el) => {
|
|
4989
|
+
const r = el.getBoundingClientRect();
|
|
4990
|
+
return r.width > 0 && r.height > 0;
|
|
4991
|
+
};
|
|
4992
|
+
const anyVis = (sel) =>
|
|
4993
|
+
Array.from(document.querySelectorAll(sel)).some(vis);
|
|
4994
|
+
const hasAuthInput = anyVis(
|
|
4995
|
+
'input[type="email"],input[type="password"],input[name="email" i],input[name="password" i]',
|
|
4996
|
+
);
|
|
4997
|
+
let hasAuthButton = false;
|
|
4998
|
+
const re = /\b(sign\s?up|continue with|log ?in with|with google|with github|with sso|create account)\b/i;
|
|
4999
|
+
for (const el of Array.from(
|
|
5000
|
+
document.querySelectorAll('button,a[href],[role="button"]'),
|
|
5001
|
+
)) {
|
|
5002
|
+
if (!vis(el)) continue;
|
|
5003
|
+
if (re.test((el.textContent ?? "").trim())) {
|
|
5004
|
+
hasAuthButton = true;
|
|
5005
|
+
break;
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
const spinnerVisible = anyVis(
|
|
5009
|
+
'[role="progressbar"],[aria-busy="true"],[class*="spin" i],[class*="loading" i],[class*="loader" i],.ant-spin,.MuiCircularProgress-root',
|
|
5010
|
+
);
|
|
5011
|
+
return { hasAuth: hasAuthInput || hasAuthButton, spinnerVisible };
|
|
5012
|
+
})()`;
|
|
3987
5013
|
const deadline = Date.now() + timeoutMs;
|
|
3988
5014
|
while (Date.now() < deadline) {
|
|
3989
5015
|
try {
|
|
3990
|
-
const state = await
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
};
|
|
3995
|
-
const anyVis = (sel) => Array.from(document.querySelectorAll(sel)).some(vis);
|
|
3996
|
-
// Auth signal: a real form input or a recognizable provider /
|
|
3997
|
-
// signup affordance.
|
|
3998
|
-
const hasAuthInput = anyVis('input[type="email"],input[type="password"],input[name="email" i],input[name="password" i]');
|
|
3999
|
-
let hasAuthButton = false;
|
|
4000
|
-
const re = /\b(sign\s?up|continue with|log ?in with|with google|with github|with sso|create account)\b/i;
|
|
4001
|
-
for (const el of Array.from(document.querySelectorAll('button,a[href],[role="button"]'))) {
|
|
4002
|
-
if (!vis(el))
|
|
4003
|
-
continue;
|
|
4004
|
-
if (re.test((el.textContent ?? "").trim())) {
|
|
4005
|
-
hasAuthButton = true;
|
|
4006
|
-
break;
|
|
4007
|
-
}
|
|
4008
|
-
}
|
|
4009
|
-
const spinnerVisible = anyVis('[role="progressbar"],[aria-busy="true"],[class*="spin" i],[class*="loading" i],[class*="loader" i],.ant-spin,.MuiCircularProgress-root');
|
|
4010
|
-
return { hasAuth: hasAuthInput || hasAuthButton, spinnerVisible };
|
|
4011
|
-
});
|
|
5016
|
+
const state = (await Promise.race([
|
|
5017
|
+
this.page.evaluate(authWidgetHydrationProbe),
|
|
5018
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("auth widget probe timed out")), 1_500)),
|
|
5019
|
+
]));
|
|
4012
5020
|
// Done the moment an auth signal appears, or once nothing is
|
|
4013
5021
|
// spinning anymore (no point waiting on a page that simply has
|
|
4014
5022
|
// no auth widget — a true OAuth-less/blank page bails honestly).
|
|
@@ -4037,20 +5045,23 @@ export class BrowserController {
|
|
|
4037
5045
|
const deadline = Date.now() + timeoutMs;
|
|
4038
5046
|
while (Date.now() < deadline) {
|
|
4039
5047
|
try {
|
|
4040
|
-
const count = await
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
const
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
5048
|
+
const count = await Promise.race([
|
|
5049
|
+
this.page.evaluate((min) => {
|
|
5050
|
+
const sels = 'input,textarea,select,button,a[href],[role="button"],[role="menuitem"],[role="option"]';
|
|
5051
|
+
const nodes = Array.from(document.querySelectorAll(sels));
|
|
5052
|
+
let visible = 0;
|
|
5053
|
+
for (const n of nodes) {
|
|
5054
|
+
const el = n;
|
|
5055
|
+
const r = el.getBoundingClientRect();
|
|
5056
|
+
if (r.width >= 2 && r.height >= 2)
|
|
5057
|
+
visible++;
|
|
5058
|
+
if (visible >= min)
|
|
5059
|
+
return visible;
|
|
5060
|
+
}
|
|
5061
|
+
return visible;
|
|
5062
|
+
}, minElements),
|
|
5063
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("interactive DOM probe timed out")), 1_500)),
|
|
5064
|
+
]);
|
|
4054
5065
|
if (count >= minElements)
|
|
4055
5066
|
return;
|
|
4056
5067
|
}
|
|
@@ -4448,9 +5459,22 @@ export class BrowserController {
|
|
|
4448
5459
|
const selectorFor = (el) => {
|
|
4449
5460
|
const tag = el.tagName.toLowerCase();
|
|
4450
5461
|
let base;
|
|
5462
|
+
const testId = el.getAttribute("data-testid") ??
|
|
5463
|
+
el.getAttribute("data-test-id") ??
|
|
5464
|
+
el.getAttribute("data-test") ??
|
|
5465
|
+
el.getAttribute("data-cy") ??
|
|
5466
|
+
el.getAttribute("data-qa");
|
|
4451
5467
|
const id = el.getAttribute("id");
|
|
4452
5468
|
const name = el.getAttribute("name");
|
|
4453
|
-
if (
|
|
5469
|
+
if (testId !== null && testId.length > 0) {
|
|
5470
|
+
const attr = el.hasAttribute("data-testid") ? "data-testid" :
|
|
5471
|
+
el.hasAttribute("data-test-id") ? "data-test-id" :
|
|
5472
|
+
el.hasAttribute("data-test") ? "data-test" :
|
|
5473
|
+
el.hasAttribute("data-cy") ? "data-cy" :
|
|
5474
|
+
"data-qa";
|
|
5475
|
+
base = `[${attr}="${CSS.escape(testId)}"]`;
|
|
5476
|
+
}
|
|
5477
|
+
else if (id !== null && /^[A-Za-z][\w-]*$/.test(id)) {
|
|
4454
5478
|
base = `#${id}`;
|
|
4455
5479
|
}
|
|
4456
5480
|
else if (name !== null && name.length > 0) {
|
|
@@ -5210,6 +6234,53 @@ export class BrowserController {
|
|
|
5210
6234
|
if (!this.page)
|
|
5211
6235
|
throw new Error("Browser not started");
|
|
5212
6236
|
if (provider === "github") {
|
|
6237
|
+
// GitHub App install flow can include an account target chooser before
|
|
6238
|
+
// the Install/Authorize screen:
|
|
6239
|
+
// /apps/<app>/installations/select_target
|
|
6240
|
+
// It renders account/org cards as links/buttons, not as an approve
|
|
6241
|
+
// button. Advance exactly one visible target and let the caller's
|
|
6242
|
+
// consent loop re-classify the next GitHub page.
|
|
6243
|
+
if (/\/apps\/[^/]+\/installations\/select_target\b/.test(new URL(this.page.url()).pathname)) {
|
|
6244
|
+
const startUrl = this.page.url();
|
|
6245
|
+
const clicked = await this.page
|
|
6246
|
+
.evaluate(() => {
|
|
6247
|
+
const visible = (el) => {
|
|
6248
|
+
const r = el.getBoundingClientRect();
|
|
6249
|
+
const s = window.getComputedStyle(el);
|
|
6250
|
+
return (r.width > 2 &&
|
|
6251
|
+
r.height > 2 &&
|
|
6252
|
+
s.display !== "none" &&
|
|
6253
|
+
s.visibility !== "hidden" &&
|
|
6254
|
+
parseFloat(s.opacity || "1") > 0.01);
|
|
6255
|
+
};
|
|
6256
|
+
const bad = /\b(settings|marketplace|learn more|cancel|skip|back|terms|privacy)\b/i;
|
|
6257
|
+
const candidates = Array.from(document.querySelectorAll('a[href], button, [role="button"], [role="link"]')).filter((el) => visible(el));
|
|
6258
|
+
const byHref = candidates.find((el) => {
|
|
6259
|
+
const href = el instanceof HTMLAnchorElement ? el.href : el.getAttribute("href") ?? "";
|
|
6260
|
+
return /\/installations\/(?:new|permissions)\b/.test(href);
|
|
6261
|
+
});
|
|
6262
|
+
const target = byHref ??
|
|
6263
|
+
candidates.find((el) => {
|
|
6264
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
6265
|
+
if (text.length === 0 || text.length > 80 || bad.test(text))
|
|
6266
|
+
return false;
|
|
6267
|
+
return true;
|
|
6268
|
+
});
|
|
6269
|
+
if (target === undefined)
|
|
6270
|
+
return false;
|
|
6271
|
+
target.click();
|
|
6272
|
+
return true;
|
|
6273
|
+
})
|
|
6274
|
+
.catch(() => false);
|
|
6275
|
+
if (clicked) {
|
|
6276
|
+
const advanced = await this.page
|
|
6277
|
+
.waitForFunction((s) => window.location.href !== s, startUrl, { timeout: 8000 })
|
|
6278
|
+
.then(() => true)
|
|
6279
|
+
.catch(() => false);
|
|
6280
|
+
if (advanced)
|
|
6281
|
+
return true;
|
|
6282
|
+
}
|
|
6283
|
+
}
|
|
5213
6284
|
// GitHub consent screen variants:
|
|
5214
6285
|
// Classic OAuth: "Authorize <app>"
|
|
5215
6286
|
// GitHub App (install + auth): "Authorize <app>", "Install",
|
|
@@ -5468,6 +6539,8 @@ export class BrowserController {
|
|
|
5468
6539
|
catch {
|
|
5469
6540
|
/* already gone */
|
|
5470
6541
|
}
|
|
6542
|
+
if (this.childChrome.pid !== undefined)
|
|
6543
|
+
selfManagedChromePids.delete(this.childChrome.pid);
|
|
5471
6544
|
this.childChrome = null;
|
|
5472
6545
|
}
|
|
5473
6546
|
// …and context.close() doesn't always kill the browser: headed Chrome
|
|
@@ -5563,8 +6636,10 @@ export function pickSubmitButtonIndex(texts) {
|
|
|
5563
6636
|
// `checkRequiredAgreementBoxes`. The evaluate runs in the page realm and
|
|
5564
6637
|
// can't import, so the same two regexes are inlined there verbatim —
|
|
5565
6638
|
// keep them BYTE-IDENTICAL with these.
|
|
5566
|
-
const AGREEMENT_TEXT_RE = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr/i;
|
|
6639
|
+
const AGREEMENT_TEXT_RE = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr|age|18\+|18 years|certif/i;
|
|
5567
6640
|
const MARKETING_TEXT_RE = /newsletter|updates|offers|product tips|marketing|promotional|receive emails|opt[- ]?in to|subscribe/i;
|
|
6641
|
+
const SAFE_SIGNUP_CHOICE_TEXT_RE = /digital products?|saas|software|developer tools?|apis?|mobile apps?|data|analytics/i;
|
|
6642
|
+
const RISKY_SIGNUP_CHOICE_TEXT_RE = /gambling|financial services?|physical products?|marketplace|human services?|adult|weapons?|medical|restricted|crypto|payments?|banking/i;
|
|
5568
6643
|
// True when a checkbox's associated text reads as a REQUIRED agreement
|
|
5569
6644
|
// (terms/privacy/consent) and NOT as a marketing/newsletter opt-in.
|
|
5570
6645
|
//
|
|
@@ -5578,6 +6653,15 @@ const MARKETING_TEXT_RE = /newsletter|updates|offers|product tips|marketing|prom
|
|
|
5578
6653
|
export function isAgreementCheckboxText(text) {
|
|
5579
6654
|
return AGREEMENT_TEXT_RE.test(text) && !MARKETING_TEXT_RE.test(text);
|
|
5580
6655
|
}
|
|
6656
|
+
// True when a required signup-category choice is a low-risk default the bot can
|
|
6657
|
+
// select deterministically. Keep byte-identical with the in-page regexes in
|
|
6658
|
+
// `checkRequiredSignupChoiceBoxes`.
|
|
6659
|
+
export function isSafeSignupChoiceText(text) {
|
|
6660
|
+
return (SAFE_SIGNUP_CHOICE_TEXT_RE.test(text) &&
|
|
6661
|
+
!RISKY_SIGNUP_CHOICE_TEXT_RE.test(text) &&
|
|
6662
|
+
!AGREEMENT_TEXT_RE.test(text) &&
|
|
6663
|
+
!MARKETING_TEXT_RE.test(text));
|
|
6664
|
+
}
|
|
5581
6665
|
// Parse a UNIVERSAL_BOT_PROXY_URL — e.g. "http://user:pass@host:8080" or
|
|
5582
6666
|
// "socks5://host:1080" — into Playwright's proxy option shape. Playwright
|
|
5583
6667
|
// wants credentials separate from `server`, so we split them out and
|