Solve CAPTCHAs Mid-Automation

View as Markdown

Your code drives the browser over CDP. When a challenge blocks the flow, hand the same browser to the hosted CAPTCHA solver - it runs inside the runtime, on the page your automation is already on - then continue where you left off.

The solver handles reCAPTCHA, Cloudflare Turnstile, hCaptcha, GeeTest, Arkose, DataDome, Amazon WAF, and more, with an AI vision fallback for challenges it doesn’t recognize. You never tell it which type is on the page; it detects that itself.

1import { Bctrl } from "@bctrl/sdk";
2import { chromium } from "playwright";
3
4const bctrl = new Bctrl({ apiKey: process.env.BCTRL_API_KEY! });
5
6const runtime = await bctrl.runtimes.create({
7 type: "browser",
8 name: "captcha-recipe",
9 config: { stealth: "high" },
10});
11const { connectUrl } = await bctrl.runtimes.start(runtime.id);
12
13const browser = await chromium.connectOverCDP(connectUrl);
14const context = browser.contexts()[0] ?? (await browser.newContext());
15const page = context.pages()[0] ?? (await context.newPage());
16
17await page.goto("https://www.google.com/recaptcha/api2/demo");
18
19// Playwright pauses; the solver clears the challenge on the same page.
20const solve = await bctrl.runtimes.invocations.createAndWait(runtime.id, {
21 action: "solveCaptcha",
22 target: "active",
23 timeoutMs: 120_000,
24});
25
26if (solve.status === "succeeded") {
27 await page.click("#recaptcha-demo-submit"); // continue the flow
28} else if (solve.error?.code === "invocation.captcha_not_found") {
29 // Nothing was blocking the page - carry on.
30} else if (solve.error?.code === "invocation.captcha_solve_failed") {
31 const retryable = solve.error.details?.retryable === true;
32 // Retry the invocation, or hand the page to a human via live view.
33}
34
35await browser.close();
36await bctrl.runtimes.stop(runtime.id);

🎬 Content TODO: record a short GIF/video of a solve happening in the live view (the reCAPTCHA demo page or a GeeTest slider being dragged is the most impressive) and embed it here.

Use one controller at a time: stop issuing CDP commands while the solve runs, the same way you would for any hosted invocation. The two failure codes are deliberate - captcha_not_found means your flow can proceed, captcha_solve_failed means it can’t, and details.retryable tells you whether trying again is worth it.

Every solve also lands on the run’s timeline as captcha.solve.started / succeeded / failed events, so you can audit how often challenges appear in production.

Fewer challenges in the first place

Most challenges never fire when the runtime runs with stealth and a residential proxy. Treat the solver as the fallback, not the strategy - see Runtime configuration.

Next