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.
- ghost_driver-0.1.0/.github/workflows/publish.yml +48 -0
- ghost_driver-0.1.0/.gitignore +7 -0
- ghost_driver-0.1.0/PKG-INFO +135 -0
- ghost_driver-0.1.0/README.md +122 -0
- ghost_driver-0.1.0/ghost_driver/__init__.py +5 -0
- ghost_driver-0.1.0/ghost_driver/browser.py +49 -0
- ghost_driver-0.1.0/ghost_driver/cookies.py +40 -0
- ghost_driver-0.1.0/ghost_driver/diagnostics.py +50 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906284_passed_pct007p6_round2.json +542 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906291_passed_pct006p7_round3.json +308 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906299_passed_pct015p6_round4.json +416 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906315_passed_pct019p1_round5.json +1028 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906323_passed_pct019p1_round6.json +650 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906357_passed_pct039p6_round8.json +740 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906366_passed_pct047p1_round9.json +1154 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906382_passed_pct060p0_round10.json +1226 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906425_passed_pct060p0_round11.json +1226 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906482_passed_pct100p0_round14.json +1712 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906489_passed_pct100p0_round15.json +1172 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906501_passed_pct012p4_round16.json +578 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906563_passed_pct039p1_round17.json +1262 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906649_passed_pct067p6_round22.json +920 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906658_passed_pct026p2_round23.json +1118 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906667_passed_pct033p3_round24.json +794 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906674_passed_pct038p2_round25.json +704 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906690_passed_pct046p7_round26.json +1262 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906700_passed_pct048p0_round27.json +920 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906707_passed_pct055p6_round28.json +812 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906715_passed_pct053p3_round29.json +1154 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906722_passed_pct000p4_round30.json +686 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906741_passed_pct065p8_round31.json +1064 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906753_passed_pct056p4_round32.json +1208 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906763_passed_pct071p1_round33.json +884 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906772_passed_pct052p9_round34.json +1028 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906799_passed_pct043p6_round36.json +614 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906824_passed_pct000p0_round38.json +398 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906841_passed_pct052p9_round39.json +542 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787906849_passed_pct040p9_round40.json +956 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907169_passed_pctNA_round1.json +20 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907173_passed_pctNA_round2.json +1 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907198_passed_pct091p1_round3.json +884 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907204_passed_pct092p4_round4.json +704 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907226_passed_pct028p0_round5.json +758 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907237_passed_pct088p9_round6.json +722 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907245_passed_pct000p0_round7.json +596 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907254_passed_pct016p9_round8.json +416 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907262_passed_pct026p2_round9.json +524 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787907283_passed_pct071p1_round10.json +992 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912039_passed_pctNA_round1.json +1 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912050_passed_pct012p4_round2.json +974 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912064_passed_pct017p8_round3.json +470 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912070_passed_pct024p0_round4.json +614 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912078_passed_pct021p3_round5.json +524 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912097_passed_pct078p7_round6.json +1694 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912112_passed_pct073p8_round7.json +1658 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912193_passed_pct050p2_round8.json +866 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912211_passed_pct027p1_round9.json +974 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912223_passed_pct051p6_round10.json +1388 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912273_passed_pct007p1_round11.json +668 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912296_passed_pct064p9_round12.json +992 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912308_passed_pct017p3_round13.json +686 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912328_passed_pct042p2_round14.json +848 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912379_passed_pct063p6_round15.json +1136 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912394_passed_pct057p3_round16.json +1136 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912404_passed_pct010p2_round17.json +830 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912412_passed_pct030p7_round18.json +632 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912421_passed_pct026p7_round19.json +1118 -0
- ghost_driver-0.1.0/ghost_driver/examples/e_disclosure_rotate_captcha/recorded_trajectories/1787912439_passed_pct026p2_round20.json +848 -0
- ghost_driver-0.1.0/ghost_driver/fingerprint/__init__.py +3 -0
- ghost_driver-0.1.0/ghost_driver/fingerprint/misc.py +100 -0
- ghost_driver-0.1.0/ghost_driver/fingerprint/native.py +19 -0
- ghost_driver-0.1.0/ghost_driver/fingerprint/webgl.py +33 -0
- ghost_driver-0.1.0/ghost_driver/fingerprint/webrtc.py +42 -0
- ghost_driver-0.1.0/ghost_driver/input/__init__.py +5 -0
- ghost_driver-0.1.0/ghost_driver/input/cdp_replay.py +85 -0
- ghost_driver-0.1.0/ghost_driver/input/drag.py +49 -0
- ghost_driver-0.1.0/ghost_driver/input/trajectories.py +109 -0
- ghost_driver-0.1.0/ghost_driver/input/x11.py +272 -0
- ghost_driver-0.1.0/ghost_driver/logging_utils.py +31 -0
- ghost_driver-0.1.0/ghost_driver/page_state.py +126 -0
- ghost_driver-0.1.0/ghost_driver/patches.py +57 -0
- ghost_driver-0.1.0/ghost_driver/recorder.py +221 -0
- ghost_driver-0.1.0/ghost_driver/session.py +179 -0
- ghost_driver-0.1.0/ghost_driver/solvers/__init__.py +52 -0
- ghost_driver-0.1.0/ghost_driver/solvers/capguru.py +110 -0
- ghost_driver-0.1.0/ghost_driver/solvers/cv_angle.py +105 -0
- ghost_driver-0.1.0/ghost_driver/solving.py +103 -0
- 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,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,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)
|