captchakraken 2.8.0 → 3.0.0
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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- package/python/src/captchakraken/trajectory.py +0 -284
|
@@ -1,48 +1,9 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
three directions at once —
|
|
8
|
-
|
|
9
|
-
- a caller driving a MOBILE page has no cursor. Dispatching mousemove at a
|
|
10
|
-
touch-only widget is not weak humanisation, it is the wrong event type: the
|
|
11
|
-
page's touch handlers never fire, and a vendor that scores pointer telemetry
|
|
12
|
-
sees a desktop mouse on a phone.
|
|
13
|
-
- a caller who has their OWN humanisation (a hardware pointer, a proxied
|
|
14
|
-
device farm, a model of their own users) was composing two of them. That is
|
|
15
|
-
not hypothetical — camoufox's `humanize` juggler re-humanises every
|
|
16
|
-
`mouse.move()` it is handed, and running both measured 82.1s against 13.4s
|
|
17
|
-
on one geetest_v4_slide solve, because each of the 60 trajectory points
|
|
18
|
-
became its own humanised sub-trajectory.
|
|
19
|
-
- a caller on their own infrastructure, against their own fixtures, is paying
|
|
20
|
-
for texture nobody is measuring.
|
|
21
|
-
|
|
22
|
-
So the vocabulary of gestures is an INTERFACE and the humanisation is an
|
|
23
|
-
implementation of it. `page_solver` says "tap here", "drag this there", "type
|
|
24
|
-
that"; a `Humanizer` decides what events that is and how long it takes.
|
|
25
|
-
|
|
26
|
-
Four modes, selected by `PageSolverConfig.humanization`:
|
|
27
|
-
|
|
28
|
-
"mouse" MouseHumanizer — the default, and byte-for-byte what the driver
|
|
29
|
-
did before this module existed.
|
|
30
|
-
"mobile" MobileHumanizer — touch events, finger kinematics. Dispatches
|
|
31
|
-
through a `TouchBackend`, so it drives either a
|
|
32
|
-
Chromium-family page (CDP) or an Appium/W3C
|
|
33
|
-
driver on a real device.
|
|
34
|
-
"none" NullHumanizer — the shortest legal path to the same DOM effect.
|
|
35
|
-
custom anything satisfying `Humanizer`, passed as
|
|
36
|
-
`PageSolverConfig.humanizer`.
|
|
37
|
-
|
|
38
|
-
THE POINTER POSITION LIVES HERE, not in the solver. A humanizer that dispatches
|
|
39
|
-
no motion at all (mobile, between taps) still has to answer "where is the
|
|
40
|
-
pointer", because that is what the next gesture starts from and what
|
|
41
|
-
`SolveResult.final_mouse_position` reports.
|
|
42
|
-
|
|
43
|
-
The TypeScript side is `js/src/humanize.ts`; the two are one design in two
|
|
44
|
-
languages, and `tests/test_humanizer_parity.py` in CaptchaKrakenFinetune pins
|
|
45
|
-
that the mode names and the pause vocabulary have not drifted.
|
|
1
|
+
"""How the driver moves: one pluggable object per input device. Mirrors js/src/humanize.ts.
|
|
2
|
+
|
|
3
|
+
Humanisation is an interface, not a dial: composing ours with camoufox's own juggler measured
|
|
4
|
+
82.1s against 13.4s on one GeeTest v4 slider, and a mousemove at a touch widget is the wrong
|
|
5
|
+
event type, not weak mimicry. The pointer position lives here, not in the solver, because a
|
|
6
|
+
touch mode that dispatches no motion between taps still has to say where the next gesture starts.
|
|
46
7
|
"""
|
|
47
8
|
|
|
48
9
|
from __future__ import annotations
|
|
@@ -50,12 +11,18 @@ from __future__ import annotations
|
|
|
50
11
|
import os
|
|
51
12
|
import random
|
|
52
13
|
import time
|
|
53
|
-
from typing import Any, Dict, Optional, Sequence, Tuple
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
54
15
|
|
|
55
|
-
from
|
|
16
|
+
from cursory import generate_trajectory
|
|
17
|
+
|
|
18
|
+
from .kinds import HumanizationMode, PauseKind
|
|
56
19
|
|
|
57
20
|
Point = Tuple[float, float]
|
|
58
21
|
|
|
22
|
+
# Each device supplies its own table. An unknown kind yields no wait rather than raising, so adding
|
|
23
|
+
# a pause site cannot break a custom humanizer written against an older release.
|
|
24
|
+
PAUSE_KINDS = tuple(PauseKind)
|
|
25
|
+
|
|
59
26
|
|
|
60
27
|
def _delay(ms: float) -> None:
|
|
61
28
|
if ms > 0:
|
|
@@ -67,61 +34,29 @@ def _log(message: str) -> None:
|
|
|
67
34
|
|
|
68
35
|
|
|
69
36
|
def _same_point(a: Point, b: Point) -> bool:
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
`_move_and_click` travels to the element and THEN clicks at the point it
|
|
73
|
-
landed on, so without this every click would dispatch one redundant move at
|
|
74
|
-
the coordinate the trajectory just finished on.
|
|
75
|
-
"""
|
|
37
|
+
"""`click` travels then clicks at the landing point; without this every click dispatched one redundant move."""
|
|
76
38
|
return abs(float(a[0]) - float(b[0])) < 1e-6 and abs(float(a[1]) - float(b[1])) < 1e-6
|
|
77
39
|
|
|
78
40
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
#: about four times as long as a mouse button is held, and neither number means
|
|
84
|
-
#: anything to the caller who turned humanisation off. Each mode supplies its own
|
|
85
|
-
#: table below; an unknown name yields no wait rather than raising, so adding a
|
|
86
|
-
#: pause site cannot break a custom humanizer written against an older release.
|
|
87
|
-
#:
|
|
88
|
-
#: tap inside a click/tap, between press and release
|
|
89
|
-
#: between between two taps of one batch (grid tiles)
|
|
90
|
-
#: grab after pressing, before a drag starts moving
|
|
91
|
-
#: drop after a drag arrives, before releasing
|
|
92
|
-
#: probe between the slider's measurement nudges
|
|
93
|
-
#: settle before releasing a slider — the release IS the submit, and some
|
|
94
|
-
#: vendors sample the final milliseconds of the gesture
|
|
95
|
-
#: key between two typed characters
|
|
96
|
-
PAUSE_KINDS = ("tap", "between", "grab", "drop", "probe", "settle", "key")
|
|
41
|
+
def trajectory(start: Point, end: Point, frequency: int = 60) -> Tuple[List[Point], List[float]]:
|
|
42
|
+
"""A recorded human movement morphed onto the endpoints; timings are cumulative ms."""
|
|
43
|
+
points, timings = generate_trajectory(start, end, frequency=frequency)
|
|
44
|
+
return [(float(x), float(y)) for x, y in points], [float(t) for t in timings]
|
|
97
45
|
|
|
98
46
|
|
|
99
47
|
class Humanizer:
|
|
100
|
-
"""The gesture vocabulary
|
|
48
|
+
"""The gesture vocabulary the driver speaks. Subclass or duck-type."""
|
|
101
49
|
|
|
102
|
-
A custom implementation only has to provide `move`, `press`, `release`,
|
|
103
|
-
`type_text` and `_pause_ms`; `click` and `drag` are composed from those and
|
|
104
|
-
are almost never worth overriding.
|
|
105
|
-
"""
|
|
106
|
-
|
|
107
|
-
#: Mode name, for logs and for `SolveResult` reporting.
|
|
108
50
|
name = "custom"
|
|
109
|
-
#: Whether this device has a cursor that can rest somewhere without
|
|
110
|
-
#: touching. False switches off every hover-for-realism behaviour — the
|
|
111
|
-
#: reCAPTCHA tile hover and the idle drift during inference — because on a
|
|
112
|
-
#: touchscreen those are not weak mimicry, they are impossible.
|
|
113
51
|
hovers = False
|
|
52
|
+
PAUSES: Dict[PauseKind, Tuple[float, float]] = {}
|
|
114
53
|
|
|
115
54
|
def __init__(self, start: Point = (0.0, 0.0)) -> None:
|
|
116
|
-
#: Where the pointer is now. Read by the solver for
|
|
117
|
-
#: `SolveResult.final_mouse_position`.
|
|
118
55
|
self.at: Point = (float(start[0]), float(start[1]))
|
|
119
56
|
|
|
120
|
-
# -- lifecycle ---------------------------------------------------------
|
|
121
57
|
def reset(self, page: Any) -> None:
|
|
122
|
-
|
|
58
|
+
pass
|
|
123
59
|
|
|
124
|
-
# -- primitives --------------------------------------------------------
|
|
125
60
|
def move(self, page: Any, to: Point) -> None:
|
|
126
61
|
raise NotImplementedError
|
|
127
62
|
|
|
@@ -134,258 +69,137 @@ class Humanizer:
|
|
|
134
69
|
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
135
70
|
raise NotImplementedError
|
|
136
71
|
|
|
137
|
-
def _pause_ms(self, kind:
|
|
138
|
-
|
|
72
|
+
def _pause_ms(self, kind: PauseKind) -> float:
|
|
73
|
+
lo, hi = self.PAUSES.get(kind, (0.0, 0.0))
|
|
74
|
+
return random.uniform(lo, hi)
|
|
139
75
|
|
|
140
|
-
|
|
141
|
-
def pause(self, kind: str) -> None:
|
|
76
|
+
def pause(self, kind: PauseKind) -> None:
|
|
142
77
|
_delay(self._pause_ms(kind))
|
|
143
78
|
|
|
144
79
|
def click(self, page: Any, to: Point) -> None:
|
|
145
80
|
self.move(page, to)
|
|
146
81
|
self.press(page)
|
|
147
|
-
self.pause(
|
|
82
|
+
self.pause(PauseKind.TAP)
|
|
148
83
|
self.release(page)
|
|
149
84
|
|
|
150
85
|
def drag(self, page: Any, src: Point, dst: Point) -> None:
|
|
151
86
|
self.move(page, src)
|
|
152
87
|
self.press(page)
|
|
153
|
-
self.pause(
|
|
88
|
+
self.pause(PauseKind.GRAB)
|
|
154
89
|
self.move(page, dst)
|
|
155
|
-
self.pause(
|
|
90
|
+
self.pause(PauseKind.DROP)
|
|
156
91
|
self.release(page)
|
|
157
92
|
|
|
158
93
|
|
|
159
|
-
# ---------------------------------------------------------------------------
|
|
160
|
-
# Mouse — the historical behaviour, moved here unchanged
|
|
161
|
-
# ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
|
|
164
94
|
class MouseHumanizer(Humanizer):
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
Everything in here was `page_solver._smooth_move` / `_trace_path` /
|
|
168
|
-
`_seed_cursor` / `_viewport` before this module existed, and is unchanged:
|
|
169
|
-
the constants were measured, the camoufox workarounds were expensive to
|
|
170
|
-
find, and a refactor is not the place to revisit either.
|
|
171
|
-
"""
|
|
172
|
-
|
|
173
|
-
name = "mouse"
|
|
95
|
+
name = HumanizationMode.MOUSE
|
|
174
96
|
hovers = True
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
"tap": (20.0, 50.0),
|
|
179
|
-
"between": (80.0, 160.0),
|
|
180
|
-
"grab": (50.0, 100.0),
|
|
181
|
-
"drop": (50.0, 100.0),
|
|
182
|
-
"probe": (40.0, 80.0),
|
|
183
|
-
"settle": (90.0, 210.0),
|
|
184
|
-
"key": (45.0, 135.0),
|
|
185
|
-
}
|
|
97
|
+
PAUSES = {PauseKind.TAP: (20.0, 50.0), PauseKind.BETWEEN: (80.0, 160.0), PauseKind.GRAB: (50.0, 100.0),
|
|
98
|
+
PauseKind.DROP: (50.0, 100.0), PauseKind.PROBE: (40.0, 80.0), PauseKind.SETTLE: (90.0, 210.0),
|
|
99
|
+
PauseKind.KEY: (45.0, 135.0)}
|
|
186
100
|
|
|
187
101
|
def __init__(self, start: Point = (0.0, 0.0), frequency: int = 60) -> None:
|
|
188
102
|
super().__init__(start)
|
|
189
103
|
self._frequency = frequency
|
|
190
104
|
self._viewport_cache: Optional[Dict[str, float]] = None
|
|
191
|
-
|
|
192
|
-
# mouse, so the first move of each solve must step off it plainly.
|
|
105
|
+
self._down = False
|
|
193
106
|
self._cursor_seeded = False
|
|
194
107
|
|
|
195
108
|
def reset(self, page: Any) -> None:
|
|
196
109
|
self._viewport_cache = None
|
|
197
110
|
self._cursor_seeded = False
|
|
111
|
+
self._down = False
|
|
198
112
|
|
|
199
|
-
def _pause_ms(self, kind: str) -> float:
|
|
200
|
-
lo, hi = self.PAUSES.get(kind, (0.0, 0.0))
|
|
201
|
-
return random.uniform(lo, hi)
|
|
202
|
-
|
|
203
|
-
# -- viewport ----------------------------------------------------------
|
|
204
113
|
def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
|
|
205
|
-
"""
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
appears dead. Same failure family as camoufox #225.
|
|
218
|
-
|
|
219
|
-
`page.viewport_size` is None under camoufox (it uses the real window
|
|
220
|
-
rather than a spoofed viewport), which is why this falls back to asking
|
|
221
|
-
the page itself instead of assuming a size.
|
|
222
|
-
"""
|
|
223
|
-
if self._viewport_cache is not None:
|
|
224
|
-
return self._viewport_cache
|
|
225
|
-
try:
|
|
226
|
-
vp = page.viewport_size
|
|
227
|
-
if callable(vp): # some adapters expose it as a method
|
|
228
|
-
vp = vp()
|
|
229
|
-
if vp and vp.get("width") and vp.get("height"):
|
|
230
|
-
self._viewport_cache = {"width": float(vp["width"]), "height": float(vp["height"])}
|
|
231
|
-
return self._viewport_cache
|
|
232
|
-
except Exception:
|
|
233
|
-
pass
|
|
234
|
-
try:
|
|
235
|
-
inner = page.evaluate("() => ({width: window.innerWidth, height: window.innerHeight})")
|
|
236
|
-
if inner and inner.get("width") and inner.get("height"):
|
|
237
|
-
self._viewport_cache = {
|
|
238
|
-
"width": float(inner["width"]),
|
|
239
|
-
"height": float(inner["height"]),
|
|
240
|
-
}
|
|
241
|
-
return self._viewport_cache
|
|
242
|
-
except Exception:
|
|
243
|
-
pass
|
|
244
|
-
return None
|
|
114
|
+
"""The window to keep the cursor inside; None under camoufox, which reports no viewport."""
|
|
115
|
+
if self._viewport_cache is None:
|
|
116
|
+
for read in (lambda: page.viewport_size() if callable(page.viewport_size) else page.viewport_size,
|
|
117
|
+
lambda: page.evaluate("() => ({width: window.innerWidth, height: window.innerHeight})")):
|
|
118
|
+
try:
|
|
119
|
+
vp = read()
|
|
120
|
+
if vp and vp.get("width") and vp.get("height"):
|
|
121
|
+
self._viewport_cache = {"width": float(vp["width"]), "height": float(vp["height"])}
|
|
122
|
+
break
|
|
123
|
+
except Exception:
|
|
124
|
+
continue
|
|
125
|
+
return self._viewport_cache
|
|
245
126
|
|
|
246
127
|
def _seed_cursor(self, page: Any) -> None:
|
|
247
|
-
"""
|
|
248
|
-
move, before any humanised trajectory runs.
|
|
249
|
-
|
|
250
|
-
Without this, the first trajectory of a solve begins at (0, 0) — the
|
|
251
|
-
exact window corner, because that is where the pointer starts and
|
|
252
|
-
nothing has moved it. Under camoufox's `humanize` juggler a short move
|
|
253
|
-
from that origin never returns: `page.mouse.move()` blocks forever at 0%
|
|
254
|
-
CPU, and since dispatch is serialised on a process-global activation
|
|
255
|
-
chain, every later input event blocks behind it too. The solve looks
|
|
256
|
-
hung with no error anywhere and the Tier 3 fixture run times out on all
|
|
257
|
-
three attempts.
|
|
258
|
-
|
|
259
|
-
Reduced to four lines against camoufox directly:
|
|
260
|
-
|
|
261
|
-
with Camoufox(headless=True, humanize=True) as b:
|
|
262
|
-
p = b.new_page(); p.goto("about:blank")
|
|
263
|
-
p.mouse.move(1.0, 1.0) # never returns
|
|
264
|
-
|
|
265
|
-
The same move after ANY interior move completes in ~1.1s, which is what
|
|
266
|
-
makes this the fix rather than a workaround: it is the ORIGIN that is
|
|
267
|
-
poisoned, not the destination. Same failure family as camoufox #225.
|
|
268
|
-
|
|
269
|
-
Deliberately not routed through `move`: that would generate a trajectory
|
|
270
|
-
from (0, 0) and reintroduce exactly the move being avoided.
|
|
271
|
-
"""
|
|
128
|
+
"""One plain move off the (0, 0) origin per solve: a trajectory from the corner wedges camoufox."""
|
|
272
129
|
if self._cursor_seeded:
|
|
273
130
|
return
|
|
274
131
|
self._cursor_seeded = True
|
|
275
132
|
if self.at != (0.0, 0.0):
|
|
276
|
-
# The caller named a starting position. Stepping plainly to THAT
|
|
277
|
-
# satisfies this just as well as the centre does, and honouring it
|
|
278
|
-
# is the whole reason the option exists.
|
|
279
133
|
cx, cy = self.at
|
|
280
134
|
else:
|
|
281
135
|
vp = self._viewport(page)
|
|
282
|
-
# Centre when the window is known, else a modest interior point —
|
|
283
|
-
# any coordinate comfortably off the corner will do.
|
|
284
136
|
cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
|
|
285
137
|
try:
|
|
286
138
|
page.mouse.move(cx, cy)
|
|
287
139
|
self.at = (cx, cy)
|
|
288
|
-
except Exception:
|
|
140
|
+
except Exception:
|
|
289
141
|
pass
|
|
290
142
|
|
|
291
|
-
# -- primitives --------------------------------------------------------
|
|
292
143
|
def move(self, page: Any, to: Point) -> None:
|
|
293
144
|
self._seed_cursor(page)
|
|
294
145
|
if _same_point(self.at, to):
|
|
295
146
|
return
|
|
296
|
-
points, timings =
|
|
147
|
+
points, timings = trajectory(self.at, to, self._frequency)
|
|
297
148
|
self._trace(page, points, timings)
|
|
298
149
|
|
|
150
|
+
def _trace(self, page: Any, points: Sequence[Point], timings: Sequence[float]) -> None:
|
|
151
|
+
# Clamp only when the viewport is known: a guessed edge coordinate deadlocks camoufox's juggler.
|
|
152
|
+
viewport = self._viewport(page)
|
|
153
|
+
start = time.monotonic() * 1000.0
|
|
154
|
+
for (x, y), t in zip(points, timings):
|
|
155
|
+
x, y = float(x), float(y)
|
|
156
|
+
if viewport is not None:
|
|
157
|
+
x = max(1.0, min(x, viewport["width"] - 1.0))
|
|
158
|
+
y = max(1.0, min(y, viewport["height"] - 1.0))
|
|
159
|
+
try:
|
|
160
|
+
page.mouse.move(x, y)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
if "Target closed" in str(exc) or "Session closed" in str(exc):
|
|
163
|
+
_log("could not move mouse; page or session closed")
|
|
164
|
+
return
|
|
165
|
+
continue
|
|
166
|
+
self.at = (x, y)
|
|
167
|
+
_delay(start + t - time.monotonic() * 1000.0)
|
|
168
|
+
|
|
299
169
|
def press(self, page: Any) -> None:
|
|
300
170
|
page.mouse.down()
|
|
171
|
+
self._down = True
|
|
301
172
|
|
|
302
173
|
def release(self, page: Any) -> None:
|
|
303
174
|
page.mouse.up()
|
|
175
|
+
self._down = False
|
|
304
176
|
|
|
305
177
|
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
306
|
-
#
|
|
307
|
-
# typing would APPEND to it — submitting a string the model never read.
|
|
178
|
+
# Clear first: a retry round arrives with the previous attempt still in the box, and typing would append.
|
|
308
179
|
try:
|
|
309
180
|
page.keyboard.press("Control+A")
|
|
310
181
|
except Exception:
|
|
311
182
|
pass
|
|
312
|
-
# Per character
|
|
313
|
-
# inter-key delay is itself a signal, and these are the vendors that
|
|
314
|
-
# score typing cadence.
|
|
183
|
+
# Per character, not `type(text, delay=…)`: a constant inter-key delay is itself a signal these vendors score.
|
|
315
184
|
for ch in text:
|
|
316
185
|
try:
|
|
317
186
|
page.keyboard.type(ch)
|
|
318
187
|
except Exception as exc:
|
|
319
188
|
_log(f"could not type into the captcha field: {exc}")
|
|
320
189
|
return False
|
|
321
|
-
self.pause(
|
|
190
|
+
self.pause(PauseKind.KEY)
|
|
322
191
|
return True
|
|
323
192
|
|
|
324
|
-
def _trace(self, page: Any, points: Sequence[Point], timings: Sequence[float]) -> None:
|
|
325
|
-
# Clamp ONLY when the viewport is actually known.
|
|
326
|
-
#
|
|
327
|
-
# camoufox reports `viewport_size is None` (it uses the real window
|
|
328
|
-
# rather than a spoofed viewport). The obvious fallback — assume
|
|
329
|
-
# 1920x1080 — is actively harmful: it clamps coordinates to the edge of
|
|
330
|
-
# a viewport that is not the real one, and a coordinate sitting EXACTLY
|
|
331
|
-
# on the boundary is the input that deadlocks camoufox's humanised-mouse
|
|
332
|
-
# juggler patch (upstream #225, "humanize edge deadlock"). The symptom is
|
|
333
|
-
# a `page.mouse.move()` that never returns: the process sits at 0% CPU
|
|
334
|
-
# with no in-flight work and the solve appears hung, which is precisely
|
|
335
|
-
# what this driver did against camoufox until this was found.
|
|
336
|
-
#
|
|
337
|
-
# A guessed clamp buys nothing anyway — the points come from a
|
|
338
|
-
# trajectory between two on-screen elements, so they are already in
|
|
339
|
-
# range. When we do clamp, we inset by a pixel so a legitimately
|
|
340
|
-
# off-screen point lands just inside the edge instead of exactly on it.
|
|
341
|
-
viewport = self._viewport(page)
|
|
342
|
-
|
|
343
|
-
start = time.monotonic() * 1000.0
|
|
344
|
-
for i, (x, y) in enumerate(points):
|
|
345
|
-
try:
|
|
346
|
-
if viewport is None:
|
|
347
|
-
cx, cy = float(x), float(y)
|
|
348
|
-
else:
|
|
349
|
-
cx = max(1.0, min(float(x), float(viewport["width"]) - 1.0))
|
|
350
|
-
cy = max(1.0, min(float(y), float(viewport["height"]) - 1.0))
|
|
351
|
-
page.mouse.move(cx, cy)
|
|
352
|
-
self.at = (cx, cy)
|
|
353
|
-
if i < len(timings):
|
|
354
|
-
target = start + timings[i]
|
|
355
|
-
_delay(target - time.monotonic() * 1000.0)
|
|
356
|
-
except Exception as exc:
|
|
357
|
-
msg = str(exc)
|
|
358
|
-
if "Target closed" in msg or "Session closed" in msg:
|
|
359
|
-
_log("could not move mouse; page or session closed")
|
|
360
|
-
return
|
|
361
|
-
# Any other per-sample failure is skipped rather than fatal —
|
|
362
|
-
# losing one mousemove must not lose the solve.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
# ---------------------------------------------------------------------------
|
|
366
|
-
# Touch dispatch backends
|
|
367
|
-
# ---------------------------------------------------------------------------
|
|
368
|
-
|
|
369
193
|
|
|
370
194
|
class TouchBackend:
|
|
371
|
-
"""Where touch events
|
|
372
|
-
|
|
373
|
-
Three methods rather than one per sample, because the two backends want
|
|
374
|
-
opposite things: CDP dispatches one event per call and we pace it locally,
|
|
375
|
-
while a W3C/Appium driver takes a whole timed action chain in one round trip
|
|
376
|
-
and paces it on the device. Handing `move` the entire leg lets each do what
|
|
377
|
-
it is good at — and on a real device, pacing over the wire is not pacing at
|
|
378
|
-
all.
|
|
379
|
-
"""
|
|
195
|
+
"""Where touch events go. `move` takes a whole leg of `(x, y, dt_ms)` samples."""
|
|
380
196
|
|
|
381
|
-
#: Human name for logs / errors.
|
|
382
197
|
name = "touch"
|
|
383
198
|
|
|
384
199
|
def down(self, x: float, y: float) -> None:
|
|
385
200
|
raise NotImplementedError
|
|
386
201
|
|
|
387
202
|
def move(self, path: Sequence[Tuple[float, float, float]]) -> None:
|
|
388
|
-
"""`path` is (x, y, dt_ms) — dt is the wait BEFORE that sample."""
|
|
389
203
|
raise NotImplementedError
|
|
390
204
|
|
|
391
205
|
def up(self, x: float, y: float) -> None:
|
|
@@ -393,62 +207,30 @@ class TouchBackend:
|
|
|
393
207
|
|
|
394
208
|
|
|
395
209
|
class CdpTouchBackend(TouchBackend):
|
|
396
|
-
"""`Input.dispatchTouchEvent` over
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
touch dispatch through Playwright at all, so the alternatives there are to
|
|
401
|
-
fail loudly or to quietly emit MOUSE events at a touch-only widget. The
|
|
402
|
-
second is worse than not running — the page's touch handlers never fire, the
|
|
403
|
-
solve fails for a reason nothing reports, and the report reads as a model
|
|
404
|
-
that cannot solve mobile puzzles.
|
|
405
|
-
|
|
406
|
-
The context must also have been created with `has_touch=True`, or the page
|
|
407
|
-
advertises no touch support to feature detection and a mobile widget renders
|
|
408
|
-
its desktop branch.
|
|
210
|
+
"""`Input.dispatchTouchEvent` over CDP; Chromium-family pages launched with has_touch=True.
|
|
211
|
+
|
|
212
|
+
Checked at construction, not per gesture: WebKit and Firefox expose no touch dispatch, and emitting mouse
|
|
213
|
+
events at a touch widget instead is worse than not running (the report reads as a model that cannot solve mobile).
|
|
409
214
|
"""
|
|
410
215
|
|
|
411
216
|
name = "cdp"
|
|
412
217
|
|
|
413
218
|
def __init__(self, page: Any) -> None:
|
|
414
|
-
self._session = self._open(page)
|
|
415
|
-
|
|
416
|
-
@staticmethod
|
|
417
|
-
def _open(page: Any) -> Any:
|
|
418
219
|
try:
|
|
419
|
-
context = page.context
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return context.new_cdp_session(page)
|
|
423
|
-
except Exception as exc: # noqa: BLE001 — the message is the whole value here
|
|
220
|
+
context = page.context() if callable(page.context) else page.context
|
|
221
|
+
self._session = context.new_cdp_session(page)
|
|
222
|
+
except Exception as exc:
|
|
424
223
|
raise RuntimeError(
|
|
425
|
-
"mobile humanisation needs touch dispatch, and this page offers "
|
|
426
|
-
|
|
427
|
-
"
|
|
428
|
-
"PageSolverConfig.touch_driver."
|
|
429
|
-
) from exc
|
|
224
|
+
f"mobile humanisation needs touch dispatch, and this page offers none ({exc}). Use a "
|
|
225
|
+
"Chromium-family Playwright browser launched with has_touch=True, or pass an Appium "
|
|
226
|
+
"driver as PageSolverConfig.touch_driver.") from exc
|
|
430
227
|
|
|
431
228
|
def _send(self, kind: str, points: Sequence[Tuple[float, float]]) -> None:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
{
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
# radiusX/Y and force are what a real digitizer reports and
|
|
438
|
-
# a synthetic tap does not. A vendor reading
|
|
439
|
-
# `Touch.radiusX === 0` has a free bot signal otherwise.
|
|
440
|
-
{
|
|
441
|
-
"x": float(x),
|
|
442
|
-
"y": float(y),
|
|
443
|
-
"radiusX": random.uniform(8.0, 14.0),
|
|
444
|
-
"radiusY": random.uniform(8.0, 14.0),
|
|
445
|
-
"force": random.uniform(0.35, 0.75),
|
|
446
|
-
"id": 1,
|
|
447
|
-
}
|
|
448
|
-
for x, y in points
|
|
449
|
-
],
|
|
450
|
-
},
|
|
451
|
-
)
|
|
229
|
+
# radius and force are what a real digitizer reports; a synthetic tap reports zero.
|
|
230
|
+
self._session.send("Input.dispatchTouchEvent", {"type": kind, "touchPoints": [
|
|
231
|
+
{"x": float(x), "y": float(y), "radiusX": random.uniform(8.0, 14.0),
|
|
232
|
+
"radiusY": random.uniform(8.0, 14.0), "force": random.uniform(0.35, 0.75), "id": 1}
|
|
233
|
+
for x, y in points]})
|
|
452
234
|
|
|
453
235
|
def down(self, x: float, y: float) -> None:
|
|
454
236
|
self._send("touchStart", [(x, y)])
|
|
@@ -459,52 +241,21 @@ class CdpTouchBackend(TouchBackend):
|
|
|
459
241
|
self._send("touchMove", [(x, y)])
|
|
460
242
|
|
|
461
243
|
def up(self, x: float, y: float) -> None:
|
|
462
|
-
# touchEnd carries no touchPoints: the point being lifted is identified
|
|
463
|
-
# by its absence, which is what the protocol says and what a real
|
|
464
|
-
# release looks like.
|
|
465
244
|
self._send("touchEnd", [])
|
|
466
245
|
|
|
467
246
|
|
|
468
247
|
class AppiumTouchBackend(TouchBackend):
|
|
469
|
-
"""W3C pointer actions
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
round-tripping over the wire — over which a 90Hz path is not 90Hz at all.
|
|
474
|
-
|
|
475
|
-
Press and release are separate `perform()` calls on purpose. W3C input state
|
|
476
|
-
is per SESSION, so a pointer put down in one chain stays down across later
|
|
477
|
-
chains until it is lifted; that is what lets the puzzle-slider driver press,
|
|
478
|
-
screenshot, steer, screenshot and only then release. (It is also why the
|
|
479
|
-
spec has a "release actions" endpoint at all.)
|
|
480
|
-
|
|
481
|
-
COORDINATES. Everything upstream of here is CSS pixels in the page's
|
|
482
|
-
viewport, because that is what `bounding_box()` returns. A real device wants
|
|
483
|
-
screen pixels, and the two differ by the device pixel ratio and by whatever
|
|
484
|
-
chrome sits above the webview. Neither is guessable from here, so both are
|
|
485
|
-
parameters:
|
|
486
|
-
|
|
487
|
-
AppiumTouchBackend(driver, scale=3.0, origin=(0, 132))
|
|
488
|
-
|
|
489
|
-
`scale` is usually `window.devicePixelRatio`; `origin` is the top-left of
|
|
490
|
-
the webview in screen coordinates. Defaults are the identity transform,
|
|
491
|
-
which is correct for browser mobile emulation and for any caller who has
|
|
492
|
-
already mapped the coordinates themselves.
|
|
248
|
+
"""W3C touch pointer actions for Appium and Selenium, paced by the device from one chain per leg.
|
|
249
|
+
|
|
250
|
+
Press and release are separate `perform` calls on purpose: W3C input state is per session, which is what
|
|
251
|
+
lets the slider press, screenshot, steer, screenshot and only then release. Raw protocol payload, so no client import.
|
|
493
252
|
"""
|
|
494
253
|
|
|
495
254
|
name = "appium"
|
|
496
255
|
|
|
497
|
-
def __init__(
|
|
498
|
-
|
|
499
|
-
driver: Any,
|
|
500
|
-
scale: Optional[float] = None,
|
|
501
|
-
origin: Optional[Point] = None,
|
|
502
|
-
page: Any = None,
|
|
503
|
-
) -> None:
|
|
256
|
+
def __init__(self, driver: Any, scale: Optional[float] = None, origin: Optional[Point] = None,
|
|
257
|
+
page: Any = None) -> None:
|
|
504
258
|
self._driver = driver
|
|
505
|
-
# UNSET is not the same fact as an explicit 1.0. The first is a caller
|
|
506
|
-
# who has not thought about the transform; the second is one who has and
|
|
507
|
-
# says the coordinates are already mapped. Only the first is checked.
|
|
508
259
|
self._scale_given = scale is not None
|
|
509
260
|
self._scale = 1.0 if scale is None else float(scale)
|
|
510
261
|
self._origin = (0.0, 0.0) if origin is None else (float(origin[0]), float(origin[1]))
|
|
@@ -512,111 +263,54 @@ class AppiumTouchBackend(TouchBackend):
|
|
|
512
263
|
self._checked = False
|
|
513
264
|
|
|
514
265
|
def _check_scale(self) -> None:
|
|
515
|
-
"""Refuse an unset scale on a
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
valid W3C, the device performs it, and the finger lands somewhere else.
|
|
519
|
-
The solve then fails looking exactly like a model that cannot read the
|
|
520
|
-
puzzle. Same failure the fixture preflight and `_shot_scale` exist to
|
|
521
|
-
prevent, one seam over.
|
|
522
|
-
|
|
523
|
-
Read once, not per gesture — it is a round trip into the page and a
|
|
524
|
-
solve makes hundreds of these. An unreadable ratio is absent evidence,
|
|
525
|
-
not evidence of a mismatch, so it falls through to the identity.
|
|
526
|
-
`origin` cannot be measured from here at all, so it is named in the
|
|
527
|
-
refusal rather than guessed at.
|
|
266
|
+
"""Refuse an unset scale on a device whose pixel ratio is not 1: the finger would land elsewhere, silently.
|
|
267
|
+
|
|
268
|
+
Read once, not per gesture (a solve makes hundreds); an unreadable ratio is absent evidence, so identity.
|
|
528
269
|
"""
|
|
529
270
|
self._checked = True
|
|
530
|
-
if self._scale_given or self._page is None:
|
|
531
|
-
return
|
|
532
|
-
evaluate = getattr(self._page, "evaluate", None)
|
|
533
|
-
if evaluate is None:
|
|
271
|
+
if self._scale_given or self._page is None or not hasattr(self._page, "evaluate"):
|
|
534
272
|
return
|
|
535
273
|
try:
|
|
536
|
-
dpr = float(evaluate("() => window.devicePixelRatio"))
|
|
537
|
-
except Exception:
|
|
274
|
+
dpr = float(self._page.evaluate("() => window.devicePixelRatio"))
|
|
275
|
+
except Exception:
|
|
538
276
|
return
|
|
539
277
|
if abs(dpr - 1.0) < 1e-6:
|
|
540
278
|
return
|
|
541
279
|
raise RuntimeError(
|
|
542
|
-
f"the touch driver maps CSS pixels onto a device reporting "
|
|
543
|
-
f"
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
f"the solve fails looking like a weak model.\n"
|
|
547
|
-
f" Fix: touch_transform={{'scale': {dpr:g}, 'origin': (x, y)}}, "
|
|
548
|
-
f"where origin is the webview's top-left in SCREEN coordinates. "
|
|
549
|
-
f"That half cannot be measured from here.\n"
|
|
550
|
-
f" Or pass scale=1 to assert the coordinates are already mapped."
|
|
551
|
-
)
|
|
280
|
+
f"the touch driver maps CSS pixels onto a device reporting devicePixelRatio {dpr:g}, and no "
|
|
281
|
+
f"scale was given. Fix: touch_transform={{'scale': {dpr:g}, 'origin': (x, y)}}, where origin "
|
|
282
|
+
"is the webview's top-left in SCREEN coordinates, or pass scale=1 to assert the coordinates "
|
|
283
|
+
"are already mapped.")
|
|
552
284
|
|
|
553
285
|
def _map(self, x: float, y: float) -> Tuple[int, int]:
|
|
286
|
+
# Unset scale and an explicit 1.0 are different facts; only the first is checked.
|
|
554
287
|
if not self._checked:
|
|
555
288
|
self._check_scale()
|
|
556
|
-
return (
|
|
557
|
-
int(round(self._origin[0] + x * self._scale)),
|
|
558
|
-
int(round(self._origin[1] + y * self._scale)),
|
|
559
|
-
)
|
|
289
|
+
return int(round(self._origin[0] + x * self._scale)), int(round(self._origin[1] + y * self._scale))
|
|
560
290
|
|
|
561
291
|
def _perform(self, actions: Sequence[Dict[str, Any]]) -> None:
|
|
562
|
-
"""
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
{
|
|
572
|
-
"type": "pointer",
|
|
573
|
-
"id": "ck-finger",
|
|
574
|
-
"parameters": {"pointerType": "touch"},
|
|
575
|
-
"actions": list(actions),
|
|
576
|
-
}
|
|
577
|
-
]
|
|
578
|
-
}
|
|
579
|
-
# `execute` is the low-level WebDriver command hook every Selenium-family
|
|
580
|
-
# client exposes; `execute_actions` is what some Appium clients call the
|
|
581
|
-
# same thing. Try the specific name first so a client that validates the
|
|
582
|
-
# payload gets to.
|
|
583
|
-
for attempt in ("execute_actions", "execute"):
|
|
584
|
-
fn = getattr(self._driver, attempt, None)
|
|
585
|
-
if fn is None:
|
|
586
|
-
continue
|
|
587
|
-
if attempt == "execute_actions":
|
|
588
|
-
fn(payload["actions"])
|
|
589
|
-
else:
|
|
590
|
-
fn("actions", payload)
|
|
591
|
-
return
|
|
592
|
-
raise RuntimeError(
|
|
593
|
-
"the touch driver speaks neither execute_actions() nor "
|
|
594
|
-
"execute('actions', …); pass a WebDriver-compatible driver or a "
|
|
595
|
-
"custom TouchBackend."
|
|
596
|
-
)
|
|
292
|
+
chain = [{"type": "pointer", "id": "ck-finger", "parameters": {"pointerType": "touch"},
|
|
293
|
+
"actions": list(actions)}]
|
|
294
|
+
if callable(getattr(self._driver, "execute_actions", None)):
|
|
295
|
+
self._driver.execute_actions(chain)
|
|
296
|
+
elif callable(getattr(self._driver, "execute", None)):
|
|
297
|
+
self._driver.execute("actions", {"actions": chain})
|
|
298
|
+
else:
|
|
299
|
+
raise RuntimeError("the touch driver speaks neither execute_actions() nor execute('actions', …); "
|
|
300
|
+
"pass a WebDriver-compatible driver or a custom TouchBackend.")
|
|
597
301
|
|
|
598
302
|
def down(self, x: float, y: float) -> None:
|
|
599
303
|
mx, my = self._map(x, y)
|
|
600
|
-
self._perform([
|
|
601
|
-
|
|
602
|
-
{"type": "pointerDown", "button": 0},
|
|
603
|
-
])
|
|
304
|
+
self._perform([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": mx, "y": my},
|
|
305
|
+
{"type": "pointerDown", "button": 0}])
|
|
604
306
|
|
|
605
307
|
def move(self, path: Sequence[Tuple[float, float, float]]) -> None:
|
|
308
|
+
# The per-sample gap becomes the move's duration, so the device interpolates like a real finger.
|
|
606
309
|
actions = []
|
|
607
310
|
for x, y, dt_ms in path:
|
|
608
311
|
mx, my = self._map(x, y)
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
# intermediate samples of its own, which is what a finger sliding
|
|
612
|
-
# across a digitizer actually produces.
|
|
613
|
-
actions.append({
|
|
614
|
-
"type": "pointerMove",
|
|
615
|
-
"duration": max(0, int(round(dt_ms))),
|
|
616
|
-
"origin": "viewport",
|
|
617
|
-
"x": mx,
|
|
618
|
-
"y": my,
|
|
619
|
-
})
|
|
312
|
+
actions.append({"type": "pointerMove", "duration": max(0, int(round(dt_ms))),
|
|
313
|
+
"origin": "viewport", "x": mx, "y": my})
|
|
620
314
|
if actions:
|
|
621
315
|
self._perform(actions)
|
|
622
316
|
|
|
@@ -627,13 +321,7 @@ class AppiumTouchBackend(TouchBackend):
|
|
|
627
321
|
|
|
628
322
|
|
|
629
323
|
class TouchscreenTouchBackend(TouchBackend):
|
|
630
|
-
"""Playwright's
|
|
631
|
-
|
|
632
|
-
The fallback for a browser with touch support but no CDP (WebKit). A tap
|
|
633
|
-
still lands correctly; a DRAG cannot be expressed at all, so it raises
|
|
634
|
-
rather than approximating one with a mouse, for the reason in
|
|
635
|
-
`CdpTouchBackend`.
|
|
636
|
-
"""
|
|
324
|
+
"""Playwright's tap-only touchscreen, for browsers with touch but no CDP. A drag cannot be expressed."""
|
|
637
325
|
|
|
638
326
|
name = "touchscreen"
|
|
639
327
|
|
|
@@ -645,11 +333,8 @@ class TouchscreenTouchBackend(TouchBackend):
|
|
|
645
333
|
self._pending = (x, y)
|
|
646
334
|
|
|
647
335
|
def move(self, path: Sequence[Tuple[float, float, float]]) -> None:
|
|
648
|
-
raise RuntimeError(
|
|
649
|
-
|
|
650
|
-
"puzzle cannot be driven on it. Use a Chromium-family browser (CDP "
|
|
651
|
-
"touch dispatch) or an Appium driver."
|
|
652
|
-
)
|
|
336
|
+
raise RuntimeError("this browser exposes taps but not touch travel, so a drag/slide puzzle cannot be "
|
|
337
|
+
"driven on it. Use a Chromium-family browser (CDP touch dispatch) or an Appium driver.")
|
|
653
338
|
|
|
654
339
|
def up(self, x: float, y: float) -> None:
|
|
655
340
|
at = self._pending or (x, y)
|
|
@@ -658,17 +343,8 @@ class TouchscreenTouchBackend(TouchBackend):
|
|
|
658
343
|
|
|
659
344
|
|
|
660
345
|
def touch_backend_for(page: Any, driver: Any = None, **kwargs: Any) -> TouchBackend:
|
|
661
|
-
"""Pick a backend. An explicit `driver` always wins.
|
|
662
|
-
|
|
663
|
-
`driver` is what a caller passes when the thing under automation is not the
|
|
664
|
-
thing being touched — an Appium session driving a real handset while the
|
|
665
|
-
page object is a webview bridge over it.
|
|
666
|
-
"""
|
|
667
346
|
if driver is not None:
|
|
668
|
-
if isinstance(driver, TouchBackend)
|
|
669
|
-
return driver
|
|
670
|
-
# The page comes along so the backend can ask what it is mapping ONTO.
|
|
671
|
-
return AppiumTouchBackend(driver, page=page, **kwargs)
|
|
347
|
+
return driver if isinstance(driver, TouchBackend) else AppiumTouchBackend(driver, page=page, **kwargs)
|
|
672
348
|
try:
|
|
673
349
|
return CdpTouchBackend(page)
|
|
674
350
|
except RuntimeError:
|
|
@@ -678,56 +354,18 @@ def touch_backend_for(page: Any, driver: Any = None, **kwargs: Any) -> TouchBack
|
|
|
678
354
|
raise
|
|
679
355
|
|
|
680
356
|
|
|
681
|
-
# ---------------------------------------------------------------------------
|
|
682
|
-
# Mobile
|
|
683
|
-
# ---------------------------------------------------------------------------
|
|
684
|
-
|
|
685
|
-
|
|
686
357
|
class MobileHumanizer(Humanizer):
|
|
687
|
-
"""A finger on glass.
|
|
688
|
-
|
|
689
|
-
What differs from `MouseHumanizer`, and none of it is cosmetic:
|
|
690
|
-
|
|
691
|
-
- **A move that is not touching dispatches nothing.** There is no hover on
|
|
692
|
-
a touchscreen. The position is still RECORDED, because the next gesture
|
|
693
|
-
starts from it, but no event is emitted — emitting one would be the
|
|
694
|
-
desktop tell this mode exists to remove.
|
|
695
|
-
- **Taps carry a contact wobble.** A finger held on glass for 90ms does not
|
|
696
|
-
report one unchanging coordinate; the centroid of the contact patch
|
|
697
|
-
rolls a pixel or two under pressure. A tap with zero movement between
|
|
698
|
-
touchstart and touchend is a synthetic tap.
|
|
699
|
-
- **Touch kinematics**, via `generate_swipe` — see its docstring for why
|
|
700
|
-
that is a different model rather than the mouse one retuned.
|
|
701
|
-
- **Longer, more variable pauses.** Every measured touch interaction is
|
|
702
|
-
slower than its mouse equivalent, and a phone's are more variable
|
|
703
|
-
because the hand holding the device is also moving.
|
|
704
|
-
"""
|
|
358
|
+
"""A finger on glass: no hover, touch events only, slower and more variable pauses."""
|
|
705
359
|
|
|
706
|
-
name =
|
|
360
|
+
name = HumanizationMode.MOBILE
|
|
707
361
|
hovers = False
|
|
362
|
+
# Tap: measured human touch dwell clusters at 60-120ms. Key: a soft keyboard is ~3x slower than a physical one.
|
|
363
|
+
PAUSES = {PauseKind.TAP: (55.0, 130.0), PauseKind.BETWEEN: (140.0, 320.0), PauseKind.GRAB: (90.0, 190.0),
|
|
364
|
+
PauseKind.DROP: (80.0, 170.0), PauseKind.PROBE: (70.0, 140.0), PauseKind.SETTLE: (140.0, 300.0),
|
|
365
|
+
PauseKind.KEY: (110.0, 320.0)}
|
|
708
366
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
# 60-120ms; a synthesised one is usually 0.
|
|
712
|
-
"tap": (55.0, 130.0),
|
|
713
|
-
"between": (140.0, 320.0),
|
|
714
|
-
"grab": (90.0, 190.0),
|
|
715
|
-
"drop": (80.0, 170.0),
|
|
716
|
-
"probe": (70.0, 140.0),
|
|
717
|
-
"settle": (140.0, 300.0),
|
|
718
|
-
# Soft-keyboard typing, which is ~3x slower than a physical keyboard
|
|
719
|
-
# and much more variable.
|
|
720
|
-
"key": (110.0, 320.0),
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
def __init__(
|
|
724
|
-
self,
|
|
725
|
-
start: Point = (0.0, 0.0),
|
|
726
|
-
backend: Any = None,
|
|
727
|
-
driver: Any = None,
|
|
728
|
-
frequency: int = 90,
|
|
729
|
-
**backend_kwargs: Any,
|
|
730
|
-
) -> None:
|
|
367
|
+
def __init__(self, start: Point = (0.0, 0.0), backend: Any = None, driver: Any = None,
|
|
368
|
+
frequency: int = 90, **backend_kwargs: Any) -> None:
|
|
731
369
|
super().__init__(start)
|
|
732
370
|
self._backend: Optional[TouchBackend] = backend
|
|
733
371
|
self._driver = driver
|
|
@@ -736,13 +374,11 @@ class MobileHumanizer(Humanizer):
|
|
|
736
374
|
self._down = False
|
|
737
375
|
|
|
738
376
|
def reset(self, page: Any) -> None:
|
|
739
|
-
# A solve that
|
|
740
|
-
# pointer down in the SESSION's input state, and the next chain would
|
|
741
|
-
# then start from a finger already on the glass. Lift it.
|
|
377
|
+
# A solve that timed out mid-gesture leaves a pointer down in the session's input state; lift it.
|
|
742
378
|
if self._down:
|
|
743
379
|
try:
|
|
744
380
|
self._touch(page).up(*self.at)
|
|
745
|
-
except Exception:
|
|
381
|
+
except Exception:
|
|
746
382
|
pass
|
|
747
383
|
self._down = False
|
|
748
384
|
|
|
@@ -751,22 +387,12 @@ class MobileHumanizer(Humanizer):
|
|
|
751
387
|
self._backend = touch_backend_for(page, self._driver, **self._backend_kwargs)
|
|
752
388
|
return self._backend
|
|
753
389
|
|
|
754
|
-
def _pause_ms(self, kind: str) -> float:
|
|
755
|
-
lo, hi = self.PAUSES.get(kind, (0.0, 0.0))
|
|
756
|
-
return random.uniform(lo, hi)
|
|
757
|
-
|
|
758
390
|
def move(self, page: Any, to: Point) -> None:
|
|
759
391
|
to = (float(to[0]), float(to[1]))
|
|
760
|
-
if
|
|
761
|
-
|
|
762
|
-
self.
|
|
763
|
-
|
|
764
|
-
points, timings = generate_swipe(self.at, to, self._frequency)
|
|
765
|
-
path = [
|
|
766
|
-
(x, y, timings[i] - (timings[i - 1] if i else 0.0))
|
|
767
|
-
for i, (x, y) in enumerate(points)
|
|
768
|
-
]
|
|
769
|
-
self._touch(page).move(path)
|
|
392
|
+
if self._down:
|
|
393
|
+
points, timings = trajectory(self.at, to, self._frequency)
|
|
394
|
+
self._touch(page).move([(x, y, timings[i] - (timings[i - 1] if i else 0.0))
|
|
395
|
+
for i, (x, y) in enumerate(points)])
|
|
770
396
|
self.at = to
|
|
771
397
|
|
|
772
398
|
def press(self, page: Any) -> None:
|
|
@@ -778,83 +404,55 @@ class MobileHumanizer(Humanizer):
|
|
|
778
404
|
self._down = False
|
|
779
405
|
|
|
780
406
|
def click(self, page: Any, to: Point) -> None:
|
|
781
|
-
"""
|
|
407
|
+
"""A tap whose contact patch wobbles a pixel while held; a motionless tap is a synthetic one."""
|
|
782
408
|
self.move(page, to)
|
|
783
409
|
self.press(page)
|
|
784
|
-
held = self._pause_ms(
|
|
410
|
+
held = self._pause_ms(PauseKind.TAP)
|
|
785
411
|
_delay(held * 0.5)
|
|
786
412
|
try:
|
|
787
|
-
self._touch(page).move([
|
|
788
|
-
|
|
789
|
-
])
|
|
790
|
-
except Exception: # noqa: BLE001 — a tap-only backend cannot wobble; the tap still lands
|
|
413
|
+
self._touch(page).move([(self.at[0] + random.gauss(0.0, 0.9), self.at[1] + random.gauss(0.0, 0.9), 0.0)])
|
|
414
|
+
except Exception:
|
|
791
415
|
pass
|
|
792
416
|
_delay(held * 0.5)
|
|
793
417
|
self.release(page)
|
|
794
418
|
|
|
795
419
|
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
The mouse path clears with Control+A. There is no Control on a phone
|
|
799
|
-
keyboard, so the box is cleared through the element instead — which also
|
|
800
|
-
works on an Appium `WebElement`, where `page.keyboard` does not exist.
|
|
801
|
-
"""
|
|
802
|
-
for clear in ("clear", "fill"):
|
|
420
|
+
# Cleared through the element: there is no Control key on a phone, and no `page.keyboard` on Appium.
|
|
421
|
+
for clear, arg in (("clear", ()), ("fill", ("",))):
|
|
803
422
|
fn = getattr(field, clear, None)
|
|
804
|
-
if fn is None:
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
continue
|
|
811
|
-
|
|
423
|
+
if fn is not None:
|
|
424
|
+
try:
|
|
425
|
+
fn(*arg)
|
|
426
|
+
break
|
|
427
|
+
except Exception:
|
|
428
|
+
continue
|
|
812
429
|
send = getattr(field, "send_keys", None)
|
|
813
430
|
for ch in text:
|
|
814
431
|
try:
|
|
815
|
-
if send is not None
|
|
816
|
-
send(ch) # Appium / Selenium WebElement
|
|
817
|
-
else:
|
|
818
|
-
page.keyboard.type(ch) # Playwright mobile emulation
|
|
432
|
+
send(ch) if send is not None else page.keyboard.type(ch)
|
|
819
433
|
except Exception as exc:
|
|
820
434
|
_log(f"could not type into the captcha field: {exc}")
|
|
821
435
|
return False
|
|
822
|
-
self.pause(
|
|
436
|
+
self.pause(PauseKind.KEY)
|
|
823
437
|
return True
|
|
824
438
|
|
|
825
439
|
|
|
826
|
-
# ---------------------------------------------------------------------------
|
|
827
|
-
# None
|
|
828
|
-
# ---------------------------------------------------------------------------
|
|
829
|
-
|
|
830
|
-
|
|
831
440
|
class NullHumanizer(Humanizer):
|
|
832
|
-
"""No humanisation:
|
|
833
|
-
|
|
834
|
-
One mousemove per gesture instead of sixty, no dwell, no jitter, and text
|
|
835
|
-
goes in with a single `fill()`. Roughly an order of magnitude faster on a
|
|
836
|
-
slider puzzle, and it will be detected by anything that scores pointer
|
|
837
|
-
telemetry — which is the whole trade, stated plainly. For fixtures, for
|
|
838
|
-
self-hosted targets, and for callers whose stack humanises somewhere else.
|
|
441
|
+
"""No humanisation: one move per gesture and a single fill. Fast, and detectable.
|
|
839
442
|
|
|
840
|
-
Still moves
|
|
841
|
-
make the widget respond at all, and a click dispatched with no preceding
|
|
842
|
-
move fails on the vendors that require a hover state first.
|
|
443
|
+
Still moves and presses: a click dispatched with no preceding move fails on vendors that need a hover state first.
|
|
843
444
|
"""
|
|
844
445
|
|
|
845
|
-
name =
|
|
446
|
+
name = HumanizationMode.NONE
|
|
846
447
|
hovers = False
|
|
847
448
|
|
|
848
|
-
def reset(self, page: Any) -> None:
|
|
849
|
-
pass
|
|
850
|
-
|
|
851
449
|
def move(self, page: Any, to: Point) -> None:
|
|
852
450
|
if _same_point(self.at, to):
|
|
853
451
|
return
|
|
854
452
|
self.at = (float(to[0]), float(to[1]))
|
|
855
453
|
try:
|
|
856
|
-
page.mouse.move(self.at
|
|
857
|
-
except Exception as exc:
|
|
454
|
+
page.mouse.move(*self.at)
|
|
455
|
+
except Exception as exc:
|
|
858
456
|
if "Target closed" in str(exc) or "Session closed" in str(exc):
|
|
859
457
|
_log("could not move mouse; page or session closed")
|
|
860
458
|
|
|
@@ -866,7 +464,7 @@ class NullHumanizer(Humanizer):
|
|
|
866
464
|
|
|
867
465
|
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
868
466
|
try:
|
|
869
|
-
field.fill(text)
|
|
467
|
+
field.fill(text)
|
|
870
468
|
return True
|
|
871
469
|
except Exception:
|
|
872
470
|
pass
|
|
@@ -882,48 +480,27 @@ class NullHumanizer(Humanizer):
|
|
|
882
480
|
return False
|
|
883
481
|
|
|
884
482
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
# ---------------------------------------------------------------------------
|
|
888
|
-
|
|
889
|
-
MODES = {"mouse": MouseHumanizer, "mobile": MobileHumanizer, "none": NullHumanizer}
|
|
483
|
+
MODES = {HumanizationMode.MOUSE: MouseHumanizer, HumanizationMode.MOBILE: MobileHumanizer,
|
|
484
|
+
HumanizationMode.NONE: NullHumanizer}
|
|
890
485
|
|
|
891
486
|
|
|
892
487
|
def resolve(config: Any) -> Humanizer:
|
|
893
|
-
"""
|
|
894
|
-
|
|
895
|
-
Precedence, and the reasoning for it:
|
|
896
|
-
|
|
897
|
-
1. `config.humanizer` — a caller who handed us an OBJECT has already
|
|
898
|
-
decided; there is nothing left to select.
|
|
899
|
-
2. `config.humanization` — an explicit mode set in code.
|
|
900
|
-
3. `CAPTCHA_HUMANIZATION` — for a caller who cannot edit the code.
|
|
901
|
-
4. "mouse".
|
|
488
|
+
"""`config.humanizer`, else `config.humanization`, else CAPTCHA_HUMANIZATION, else mouse.
|
|
902
489
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
the PAGE the caller is driving, and an env var flipping a desktop solve to
|
|
906
|
-
touch dispatch would break every one of them silently. Pinning a model is a
|
|
907
|
-
deployment decision; picking an input device is not.
|
|
490
|
+
The env var loses to code, the opposite of the model-identity settings, because the right mode is a
|
|
491
|
+
property of the page: an env var flipping a desktop solve to touch dispatch would break every one silently.
|
|
908
492
|
"""
|
|
909
493
|
custom = getattr(config, "humanizer", None)
|
|
910
494
|
if custom is not None:
|
|
911
495
|
return custom
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
raise ValueError(
|
|
917
|
-
|
|
918
|
-
f"{', '.join(sorted(MODES))}, or pass your own object as "
|
|
919
|
-
f"PageSolverConfig.humanizer"
|
|
920
|
-
)
|
|
921
|
-
|
|
496
|
+
raw = str(getattr(config, "humanization", None) or os.getenv("CAPTCHA_HUMANIZATION") or "mouse").strip().lower()
|
|
497
|
+
try:
|
|
498
|
+
mode = HumanizationMode(raw)
|
|
499
|
+
except ValueError:
|
|
500
|
+
raise ValueError(f"unknown humanization mode {raw!r}; expected one of {', '.join(sorted(MODES))}, "
|
|
501
|
+
"or pass your own object as PageSolverConfig.humanizer") from None
|
|
922
502
|
start = getattr(config, "starting_mouse_position", None) or (0.0, 0.0)
|
|
923
|
-
if mode
|
|
924
|
-
return MobileHumanizer(
|
|
925
|
-
|
|
926
|
-
driver=getattr(config, "touch_driver", None),
|
|
927
|
-
**(getattr(config, "touch_transform", None) or {}),
|
|
928
|
-
)
|
|
503
|
+
if mode is HumanizationMode.MOBILE:
|
|
504
|
+
return MobileHumanizer(start, driver=getattr(config, "touch_driver", None),
|
|
505
|
+
**(getattr(config, "touch_transform", None) or {}))
|
|
929
506
|
return MODES[mode](start)
|