Docs · Capabilities

Native CAPTCHA helpers

A puzzle piece completing a "verify you are human" challenge

BetterWright solves simple CAPTCHAs inside the existing browser session. It does not send sitekeys, tokens, screenshots, or API keys to a third-party solving service, and it does not load a heavy local ML runtime. The automatic solver is open-source, self-hosted by definition (it runs in your managed browser), and keeps a small memory footprint: ordinary DOM inspection plus the same human-shaped mouse motion used elsewhere.

Use these helpers only for a legitimate flow you are authorized to complete. Never rotate identities or repeat a failed action. A rejected repeat of the same stage requires an immediate alternate source or human handoff; otherwise, work through at most three distinct challenge types before taking that handoff. Replacement photo grids after Verify are the same image-grid stage.

When BetterWright detects a visible challenge, the result contains a structured challenges entry (including stage, autoSolvable, and needsVision) and, when capture succeeds, an attached captcha image. This also happens when the browser snippet itself failed. Keep the same page and profile, inspect that state, and continue on the next turn.

Automatic solver (preferred)

captcha.solve(options?) is the 2Captcha-shaped entry point that stays fully local:

return await captcha.solve({ timeoutMs: 45_000, maxStages: 3 });

timeoutMs is in milliseconds (clamped to 3000–180000) — unlike the host run() timeout, which is in seconds. timeout is accepted as an alias with the same millisecond unit; prefer timeoutMs so the unit stays visible.

maxStages defaults to 3 and is clamped to 13: handoff is required after at most three distinct stages, so a caller cannot ask the solver to keep working a challenge that has already rejected it three times.

FieldMeaning
statusready (cleared), processing (needs vision / another stage), or error
request / requestIdLocal solve id (2Captcha-style request handle)
providerrecaptcha, hcaptcha, turnstile, cloudflare, bing, google, generic, …
stagecheckbox, turnstile, managed_challenge, image_grid, slider, text, …
clearedtrue when the challenge is gone or a response token is present
tokenResponse token when a widget wrote one into the page
tilesImage-grid tile bounds and indexes for host vision (when status === "processing")
grid{ rows, cols } when a numbered image grid was captured
artifactAttached captcha PNG (MEDIA:…) for vision stages — a tight crop with overlay numbers
attemptsPer-stage action log
local / externalApiAlways true / false — no remote solver

What auto-solves without vision

StageStrategy
Checkbox (reCAPTCHA / hCaptcha / generic)Human-shaped click on the widget
Cloudflare TurnstileClick the widget, wait for cf-turnstile-response
Managed Cloudflare checkClick verify if present, then wait for clearance
Slider / puzzle handleHuman-shaped drag across the track
Motion / canvas (“shape that grows”)Sample animation frames, click the blob that grew, then Next
Drag-to-fitDrag the filled piece onto the hollow slot

What still needs the host model

StageWhat solve() returns
Image grid (“select all images with …”)status: "processing", numbered crop + tiles[] + grid. Open the image, then captcha.solve({ tiles: [indexes] })
Text CAPTCHAstatus: "processing", tight crop + instruction
Motion (only if frame-diff is ambiguous)Two live crops; click the growing shape, then captcha.solve() again

Example vision handoff for an image grid:

const first = await captcha.solve();
if (first.status === "ready") return first;
if (first.status === "processing" && first.tiles?.length) {
  // Host vision picks matching tile indexes from the numbered crop.
  return captcha.solve({ tiles: [0, 3, 5] });
}
return first;

Tile indexes only mean something relative to the crop they came from. If the page navigates or the challenge swaps in a new grid between the capture and the picks, the stored coordinates are discarded rather than replayed: solve() returns processing again with a fresh numbered crop (the attempt log records recapture_tiles), and captcha.clickTiles throws and asks for a new captcha.solve(). That keeps clicks off the wrong tiles and stops a stale selection from being submitted to Verify.

Detection only

return await captcha.detect();
// { present, challenge, classification, widgets, tokens, cleared, url }

Manual helpers

Every run() snippet has a frozen captcha global:

HelperPurposeResult
captcha.solve(options?)Local automatic multi-stage solver. Pass { tiles: [indexes] } after reading the numbered crop.Solve envelope (ready / processing / error)
captcha.detect()Structured widget + stage reportDetection object
captcha.inspect(bounds?)Capture the whole page or a challenge region for the agent's visioncaptcha image artifact
captcha.click(bounds)Click a checkbox-style widgetFresh accessibility snapshot
captcha.clickTiles(indexes)Click stored numbered tiles and the verify controlFresh accessibility snapshot
captcha.drag(from, to, {steps: 20})Smoothly drag a slider or puzzle handleFresh accessibility snapshot
captcha.readText(bounds?)Capture only a text challenge for the agent's existing visioncaptcha image artifact

bounds uses CSS pixels: {x, y, width, height}. from and to use {x, y}. The click helper targets the left side of the supplied widget bounds, where checkbox challenges normally place their control.

Checkbox challenge

// Preferred: let the local solver find and click the widget.
return captcha.solve();

// Manual fallback:
const frame = page.locator('iframe[title*="challenge" i], iframe[src*="captcha" i]').first();
const bounds = await frame.boundingBox();
if (!bounds) throw new Error('CAPTCHA widget is not visible');
return captcha.click(bounds);

The returned snapshot (manual path) is captured after the click. The result envelope also contains BetterWright's current challenges report. If the checkbox opens an image grid, that is a new stage; call captcha.solve() again or inspect rather than clicking the checkbox again.

Slider or puzzle drag

return captcha.solve(); // auto-drags when a slider stage is classified

// Manual fallback:
const handle = page.locator('[role="slider"], .slider-handle').first();
const bounds = await handle.boundingBox();
if (!bounds) throw new Error('Slider handle is not visible');
const from = {x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2};
return captcha.drag(from, {x: from.x + 280, y: from.y}, {steps: 24});

Text challenge

const solved = await captcha.solve();
if (solved.status === "processing") {
  // Host vision reads solved.artifact, then:
  // await page.locator('input[name*="captcha" i]').fill(text);
  // await page.locator('button[type="submit"]').click();
  // return captcha.solve();
}
return solved;

Image-grid challenge (reCAPTCHA / hCaptcha)

A checkbox click frequently escalates to an image grid — "select all images with bicycles". captcha.solve() crops the widget, overlays a number on each tile, and returns that image with tiles[] and grid. Open the attached crop, pick the matching indexes, and finish in one call:

const first = await captcha.solve();
if (first.status === "ready") return first;
if (first.status === "processing" && first.tiles?.length) {
  return captcha.solve({ tiles: [0, 3, 5] }); // indexes your vision picked
}
return first;

Inspect the fresh challenges report after Verify. A replacement set of photos with the same prompt is still the image-grid stage — pick the new numbered crop and call captcha.solve({ tiles }) again. A rejected Verify (try-again on the same grid) is not: stop native attempts and use an alternate first-party source or request human help. Otherwise continue through no more than three distinct challenge types (checkbox, image grid, slider, …) before that handoff. When the challenge clears, verify the current application state before resuming. Replay the original action only if it is idempotent or the state proves it did not already complete; never duplicate a submission, purchase, or message.

When detection runs

Every run() result is scanned for a challenge, but the scan is staged. Stage 1 always reads the main frame — its title, its body text, its filled provider response fields, and the geometry and (same-origin) text of its child frames. Stage 2 reads every frame individually, which costs a round trip per frame and so runs only when something already points at a challenge:

  • a provider frame or main-document URL (reCAPTCHA, hCaptcha, Turnstile, the Cloudflare challenge platform, Google /sorry, Bing challenge paths);
  • main-frame or same-origin-frame text that the detector matches;
  • a 403, 429 or 503 document response — main frame or subframe — within the last 10 seconds;
  • a challenge still unresolved at the end of the previous run();
  • any frame stage 1 could not read (cross-origin) whose URL is challenge-shaped — the commercial vendors that do not name a provider in their frame URL (DataDome, Arkose/FunCaptcha, AWS WAF, PerimeterX, GeeTest) as well as self-hosted …/captcha/…, …/challenge…, …/verify… endpoints;
  • up to three unreadable cross-origin frames regardless of what their URLs say.

captcha.solve() and captcha.detect() never stage: they always read every frame.

Accepted limitation. A page carrying more than three cross-origin frames whose URLs are entirely unremarkable, where one of them is a challenge identifiable only by its text, is not detected automatically. Text is the only evidence in that case and reading it is exactly the per-frame cost the staging exists to avoid. Call captcha.detect() explicitly if you are working a page you expect to challenge you from an opaque embedded frame.

Efficiency

The solver adds no worker processes, ONNX/TensorFlow runtimes, or OCR binaries. Peak cost is a few short Playwright frame queries, optional PNG screenshots already budgeted by BetterWright's artifact quota, and ordinary mouse events. Host vision (when needed for image grids) reuses the model you already run the agent with — it is not a second paid captcha API.

Limits

These helpers reproduce normal mouse interaction, wait for provider response fields (g-recaptcha-response, h-captcha-response, cf-turnstile-response), and provide a token-efficient vision crop. They do not manufacture reCAPTCHA, hCaptcha, or Turnstile tokens offline, send challenges to a third-party solver, or guarantee that a provider will accept the managed browser. An invisible or scored challenge may have no native interaction to perform; preserve the page and request human help instead of looping or changing identity.

Public unit and browser fixtures cover the local pipeline end-to-end.

Live contract tests are not verified solves

The optional BETTERWRIGHT_LIVE_CAPTCHA=1 tests in tests/node/captcha-e2e.test.ts exercise Google reCAPTCHA, hCaptcha, and Cloudflare Turnstile demos. A passing test validates the helper contract, including its permitted ready, processing, and error statuses and the local/no-external-API assertions made by that test. It does not establish that the CAPTCHA was solved: a processing result with no token can pass, as can a helper-reported error. Bot-scoring may still block headless or datacenter IPs regardless of correct clicks.

Each demo logs an explicit harness-only outcome:

OutcomeEvidence
errorThe helper returned error; this takes precedence even if a response field contains a token.
token_receivedA nonempty provider response field was observed after the attempt, without a helper error. This is token receipt, not server acceptance.
needs_visionNo token was observed and the helper returned processing for an image-grid or text stage. Host vision is still needed; the live test does not perform that step.
unresolvedNo token was observed and the helper returned another processing stage. The bounded demo attempt did not establish completion; this alone does not establish that human handoff is required.
unverifiedNo token was observed and none of the above applies, including ready when a widget disappeared or no challenge was detected.

The logs report only response lengths (tokenLen), including hCaptcha's; they do not print token values. Every demo reports serverAcceptance: "unverified" because these tests do not submit the demo form and verify the server's response. A verified solve requires explicit server acceptance or a confirmed successful protected action, not merely a token, a ready status, or a green contract test. This reporting lives only in the test harness; it adds no runtime envelope fields or model-token cost.

Edit this page on GitHubSynced from commit 24c7cd0 on 2026-09-10