nodriver and zendriver: setup, proxies and hCaptcha.
nodriver is an async Python library that drives Chrome without
Selenium or ChromeDriver. How to install it, a quick start, headless mode
and proxies, how its fork zendriver differs, and what to do
when a site shows hCaptcha.
What nodriver is
nodriver is written by UltrafunkAmsterdam, who also wrote undetected-chromedriver. Its PyPI page says, in bold: “This is the official successor of the Undetected-Chromedriver python package.” and “No more webdriver, no more selenium”. Where undetected-chromedriver patches the ChromeDriver that Selenium uses, nodriver leaves both out: it starts the browser itself and sends commands over the Chrome DevTools Protocol (CDP), the same channel Chrome’s developer tools use.
| Package | Maintainer | Latest release | Licence and Python |
|---|---|---|---|
nodriver | UltrafunkAmsterdam | 0.50.99, 26 October 2026 | AGPL-3.0, Python 3.9+ |
zendriver, a fork of nodriver | Stephan Lensky, cdpdriver on GitHub | 0.17.1, 2 October 2026 | AGPL-3.0, Python 3.10+ |
The README lists Chromium, Chrome, Edge and Brave as “known to work with”, and sums up its aim as “the optimization to stay undetected for most anti-bot solutions”. Those are the project’s claims; we did not test them against any anti-bot service.
How it differs from Selenium and undetected-chromedriver
- No WebDriver. There is no ChromeDriver to download or keep in step with your Chrome version, so the version-mismatch error that undetected-chromedriver users know does not come up.
- Async. You await nearly every call inside an
asyncfunction. A Selenium script has to be rewritten, not just re-imported. - A fresh profile per run. By default nodriver starts
Chrome with a new temporary profile and deletes it on exit. Pass
user_data_dirto keep one. - Its own element lookup.
tab.select()takes a CSS selector andtab.find()searches by visible text; both wait for the element to appear. Onlyfind()andselect_all(include_frames=True)look inside iframes: in our runselect()returnedNonefor an input inside one.
Selenium-Driverless also drives Chrome over CDP without ChromeDriver, but keeps Selenium’s method names; it is now out of maintenance.
Install nodriver
pip install nodriver
It needs Python 3.9 or newer, and it does not bring a browser: install
Chrome or another Chromium browser first, “preferably in the default
location”, as the README puts it, or point to it with
browser_executable_path. Its only
dependencies are websockets, mss and
deprecated.
One thing to know when you check which version you have:
nodriver.__version__ in the
0.50.99 package still says 0.50.8. Use
pip show nodriver instead.
Quick start in Python
import nodriver as uc
async def main():
browser = await uc.start(headless=True)
tab = await browser.get("https://your-site.example/")
print(await tab.evaluate("navigator.webdriver"))
print(await tab.evaluate("document.title"))
browser.stop()
if __name__ == "__main__":
uc.loop().run_until_complete(main())
We ran this on 2 November 2026 with nodriver 0.50.99, Python 3.13 and
Chrome 156, headless. navigator.webdriver read
False. The README starts its examples with
uc.loop().run_until_complete(main()),
noting that “asyncio.run never worked (for me)”; in our run,
asyncio.run(main()) worked as
well. browser.stop() is a plain
method in nodriver, so it is not awaited.
Finding and filling elements looks like this:
button = await tab.find("accept all")
await button.click()
field = await tab.select("input[name=username]")
await field.send_keys("my-username") find() matches by text and, by default, prefers the element
whose text is closest in length, so “accept all” lands on the cookie
button rather than a script that contains the words.
Headless mode
uc.start(headless=True) runs
Chrome’s headless mode. The README’s first choice on a server
with no screen is a virtual display such as Xvfb; headless is the
alternative. In our run, headless nodriver kept
navigator.webdriver false, but both
navigator.userAgent and the User-Agent header
said HeadlessChrome/156.0.0.0. Passing a normal user agent
yourself removed it from both:
ua = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36"
)
browser = await uc.start(headless=True, browser_args=[f"--user-agent={ua}"]) Use a proxy with nodriver
To send the whole browser through one proxy, give Chrome its own flag:
browser = await uc.start(
browser_args=["--proxy-server=http://203.0.113.10:8080"],
)
Chrome’s flag has no place for a username and password. For a proxy
that needs a login, nodriver has
browser.create_context(), which
opens a new browser context (its own cookies and storage) with its own
proxy. For any proxy you pass there, nodriver starts a small proxy on
your machine that forwards to it and adds the login when the URL has
one:
browser = await uc.start()
tab = await browser.create_context(
"https://your-site.example/",
proxy_server="http://user:pass@host:port",
)
We tested both against local test proxies. With the flag, every request
went through the proxy. With create_context, an https page
loaded through a proxy that required a login, and the login was sent. A
plain http:// page did not load through that context:
nodriver’s local proxy only passes on https traffic. The docstring
marks proxy_server as “(EXPERIMENTAL)”.
Is nodriver still maintained?
Yes. Version 0.50.99 went to PyPI on 26 October 2026, the fourth release that day, after 0.50.7, 0.50.8 and 0.50.9. The project has had 50 releases since the first one in February 2024, and the author wrote 149 of its commits. Its GitHub tracker takes issues and pull requests; 22 were open on 2 November 2026. PyPI still classifies it as “Development Status :: 3 - Alpha”, and the README asks you to “test thoroughly, especially if you run large projects” after the 0.50 rewrite of how it connects to pages.
zendriver, the nodriver fork
zendriver
is maintained by Stephan Lensky under the cdpdriver
organisation on GitHub. Its README says it “is a fork of
ultrafunkamsterdam/nodriver, created to add new features, compile unmerged
bugfixes, and increase community engagement”, and gives the reason:
“contributions to the original nodriver repo are heavily restricted,
making it difficult to submit issues or pull requests.” That was written
when the fork started; nodriver’s own tracker took new issues and
pull requests in October 2026. zendriver’s latest
release is 0.17.1, from 2 October 2026, and 61 issues and pull requests
were open on 2 November 2026. Its docs are at
zendriver.dev.
pip install zendriver import asyncio
import zendriver as zd
async def main():
browser = await zd.start(headless=True)
page = await browser.get("https://your-site.example/")
print(await page.evaluate("navigator.userAgent"))
await browser.stop()
if __name__ == "__main__":
asyncio.run(main())
The API is close to nodriver’s: start(),
browser.get(), select(), find() and
evaluate() work the same way. What we saw differ when we ran
both on 2 November 2026:
| nodriver 0.50.99 | zendriver 0.17.1 | |
|---|---|---|
| Run it with | uc.loop().run_until_complete(main()) in the README; asyncio.run worked too | asyncio.run(main()) |
| Close the browser | browser.stop(), not awaited | await browser.stop() |
| Headless user agent | Says HeadlessChrome unless you pass --user-agent | Drops “Headless” from it by itself |
| Your own user agent | browser_args=["--user-agent=..."] | zd.start(user_agent="...") |
| Issues and pull requests | 22 open | 61 open |
zendriver’s create_context() also takes a proxy with a
login; in our test it loaded an https page through one. Its
start() also takes
browser="chrome", "msedge"
or "brave" to choose which installed browser to launch.
Another Python library that drives Chrome over CDP without WebDriver is Pydoll, which has both a sync and an async API; our Pydoll guide covers its setup, proxies and hCaptcha.
What nodriver does when a site shows hCaptcha
The nodriver repository describes itself as a framework for “web
automation, webscraping, bots and any other creative ideas which are
normally hindered by annoying anti bot systems like Captcha / CloudFlare /
Imperva / hCaptcha”. Neither library has any hCaptcha code, though. The one
challenge helper in each, tab.verify_cf(),
looks for Cloudflare’s checkbox and clicks it; the nodriver README
lists it as cf_verify() and says it needs
opencv-python and works with English pages only. The README
also notes that since 0.50.1 find() searches inside iframes,
so you can “click the verification checkbox in js challenges”. Clicking
hCaptcha’s checkbox only hands the decision to hCaptcha, which may
answer with a puzzle.
A browser that shows fewer automation signals is one of several things
that decide whether hCaptcha challenges you at all;
why am I getting hCaptcha
covers the others, starting with the reputation of your IP address. Once
hCaptcha is on the page, the site accepts the form only with a valid
token in the h-captcha-response
field, a P1_ string its server checks with hCaptcha.
Invisible hCaptcha needs that token too. nodriver and zendriver do not
produce one.
Get an hCaptcha token into a nodriver session
NoneCap is an hCaptcha solving API. Keep nodriver for the rest of the run,
and when a page has hCaptcha, send its sitekey and
url to NoneCap, get a P1_ token back, put it in
the form and submit. The Python SDK
(pip install nonecap) has an async
client, AsyncNoneCap, that fits nodriver’s event loop,
and solve() waits for the result:
import os
import nodriver as uc
from nonecap import AsyncNoneCap
async def main():
browser = await uc.start()
tab = await browser.get("https://your-site.example/signup")
widget = await tab.select("[data-sitekey]")
async with AsyncNoneCap(api_key=os.environ["NONECAP_KEY"]) as nc:
solve = await nc.solve(
type="hcaptcha",
sitekey=widget.attrs["data-sitekey"],
url=await tab.evaluate("location.href"),
user_agent=await tab.evaluate("navigator.userAgent"),
)
await fill_and_submit(tab, solve.token)
browser.stop()
Then put the token in both response fields, call the widget’s
callback if the page set one with data-callback, and submit:
import json
FILL = """(() => {
const token = %s;
for (const name of ["h-captcha-response", "g-recaptcha-response"]) {
const el = document.querySelector(`textarea[name="${name}"]`);
if (el) el.value = token;
}
const cb = document.querySelector("[data-callback]")?.getAttribute("data-callback");
if (cb && typeof window[cb] === "function") window[cb](token);
})()"""
async def fill_and_submit(tab, token):
await tab.evaluate(FILL % json.dumps(token))
button = await tab.select("button[type=submit]")
await button.click()
if __name__ == "__main__":
uc.loop().run_until_complete(main())
We tested the fill-and-submit step against a local test page with an
hCaptcha-style form and a fake token: both fields held the token, the
page’s callback received it, and the form posted it as
h-captcha-response and
g-recaptcha-response. The same
fill-and-submit step ran in zendriver with import zendriver as zd,
await browser.stop() and
asyncio.run.
Sending the browser’s own user_agent is optional; some
sites need it for the token to be accepted. If the browser goes through a
proxy, you can pass the same one to NoneCap as proxy
(http://user:pass@host:port) so the
token and the form come from the same IP address. Failed, cancelled and
expired solves are never charged.
Every field, status and error is in the API reference. NoneCap solves hCaptcha only; it does not help with reCAPTCHA, Turnstile or other CAPTCHAs.
Last updated November 2026.
Frequently asked
What is nodriver?
pip install nodriver.Is nodriver the successor of undetected-chromedriver?
Is nodriver still maintained?
What is zendriver, and should I use it or nodriver?
asyncio.run, awaits browser.stop(), takes a user_agent argument and hides “Headless” in the user agent by itself. Pick the one whose releases and issue tracker suit you; both were updated in October 2026.Does nodriver work in headless mode?
uc.start(headless=True). The README prefers a virtual display such as Xvfb on servers and calls headless the alternative. In our run, headless nodriver kept navigator.webdriver false but sent HeadlessChrome in its user agent; passing your own --user-agent in browser_args removed it.How do I use a proxy with nodriver?
browser_args=["--proxy-server=http://host:port"]. For a proxy with a username and password, open a new context with browser.create_context(url, proxy_server="http://user:pass@host:port"); nodriver runs a small local proxy that adds the login. In our test that loaded an https page through a proxy that requires a login.Does nodriver bypass hCaptcha?
verify_cf, clicks Cloudflare’s checkbox. When a site shows hCaptcha, the form still needs a valid token in h-captcha-response, and neither library produces one. NoneCap returns that token.