How to Solve CAPTCHA in nodriver With the Async SDK

A nodriver captcha step is the usual three moves: read the sitekey off the page, send it to a solver, write the token back with JavaScript. What changes is that nodriver is asynchronous all the way down. It drives Chrome over an asyncio websocket, so a blocking solve does not just make your script wait, it stalls the socket that carries every DevTools message. Pair it with the async client and the whole thing stays responsive.
What you need
- Python 3.10 or newer, with nodriver 0.50 and the CapSkip SDK installed.
- Chrome, Chromium, Edge or Brave installed where the script runs. nodriver launches it directly.
- 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.
# Both packages, one line. pip install nodriver capskip
Why nodriver changes the shape of this
nodriver is the official successor to undetected-chromedriver, written by the same author, and its headline is that there is no webdriver and no Selenium anywhere in the stack. It speaks the DevTools Protocol to a browser it launched itself. No chromedriver binary to patch, no driver version to keep in step with Chrome.
The part that matters for CAPTCHA work is the second half of that sentence: it is fully asynchronous. The connection is a websocket handled by asyncio, and a background task reads protocol messages off it. Every element lookup, every navigation and every event handler depends on that task getting scheduled. Call a synchronous solver in the middle and nothing else in the process runs for the length of the solve, which for reCAPTCHA v2 is routinely fifteen to forty-five seconds.
So the rule for this framework is short. Use the async client, and await it.
One thing worth being straight about: no webdriver does not mean no detection. Removing the driver removes one signal and leaves the rest of your fingerprint where it was. Solving a challenge and looking like a browser are separate jobs, and this post covers the first.
Step 1: read the sitekey off the page
The sitekey lives on the host document, not inside the widget iframe. Google’s markup puts it on a container as a data-sitekey attribute, and nodriver’s select method finds that container by CSS selector. Watch the bracket access on the last line, because it is the thing that catches people out.
# pip install nodriver
import nodriver as uc
async def main():
browser = await uc.start()
page = await browser.get("https://example.com/page-with-recaptcha")
# select() retries for 10 seconds by default, so it doubles
# as a wait condition for a widget that renders late.
holder = await page.select("div.g-recaptcha")
sitekey = holder.attrs["data-sitekey"]
print(sitekey) # 6Lxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
uc.loop().run_until_complete(main())Attribute names are stored on the element exactly as the HTML spelled them, hyphens included, so bracket access is the only reliable way to read one. The dotted shortcut looks like it should work and quietly does not: asking an element for data_sitekey hands back None rather than raising, because that lookup falls through to a default. The None then travels to the solver as an empty key and surfaces much later as ERROR_GOOGLEKEY, a long way from the line that caused it. There is exactly one real rename to know about, which is that the class attribute is stored under class_ to keep it off the Python keyword.
Some sites never expose the sitekey on the host page and only pass it in the widget iframe URL. Read it from the query string instead.
# Fallback: the k= parameter on the anchor iframe.
from urllib.parse import urlparse, parse_qs
frame = await page.select("iframe[src*='recaptcha/api2/anchor']")
sitekey = parse_qs(urlparse(frame.attrs["src"]).query)["k"][0]Step 2: solve it without stalling the socket
The Python SDK ships two clients. CapSkip is synchronous and AsyncCapSkip is a genuine asyncio implementation rather than an alias, which is exactly what this framework needs. Both talk to the solver on your own machine on port 8080, and neither bills per solve.
# pip install capskip from capskip import AsyncCapSkip solver = AsyncCapSkip(host="127.0.0.1", port=8080) # Same call shape for v3 (version="v3") and Enterprise # (enterprise=1). Invisible v2 takes invisible=1. result = await solver.recaptcha(sitekey=sitekey, url=PAGE_URL) token = result["code"] # the g-recaptcha-response value
Because the call is awaitable, several tabs can solve at once without any threading. Open the pages, gather the solves, then inject each token into the tab it belongs to. The wider pattern, including how the SDK backs its polling off instead of sleeping on a flat interval, is covered in solving CAPTCHAs in parallel.
# Three tabs, three solves, one wait.
import asyncio
results = await asyncio.gather(*[
solver.recaptcha(sitekey=k, url=u) for k, u in targets
])Turnstile and GeeTest have their own methods, and both take the same shape as the call above. Full parameter lists for each are in the CapSkip API documentation.
Step 3: inject the token and submit
The response textarea is hidden with display:none, so typing into it is not an option in any automation tool. You write it with JavaScript. nodriver’s evaluate method takes an expression string with no way to pass arguments alongside it, so the token has to be embedded in that string, and the safe way to do that is json.dumps rather than an f-string. A JSON string literal is a valid JavaScript string literal, quoting and escaping included.
# json.dumps gives a correctly quoted JS string literal.
import json
await page.evaluate(
"document.getElementById('g-recaptcha-response').value = "
+ json.dumps(token)
)
# Then submit the form the way the page expects.
button = await page.select("button[type=submit]")
await button.click()Checking that the value landed has a trap of its own, and it is worth ten seconds of your attention. With return_by_value set, evaluate only hands back the plain Python value when that value is truthy. An empty string or a zero falls through and you get a protocol object instead. So do not read the length and test it, because a length of zero is the exact case you are trying to detect. Return something that can never be falsy.
# String() keeps a zero-length answer truthy, so the check
# reports the real number instead of a protocol object.
length = await page.evaluate(
"String(document.getElementById('g-recaptcha-response').value.length)",
return_by_value=True,
)
print(length) # "0" means the injection did not landIf the site defines a callback instead of reading the textarea on submit, call it after setting the value. The function name is site-specific, so read it out of the page’s own markup rather than guessing. This is still ordinary reCAPTCHA v2 either way: the callback changes how you hand the token over, not how it gets solved. The reCAPTCHA v2 solver page covers both submission styles.
Full working example
Everything above in one script. The solver is created once and reused, and the browser is stopped in a finally block so a failed solve does not leave a Chrome process behind.
# pip install nodriver capskip
import json
import nodriver as uc
from capskip import AsyncCapSkip
PAGE_URL = "https://example.com/page-with-recaptcha"
async def main():
solver = AsyncCapSkip(host="127.0.0.1", port=8080)
browser = await uc.start()
try:
page = await browser.get(PAGE_URL)
holder = await page.select("div.g-recaptcha")
sitekey = holder.attrs["data-sitekey"]
if not sitekey:
raise RuntimeError("Widget found but data-sitekey was empty.")
result = await solver.recaptcha(sitekey=sitekey, url=PAGE_URL)
await page.evaluate(
"document.getElementById('g-recaptcha-response').value = "
+ json.dumps(result["code"])
)
button = await page.select("button[type=submit]")
await button.click()
await page.sleep(2)
print(page.target.url) # the page you land on after submitting
finally:
browser.stop()
uc.loop().run_until_complete(main())Running the solver on another machine
nodriver ends up on a server sooner or later, and it has two needs there: a Chromium binary, and a desktop session for Chrome to draw on. Headless mode is off by default, so a server without one needs headless switched on explicitly. The solver does not have to make that trip with it.
CapSkip has two connection modes. Local binds to 127.0.0.1 and answers only that device, which is the right setting while you are writing the script. Server binds to your network or public IP, so a scraping VM, a container host or a second workstation calls the same solver over the API. A static public IP keeps that address stable. Nothing in the code changes except the host you pass in, and nothing about the cost changes either, because it is still your hardware.
# Same SDK, same call. Only the host moves. solver = AsyncCapSkip(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 rest. The setup guide walks through both modes.
One naming collision to keep straight while you do that. nodriver’s own start function also accepts a host and a port, and those describe a Chrome debugging endpoint you want to attach to, not the solver. Supply both and nodriver will not launch a browser at all. The solver address belongs to the client constructor and nowhere else.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| AttributeError on an attrs lookup | select() found nothing and handed back None | Widen the selector, or raise the select timeout |
| Sitekey is None with no error at all | Dotted access cannot reach a hyphenated attribute | Read it from attrs with bracket access |
| ERROR_GOOGLEKEY | An empty sitekey reached the solver | Check the value before spending a solve on it |
| The script hangs for the whole solve | A synchronous client blocked the event loop | Use AsyncCapSkip and await the call |
| NetworkException | CapSkip is not running, or the host is wrong | Start the app, or point host at the server address |
| TimeoutException | The solve outlasted recaptchaTimeout | Raise it above the default 300 seconds |
| evaluate returns an object, not a string | The value was falsy, so the plain return was skipped | Wrap the length in String(), not the value itself |
FAQ
Can I keep the synchronous client if I only solve once?
You can, and on a short script you may never notice. What you are trading away is every protocol message that arrives during the solve: navigation events, load events, anything an event handler was waiting for. On a long run that shows up as lookups timing out for no visible reason. The async client costs one import and one await, so there is not much reason to take the trade.
Do I need to enter the reCAPTCHA iframe?
No, and this is the part people over-engineer. The checkbox lives in an iframe, but the sitekey attribute and the hidden response textarea both belong to the host document. You only touch a frame when the site withholds the sitekey from the page and you have to read it out of the frame’s own URL.
I am migrating from undetected-chromedriver. What carries over?
The three moves carry over unchanged, because they were never driver-specific: read the sitekey, solve it, write the token into the textarea. What does not carry over is the API around them, since every call is now awaitable and there is no driver object. nodriver also ships a helper that converts a running undetected-chromedriver instance into a browser object, which lets you move a script in stages. The older approach is written up in the undetected-chromedriver CAPTCHA guide.
My scraper runs on a VPS. Where does the solver go?
Wherever you like, as long as the two can reach each other. Server mode makes the solver listen on a network address instead of the loopback address, so the VPS calls it over the API exactly as it would any internal service. Point the host argument at that address, enable key validation, and give the VPS its own key. The solver needs no display, which is convenient given that the browser does.
The short version
Read the sitekey with bracket access on attrs, solve it with AsyncCapSkip on 127.0.0.1:8080, inject the token through evaluate with json.dumps, then submit. Await everything, because a synchronous solve holds the socket that drives the browser. For the wider Python picture, including Selenium and Playwright, see the Python CAPTCHA solver page.
One consequence is worth spelling out before you scale a crawl up. Because CapSkip is an unlimited captcha solver running on your own hardware, a run that retries a thousand pages costs exactly what one that retries ten costs.
