How to Solve CAPTCHA in Patchright Without Breaking Stealth

patchright captcha - How to Solve CAPTCHA in Patchright Without Breaking Stealth

A Patchright captcha flow is the same three moves as any other: read the sitekey, send it to a solver, put the token back in the page. The third move is where this library surprises people. Patchright runs your JavaScript in an isolated context by default, and an isolated context shares the page’s DOM but not the page’s JavaScript global. So the token write lands, the textarea really does hold the value, and the site’s own callback never runs because it does not exist in the scope your code is executing in. One extra argument fixes it.

What you need

  • Python 3.10 or newer, or Node.js if you prefer the JavaScript package. Patchright ships for both runtimes and is a drop-in replacement for Playwright in each.
  • A Chromium browser downloaded through Patchright’s own installer. Firefox and WebKit are not patched and are not supported.
  • The page URL of the protected form. The sitekey is read at runtime.
  • CapSkip running in Local mode when the script and the solver share a machine, or in Server mode when they do not. Both are described under connection settings.
# pip install patchright
pip install -U capskip patchright

# Pulls the browser. Real Chrome is the recommended channel,
# and the maintainers say so explicitly.
patchright install chrome

What Patchright actually changes

Patchright is Playwright with the obvious automation tells removed. The headline patch is that it never calls Runtime.enable, which is the single loudest signal a stock Playwright session gives off, and it gets there by running your scripts in isolated ExecutionContexts instead. It also disables the Console API outright, adds the flag that hides navigator.webdriver, and drops several Playwright defaults that mark a session as automated: the automation flag itself, the popup blocker override, the component update block, and the switches that disabled default apps and extensions.

Two of those have direct consequences for CAPTCHA work, and both are easy to trip over.

The console being off means nothing you log from inside the page reaches you. Console functionality does not work at all in Patchright, so the usual habit of dropping a log line into an evaluate call and reading it from the driver is dead here. Return a value out of the call instead. That is better practice anyway and it is the only option you have.

Extensions being re-enabled is the friendlier one. Playwright normally launches with extensions disabled, and Patchright removes that switch, so a browser extension loaded into a persistent profile actually runs. If you would rather not write any of this code, the CapSkip browser extension handles the widget in the page and you drive the form as if a human had passed it.

One more capability worth knowing: Patchright reaches into closed shadow roots with ordinary locators and XPaths. Widgets that hide their markup behind a closed root are addressable without any special handling.

Step 1: launch it the way the maintainers recommend

Patchright’s stealth depends on the launch configuration as much as on the patches. The documented setup is a persistent context on the real Chrome channel, with a visible window and no viewport override, and no custom user agent or headers at all. Those last two matter: a hand-set user agent contradicts the rest of the fingerprint and undoes the work.

# pip install patchright
from patchright.sync_api import sync_playwright

with sync_playwright() as p:
    context = p.chromium.launch_persistent_context(
        user_data_dir="C:\\profiles\\scraper",
        channel="chrome",
        headless=False,
        no_viewport=True,
        # Do not set user_agent or extra headers here.
    )
    page = context.new_page()
    page.goto("https://example.com/page-with-recaptcha")

Note the visible window. Headless is where most detection budget gets spent, and the recommended configuration does not use it. On Windows that means the account running the script needs an interactive desktop session, which is worth planning for before you put this on a server.

Step 2: read the sitekey off the page

The sitekey sits on the host document, not inside the widget iframe, and a plain locator reads it. Locators travel over the browser protocol rather than through any execution context, so nothing about the isolated world affects this step.

# Locators auto-wait, so this doubles as a wait condition
# for a widget that renders late.
holder = page.locator("div.g-recaptcha")
holder.wait_for(state="attached", timeout=15000)

sitekey = holder.get_attribute("data-sitekey")
print(sitekey)   # 6Lxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Some sites never put the key on the host page and pass it only in the widget iframe URL. Read it out of the query string in that case, and check it before spending a solve, because an empty value travels all the way to the solver and comes back as ERROR_GOOGLEKEY, a long way from the read that actually failed.

# Fallback: the k= parameter on the anchor iframe.
from urllib.parse import urlparse, parse_qs

src = page.locator("iframe[src*='recaptcha/api2/anchor']").get_attribute("src")
sitekey = parse_qs(urlparse(src).query)["k"][0]

Step 3: solve it on your own machine

The Python SDK talks to CapSkip on port 8080 and hands back the token as a plain string. One method covers reCAPTCHA v2, Invisible, Enterprise and v3, with the variants passed as options rather than as separate calls.

# pip install capskip
from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

# Invisible v2 takes invisible=1, Enterprise takes enterprise=1,
# and v3 takes version="v3" with an action.
result = solver.recaptcha(sitekey=sitekey, url=PAGE_URL)

token = result["code"]   # the g-recaptcha-response value

Turnstile and GeeTest have their own methods and take the same shape. Turnstile also returns the user agent the solve was made with, and a challenge page rejects the token unless that user agent is sent back with it. Send it on the form request only. Do not feed it to the browser launcher, because a hand-set user agent is exactly what the stealth configuration tells you to avoid. Full parameter lists are in the CapSkip API documentation.

Step 4: put the token where the page can use it

Here is the part that is specific to this library. The response textarea is hidden with display:none, so nothing can type into it and the value has to be assigned with JavaScript. That assignment works from the isolated context, because the DOM is shared. Build the string with json.dumps rather than an f-string, since a JSON string literal is also a valid JavaScript string literal, quoting and escaping included.

# A DOM write is fine from the isolated context.
import json

page.evaluate(
    "document.getElementById('g-recaptcha-response').value = "
    + json.dumps(token)
)

page.click("button[type=submit]")

That covers the sites whose form reads the textarea on submit. Plenty of sites do not. They register a success callback with the widget and never look at the textarea at all, so the token has to be handed to a function the page defined. Patchright’s isolated context has its own JavaScript global, which means the page’s functions and the reCAPTCHA client configuration are simply absent from it. The call fails with a reference error and no amount of retrying changes that.

Patchright’s answer is an extra argument. The evaluate, evaluate_handle and evaluate_all methods all take isolated_context, it defaults to True, and setting it to False runs the script in the page’s own main world.

# The main world is where the page's own globals live.
page.evaluate(
    "token => window.onRecaptchaSuccess(token)",
    token,
    isolated_context=False,
)

Read the callback name out of the page’s markup rather than guessing it. Use the main world for the injection and nothing else: code running there is visible to the site, which is the whole reason the isolated context is the default. Either submission style is still ordinary reCAPTCHA v2 underneath, and both are written up on the reCAPTCHA v2 solver page.

Full working example

Everything above in one script. The solver is created once, the sitekey is checked before a solve is spent on it, and the token is verified by returning a length rather than by logging inside the page.

# pip install capskip patchright
import json
from patchright.sync_api import sync_playwright
from capskip import CapSkip

PAGE_URL = "https://example.com/page-with-recaptcha"

solver = CapSkip(host="127.0.0.1", port=8080)

with sync_playwright() as p:
    context = p.chromium.launch_persistent_context(
        user_data_dir="C:\\profiles\\scraper",
        channel="chrome",
        headless=False,
        no_viewport=True,
    )
    page = context.new_page()
    page.goto(PAGE_URL)

    holder = page.locator("div.g-recaptcha")
    holder.wait_for(state="attached", timeout=15000)
    sitekey = holder.get_attribute("data-sitekey")
    if not sitekey:
        raise RuntimeError("Widget found but data-sitekey was empty.")

    token = solver.recaptcha(sitekey=sitekey, url=PAGE_URL)["code"]
    page.evaluate(
        "document.getElementById('g-recaptcha-response').value = "
        + json.dumps(token)
    )

    length = page.evaluate(
        "document.getElementById('g-recaptcha-response').value.length"
    )
    print(length)   # 0 means the injection did not land

    page.click("button[type=submit]")
    page.wait_for_load_state("networkidle")
    context.close()

Running the solver on another machine

Patchright usually ends up on a bigger box than the one you wrote the script on, and the recommended visible-window setup pushes people towards a dedicated VM fairly quickly. The solver does not have to move with it.

CapSkip has two connection modes. Local binds to 127.0.0.1 and answers that device only, which is right while you are writing the script on the machine the app runs on. Server binds to your network or public IP, so a scraping VM, a second workstation or a whole pool of them call the same Windows machine over the API. A static public IP keeps that address stable. Nothing in the code changes except the host, and nothing about the cost changes either, because it is still hardware you own.

# Same SDK, same call. Only the host moves.
solver = CapSkip(host="10.0.0.12", port=8080, apiKey="YOUR_API_KEY")

Turn on key validation once the solver listens on a network address, and give each machine its own key so one can be revoked without touching the others. Both modes are walked through in the CapSkip setup guide.

Common errors and what they mean

What you seeCauseFix
The textarea holds the token but the form failsThe site uses a callback and never reads the textareaCall the callback with isolated_context=False
A reference error naming a page functionPage globals do not exist in the isolated contextSame fix: run that one call in the main world
Nothing arrives from a console log in the pagePatchright disables the Console API completelyReturn a value out of evaluate instead of logging
Firefox or WebKit behaves like plain PlaywrightOnly Chromium browsers are patchedUse the Chromium or Chrome channel
Blocked despite the patchesA custom user agent or header contradicts the fingerprintRemove them and use a persistent context on Chrome
ERROR_GOOGLEKEYAn empty sitekey reached the solverAssert the value before calling recaptcha
NetworkExceptionCapSkip is not running, or the host is wrongStart the app, or point host at the server address
TimeoutExceptionThe solve outlasted recaptchaTimeoutRaise it above the default of 300 seconds

FAQ

Can I port my Playwright script over unchanged?

Change the import and most of it runs. Locators, contexts, routes, waits and navigation all behave as they did. Three things need a second look: any evaluate call that touches something the page defined, which now needs the extra argument; anything that relied on console output, which is gone; and any Firefox or WebKit target, which is unpatched. The wider Playwright picture is on the Playwright CAPTCHA solver page.

Does using the main world get me detected?

In principle yes, since the page can see code running there. In practice the exposure is one function call lasting microseconds, and it looks the same as what the widget’s own script does when a human passes the challenge. Keep everything else in the isolated context, do the injection in a single call rather than several, and the surface stays small.

Should I click the Turnstile widget instead of injecting a token?

Sometimes. A Turnstile checkbox in managed mode can pass on its own when the browser looks convincing, which is exactly what Patchright is for, so it is worth trying the click first and falling back to a solve. A reCAPTCHA checkbox click only opens an image challenge, so there is nothing to gain there. The widget side is covered on the Cloudflare Turnstile solver page.

My crawler runs on Linux. Where does CapSkip go?

On a Windows machine you control, with Server mode switched on. The Linux box then calls it over the API like any other internal service, so Patchright and the solver do not need to share an operating system or even a network segment. Point the host argument at that address, enable key validation, and give the crawler its own key.

The short version

Install Patchright, launch a persistent context on the Chrome channel with a visible window and no custom user agent, read the sitekey with an ordinary locator, and solve it against CapSkip on 127.0.0.1:8080. Write the token straight into the textarea from the default isolated context, and reach for isolated_context=False only when the site wants a callback called. For the rest of the Python landscape, including Selenium and Playwright, see the Python CAPTCHA solver page.

One thing is worth knowing before you scale a crawl up. CapSkip is an unlimited captcha solver that runs on hardware you already own, so retrying a thousand pages costs exactly what retrying ten costs.