Patchright: setup, Playwright differences and hCaptcha.
Patchright is Playwright with a patched driver for Chromium. How to install it in Python or Node, what it changes, what its README claims, and how to get an hCaptcha token in a Patchright session.
What Patchright is
Patchright is a fork of Microsoft’s Playwright. Its README describes it as “a patched and undetected version of the Playwright Testing and Automation Framework” that “can be used as a drop-in replacement for Playwright”. You keep the Playwright API and change the import. The patches sit in the driver, the part of Playwright that sends commands to the browser, so the page sees fewer of the traces automation leaves behind.
| Package | Install | Latest version | Needs |
|---|---|---|---|
patchright (PyPI) | pip install patchright | 1.64.3, released 25 October 2026 | Python 3.10+ |
patchright (npm) | npm i patchright | 1.64.2, published 25 October 2026 | Node 20+ |
The project lives in three repositories under Kaliiiiiiiiii-Vinyzu on GitHub: the driver, the Python package and the Node package. A .NET port is run by the community. The active maintainer is Vinyzu, with Kaliiiiiiiiii as co-maintainer, and the code is Apache 2.0. The driver repository dates from November 2023 and had about 5,000 GitHub stars on 2 November 2026. Version numbers follow Playwright’s: Patchright 1.64.x is built on Playwright 1.64, and its README says new versions deploy automatically, with fixes for Playwright changes sometimes taking a few days.
Patchright vs Playwright
Per the Patchright README, these are the differences you will notice:
| Playwright | Patchright | |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium-based browsers only |
navigator.webdriver | true | false, through changed launch flags (listed below the table) |
CDP Runtime.enable | Used | Avoided; scripts run in isolated execution contexts |
| Console | page.on("console") fires | Disabled: the README says console functionality will not work |
page.evaluate | Runs in the page’s own JavaScript world | Runs in an isolated world by default; an extra argument picks the page’s world |
| Closed shadow roots | Locators do not reach inside | Normal locators and XPath reach inside |
| Launch flags | Playwright’s defaults | One flag added and five dropped (listed below the table) |
The launch flags it changes, per the README: adding the first and dropping
the second make navigator.webdriver read false;
the others are dropped to avoid popup crashes, to look less like an
automation driver, and to allow default apps and extensions.
+ --disable-blink-features=AutomationControlled
- --enable-automation
- --disable-popup-blocking
- --disable-component-update
- --disable-default-apps
- --disable-extensions
We ran Patchright 1.64.3 (Python) and 1.64.2 (Node) next to Playwright
1.64.1 on 2 November 2026, headless, on Chromium 156. With Playwright,
navigator.webdriver read true, the page’s
console.log reached page.on("console"),
and page.evaluate could read a variable the page had set. With
Patchright, navigator.webdriver read false, no
console event arrived, and the default evaluate returned
None for that variable until we asked for the page’s world.
A button inside a closed shadow root was found by Patchright’s CSS and
XPath locators and by neither in Playwright. In headless mode the user
agent still said HeadlessChrome with both libraries;
Patchright does not rewrite it.
Install and use Patchright in Python
pip install patchright
patchright install chromium
Then write Playwright code with the patchright import:
from patchright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/")
print(page.title())
print(page.evaluate("navigator.webdriver")) # False (True in Playwright)
browser.close()
The async API is the same, from patchright.async_api:
import asyncio
from patchright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto("https://example.com/")
print(await page.title())
await browser.close()
asyncio.run(main()) Moving an existing Playwright project over is a change to the import lines:
- from playwright.sync_api import sync_playwright
+ from patchright.sync_api import sync_playwright
- import { chromium } from 'playwright'
+ import { chromium } from 'patchright' Install and use Patchright in Node
npm i patchright
npx patchright install chromium
The example uses top-level await, so save it as an
.mjs file. The README’s own example uses
require('patchright'), which works
too.
import { chromium } from 'patchright'
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto('https://example.com/')
console.log(await page.title())
console.log(await page.evaluate(() => navigator.webdriver)) // false
await browser.close() Reading page variables: the isolated world
Because Patchright runs evaluate in an isolated world by
default, your script sees the page’s DOM but not the JavaScript globals the
page defines. The README documents an extra option on
evaluate, evaluate_handle and
evaluate_all to choose the world. In Node 1.64.2 it is the
fourth argument, after the options argument the README writes as
...; reading that signature as a third argument throws
“Too many arguments”.
page.evaluate("window.appState") # None
page.evaluate("window.appState", isolated_context=False) # the page's value await page.evaluate(() => window.appState) // undefined
await page.evaluate(() => window.appState, undefined, undefined, false) // the page's value The setup its README recommends
The quick starts above run headless Chromium, which is what we tested.
For the least detectable setup, the README recommends Google Chrome instead
of Chromium (patchright install chrome),
a persistent profile, a visible window, no fixed viewport, and no custom
headers or user agent. We did not run this one, since it opens a visible
browser window:
from patchright.sync_api import sync_playwright
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir="./profile",
channel="chrome",
headless=False,
no_viewport=True,
)
page = context.new_page() What its README claims, and what it says to watch for
- Detection. “With the right setup, Patchright currently is considered undetectable.” The README then lists the bot checks and test pages it says it passes, among them Cloudflare, Akamai, DataDome, Kasada and CreepJS. hCaptcha is not on the list. We have not tested these claims.
- Chromium only. “Patchright only patches CHROMIUM based browsers. Firefox and Webkit are not supported.”
- Not every Playwright test passes. “Patchright passes most, but not all the Playwright tests.” The maintainers keep a list of the known failures in the driver repository’s issues.
- Init scripts go through routes. To run init scripts
without
Runtime.enable, Patchright injects them into HTML requests with Playwright routes, so they can cause the same bugs as routes, and the README notes they “can be detected by Timing Attacks”. - Educational use. The README is published “for educational purposes only” and puts responsibility for misuse on the user.
Does Patchright get past hCaptcha?
A patched driver changes what the browser reveals about automation, and that is one of the things that decide whether a site’s hCaptcha shows a challenge at all. Why am I getting hCaptcha covers the others, starting with the reputation of your IP address, which no driver patch changes.
Once a challenge appears, Patchright does nothing more. The site accepts
the form only with a valid token in the
h-captcha-response field, a
P1_ string its server checks with hCaptcha before it lets the
request through.
Invisible hCaptcha needs that
token too, even when no puzzle is shown. A patched driver does not
produce one.
Get an hCaptcha token in a Patchright session
NoneCap is an hCaptcha solving API. Keep Patchright for the rest of your
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. Failed, cancelled and expired solves are never
charged.
import os
import time
import requests
from patchright.sync_api import sync_playwright
API = "https://api.nonecap.com/v1/solves"
HEADERS = {"Authorization": f"Bearer {os.environ['NONECAP_KEY']}"}
def solve_hcaptcha(sitekey: str, url: str) -> str:
r = requests.post(
API,
headers=HEADERS,
params={"wait": 90},
json={"type": "hcaptcha", "sitekey": sitekey, "url": url},
timeout=120,
)
r.raise_for_status()
data = r.json()
while data["status"] in ("pending", "solving"):
time.sleep(2)
data = requests.get(f"{API}/{data['id']}", headers=HEADERS, timeout=30).json()
if data["status"] != "solved":
raise RuntimeError(f"solve {data['status']}: {data.get('error')}")
return data["token"]
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://your-site.example/signup")
sitekey = page.locator("[data-sitekey]").first.get_attribute("data-sitekey")
token = solve_hcaptcha(sitekey, page.url)
# isolated_context=False: the site's data-callback lives in the page's world,
# which Patchright's default evaluate cannot see.
page.evaluate(
"""(token) => {
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);
}""",
token,
isolated_context=False,
)
page.click("button[type=submit]")
browser.close()
One Patchright detail matters here. Many hCaptcha forms submit from a
data-callback function the page defines, and Patchright’s
default evaluate cannot see it. In our test with a stub
token, the default call filled the textarea but never called the
callback; with isolated_context=False
it did both. In Node, pass false as the fourth argument
of page.evaluate.
The Playwright guide has the full Node
version, enterprise (rqdata) sitekeys, and how to tell
hCaptcha from reCAPTCHA and Turnstile in the DOM; it applies to Patchright
once you change the import. If you use a stealth plugin instead of a
patched driver, see Playwright
stealth. Every field and status 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 Patchright?
patchright on PyPI and npm, with a community-run .NET port, and is maintained by Vinyzu with Kaliiiiiiiiii as co-maintainer under the Apache 2.0 licence.How do I install Patchright in Python?
pip install patchright, then patchright install chromium to download the browser. In your code, import from patchright.sync_api or patchright.async_api instead of playwright; the rest of the Playwright API stays the same.What is the difference between Patchright and Playwright?
Runtime.enable command, disables the console, changes Chromium’s launch flags so navigator.webdriver reads false, runs evaluate in an isolated world by default, and can reach elements inside closed shadow roots. It supports only Chromium-based browsers, where Playwright also drives Firefox and WebKit.Why does page.on("console") never fire in Patchright?
console.log produced no console events under Patchright and one under Playwright. Log through your own script code or the network instead.Why does page.evaluate return None or undefined for my page’s variables?
evaluate in an isolated world by default, so globals the page defines are not visible there; the DOM is. Pass isolated_context=False in Python, or false as the fourth argument in Node 1.64.2, to run in the page’s own world.Does Patchright work with Firefox or WebKit?
patchright install chrome and opened with channel="chrome".Does Patchright bypass hCaptcha?
h-captcha-response. NoneCap returns that token, and you set it from your Patchright script.