ghost-driver 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. ghost_driver-0.1.0/.github/workflows/publish.yml +48 -0
  2. ghost_driver-0.1.0/.gitignore +7 -0
  3. ghost_driver-0.1.0/PKG-INFO +135 -0
  4. ghost_driver-0.1.0/README.md +122 -0
  5. ghost_driver-0.1.0/ghost_driver/__init__.py +5 -0
  6. ghost_driver-0.1.0/ghost_driver/browser.py +49 -0
  7. ghost_driver-0.1.0/ghost_driver/cookies.py +40 -0
  8. ghost_driver-0.1.0/ghost_driver/diagnostics.py +50 -0
  9. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906284_passed_pct007p6_round2.json +542 -0
  10. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906291_passed_pct006p7_round3.json +308 -0
  11. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906299_passed_pct015p6_round4.json +416 -0
  12. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906315_passed_pct019p1_round5.json +1028 -0
  13. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906323_passed_pct019p1_round6.json +650 -0
  14. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906357_passed_pct039p6_round8.json +740 -0
  15. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906366_passed_pct047p1_round9.json +1154 -0
  16. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906382_passed_pct060p0_round10.json +1226 -0
  17. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906425_passed_pct060p0_round11.json +1226 -0
  18. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906482_passed_pct100p0_round14.json +1712 -0
  19. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906489_passed_pct100p0_round15.json +1172 -0
  20. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906501_passed_pct012p4_round16.json +578 -0
  21. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906563_passed_pct039p1_round17.json +1262 -0
  22. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906649_passed_pct067p6_round22.json +920 -0
  23. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906658_passed_pct026p2_round23.json +1118 -0
  24. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906667_passed_pct033p3_round24.json +794 -0
  25. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906674_passed_pct038p2_round25.json +704 -0
  26. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906690_passed_pct046p7_round26.json +1262 -0
  27. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906700_passed_pct048p0_round27.json +920 -0
  28. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906707_passed_pct055p6_round28.json +812 -0
  29. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906715_passed_pct053p3_round29.json +1154 -0
  30. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906722_passed_pct000p4_round30.json +686 -0
  31. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906741_passed_pct065p8_round31.json +1064 -0
  32. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906753_passed_pct056p4_round32.json +1208 -0
  33. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906763_passed_pct071p1_round33.json +884 -0
  34. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906772_passed_pct052p9_round34.json +1028 -0
  35. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906799_passed_pct043p6_round36.json +614 -0
  36. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906824_passed_pct000p0_round38.json +398 -0
  37. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906841_passed_pct052p9_round39.json +542 -0
  38. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906849_passed_pct040p9_round40.json +956 -0
  39. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907169_passed_pctNA_round1.json +20 -0
  40. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907173_passed_pctNA_round2.json +1 -0
  41. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907198_passed_pct091p1_round3.json +884 -0
  42. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907204_passed_pct092p4_round4.json +704 -0
  43. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907226_passed_pct028p0_round5.json +758 -0
  44. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907237_passed_pct088p9_round6.json +722 -0
  45. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907245_passed_pct000p0_round7.json +596 -0
  46. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907254_passed_pct016p9_round8.json +416 -0
  47. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907262_passed_pct026p2_round9.json +524 -0
  48. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907283_passed_pct071p1_round10.json +992 -0
  49. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912039_passed_pctNA_round1.json +1 -0
  50. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912050_passed_pct012p4_round2.json +974 -0
  51. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912064_passed_pct017p8_round3.json +470 -0
  52. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912070_passed_pct024p0_round4.json +614 -0
  53. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912078_passed_pct021p3_round5.json +524 -0
  54. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912097_passed_pct078p7_round6.json +1694 -0
  55. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912112_passed_pct073p8_round7.json +1658 -0
  56. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912193_passed_pct050p2_round8.json +866 -0
  57. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912211_passed_pct027p1_round9.json +974 -0
  58. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912223_passed_pct051p6_round10.json +1388 -0
  59. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912273_passed_pct007p1_round11.json +668 -0
  60. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912296_passed_pct064p9_round12.json +992 -0
  61. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912308_passed_pct017p3_round13.json +686 -0
  62. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912328_passed_pct042p2_round14.json +848 -0
  63. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912379_passed_pct063p6_round15.json +1136 -0
  64. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912394_passed_pct057p3_round16.json +1136 -0
  65. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912404_passed_pct010p2_round17.json +830 -0
  66. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912412_passed_pct030p7_round18.json +632 -0
  67. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912421_passed_pct026p7_round19.json +1118 -0
  68. ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912439_passed_pct026p2_round20.json +848 -0
  69. ghost_driver-0.1.0/ghost_driver/fingerprint/__init__.py +3 -0
  70. ghost_driver-0.1.0/ghost_driver/fingerprint/misc.py +100 -0
  71. ghost_driver-0.1.0/ghost_driver/fingerprint/native.py +19 -0
  72. ghost_driver-0.1.0/ghost_driver/fingerprint/webgl.py +33 -0
  73. ghost_driver-0.1.0/ghost_driver/fingerprint/webrtc.py +42 -0
  74. ghost_driver-0.1.0/ghost_driver/input/__init__.py +5 -0
  75. ghost_driver-0.1.0/ghost_driver/input/cdp_replay.py +85 -0
  76. ghost_driver-0.1.0/ghost_driver/input/drag.py +49 -0
  77. ghost_driver-0.1.0/ghost_driver/input/trajectories.py +109 -0
  78. ghost_driver-0.1.0/ghost_driver/input/x11.py +272 -0
  79. ghost_driver-0.1.0/ghost_driver/logging_utils.py +31 -0
  80. ghost_driver-0.1.0/ghost_driver/page_state.py +126 -0
  81. ghost_driver-0.1.0/ghost_driver/patches.py +57 -0
  82. ghost_driver-0.1.0/ghost_driver/recorder.py +221 -0
  83. ghost_driver-0.1.0/ghost_driver/session.py +179 -0
  84. ghost_driver-0.1.0/ghost_driver/solvers/__init__.py +52 -0
  85. ghost_driver-0.1.0/ghost_driver/solvers/capguru.py +110 -0
  86. ghost_driver-0.1.0/ghost_driver/solvers/cv_angle.py +105 -0
  87. ghost_driver-0.1.0/ghost_driver/solving.py +103 -0
  88. ghost_driver-0.1.0/pyproject.toml +27 -0
@@ -0,0 +1,48 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distribution
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.x"
18
+ - name: Install build
19
+ run: python -m pip install --upgrade build
20
+ - name: Build sdist and wheel
21
+ run: python -m build
22
+ - name: Store distributions
23
+ uses: actions/upload-artifact@v4
24
+ with:
25
+ name: python-package-distributions
26
+ path: dist/
27
+
28
+ publish:
29
+ name: Publish to PyPI
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ # must match the "Environment name" set on the trusted publisher at
33
+ # https://pypi.org/manage/account/publishing/ -- omit this whole
34
+ # `environment:` block (and the environment field on PyPI's side) if
35
+ # you'd rather not bother creating a GitHub Environment for it.
36
+ environment:
37
+ name: pypi
38
+ url: https://pypi.org/project/ghost-driver/
39
+ permissions:
40
+ id-token: write # required for PyPI trusted publishing (OIDC) -- no API token/secret needed at all
41
+ steps:
42
+ - name: Download distributions
43
+ uses: actions/download-artifact@v4
44
+ with:
45
+ name: python-package-distributions
46
+ path: dist/
47
+ - name: Publish to PyPI
48
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv
2
+ .idea
3
+ __pycache__/
4
+ *.pyc
5
+ *.egg-info/
6
+ build/
7
+ dist/
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: ghost-driver
3
+ Version: 0.1.0
4
+ Summary: A nodriver wrapper for automation that needs to survive behavioral antibot checks, not just fingerprint checks: real X11 input instead of CDP-synthesized events, human trajectory replay, and pluggable captcha solvers.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: nodriver==0.50.3
8
+ Requires-Dist: requests>=2.31.0
9
+ Provides-Extra: cv
10
+ Requires-Dist: numpy<2,>=1.24; extra == 'cv'
11
+ Requires-Dist: opencv-python-headless<5,>=4.8; extra == 'cv'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # ghost-driver
15
+
16
+ A [nodriver](https://github.com/ultrafunkamsterdam/nodriver) wrapper for automation that needs to survive **behavioral** antibot checks, not just fingerprint checks.
17
+
18
+ ## Why
19
+
20
+ CDP's `Input.dispatchMouseEvent`, however carefully its parameters are set — coordinates, pressure, the `buttons` bitmask — does not trigger the same internal Blink engine state a genuine X11 input event does. This was confirmed with a rotate-captcha (drag a slider to un-rotate a photo) that a real human solved manually at ~90-100%, while every synthetic-input variant we tried — careful CDP dispatch, algorithmic "natural" mouse easing, even over real X11 — stayed stuck at 20-36%.
21
+
22
+ The tell: the slider button's CSS `:active` pseudo-class engages correctly on a CDP `mousePressed`, then flips `false` on the very first subsequent `mousemove` and never recovers — 98% `false` across CDP-driven drags vs 96% `true` for real manual ones. Real X11/XTest events (the same event class VNC's own mouse injection uses, via `xdotool`) don't have this problem.
23
+
24
+ Driving the **entire drag** through real X11 input, replaying an **actual recorded human trajectory** (not an algorithmic approximation), closed the gap: 15/15 (100%) on a live confirming run.
25
+
26
+ ghost-driver packages that finding — plus everything that had to be fixed alongside it (WebGL being entirely absent under Xvfb, `document.hasFocus()` never being true, fractional pixel coordinates no real mouse can produce) — into a reusable library on top of `nodriver`.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install ghost-driver
32
+ # for the free CV-based captcha solver (needs opencv/numpy):
33
+ pip install ghost-driver[cv]
34
+ ```
35
+
36
+ nodriver itself needs two small source patches applied after install (see [`ghost_driver/patches.py`](ghost_driver/patches.py) for exactly what and why — both live deep inside methods too large to cleanly monkeypatch, so this edits the installed package on disk, idempotently, with an assertion that fails loudly if nodriver's source has changed underneath it):
37
+
38
+ ```bash
39
+ ghost-driver-patch-nodriver
40
+ ```
41
+
42
+ Run it once after every `pip install`/`pip install --upgrade` of `nodriver` — e.g. right after `pip install` in a Dockerfile.
43
+
44
+ ### System requirements
45
+
46
+ - `xdotool` on `PATH` — this is the actual mechanism, not optional tooling
47
+ - A real X11 `DISPLAY` with a window manager in front of it. **Xvfb is fine** (virtual, no GPU needed) — bare Xvfb has no window manager though, so also run something minimal like `fluxbox` alongside it
48
+ - Google Chrome (not Debian's `chromium` package — this was developed and tested against `google-chrome-stable`)
49
+
50
+ Real X11 input has no mapped window to target at all under `--headless=new` — this is an architectural limit, not a bug, and `ghost_driver.input.x11` raises `NoX11DisplayError` rather than silently degrading. See [Headless vs headful](#headless-vs-headful) below.
51
+
52
+ ## Quickstart
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ import ghost_driver
58
+ from ghost_driver.input import trajectories, x11
59
+ from ghost_driver.solvers.capguru import CapGuruSolver
60
+ from ghost_driver.solvers import correction_to_percent
61
+ from ghost_driver import page_state
62
+
63
+
64
+ async def main():
65
+ browser = await ghost_driver.launch(headless=False) # real X11 input needs headful (Xvfb is fine)
66
+ page = await browser.create_context(url="https://example.com/captcha-page")
67
+
68
+ state, _data = await page_state.wait_for_widget_or_content(
69
+ page, timeout_s=30,
70
+ has_widget_js_expr="!!document.querySelector('.your-widget-class')",
71
+ )
72
+ if state != "widget":
73
+ return
74
+
75
+ await x11.ensure_real_focus(page) # a real xdotool click -- CDP's page.activate() alone doesn't flip document.hasFocus()
76
+
77
+ solver = CapGuruSolver(api_key="your-cap-guru-key") # or set CAP_GURU_KEY in the environment
78
+ image_bytes = ... # capture your widget's already-rendered image (see note below)
79
+ result = await solver.solve(image_bytes)
80
+ target_percent = correction_to_percent(result.correction_deg)
81
+
82
+ # geometry is yours to read off your own widget's DOM
83
+ start_x, start_y, max_left = ...
84
+ needed_delta_x = max_left * (target_percent / 100.0)
85
+
86
+ pool = trajectories.load_trajectories() # defaults to the bundled e-disclosure.ru example recordings
87
+ events = trajectories.pick_for_target(pool, needed_delta_x)
88
+ await x11.drag_replay(page, events, start_x, start_y, start_x + needed_delta_x)
89
+
90
+
91
+ asyncio.run(main())
92
+ ```
93
+
94
+ Capture the captcha image by reading an already-rendered `<img>` through a `<canvas>` (`drawImage` + `toDataURL`), not a raw `fetch()` of the image URL — an endpoint that serves a fresh random image per request will hand a raw fetch a *different* image than the one actually shown.
95
+
96
+ `trajectories.load_trajectories()` uses 60 real recorded human trajectories bundled with the package (see [`ghost_driver/examples/e_disclosure_rotate_captcha/`](ghost_driver/examples/e_disclosure_rotate_captcha/)) by default, so drag replay works out of the box without recording anything yourself first. These are calibrated to one specific site's slider though — pass your own `directory` (e.g. wherever `ghost_driver.recorder.record_round` saves to) once you're targeting a different widget.
97
+
98
+ ## Modules
99
+
100
+ | Module | What it does |
101
+ |---|---|
102
+ | `ghost_driver.browser` | `launch()` — thin wrapper around `nodriver.start()` with WebGL (`swiftshader`) and a launch-retry loop already applied |
103
+ | `ghost_driver.patches` | Idempotent, assertion-guarded source patches for nodriver's CDP connection-wait retry count and proxy-forwarder timeout |
104
+ | `ghost_driver.input.x11` | The core mechanism: real X11 input via `xdotool` — `ensure_real_focus`, `window_geometry`, `drag_replay` |
105
+ | `ghost_driver.input.cdp_replay` | CDP-based drag replay — a documented, **confirmed weaker** fallback for when there's no real X11 display at all |
106
+ | `ghost_driver.input.drag` | `drag_replay()` — picks X11 when available, falls back to CDP automatically |
107
+ | `ghost_driver.input.trajectories` | Loading, filtering, and picking recorded human drag trajectories from JSON recordings |
108
+ | `ghost_driver.recorder` | `record_round()` — captures real human pointer/mouse events during a manual (e.g. VNC) solving session, with correct handling of a challenge that bounces through multiple navigations in one round |
109
+ | `ghost_driver.solvers` | `RotationSolver` protocol + `RotationResult` — pluggable rotate-captcha solvers |
110
+ | `ghost_driver.solvers.capguru` | `CapGuruSolver` — paid, full 0-360° answer in one shot |
111
+ | `ghost_driver.solvers.cv_angle` | `CVAngleSolver` — free, local, purely geometric (needs the `cv` extra); only tells axis-alignment (mod 90°), see `RotationResult.family_period_deg` |
112
+ | `ghost_driver.page_state` | `wait_for_widget_or_content` / `wait_for_stable_content` — reliable "did the challenge appear / did it actually pass" polling with a debounce against mid-navigation false reads |
113
+ | `ghost_driver.fingerprint` | `webrtc` (IP-leak fix), `misc` (window dims, pointer capability, device memory, color depth, languages), `native` (`Function.toString` spoofing helper) |
114
+ | `ghost_driver.cookies` | `to_cookie_param()` — converting a captured `Cookie` into a `CookieParam` for reuse in a fresh context |
115
+ | `ghost_driver.diagnostics` | `dump()` / `save_html()` / `save_json()` — save what actually happened on an attempt, so a confusing result has something to inspect afterward |
116
+
117
+ ## Headless vs headful
118
+
119
+ `ghost_driver.browser.launch(headless=...)` supports both — it's just `nodriver.start()`'s own parameter. But the reason this library exists, `ghost_driver.input.x11`, **only works headful** (Xvfb counts as headful — it's a real, if virtual, X11 display and window). Under true `--headless=new` there is no mapped window at all for `xdotool` to target; calling any `ghost_driver.input.x11` function with `headless=True` raises `NoX11DisplayError` immediately rather than silently grabbing an unrelated X11 surface (confirmed live: a blind `xdotool getactivewindow` under real headless doesn't fail cleanly, it happily returns geometry for *some* unrelated surface, and a drag built on it "succeeds" with no exception while touching nothing).
120
+
121
+ `ghost_driver.input.drag_replay()` handles this automatically: it tries X11 first (unless you pass `headless=True`) and falls back to `cdp_replay` on `NoX11DisplayError`. The CDP path is real and documented, but it's the ~20-36%-pass-rate path this whole library exists to get away from — treat it as a fallback, not a primary option.
122
+
123
+ ## Known limitations
124
+
125
+ - **Cookie reuse needs a static IP.** Reusing a verified session's cookies in a fresh context (`ghost_driver.cookies`) only works if both contexts share the same exit IP — a verification cookie can be bound server-side to the IP it was issued to, not just its own value. A rotating proxy pool between the solve and reuse phases breaks this even with the cookie set correctly.
126
+ - **The free CV solver can't tell orientation.** `CVAngleSolver` only detects axis-alignment (mod 90°) via background texture direction — it can't tell which of the 4 rotationally-symmetric orientations is actually upright. Use `ghost_driver.solvers.candidate_corrections()` and try multiple candidates across retry rounds. `CapGuruSolver` doesn't have this problem (it looks at the actual photographed object) but costs money per solve.
127
+ - **Don't add `Emulation.setHardwareConcurrencyOverride`.** Confirmed via an isolated A/B (10/10 passed with it off, 0/8 with it on) that this actively breaks solving — it's presumed to change the real worker-thread pool Chrome allows, not just the reported value, corrupting whatever computation signs a site's behavioral-telemetry payload. Documented in `ghost_driver.fingerprint.misc`'s module docstring; deliberately not shipped as a working helper.
128
+
129
+ ## Credentials
130
+
131
+ `CapGuruSolver` takes `api_key` as a constructor argument, or reads the `CAP_GURU_KEY` environment variable if not passed explicitly. There is no built-in fallback key — never hardcode a real API key as a source-level default in code that might be published or shared.
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,122 @@
1
+ # ghost-driver
2
+
3
+ A [nodriver](https://github.com/ultrafunkamsterdam/nodriver) wrapper for automation that needs to survive **behavioral** antibot checks, not just fingerprint checks.
4
+
5
+ ## Why
6
+
7
+ CDP's `Input.dispatchMouseEvent`, however carefully its parameters are set — coordinates, pressure, the `buttons` bitmask — does not trigger the same internal Blink engine state a genuine X11 input event does. This was confirmed with a rotate-captcha (drag a slider to un-rotate a photo) that a real human solved manually at ~90-100%, while every synthetic-input variant we tried — careful CDP dispatch, algorithmic "natural" mouse easing, even over real X11 — stayed stuck at 20-36%.
8
+
9
+ The tell: the slider button's CSS `:active` pseudo-class engages correctly on a CDP `mousePressed`, then flips `false` on the very first subsequent `mousemove` and never recovers — 98% `false` across CDP-driven drags vs 96% `true` for real manual ones. Real X11/XTest events (the same event class VNC's own mouse injection uses, via `xdotool`) don't have this problem.
10
+
11
+ Driving the **entire drag** through real X11 input, replaying an **actual recorded human trajectory** (not an algorithmic approximation), closed the gap: 15/15 (100%) on a live confirming run.
12
+
13
+ ghost-driver packages that finding — plus everything that had to be fixed alongside it (WebGL being entirely absent under Xvfb, `document.hasFocus()` never being true, fractional pixel coordinates no real mouse can produce) — into a reusable library on top of `nodriver`.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install ghost-driver
19
+ # for the free CV-based captcha solver (needs opencv/numpy):
20
+ pip install ghost-driver[cv]
21
+ ```
22
+
23
+ nodriver itself needs two small source patches applied after install (see [`ghost_driver/patches.py`](ghost_driver/patches.py) for exactly what and why — both live deep inside methods too large to cleanly monkeypatch, so this edits the installed package on disk, idempotently, with an assertion that fails loudly if nodriver's source has changed underneath it):
24
+
25
+ ```bash
26
+ ghost-driver-patch-nodriver
27
+ ```
28
+
29
+ Run it once after every `pip install`/`pip install --upgrade` of `nodriver` — e.g. right after `pip install` in a Dockerfile.
30
+
31
+ ### System requirements
32
+
33
+ - `xdotool` on `PATH` — this is the actual mechanism, not optional tooling
34
+ - A real X11 `DISPLAY` with a window manager in front of it. **Xvfb is fine** (virtual, no GPU needed) — bare Xvfb has no window manager though, so also run something minimal like `fluxbox` alongside it
35
+ - Google Chrome (not Debian's `chromium` package — this was developed and tested against `google-chrome-stable`)
36
+
37
+ Real X11 input has no mapped window to target at all under `--headless=new` — this is an architectural limit, not a bug, and `ghost_driver.input.x11` raises `NoX11DisplayError` rather than silently degrading. See [Headless vs headful](#headless-vs-headful) below.
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ import asyncio
43
+
44
+ import ghost_driver
45
+ from ghost_driver.input import trajectories, x11
46
+ from ghost_driver.solvers.capguru import CapGuruSolver
47
+ from ghost_driver.solvers import correction_to_percent
48
+ from ghost_driver import page_state
49
+
50
+
51
+ async def main():
52
+ browser = await ghost_driver.launch(headless=False) # real X11 input needs headful (Xvfb is fine)
53
+ page = await browser.create_context(url="https://example.com/captcha-page")
54
+
55
+ state, _data = await page_state.wait_for_widget_or_content(
56
+ page, timeout_s=30,
57
+ has_widget_js_expr="!!document.querySelector('.your-widget-class')",
58
+ )
59
+ if state != "widget":
60
+ return
61
+
62
+ await x11.ensure_real_focus(page) # a real xdotool click -- CDP's page.activate() alone doesn't flip document.hasFocus()
63
+
64
+ solver = CapGuruSolver(api_key="your-cap-guru-key") # or set CAP_GURU_KEY in the environment
65
+ image_bytes = ... # capture your widget's already-rendered image (see note below)
66
+ result = await solver.solve(image_bytes)
67
+ target_percent = correction_to_percent(result.correction_deg)
68
+
69
+ # geometry is yours to read off your own widget's DOM
70
+ start_x, start_y, max_left = ...
71
+ needed_delta_x = max_left * (target_percent / 100.0)
72
+
73
+ pool = trajectories.load_trajectories() # defaults to the bundled e-disclosure.ru example recordings
74
+ events = trajectories.pick_for_target(pool, needed_delta_x)
75
+ await x11.drag_replay(page, events, start_x, start_y, start_x + needed_delta_x)
76
+
77
+
78
+ asyncio.run(main())
79
+ ```
80
+
81
+ Capture the captcha image by reading an already-rendered `<img>` through a `<canvas>` (`drawImage` + `toDataURL`), not a raw `fetch()` of the image URL — an endpoint that serves a fresh random image per request will hand a raw fetch a *different* image than the one actually shown.
82
+
83
+ `trajectories.load_trajectories()` uses 60 real recorded human trajectories bundled with the package (see [`ghost_driver/examples/e_disclosure_rotate_captcha/`](ghost_driver/examples/e_disclosure_rotate_captcha/)) by default, so drag replay works out of the box without recording anything yourself first. These are calibrated to one specific site's slider though — pass your own `directory` (e.g. wherever `ghost_driver.recorder.record_round` saves to) once you're targeting a different widget.
84
+
85
+ ## Modules
86
+
87
+ | Module | What it does |
88
+ |---|---|
89
+ | `ghost_driver.browser` | `launch()` — thin wrapper around `nodriver.start()` with WebGL (`swiftshader`) and a launch-retry loop already applied |
90
+ | `ghost_driver.patches` | Idempotent, assertion-guarded source patches for nodriver's CDP connection-wait retry count and proxy-forwarder timeout |
91
+ | `ghost_driver.input.x11` | The core mechanism: real X11 input via `xdotool` — `ensure_real_focus`, `window_geometry`, `drag_replay` |
92
+ | `ghost_driver.input.cdp_replay` | CDP-based drag replay — a documented, **confirmed weaker** fallback for when there's no real X11 display at all |
93
+ | `ghost_driver.input.drag` | `drag_replay()` — picks X11 when available, falls back to CDP automatically |
94
+ | `ghost_driver.input.trajectories` | Loading, filtering, and picking recorded human drag trajectories from JSON recordings |
95
+ | `ghost_driver.recorder` | `record_round()` — captures real human pointer/mouse events during a manual (e.g. VNC) solving session, with correct handling of a challenge that bounces through multiple navigations in one round |
96
+ | `ghost_driver.solvers` | `RotationSolver` protocol + `RotationResult` — pluggable rotate-captcha solvers |
97
+ | `ghost_driver.solvers.capguru` | `CapGuruSolver` — paid, full 0-360° answer in one shot |
98
+ | `ghost_driver.solvers.cv_angle` | `CVAngleSolver` — free, local, purely geometric (needs the `cv` extra); only tells axis-alignment (mod 90°), see `RotationResult.family_period_deg` |
99
+ | `ghost_driver.page_state` | `wait_for_widget_or_content` / `wait_for_stable_content` — reliable "did the challenge appear / did it actually pass" polling with a debounce against mid-navigation false reads |
100
+ | `ghost_driver.fingerprint` | `webrtc` (IP-leak fix), `misc` (window dims, pointer capability, device memory, color depth, languages), `native` (`Function.toString` spoofing helper) |
101
+ | `ghost_driver.cookies` | `to_cookie_param()` — converting a captured `Cookie` into a `CookieParam` for reuse in a fresh context |
102
+ | `ghost_driver.diagnostics` | `dump()` / `save_html()` / `save_json()` — save what actually happened on an attempt, so a confusing result has something to inspect afterward |
103
+
104
+ ## Headless vs headful
105
+
106
+ `ghost_driver.browser.launch(headless=...)` supports both — it's just `nodriver.start()`'s own parameter. But the reason this library exists, `ghost_driver.input.x11`, **only works headful** (Xvfb counts as headful — it's a real, if virtual, X11 display and window). Under true `--headless=new` there is no mapped window at all for `xdotool` to target; calling any `ghost_driver.input.x11` function with `headless=True` raises `NoX11DisplayError` immediately rather than silently grabbing an unrelated X11 surface (confirmed live: a blind `xdotool getactivewindow` under real headless doesn't fail cleanly, it happily returns geometry for *some* unrelated surface, and a drag built on it "succeeds" with no exception while touching nothing).
107
+
108
+ `ghost_driver.input.drag_replay()` handles this automatically: it tries X11 first (unless you pass `headless=True`) and falls back to `cdp_replay` on `NoX11DisplayError`. The CDP path is real and documented, but it's the ~20-36%-pass-rate path this whole library exists to get away from — treat it as a fallback, not a primary option.
109
+
110
+ ## Known limitations
111
+
112
+ - **Cookie reuse needs a static IP.** Reusing a verified session's cookies in a fresh context (`ghost_driver.cookies`) only works if both contexts share the same exit IP — a verification cookie can be bound server-side to the IP it was issued to, not just its own value. A rotating proxy pool between the solve and reuse phases breaks this even with the cookie set correctly.
113
+ - **The free CV solver can't tell orientation.** `CVAngleSolver` only detects axis-alignment (mod 90°) via background texture direction — it can't tell which of the 4 rotationally-symmetric orientations is actually upright. Use `ghost_driver.solvers.candidate_corrections()` and try multiple candidates across retry rounds. `CapGuruSolver` doesn't have this problem (it looks at the actual photographed object) but costs money per solve.
114
+ - **Don't add `Emulation.setHardwareConcurrencyOverride`.** Confirmed via an isolated A/B (10/10 passed with it off, 0/8 with it on) that this actively breaks solving — it's presumed to change the real worker-thread pool Chrome allows, not just the reported value, corrupting whatever computation signs a site's behavioral-telemetry payload. Documented in `ghost_driver.fingerprint.misc`'s module docstring; deliberately not shipped as a working helper.
115
+
116
+ ## Credentials
117
+
118
+ `CapGuruSolver` takes `api_key` as a constructor argument, or reads the `CAP_GURU_KEY` environment variable if not passed explicitly. There is no built-in fallback key — never hardcode a real API key as a source-level default in code that might be published or shared.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,5 @@
1
+ from .browser import launch
2
+ from .logging_utils import enable_logging
3
+
4
+ __all__ = ["launch", "enable_logging"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,49 @@
1
+ import asyncio
2
+ from typing import Sequence
3
+
4
+ import nodriver as uc
5
+
6
+ DEFAULT_BROWSER_ARGS = [
7
+ "--use-gl=swiftshader",
8
+ "--use-angle=swiftshader",
9
+ ]
10
+
11
+
12
+ async def launch(
13
+ *,
14
+ headless: bool = False,
15
+ sandbox: bool = False,
16
+ browser_args: Sequence[str] | None = None,
17
+ max_attempts: int = 5,
18
+ retry_delay_s: float = 2.0,
19
+ **kwargs,
20
+ ) -> uc.Browser:
21
+ """Thin wrapper around nodriver.start() with this library's defaults
22
+ applied, plus a retry loop around the browser PROCESS launch itself
23
+ (Chrome under load -- containers, cold cache -- can fail to start
24
+ transiently; this is separate from nodriver's own CDP-handshake
25
+ connection-wait retry, which ghost_driver.patches extends).
26
+
27
+ --use-gl=swiftshader --use-angle=swiftshader default ON: under Xvfb,
28
+ WebGL was found completely ABSENT without these (getContext("webgl")
29
+ returned null outright -- not just a suspicious vendor/renderer
30
+ string, something no real consumer browser ever does).
31
+
32
+ Defaults to headless=False: ghost_driver.input.x11's real-X11-input
33
+ drag mechanism (this library's whole reason to exist) requires a real
34
+ mapped window, which --headless=new never creates. Pass a real
35
+ (virtual is fine, e.g. Xvfb) DISPLAY and a window manager alongside
36
+ it if you're running this outside an existing desktop session.
37
+ """
38
+ args = list(DEFAULT_BROWSER_ARGS)
39
+ if browser_args:
40
+ args.extend(browser_args)
41
+ last_error: Exception | None = None
42
+ for attempt in range(max_attempts):
43
+ try:
44
+ return await uc.start(headless=headless, sandbox=sandbox, browser_args=args, **kwargs)
45
+ except Exception as e:
46
+ last_error = e
47
+ if attempt + 1 < max_attempts:
48
+ await asyncio.sleep(retry_delay_s)
49
+ raise RuntimeError(f"could not launch browser after {max_attempts} attempts") from last_error
@@ -0,0 +1,40 @@
1
+ import time
2
+ from datetime import datetime, timezone
3
+
4
+ from nodriver import cdp
5
+
6
+
7
+ def describe(cookies: list) -> list[str]:
8
+ """One human-readable line per cookie -- name, a truncated value,
9
+ domain, and remaining lifetime -- for a quick print() after a solve.
10
+ Takes the same list `Network.get_cookies()` (or NavigateResult.cookies)
11
+ returns."""
12
+ now = time.time()
13
+ lines = []
14
+ for c in cookies:
15
+ if c.session or c.expires is None or c.expires < 0:
16
+ lifetime = "SESSION (dies when browser closes)"
17
+ else:
18
+ remaining_h = (c.expires - now) / 3600.0
19
+ expires_iso = datetime.fromtimestamp(c.expires, tz=timezone.utc).isoformat()
20
+ lifetime = f"expires {expires_iso} (~{remaining_h:.1f}h from now)"
21
+ lines.append(f"{c.name}={c.value[:24]}... domain={c.domain} -- {lifetime}")
22
+ return lines
23
+
24
+
25
+ def to_cookie_param(cookie: "cdp.network.Cookie") -> "cdp.network.CookieParam":
26
+ """Converts a cdp.network.Cookie (from Network.get_cookies()) into a
27
+ cdp.network.CookieParam (for Network.set_cookies()) -- these are
28
+ DIFFERENT types despite the overlapping fields. In particular
29
+ Cookie.expires is a plain float; CookieParam.expires needs it wrapped
30
+ in TimeSinceEpoch, which has the to_json() the CDP serializer calls --
31
+ a bare float doesn't, and fails with an unhelpful AttributeError deep
32
+ in serialization if passed straight through.
33
+ """
34
+ expires = cdp.network.TimeSinceEpoch(cookie.expires) if cookie.expires is not None else None
35
+ return cdp.network.CookieParam(
36
+ name=cookie.name, value=cookie.value, domain=cookie.domain, path=cookie.path,
37
+ secure=cookie.secure, http_only=cookie.http_only, same_site=cookie.same_site,
38
+ expires=expires, priority=cookie.priority, source_scheme=cookie.source_scheme,
39
+ source_port=cookie.source_port,
40
+ )
@@ -0,0 +1,50 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+
7
+ def save_html(findings_dir: Path | str, label: str, html: str | None) -> str:
8
+ findings_dir = Path(findings_dir)
9
+ findings_dir.mkdir(parents=True, exist_ok=True)
10
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
11
+ path = findings_dir / f"{ts}_{label}.html"
12
+ path.write_text(html or "", encoding="utf-8")
13
+ return str(path)
14
+
15
+
16
+ def save_json(findings_dir: Path | str, label: str, payload: dict) -> str:
17
+ findings_dir = Path(findings_dir)
18
+ findings_dir.mkdir(parents=True, exist_ok=True)
19
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
20
+ path = findings_dir / f"{ts}_{label}.json"
21
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
22
+ return str(path)
23
+
24
+
25
+ def short_url(url: str) -> str:
26
+ """Path + query KEYS only, values dropped -- some sites' flows carry
27
+ large opaque behavioral-telemetry blobs in query values (thousands of
28
+ chars each); printing full URLs to a live console makes every run
29
+ unreadable. Keep the full URL in saved JSON diagnostics (save_json)
30
+ for later analysis; use this only for human-readable log lines."""
31
+ parsed = urlparse(url)
32
+ keys = ",".join(parse_qs(parsed.query).keys())
33
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{keys}" if keys else url
34
+
35
+
36
+ async def dump(page, findings_dir: Path | str, label: str, extra: dict | None = None) -> str:
37
+ """Saves the live page's HTML plus whatever extra diagnostic payload
38
+ you pass, both timestamped consistently under one label so they're
39
+ easy to correlate on disk. Call this at every meaningful exit point of
40
+ an attempt, not just failures -- a "passed" that later turns out wrong
41
+ has nothing to inspect after the fact otherwise."""
42
+ try:
43
+ html = await page.evaluate("document.documentElement.outerHTML")
44
+ except Exception:
45
+ html = None
46
+ saved_html = save_html(findings_dir, f"{label}_html", html)
47
+ payload = {"label": label, "saved_html": saved_html}
48
+ if extra:
49
+ payload.update(extra)
50
+ return save_json(findings_dir, label, payload)