diff --git a/CHANGELOG.md b/CHANGELOG.md index 23334bd..ce2ae25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) diff --git a/README.md b/README.md index ed8b5ad..ccfed09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index a7e6fb3..ed4cbed 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -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 # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 783acb0..cd7f691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 9216562..0000000 --- a/tests/conftest.py +++ /dev/null @@ -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) diff --git a/tests/test_backend.py b/tests/test_backend.py deleted file mode 100644 index d881613..0000000 --- a/tests/test_backend.py +++ /dev/null @@ -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) diff --git a/tests/test_extension_loading.py b/tests/test_extension_loading.py index 80ba160..c3a461e 100644 --- a/tests/test_extension_loading.py +++ b/tests/test_extension_loading.py @@ -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"]) diff --git a/tests/test_launch.py b/tests/test_launch.py index 2d7462e..f805e19 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -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() diff --git a/tests/test_stealth.py b/tests/test_stealth.py index 9955d76..2fa13dc 100644 --- a/tests/test_stealth.py +++ b/tests/test_stealth.py @@ -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()