@trusty-squire/mcp 0.5.9 → 0.6.0
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/README.md +128 -68
- package/dist/api-client.d.ts +1 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +27 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bot/agent.d.ts +8 -0
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +492 -57
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +11 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +365 -20
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/debug.d.ts.map +1 -1
- package/dist/bot/debug.js +19 -8
- package/dist/bot/debug.js.map +1 -1
- package/dist/bot/google-login.d.ts +4 -0
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +86 -7
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/index.d.ts +3 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +3 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/xvfb.d.ts +10 -0
- package/dist/bot/xvfb.d.ts.map +1 -0
- package/dist/bot/xvfb.js +75 -0
- package/dist/bot/xvfb.js.map +1 -0
- package/dist/install/agents.d.ts.map +1 -1
- package/dist/install/agents.js +37 -4
- package/dist/install/agents.js.map +1 -1
- package/dist/install/cli.d.ts +1 -0
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +148 -33
- package/dist/install/cli.js.map +1 -1
- package/dist/tools/provision-any.d.ts +23 -0
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +135 -9
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +1 -1
package/dist/bot/browser.js
CHANGED
|
@@ -25,6 +25,7 @@ import { chromium as baseChromium } from "playwright";
|
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
26
|
import { detectAsn } from "./asn.js";
|
|
27
27
|
import { CHROME_PROFILE_DIR } from "./profile.js";
|
|
28
|
+
import { startXvfb, xvfbAvailable } from "./xvfb.js";
|
|
28
29
|
// Lazy registration: installing the plugin mutates the chromium singleton
|
|
29
30
|
// from playwright-extra so we only do it once per process. We require()
|
|
30
31
|
// the CJS modules lazily (the stealth toolchain only ships CJS) and treat
|
|
@@ -170,6 +171,19 @@ export class BrowserController {
|
|
|
170
171
|
// parked here so settleAfterOAuth() can switch back to it once the
|
|
171
172
|
// Google handshake completes.
|
|
172
173
|
oauthProductPage = null;
|
|
174
|
+
// F13 — on-demand Xvfb. Set when start() determined the host has no
|
|
175
|
+
// display surface but Xvfb is available, so Chrome can run with
|
|
176
|
+
// `headless: false` against a virtual display (Cloudflare/Stytch et
|
|
177
|
+
// al. detect Chromium-headless and block their signup forms). Torn
|
|
178
|
+
// down by close().
|
|
179
|
+
xvfb = null;
|
|
180
|
+
// F13 — which launch path start() took. Surfaced via .launchMode so
|
|
181
|
+
// the agent can push it into the run's step trail and we can see
|
|
182
|
+
// (from outside the box) whether the bot ran headed.
|
|
183
|
+
launchedMode = "unknown";
|
|
184
|
+
get launchMode() {
|
|
185
|
+
return this.launchedMode;
|
|
186
|
+
}
|
|
173
187
|
constructor(opts = {}) {
|
|
174
188
|
this.humanize = opts.humanize ?? true;
|
|
175
189
|
this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
|
|
@@ -214,13 +228,71 @@ export class BrowserController {
|
|
|
214
228
|
? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
|
|
215
229
|
: ""));
|
|
216
230
|
}
|
|
231
|
+
// F13 — decide whether to spin up Xvfb and run Chrome headed.
|
|
232
|
+
// Modern SaaS signups (Cloudflare/Stytch, Clerk, Auth0) detect
|
|
233
|
+
// Chromium-headless via JS fingerprints and gate their forms
|
|
234
|
+
// behind the check. Running headed against Xvfb defeats the gate
|
|
235
|
+
// — the user never sees the display.
|
|
236
|
+
//
|
|
237
|
+
// The decision matrix:
|
|
238
|
+
// - UNIVERSAL_BOT_HEADLESS=true (explicit opt-in): keep true
|
|
239
|
+
// headless. CI / Codespaces that lack Xvfb.
|
|
240
|
+
// - UNIVERSAL_BOT_HEADLESS=false (explicit opt-out): the
|
|
241
|
+
// current pre-F13 behavior — DISPLAY must exist already.
|
|
242
|
+
// - default + DISPLAY set: run headed against the existing
|
|
243
|
+
// display (laptop/desktop install).
|
|
244
|
+
// - default + no DISPLAY + Xvfb on PATH: spawn Xvfb, run
|
|
245
|
+
// headed against it (the headless-server install — what
|
|
246
|
+
// Cloudflare needed).
|
|
247
|
+
// - default + no DISPLAY + no Xvfb: fall back to true
|
|
248
|
+
// headless with a clear stderr warning.
|
|
249
|
+
let chromeEnv;
|
|
250
|
+
let chromeHeadless;
|
|
251
|
+
const explicitHeadless = process.env.UNIVERSAL_BOT_HEADLESS;
|
|
252
|
+
const hostHasDisplay = process.platform === "darwin" ||
|
|
253
|
+
process.platform === "win32" ||
|
|
254
|
+
(typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0);
|
|
255
|
+
if (explicitHeadless === "true") {
|
|
256
|
+
chromeHeadless = true;
|
|
257
|
+
this.launchedMode = "headless";
|
|
258
|
+
}
|
|
259
|
+
else if (explicitHeadless === "false") {
|
|
260
|
+
chromeHeadless = false;
|
|
261
|
+
this.launchedMode = "display";
|
|
262
|
+
}
|
|
263
|
+
else if (hostHasDisplay) {
|
|
264
|
+
chromeHeadless = false;
|
|
265
|
+
this.launchedMode = "display";
|
|
266
|
+
}
|
|
267
|
+
else if (xvfbAvailable()) {
|
|
268
|
+
try {
|
|
269
|
+
this.xvfb = await startXvfb({ width: 1280, height: 720 });
|
|
270
|
+
chromeEnv = { ...process.env, DISPLAY: this.xvfb.display };
|
|
271
|
+
chromeHeadless = false;
|
|
272
|
+
this.launchedMode = "xvfb";
|
|
273
|
+
console.error(`[universal-bot] no DISPLAY — spawned Xvfb at ${this.xvfb.display} for headed Chrome`);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
console.error(`[universal-bot] Xvfb failed (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
277
|
+
`falling back to true headless; Cloudflare/Stytch-class signups may fail`);
|
|
278
|
+
chromeHeadless = true;
|
|
279
|
+
this.launchedMode = "headless";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
console.error(`[universal-bot] no DISPLAY and Xvfb not installed — running true headless. ` +
|
|
284
|
+
`For Cloudflare/Stytch-class signups install xvfb: apt-get install -y xvfb`);
|
|
285
|
+
chromeHeadless = true;
|
|
286
|
+
this.launchedMode = "headless";
|
|
287
|
+
}
|
|
217
288
|
// T3: a PERSISTENT context. The profile dir carries the user's
|
|
218
289
|
// Google session (established by `mcp login` — see google-login.ts),
|
|
219
290
|
// so the OAuth-first signup path reuses it instead of starting
|
|
220
291
|
// logged-out. launchPersistentContext takes launch + context
|
|
221
292
|
// options in one call.
|
|
222
293
|
const context = await getChromium().launchPersistentContext(this.profileDir, {
|
|
223
|
-
headless:
|
|
294
|
+
headless: chromeHeadless,
|
|
295
|
+
...(chromeEnv !== undefined ? { env: chromeEnv } : {}),
|
|
224
296
|
// `channel:` selects a real installed browser over the bundled
|
|
225
297
|
// binary; omitted entirely when null.
|
|
226
298
|
...(channel !== null ? { channel } : {}),
|
|
@@ -241,9 +313,20 @@ export class BrowserController {
|
|
|
241
313
|
// timezone + geolocation track the real egress (T3.1); a fixed
|
|
242
314
|
// default when the probe failed.
|
|
243
315
|
timezoneId: geo?.timezoneId ?? "America/New_York",
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
316
|
+
// F10: `clipboard-read` is what makes `navigator.clipboard.readText()`
|
|
317
|
+
// return the user's just-clicked Copy-button value, which is how
|
|
318
|
+
// every modern API-key modal (OpenRouter, Anthropic, OpenAI,
|
|
319
|
+
// Stripe) reveals the full secret — the visible display is
|
|
320
|
+
// masked / truncated and only the clipboard has the whole key.
|
|
321
|
+
// `clipboard-write` is a freebie; some Copy buttons no-op without
|
|
322
|
+
// it. Granting both at context-creation time so we don't have to
|
|
323
|
+
// re-grant on every nav.
|
|
324
|
+
permissions: [
|
|
325
|
+
...(geo?.geolocation !== undefined ? ["geolocation"] : []),
|
|
326
|
+
"clipboard-read",
|
|
327
|
+
"clipboard-write",
|
|
328
|
+
],
|
|
329
|
+
...(geo?.geolocation !== undefined ? { geolocation: geo.geolocation } : {}),
|
|
247
330
|
});
|
|
248
331
|
this.context = context;
|
|
249
332
|
// Patch the navigator.webdriver flag — most anti-bot heuristics look here.
|
|
@@ -578,25 +661,149 @@ export class BrowserController {
|
|
|
578
661
|
await this.page.check(selector, { force: true });
|
|
579
662
|
}
|
|
580
663
|
}
|
|
581
|
-
// Pick a valid option for a <select>
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
664
|
+
// Pick a valid option for either a native <select> OR a custom
|
|
665
|
+
// ARIA combobox (Radix, Headless UI, React Aria, cmdk — F11). The
|
|
666
|
+
// bot must not call type() on a select-shaped element (Sentry,
|
|
667
|
+
// legacy form path: "Element is not an <input>"); modern dashboards
|
|
668
|
+
// increasingly render permission / role / region pickers as
|
|
669
|
+
// <button role="combobox"> that open a <ul role="listbox"> with
|
|
670
|
+
// <li role="option"> children, so Playwright's selectOption fails
|
|
671
|
+
// with "no selectable option" on them.
|
|
672
|
+
//
|
|
673
|
+
// Dispatch: read the element's tag. <select> → native path
|
|
674
|
+
// (existing behavior, picks the first non-placeholder option).
|
|
675
|
+
// Anything else → combobox path (click to open, find role=option,
|
|
676
|
+
// click the chosen one).
|
|
677
|
+
//
|
|
678
|
+
// `optionMatcher` is the planner-supplied text of the option to
|
|
679
|
+
// pick (e.g. "Project: Read"). Case-insensitive substring match
|
|
680
|
+
// against the option's visible text. When undefined, picks the
|
|
681
|
+
// first option — preserves the existing behavior for native
|
|
682
|
+
// selects whose contents are interchangeable (country pickers).
|
|
683
|
+
async selectOption(selector, optionMatcher) {
|
|
587
684
|
if (!this.page)
|
|
588
685
|
throw new Error("Browser not started");
|
|
589
686
|
await this.page.waitForSelector(selector, { state: "attached", timeout: 10000 });
|
|
590
|
-
const
|
|
591
|
-
.locator(
|
|
592
|
-
.
|
|
593
|
-
.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
687
|
+
const tagName = await this.page
|
|
688
|
+
.locator(selector)
|
|
689
|
+
.first()
|
|
690
|
+
.evaluate((node) => node.tagName.toLowerCase());
|
|
691
|
+
if (tagName === "select") {
|
|
692
|
+
// Native path — unchanged.
|
|
693
|
+
const values = await this.page
|
|
694
|
+
.locator(`${selector} option`)
|
|
695
|
+
.evaluateAll((opts) => opts
|
|
696
|
+
.map((o) => (o instanceof HTMLOptionElement ? o.value : ""))
|
|
697
|
+
.filter((v) => v.length > 0));
|
|
698
|
+
const first = values[0];
|
|
699
|
+
if (first === undefined) {
|
|
700
|
+
throw new Error(`<select> ${selector} has no selectable option`);
|
|
701
|
+
}
|
|
702
|
+
// When the planner specified an option, prefer the one whose
|
|
703
|
+
// visible text matches it; fall back to first.
|
|
704
|
+
let chosenValue = first;
|
|
705
|
+
if (optionMatcher !== undefined) {
|
|
706
|
+
const matcherLower = optionMatcher.toLowerCase();
|
|
707
|
+
const matched = await this.page
|
|
708
|
+
.locator(`${selector} option`)
|
|
709
|
+
.evaluateAll((opts, needle) => opts
|
|
710
|
+
.filter((o) => o instanceof HTMLOptionElement)
|
|
711
|
+
.find((o) => o.textContent?.toLowerCase().includes(needle))
|
|
712
|
+
?.value ?? null, matcherLower);
|
|
713
|
+
if (typeof matched === "string" && matched.length > 0) {
|
|
714
|
+
chosenValue = matched;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
await this.page.selectOption(selector, chosenValue);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// Custom combobox path. Sentry, Radix, Headless UI, React Aria
|
|
721
|
+
// — every modern React picker emits role=option on its items.
|
|
722
|
+
await this.selectFromCombobox(selector, optionMatcher);
|
|
723
|
+
}
|
|
724
|
+
// F11 (+rc.7 hardening): click a combobox trigger, wait for the
|
|
725
|
+
// listbox to open, click an option.
|
|
726
|
+
//
|
|
727
|
+
// Tries option-selector patterns in priority order — each tier
|
|
728
|
+
// targets one combobox-library convention. The text-based final
|
|
729
|
+
// tier catches libraries that ship NO ARIA roles at all (Sentry's
|
|
730
|
+
// permissions picker is the canonical case: it uses `<div>`s with
|
|
731
|
+
// plain text for options and pure JS click handlers).
|
|
732
|
+
//
|
|
733
|
+
// 1. [role=option] — Radix, Headless UI, React Aria, cmdk
|
|
734
|
+
// 2. [role=menuitem] — ARIA menu pattern (libs that model
|
|
735
|
+
// a dropdown as a menu)
|
|
736
|
+
// 3. [role=listbox] li — listbox container without role
|
|
737
|
+
// attribute on its children
|
|
738
|
+
// 4. text-based (matcher only) — after the trigger click, any newly-
|
|
739
|
+
// visible element whose text matches
|
|
740
|
+
// the planner-supplied label is
|
|
741
|
+
// almost certainly the option. Only
|
|
742
|
+
// enabled when a matcher exists,
|
|
743
|
+
// since "first text on the page"
|
|
744
|
+
// with no matcher would catch
|
|
745
|
+
// unrelated UI text.
|
|
746
|
+
async selectFromCombobox(triggerSelector, optionMatcher) {
|
|
747
|
+
if (!this.page)
|
|
748
|
+
throw new Error("Browser not started");
|
|
749
|
+
await this.humanClick(triggerSelector);
|
|
750
|
+
const patternSelectors = [
|
|
751
|
+
'[role="option"]:visible',
|
|
752
|
+
'[role="menuitem"]:visible',
|
|
753
|
+
'[role="listbox"]:visible li:visible',
|
|
754
|
+
];
|
|
755
|
+
const triedDescriptors = [];
|
|
756
|
+
for (const sel of patternSelectors) {
|
|
757
|
+
triedDescriptors.push(sel);
|
|
758
|
+
const locator = this.page.locator(sel);
|
|
759
|
+
try {
|
|
760
|
+
await locator.first().waitFor({ state: "visible", timeout: 1500 });
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const count = await locator.count();
|
|
766
|
+
if (count === 0)
|
|
767
|
+
continue;
|
|
768
|
+
await this.pickComboboxOption(locator, optionMatcher);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// ARIA tiers all empty. Text-based fallback, only if the planner
|
|
772
|
+
// told us WHICH option to pick — without a matcher, "first text
|
|
773
|
+
// on the page" would click unrelated UI.
|
|
774
|
+
if (optionMatcher !== undefined) {
|
|
775
|
+
const byText = this.page.getByText(optionMatcher, { exact: false }).first();
|
|
776
|
+
triedDescriptors.push(`text="${optionMatcher}"`);
|
|
777
|
+
try {
|
|
778
|
+
await byText.waitFor({ state: "visible", timeout: 2000 });
|
|
779
|
+
await this.humanClickLocator(byText);
|
|
780
|
+
await this.wait(0.5);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// not found — fall through to error
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
throw new Error(`combobox ${triggerSelector}: no options found after click. ` +
|
|
788
|
+
`Tried: ${triedDescriptors.join(", ")}. ` +
|
|
789
|
+
`The trigger may not have opened a popover, or the popover uses ` +
|
|
790
|
+
`an option pattern this executor doesn't recognize.`);
|
|
791
|
+
}
|
|
792
|
+
// F11: pick an option from a Playwright Locator already-narrowed to
|
|
793
|
+
// candidates. Matcher → filter by hasText (case-insensitive by
|
|
794
|
+
// default in Playwright). No matcher → first.
|
|
795
|
+
async pickComboboxOption(options, matcher) {
|
|
796
|
+
if (matcher !== undefined) {
|
|
797
|
+
const filtered = options.filter({ hasText: matcher });
|
|
798
|
+
const filteredCount = await filtered.count();
|
|
799
|
+
if (filteredCount > 0) {
|
|
800
|
+
await this.humanClickLocator(filtered.first());
|
|
801
|
+
await this.wait(0.5);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
await this.humanClickLocator(options.first());
|
|
806
|
+
await this.wait(0.5);
|
|
600
807
|
}
|
|
601
808
|
// ───────────── humanization internals ─────────────
|
|
602
809
|
// Click that mimics a real user: locate element, bezier-path the
|
|
@@ -874,6 +1081,23 @@ export class BrowserController {
|
|
|
874
1081
|
return buffer.toString("base64");
|
|
875
1082
|
}
|
|
876
1083
|
async getState() {
|
|
1084
|
+
if (!this.page)
|
|
1085
|
+
throw new Error("Browser not started");
|
|
1086
|
+
// page.content() / page.title() / screenshot() all throw
|
|
1087
|
+
// "Execution context was destroyed" when the page is mid-
|
|
1088
|
+
// navigation — common after an OAuth-button click that kicks off
|
|
1089
|
+
// a 3-5 hop redirect chain (sentry.io → accounts.google.com →
|
|
1090
|
+
// consent → callback → onboarding). Retry once after a short
|
|
1091
|
+
// settle: most navigations finish in <500ms even on slow links.
|
|
1092
|
+
try {
|
|
1093
|
+
return await this.snapshotState();
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
await this.wait(0.8);
|
|
1097
|
+
return await this.snapshotState();
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
async snapshotState() {
|
|
877
1101
|
if (!this.page)
|
|
878
1102
|
throw new Error("Browser not started");
|
|
879
1103
|
return {
|
|
@@ -902,6 +1126,46 @@ export class BrowserController {
|
|
|
902
1126
|
// immediate children, excluding descendants. A key in a
|
|
903
1127
|
// <code>/<span>/<div> yields its clean value here even when a
|
|
904
1128
|
// sibling button shares the same parent.
|
|
1129
|
+
// F10: read the clipboard contents (typically populated by the
|
|
1130
|
+
// user-modal's Copy button — every modern API-key reveal modal puts
|
|
1131
|
+
// the full secret here while displaying a masked stub). Requires
|
|
1132
|
+
// `clipboard-read` permission, granted at context creation. Returns
|
|
1133
|
+
// an empty string if the clipboard is empty; throws on permission
|
|
1134
|
+
// failure (caller catches and falls through to other paths).
|
|
1135
|
+
async readClipboard() {
|
|
1136
|
+
if (!this.page)
|
|
1137
|
+
throw new Error("Browser not started");
|
|
1138
|
+
return await this.page.evaluate(async () => {
|
|
1139
|
+
try {
|
|
1140
|
+
return await navigator.clipboard.readText();
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
return "";
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
// F10 fallback: ALL <input> / <textarea> values, ignoring
|
|
1148
|
+
// visibility and type filters. extractCredentialCandidates
|
|
1149
|
+
// deliberately skips `type=hidden` / `type=password` / invisible
|
|
1150
|
+
// elements (correct for general candidate scanning), but some
|
|
1151
|
+
// API-key modals stash the full key in a hidden input the masked
|
|
1152
|
+
// display reads from — and that needs to be reachable when the
|
|
1153
|
+
// visible extraction comes back truncated.
|
|
1154
|
+
async extractAllInputValues() {
|
|
1155
|
+
if (!this.page)
|
|
1156
|
+
throw new Error("Browser not started");
|
|
1157
|
+
return await this.page.evaluate(() => {
|
|
1158
|
+
const out = [];
|
|
1159
|
+
document.querySelectorAll("input, textarea").forEach((el) => {
|
|
1160
|
+
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement))
|
|
1161
|
+
return;
|
|
1162
|
+
const value = el.value;
|
|
1163
|
+
if (value.trim().length > 0)
|
|
1164
|
+
out.push(value.trim());
|
|
1165
|
+
});
|
|
1166
|
+
return out;
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
905
1169
|
async extractCredentialCandidates() {
|
|
906
1170
|
if (!this.page)
|
|
907
1171
|
throw new Error("Browser not started");
|
|
@@ -956,6 +1220,14 @@ export class BrowserController {
|
|
|
956
1220
|
// networkidle never settles on pages with analytics sockets or
|
|
957
1221
|
// long-poll — not fatal, fall through to the element wait.
|
|
958
1222
|
}
|
|
1223
|
+
// F13 follow-up — if we landed on a full-page anti-bot interstitial
|
|
1224
|
+
// (Cloudflare "Just a moment..." / Turnstile pre-clear / similar),
|
|
1225
|
+
// wait for it to clear and the real page to render. networkidle
|
|
1226
|
+
// sometimes fires DURING the interstitial because Cloudflare keeps
|
|
1227
|
+
// the connection quiet between the verify-handshake and the
|
|
1228
|
+
// redirect to the real page. Without this, the bot snapshots a
|
|
1229
|
+
// 2-element interstitial inventory and bails.
|
|
1230
|
+
await this.waitForAntiBotInterstitialToClear(timeoutMs);
|
|
959
1231
|
try {
|
|
960
1232
|
await this.page.waitForSelector("input, button", {
|
|
961
1233
|
state: "visible",
|
|
@@ -967,6 +1239,72 @@ export class BrowserController {
|
|
|
967
1239
|
// anyway; it fails cleanly rather than hanging.
|
|
968
1240
|
}
|
|
969
1241
|
}
|
|
1242
|
+
// Cloudflare and similar gateways serve a full-page interstitial
|
|
1243
|
+
// ("Just a moment..." / Turnstile pre-clear) before the real page.
|
|
1244
|
+
// The challenge usually clears within ~5-10s — the bot just needs
|
|
1245
|
+
// to wait. Detected from page text patterns rather than URL: the
|
|
1246
|
+
// URL stays the same; the body replaces.
|
|
1247
|
+
//
|
|
1248
|
+
// Returns when the interstitial is gone, or after `timeoutMs` if it
|
|
1249
|
+
// never cleared. Best-effort: any unexpected error returns early
|
|
1250
|
+
// rather than failing the whole signup.
|
|
1251
|
+
async waitForAntiBotInterstitialToClear(timeoutMs) {
|
|
1252
|
+
if (!this.page)
|
|
1253
|
+
return;
|
|
1254
|
+
let detected = await this.pollUntilInterstitialClears(timeoutMs);
|
|
1255
|
+
if (!detected) {
|
|
1256
|
+
// We either never saw an interstitial, or we saw one and it
|
|
1257
|
+
// cleared on its own. Nothing more to do.
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
// The interstitial outlived the wait. Cloudflare frequently shows
|
|
1261
|
+
// "Verification successful. Wait" but then never fires the JS
|
|
1262
|
+
// redirect — the challenge passed, but the redirect script got
|
|
1263
|
+
// stuck or the cookie set is racing the navigation. A single
|
|
1264
|
+
// reload, now that the cf_clearance cookie is set, often lets the
|
|
1265
|
+
// real page render. (If the issue is a server-side risk-score
|
|
1266
|
+
// block — fingerprint/IP — reload won't help, but the caller's
|
|
1267
|
+
// inventory diagnostic will still surface the block.)
|
|
1268
|
+
try {
|
|
1269
|
+
await this.page.reload({ waitUntil: "networkidle", timeout: 10_000 });
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
// reload failed — proceed with what's there
|
|
1273
|
+
}
|
|
1274
|
+
await this.pollUntilInterstitialClears(Math.max(5000, timeoutMs / 2));
|
|
1275
|
+
}
|
|
1276
|
+
// One poll loop. Returns true if an interstitial was ever observed
|
|
1277
|
+
// (cleared or still there at timeout), false if never seen.
|
|
1278
|
+
async pollUntilInterstitialClears(timeoutMs) {
|
|
1279
|
+
if (!this.page)
|
|
1280
|
+
return false;
|
|
1281
|
+
const deadline = Date.now() + timeoutMs;
|
|
1282
|
+
let detected = false;
|
|
1283
|
+
while (Date.now() < deadline) {
|
|
1284
|
+
let title = "";
|
|
1285
|
+
let bodyText = "";
|
|
1286
|
+
try {
|
|
1287
|
+
title = await this.page.title();
|
|
1288
|
+
bodyText = await this.page.evaluate(() => (document.body?.innerText ?? "").slice(0, 500));
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
const onInterstitial = /just a moment|performing security verification|verifying you are human|checking your browser|attention required/i.test(title + " " + bodyText);
|
|
1295
|
+
if (!onInterstitial) {
|
|
1296
|
+
if (detected) {
|
|
1297
|
+
// Give the freshly-revealed page a tick to hydrate before
|
|
1298
|
+
// the inventory scan.
|
|
1299
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
1300
|
+
}
|
|
1301
|
+
return detected;
|
|
1302
|
+
}
|
|
1303
|
+
detected = true;
|
|
1304
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1305
|
+
}
|
|
1306
|
+
return detected;
|
|
1307
|
+
}
|
|
970
1308
|
// Walk the live DOM (piercing open shadow roots) and return every
|
|
971
1309
|
// visible interactive element with a bot-computed selector (F3 T1).
|
|
972
1310
|
// The planner picks from this inventory instead of inventing
|
|
@@ -1271,6 +1609,13 @@ export class BrowserController {
|
|
|
1271
1609
|
// Closing the persistent context shuts the browser down too.
|
|
1272
1610
|
if (this.context)
|
|
1273
1611
|
await this.context.close();
|
|
1612
|
+
// F13 — release the on-demand Xvfb if we spawned one. Order
|
|
1613
|
+
// matters: kill Chrome (context.close) first so it has its
|
|
1614
|
+
// display until it exits, THEN kill Xvfb.
|
|
1615
|
+
if (this.xvfb !== null) {
|
|
1616
|
+
this.xvfb.stop();
|
|
1617
|
+
this.xvfb = null;
|
|
1618
|
+
}
|
|
1274
1619
|
}
|
|
1275
1620
|
}
|
|
1276
1621
|
// Random integer in [min, max]. We use Math.random() (not crypto)
|