refactor: remove optional patchright backend

Patchright scored identically to plain Playwright on reCAPTCHA v3 (the
binary handles stealth at C++ level) while breaking proxy auth and
add_init_script (#27). Removed the backend param, CLOAKBROWSER_BACKEND
env var, the patchright extra, and the two backend-specific tests.
Stock Playwright is now the only backend.
This commit is contained in:
CloakHQ
2026-06-20 21:50:22 +02:00
parent 776630e08b
commit d67c21abbe
9 changed files with 58 additions and 135 deletions
+2
View File
@@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
## [0.3.32] — 2026-06-20
- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`)
-1
View File
@@ -1240,7 +1240,6 @@ await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
+27 -62
View File
@@ -41,6 +41,15 @@ def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | Non
return timezone
def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
"""Raise a clear error for removed parameters that now fall into **kwargs."""
if "backend" in kwargs:
raise TypeError(
"The 'backend' parameter has been removed — patchright is no longer "
"supported and stock Playwright is the only backend. Remove the argument."
)
class _ProxySettingsRequired(TypedDict):
server: str
@@ -61,7 +70,6 @@ def launch(
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -86,10 +94,6 @@ def launch(
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
backend: Playwright backend 'playwright' (default) or 'patchright'.
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
but breaks proxy auth and add_init_script.
Override globally with CLOAKBROWSER_BACKEND env var.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -106,7 +110,9 @@ def launch(
>>> print(page.title())
>>> browser.close()
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.sync_api import sync_playwright
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -159,7 +165,6 @@ async def launch_async( # noqa: C901
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -177,7 +182,6 @@ async def launch_async( # noqa: C901
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -199,7 +203,9 @@ async def launch_async( # noqa: C901
>>>
>>> asyncio.run(main())
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.async_api import async_playwright
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -255,7 +261,6 @@ def launch_persistent_context(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -286,7 +291,6 @@ def launch_persistent_context(
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``.
backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -303,7 +307,9 @@ def launch_persistent_context(
>>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.sync_api import sync_playwright
timezone = _resolve_timezone(timezone, kwargs)
@@ -383,7 +389,6 @@ async def launch_persistent_context_async(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -411,7 +416,6 @@ async def launch_persistent_context_async(
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -433,7 +437,9 @@ async def launch_persistent_context_async(
>>>
>>> asyncio.run(main())
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.async_api import async_playwright
timezone = _resolve_timezone(timezone, kwargs)
@@ -512,7 +518,6 @@ def launch_context(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -538,7 +543,6 @@ def launch_context(
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -547,6 +551,8 @@ def launch_context(
Returns:
Playwright BrowserContext object.
"""
_check_removed_kwargs(kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
@@ -560,7 +566,7 @@ def launch_context(
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend, extension_paths=extension_paths)
timezone=timezone, locale=locale, extension_paths=extension_paths)
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -613,7 +619,6 @@ async def launch_context_async(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -640,7 +645,6 @@ async def launch_context_async(
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -668,6 +672,8 @@ async def launch_context_async(
>>>
>>> asyncio.run(main())
"""
_check_removed_kwargs(kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
@@ -680,7 +686,7 @@ async def launch_context_async(
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend, extension_paths=extension_paths)
timezone=timezone, locale=locale, extension_paths=extension_paths)
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -728,47 +734,6 @@ async def launch_context_async(
return context
# ---------------------------------------------------------------------------
# Backend resolution
# ---------------------------------------------------------------------------
def _resolve_backend(backend: str | None) -> str:
"""Resolve backend: param > env var > default ('playwright')."""
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
if b not in ("playwright", "patchright"):
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
return b
def _import_sync_playwright(backend: str):
"""Import sync_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.sync_api import sync_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return sync_playwright
from playwright.sync_api import sync_playwright
return sync_playwright
def _import_async_playwright(backend: str):
"""Import async_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.async_api import async_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return async_playwright
from playwright.async_api import async_playwright
return async_playwright
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
-1
View File
@@ -55,7 +55,6 @@ dependencies = [
[project.optional-dependencies]
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
-11
View File
@@ -1,11 +0,0 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
-45
View File
@@ -1,45 +0,0 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+3 -6
View File
@@ -5,8 +5,8 @@ from cloakbrowser import launch
@patch("cloakbrowser.browser.ensure_binary")
@patch("cloakbrowser.browser._import_sync_playwright")
def test_extension_loading(mock_playwright_import, mock_ensure_binary):
@patch("playwright.sync_api.sync_playwright")
def test_extension_loading(mock_sync_playwright, mock_ensure_binary):
mock_ensure_binary.return_value = "/fake/chrome"
mock_browser = MagicMock()
@@ -14,10 +14,7 @@ def test_extension_loading(mock_playwright_import, mock_ensure_binary):
mock_pw = MagicMock()
mock_pw.chromium.launch.return_value = mock_browser
mock_pw_manager = MagicMock()
mock_pw_manager.return_value.start.return_value = mock_pw
mock_playwright_import.return_value = mock_pw_manager
mock_sync_playwright.return_value.start.return_value = mock_pw
launch(extension_paths=["./ext"])
+24 -1
View File
@@ -1,10 +1,33 @@
"""Basic launch tests for cloakbrowser."""
import pytest
from cloakbrowser import launch, launch_async, binary_info
from cloakbrowser import (
launch,
launch_async,
launch_context,
launch_persistent_context,
binary_info,
)
from cloakbrowser.config import get_chromium_version
@pytest.mark.parametrize("env", [None, "patchright"])
def test_removed_backend_kwarg_raises(env, monkeypatch):
"""The removed `backend` parameter raises a clear TypeError before any
launch side effects, regardless of the (also removed) CLOAKBROWSER_BACKEND
env var. Guards the patchright removal."""
if env is None:
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
else:
monkeypatch.setenv("CLOAKBROWSER_BACKEND", env)
with pytest.raises(TypeError, match="backend"):
launch(backend="patchright")
with pytest.raises(TypeError, match="backend"):
launch_context(backend="patchright")
with pytest.raises(TypeError, match="backend"):
launch_persistent_context("/tmp/cloakbrowser-test-profile", backend="patchright")
def test_binary_info():
"""binary_info() returns expected structure."""
info = binary_info()
+2 -8
View File
@@ -259,9 +259,8 @@ class TestIssueRegressions:
def test_add_init_script_with_proxy(self, browser):
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
Patchright bug: add_init_script breaks proxy auth. This test guards
against regression if/when the upstream fix lands. Uses context-level
proxy to avoid launching a separate browser (event loop conflict).
Uses context-level proxy to avoid launching a separate browser
(event loop conflict).
"""
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
if not proxy:
@@ -276,11 +275,6 @@ class TestIssueRegressions:
val = page.evaluate("window.__cloaktest")
assert val == 99, f"init_script value wrong: {val}"
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
except Exception as e:
err = str(e)
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
raise
finally:
page.close()
ctx.close()