From e29112f87b07fe17c848e91545df7d675d93003e Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 13:14:35 -0400 Subject: [PATCH] =?UTF-8?q?Swap=20Playwright=20=E2=86=92=20Patchright=20to?= =?UTF-8?q?=20clear=20Cloudflare=20on=20PP=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock Playwright fails Cloudflare's bot fingerprint check on pointspath.com because of three signals it can't hide: - navigator.webdriver=true (W3C-mandated for Playwright) - CDP Runtime.enable leak (anti-bot scripts watch for this specifically) - Playwright's bundled Chromium JA3/JA4 TLS fingerprint doesn't match any real Chrome release Patchright is a drop-in Playwright fork that patches the first two at the source level. Using channel="chrome" makes us launch the real Chrome binary, fixing the third. The login now uses launch_persistent_context with a profile dir at ~/.cache/flight-cli/browser-profile/ so cf_clearance cookies persist across runs — the user only has to clear the human-check once. Optional install extra renamed: browser-login now requires patchright>=1.59 instead of playwright>=1.40. README + login command help text updated to point at `uv run --with patchright` and `patchright install chrome`. --- README.md | 18 ++++++------ pyproject.toml | 14 ++++++---- src/flight_cli/pp/auth.py | 58 +++++++++++++++++++++++++++++---------- src/flight_cli/pp/cli.py | 10 ++++--- uv.lock | 28 +++++++++---------- 5 files changed, 82 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 2361a4c..61f4873 100644 --- a/README.md +++ b/README.md @@ -106,21 +106,23 @@ flight search JFK LHR --dep 2026-08-15 --backend matrix --pp-only PointsPath requires a paid subscription (free tier is the browser extension only). Three login modes: -**1. Headed browser login (default, recommended).** Opens a Playwright Chromium so you can sign in normally; the CLI captures the resulting session into `~/.config/flight-cli/pp.json`. Independent of any Chrome PP session you have open elsewhere — different server-side Supabase session, so the refresh chains never race. +**1. Headed browser login (default, recommended).** Opens a Patchright Chrome so you can sign in normally; the CLI captures the resulting session into `~/.config/flight-cli/pp.json`. Independent of any Chrome PP session you have open elsewhere — different server-side Supabase session, so the refresh chains never race. + +We use [Patchright](https://pypi.org/project/patchright/) (a drop-in Playwright fork that patches the CDP `Runtime.enable` leak and the `navigator.webdriver` flag) because pointspath.com is behind Cloudflare's bot fingerprint check, which stock Playwright fails. The browser profile is persisted at `~/.cache/flight-cli/browser-profile/` so the Cloudflare `cf_clearance` cookie survives across login sessions — you usually only have to clear the human-check once. ```sh -# One-time: download Chromium into Playwright's global cache -# (~/Library/Caches/ms-playwright on macOS). The browser install is -# disk-cached, not venv-resident. -uvx --from playwright playwright install chromium +# One-time: download real Chrome (~150MB) into Patchright's cache. +# `channel="chrome"` uses the real Chrome binary because its TLS +# fingerprint matches real Chrome traffic — bundled Chromium doesn't. +uvx --from patchright patchright install chrome -# Then log in. `--with playwright` adds the Python package ephemerally +# Then log in. `--with patchright` adds the Python package ephemerally # for this one invocation — no need to mutate flight-cli's venv. -uv run --with playwright flight auth pp login +uv run --with patchright flight auth pp login flight auth pp whoami # confirm ``` -If you'd rather make playwright a permanent venv resident (skip `--with` every time), there's an optional install extra: `uv pip install -e '.[browser-login]'`. Most users don't need this. +If you'd rather make patchright a permanent venv resident (skip `--with` every time), there's an optional install extra: `uv pip install -e '.[browser-login]'`. Most users don't need this. **2. `--from-chrome` (cookie import).** Reads Supabase cookies from your local Chrome profile via `rookiepy`. Quicker than headed login since you don't sign in again — but the CLI then *shares* Chrome's refresh-token chain. Supabase rotates refresh tokens single-use, so a refresh on one side will eventually invalidate the other. Use this when you don't mind re-importing periodically. diff --git a/pyproject.toml b/pyproject.toml index c1ea7e3..4995e5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,13 @@ dependencies = [ ] [project.optional-dependencies] -# Heavier interactive-login path. Optional because the package is small -# but `playwright install chromium` is ~150MB — non-PP users shouldn't pay. -browser-login = ["playwright>=1.40"] +# Heavier interactive-login path. Patchright is a Playwright fork that +# patches CDP leaks + navigator.webdriver detection so Cloudflare's bot +# fingerprint check passes — required for the PP login flow (Cloudflare- +# protected). Package itself is small; `patchright install chrome` is what +# pulls the real Chrome binary (~150MB) and is only needed for the headed +# login path, so it's not auto-installed. +browser-login = ["patchright>=1.59"] [dependency-groups] dev = [ @@ -40,9 +44,9 @@ dev = [ "pytest>=8.3", "pytest-cov>=5", "hypothesis>=6.100", - # Pulled in only for type-checking; `playwright install chromium` is NOT + # Pulled in only for type-checking; `patchright install chrome` is NOT # run automatically — the dev Python package itself is small. - "playwright>=1.40", + "patchright>=1.59", ] [project.scripts] diff --git a/src/flight_cli/pp/auth.py b/src/flight_cli/pp/auth.py index 273a423..5b375b8 100644 --- a/src/flight_cli/pp/auth.py +++ b/src/flight_cli/pp/auth.py @@ -289,35 +289,63 @@ def login_from_chrome() -> Tokens: return tokens_from_supabase_cookies(cookies) +# Persistent browser profile for the PP login flow. Cloudflare uses cf_clearance +# cookies that are bound to the browser fingerprint; persisting them across runs +# means the user only has to clear the human-check once. +BROWSER_PROFILE_DIR = ( + Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) + / "flight-cli" + / "browser-profile" +) + + def login_via_browser( *, timeout_secs: int = _DEFAULT_BROWSER_LOGIN_TIMEOUT_SECS, poll_interval_secs: float = 1.0, ) -> Tokens: - """Open a headed Playwright Chromium for the user to log into PointsPath. - - Independent of any user-facing Chrome session — this is the recommended - primary path. Polls for the Supabase auth-token cookie; returns as soon - as it appears, or raises PPAuthError on timeout / browser close. + """Open a headed Patchright Chromium for the user to log into PointsPath. + + Uses `patchright` (a Playwright fork that patches the CDP `Runtime.enable` + leak and `navigator.webdriver` exposure) so Cloudflare's bot fingerprint + check passes. Launches with `channel="chrome"` to use the real Chrome + binary (whose JA3/JA4 TLS fingerprint matches real Chrome traffic) and a + persistent context under ~/.cache/flight-cli/browser-profile so cf_clearance + cookies survive between login sessions. + + Independent of any user-facing Chrome session. Polls for the Supabase + auth-token cookie; returns as soon as it appears, or raises PPAuthError + on timeout / browser close. """ try: - from playwright.sync_api import Error as PlaywrightError # noqa: PLC0415 - from playwright.sync_api import sync_playwright # noqa: PLC0415 + from patchright.sync_api import Error as PlaywrightError # noqa: PLC0415 + from patchright.sync_api import sync_playwright # noqa: PLC0415 except ImportError as e: # pragma: no cover — install-time concern raise PPAuthError( - "playwright isn't installed (needed for headed browser login). " - "Quickest path: rerun this command via " - "`uv run --with playwright flight auth pp login` " - "(plus a one-time `uvx --from playwright playwright install chromium`). " - "Alternatives: --from-chrome or --tokens-file PATH.", + "patchright isn't installed (needed for Cloudflare-resistant browser " + "login). Quickest path: rerun this command via " + "`uv run --with patchright flight auth pp login` " + "(plus a one-time `uvx --from patchright patchright install chrome`). " + "Alternatives: --from-chrome (reads cookies from your real Chrome) " + "or --tokens-file PATH.", ) from e import time # noqa: PLC0415 + BROWSER_PROFILE_DIR.mkdir(parents=True, exist_ok=True) + with sync_playwright() as p: - browser = p.chromium.launch(headless=False) + # launch_persistent_context (not launch + new_context) is the + # recommended Patchright pattern for Cloudflare: it persists cookies + + # storage between runs, and lets the browser appear as a fully real + # Chrome session. + context = p.chromium.launch_persistent_context( + user_data_dir=str(BROWSER_PROFILE_DIR), + channel="chrome", + headless=False, + no_viewport=True, + ) try: - context = browser.new_context() page = context.new_page() page.goto(_PP_HOME_URL) @@ -339,4 +367,4 @@ def login_via_browser( "Re-run `flight auth pp login` and complete sign-in before the timeout.", ) finally: - browser.close() + context.close() diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index 097a620..2deef67 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -102,12 +102,14 @@ def pp_login( ) -> None: """Authenticate with PointsPath. Three modes (mutually exclusive). - Default (no flag): open a headed Playwright Chromium so you can sign + Default (no flag): open a headed Patchright Chrome so you can sign in normally. Captures the resulting Supabase session into ~/.config/flight-cli/pp.json. Independent of any user-facing Chrome - PP session. Needs playwright at runtime (ephemeral or installed): the - README PP Setup section covers `uv run --with playwright` + the - one-time `uvx --from playwright playwright install chromium`. + PP session. Uses Patchright (a Playwright fork that patches the CDP + Runtime.enable leak + navigator.webdriver) to clear Cloudflare's + bot fingerprint check. Needs patchright at runtime (ephemeral or + installed): README PP Setup covers `uv run --with patchright` + the + one-time `uvx --from patchright patchright install chrome`. --from-chrome: import the session from your local Chrome profile via cookies. Quicker, but the CLI then shares Chrome's refresh-token diff --git a/uv.lock b/uv.lock index d405025..ce1fe88 100644 --- a/uv.lock +++ b/uv.lock @@ -302,14 +302,14 @@ dependencies = [ [package.optional-dependencies] browser-login = [ - { name = "playwright" }, + { name = "patchright" }, ] [package.dev-dependencies] dev = [ { name = "basedpyright" }, { name = "hypothesis" }, - { name = "playwright" }, + { name = "patchright" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -323,7 +323,7 @@ requires-dist = [ { name = "flights", specifier = ">=0.8" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-curl-cffi", specifier = ">=0.1.5" }, - { name = "playwright", marker = "extra == 'browser-login'", specifier = ">=1.40" }, + { name = "patchright", marker = "extra == 'browser-login'", specifier = ">=1.59" }, { name = "pydantic", specifier = ">=2.7" }, { name = "rich", specifier = ">=13" }, { name = "rookiepy", specifier = ">=0.5" }, @@ -337,7 +337,7 @@ provides-extras = ["browser-login"] dev = [ { name = "basedpyright", specifier = ">=1.20" }, { name = "hypothesis", specifier = ">=6.100" }, - { name = "playwright", specifier = ">=1.40" }, + { name = "patchright", specifier = ">=1.59" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-cov", specifier = ">=5" }, { name = "ruff", specifier = ">=0.6" }, @@ -538,22 +538,22 @@ wheels = [ ] [[package]] -name = "playwright" -version = "1.59.0" +name = "patchright" +version = "1.59.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet" }, { name = "pyee" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" }, - { url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" }, - { url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" }, - { url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1d/e6a4b7ff95c35617495a9b686cbf78a968f26ad51c91cef6b2a5eee88f3e/patchright-1.59.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:de2e0ea51a270e77ef6588bea1370510695cb94fe6ef848bc7e587d3458764ea", size = 43143713, upload-time = "2026-04-29T08:02:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/de/55/0b4bb56c141f2906e212c5b31f21ba6107a98105240c173b54d72c74f480/patchright-1.59.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e93fe960714c39152ea3300b7a2c3882cb8d1f992f6120db53406b5590fa4208", size = 41933976, upload-time = "2026-04-29T08:02:51.531Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/98ad4f09613051b8c673701043ba6744a213175b01399e555b73de742006/patchright-1.59.1-py3-none-macosx_11_0_universal2.whl", hash = "sha256:bfaad3600817f271ccab34db9f33bb8148c649a157bad28b727097d0b45b8c5d", size = 43143717, upload-time = "2026-04-29T08:02:55.619Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cc/f62063e59db98b154d9faf79603164d548f5117c95acc9d2a8104df9607a/patchright-1.59.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:f0a7ee31e56391a96cd81f9ac1db130e8166c6083b7f3fb3bc9ba19d80d68127", size = 47138586, upload-time = "2026-04-29T08:03:00.04Z" }, + { url = "https://files.pythonhosted.org/packages/ac/74/2965635dd1d461243281e1774f78d865c2d1df24808cbe3b27a5ede64d7e/patchright-1.59.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69165d20308a045ddf1ad512467625248c5763ea9ee36ee57b0003d1ff8b9b12", size = 46864405, upload-time = "2026-04-29T08:03:04.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c2/959acc3fd3d867af4e567bb5e0cc63d4b1ca17fe4b7f673b3eef52fbf287/patchright-1.59.1-py3-none-win32.whl", hash = "sha256:56d9f2f3325f7205dfe6d22abc0969378225cd58b6ce29e7a7e3526c1d78c160", size = 37700491, upload-time = "2026-04-29T08:03:09.81Z" }, + { url = "https://files.pythonhosted.org/packages/19/b1/1e7a4844ed6bc6bd3653c6e708f614a2b27ac54875ebd0e9c7badf7d25b6/patchright-1.59.1-py3-none-win_amd64.whl", hash = "sha256:3af6515b6103aedd1cdd09be7fa08ac028a954c6687d73fdee92570ae580fe4c", size = 37700495, upload-time = "2026-04-29T08:03:13.727Z" }, + { url = "https://files.pythonhosted.org/packages/10/95/b148a41c6a099cd3d9f11a0641e4f10d50141cb8dca052f75f64dc631634/patchright-1.59.1-py3-none-win_arm64.whl", hash = "sha256:e259fae74b59a89ee020703ba142b8b5dd81a08f1c95ee3cda92829026057092", size = 33943539, upload-time = "2026-04-29T08:03:17.355Z" }, ] [[package]]