captchakraken 2.6.1 → 2.8.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/README.md +90 -7
- package/dist/cli-invocation.d.ts +2 -0
- package/dist/cli-invocation.js +5 -1
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +112 -5
- package/dist/solver.js +527 -174
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +27 -0
- package/dist/trajectory.js +104 -0
- package/dist/types.d.ts +99 -0
- package/package.json +1 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +62 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +99 -0
- package/python/src/captchakraken/models.json +149 -13
- package/python/src/captchakraken/page_solver.py +476 -270
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +118 -9
- package/python/src/captchakraken/prompts.py +182 -0
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +97 -57
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"""
|
|
2
|
+
How the driver MOVES. One pluggable object per input device.
|
|
3
|
+
|
|
4
|
+
Humanisation used to be wired straight into `page_solver`: every gesture was a
|
|
5
|
+
`page.mouse.*` call with a Bezier trajectory in front of it and a `random()`
|
|
6
|
+
sleep behind it, and there was no way to ask for anything else. That is wrong in
|
|
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.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
import random
|
|
52
|
+
import time
|
|
53
|
+
from typing import Any, Dict, Optional, Sequence, Tuple
|
|
54
|
+
|
|
55
|
+
from .trajectory import generate_swipe, generate_trajectory
|
|
56
|
+
|
|
57
|
+
Point = Tuple[float, float]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _delay(ms: float) -> None:
|
|
61
|
+
if ms > 0:
|
|
62
|
+
time.sleep(ms / 1000.0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _log(message: str) -> None:
|
|
66
|
+
print(f"[captchakraken] {message}", flush=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _same_point(a: Point, b: Point) -> bool:
|
|
70
|
+
"""A move to where the pointer already is emits nothing.
|
|
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
|
+
"""
|
|
76
|
+
return abs(float(a[0]) - float(b[0])) < 1e-6 and abs(float(a[1]) - float(b[1])) < 1e-6
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#: Every inter-gesture wait the driver takes, named.
|
|
80
|
+
#:
|
|
81
|
+
#: A vocabulary rather than a number at each call site, because the whole point
|
|
82
|
+
#: of the mode switch is that these differ per device — a finger dwells on a tap
|
|
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")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Humanizer:
|
|
100
|
+
"""The gesture vocabulary `page_solver` drives. Subclass or duck-type.
|
|
101
|
+
|
|
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
|
+
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
|
+
hovers = False
|
|
114
|
+
|
|
115
|
+
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
|
+
self.at: Point = (float(start[0]), float(start[1]))
|
|
119
|
+
|
|
120
|
+
# -- lifecycle ---------------------------------------------------------
|
|
121
|
+
def reset(self, page: Any) -> None:
|
|
122
|
+
"""Called once at the top of each `solve()`. Drop per-page caches."""
|
|
123
|
+
|
|
124
|
+
# -- primitives --------------------------------------------------------
|
|
125
|
+
def move(self, page: Any, to: Point) -> None:
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
def press(self, page: Any) -> None:
|
|
129
|
+
raise NotImplementedError
|
|
130
|
+
|
|
131
|
+
def release(self, page: Any) -> None:
|
|
132
|
+
raise NotImplementedError
|
|
133
|
+
|
|
134
|
+
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
135
|
+
raise NotImplementedError
|
|
136
|
+
|
|
137
|
+
def _pause_ms(self, kind: str) -> float:
|
|
138
|
+
return 0.0
|
|
139
|
+
|
|
140
|
+
# -- composed ----------------------------------------------------------
|
|
141
|
+
def pause(self, kind: str) -> None:
|
|
142
|
+
_delay(self._pause_ms(kind))
|
|
143
|
+
|
|
144
|
+
def click(self, page: Any, to: Point) -> None:
|
|
145
|
+
self.move(page, to)
|
|
146
|
+
self.press(page)
|
|
147
|
+
self.pause("tap")
|
|
148
|
+
self.release(page)
|
|
149
|
+
|
|
150
|
+
def drag(self, page: Any, src: Point, dst: Point) -> None:
|
|
151
|
+
self.move(page, src)
|
|
152
|
+
self.press(page)
|
|
153
|
+
self.pause("grab")
|
|
154
|
+
self.move(page, dst)
|
|
155
|
+
self.pause("drop")
|
|
156
|
+
self.release(page)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Mouse — the historical behaviour, moved here unchanged
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MouseHumanizer(Humanizer):
|
|
165
|
+
"""A hand on a mouse. Bezier arcs, Fitts's-law durations, overshoot.
|
|
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"
|
|
174
|
+
hovers = True
|
|
175
|
+
|
|
176
|
+
#: Inclusive ranges, in ms. See PAUSE_KINDS.
|
|
177
|
+
PAUSES: Dict[str, Tuple[float, float]] = {
|
|
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
|
+
}
|
|
186
|
+
|
|
187
|
+
def __init__(self, start: Point = (0.0, 0.0), frequency: int = 60) -> None:
|
|
188
|
+
super().__init__(start)
|
|
189
|
+
self._frequency = frequency
|
|
190
|
+
self._viewport_cache: Optional[Dict[str, float]] = None
|
|
191
|
+
# See `_seed_cursor`: the (0, 0) origin wedges camoufox's humanised
|
|
192
|
+
# mouse, so the first move of each solve must step off it plainly.
|
|
193
|
+
self._cursor_seeded = False
|
|
194
|
+
|
|
195
|
+
def reset(self, page: Any) -> None:
|
|
196
|
+
self._viewport_cache = None
|
|
197
|
+
self._cursor_seeded = False
|
|
198
|
+
|
|
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
|
+
def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
|
|
205
|
+
"""
|
|
206
|
+
The window we must keep the cursor inside, cached per solve.
|
|
207
|
+
|
|
208
|
+
MUST NOT be skipped and MUST NOT be guessed, because a mouse move to a
|
|
209
|
+
coordinate outside the window WEDGES camoufox. Its juggler humanises
|
|
210
|
+
each move into a trajectory, guards the intermediate points against the
|
|
211
|
+
bounds, and then dispatches the requested destination unguarded
|
|
212
|
+
("Always finish exactly on the requested destination"). An out-of-window
|
|
213
|
+
destination fires as an exit event instead of eMouseMove, so no
|
|
214
|
+
hit-renderer ack comes back; dispatch is serialised on a process-global
|
|
215
|
+
activation chain, so that one missing ack hangs every later input event
|
|
216
|
+
forever. Symptom: `page.mouse.move()` never returns, 0% CPU, solve
|
|
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
|
|
245
|
+
|
|
246
|
+
def _seed_cursor(self, page: Any) -> None:
|
|
247
|
+
"""Move the cursor off its (0, 0) origin once per solve, in ONE plain
|
|
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
|
+
"""
|
|
272
|
+
if self._cursor_seeded:
|
|
273
|
+
return
|
|
274
|
+
self._cursor_seeded = True
|
|
275
|
+
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
|
+
cx, cy = self.at
|
|
280
|
+
else:
|
|
281
|
+
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
|
+
cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
|
|
285
|
+
try:
|
|
286
|
+
page.mouse.move(cx, cy)
|
|
287
|
+
self.at = (cx, cy)
|
|
288
|
+
except Exception: # noqa: BLE001 — an adapter without a mouse must not fail the solve
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
# -- primitives --------------------------------------------------------
|
|
292
|
+
def move(self, page: Any, to: Point) -> None:
|
|
293
|
+
self._seed_cursor(page)
|
|
294
|
+
if _same_point(self.at, to):
|
|
295
|
+
return
|
|
296
|
+
points, timings = generate_trajectory(self.at, to, self._frequency)
|
|
297
|
+
self._trace(page, points, timings)
|
|
298
|
+
|
|
299
|
+
def press(self, page: Any) -> None:
|
|
300
|
+
page.mouse.down()
|
|
301
|
+
|
|
302
|
+
def release(self, page: Any) -> None:
|
|
303
|
+
page.mouse.up()
|
|
304
|
+
|
|
305
|
+
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
306
|
+
# A retry round arrives with the previous attempt still in the box, and
|
|
307
|
+
# typing would APPEND to it — submitting a string the model never read.
|
|
308
|
+
try:
|
|
309
|
+
page.keyboard.press("Control+A")
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
# Per character rather than one `type(text, delay=…)` call: a constant
|
|
313
|
+
# inter-key delay is itself a signal, and these are the vendors that
|
|
314
|
+
# score typing cadence.
|
|
315
|
+
for ch in text:
|
|
316
|
+
try:
|
|
317
|
+
page.keyboard.type(ch)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
_log(f"could not type into the captcha field: {exc}")
|
|
320
|
+
return False
|
|
321
|
+
self.pause("key")
|
|
322
|
+
return True
|
|
323
|
+
|
|
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
|
+
|
|
370
|
+
class TouchBackend:
|
|
371
|
+
"""Where touch events actually go.
|
|
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
|
+
"""
|
|
380
|
+
|
|
381
|
+
#: Human name for logs / errors.
|
|
382
|
+
name = "touch"
|
|
383
|
+
|
|
384
|
+
def down(self, x: float, y: float) -> None:
|
|
385
|
+
raise NotImplementedError
|
|
386
|
+
|
|
387
|
+
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
|
+
raise NotImplementedError
|
|
390
|
+
|
|
391
|
+
def up(self, x: float, y: float) -> None:
|
|
392
|
+
raise NotImplementedError
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class CdpTouchBackend(TouchBackend):
|
|
396
|
+
"""`Input.dispatchTouchEvent` over a CDP session on a Playwright page.
|
|
397
|
+
|
|
398
|
+
Chromium-family only, and that is checked at construction rather than
|
|
399
|
+
discovered per gesture: WebKit and Firefox (camoufox included) expose no
|
|
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.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
name = "cdp"
|
|
412
|
+
|
|
413
|
+
def __init__(self, page: Any) -> None:
|
|
414
|
+
self._session = self._open(page)
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _open(page: Any) -> Any:
|
|
418
|
+
try:
|
|
419
|
+
context = page.context
|
|
420
|
+
if callable(context):
|
|
421
|
+
context = context()
|
|
422
|
+
return context.new_cdp_session(page)
|
|
423
|
+
except Exception as exc: # noqa: BLE001 — the message is the whole value here
|
|
424
|
+
raise RuntimeError(
|
|
425
|
+
"mobile humanisation needs touch dispatch, and this page offers "
|
|
426
|
+
f"none ({exc}). Use a Chromium-family Playwright browser launched "
|
|
427
|
+
"with has_touch=True, or pass an Appium driver as "
|
|
428
|
+
"PageSolverConfig.touch_driver."
|
|
429
|
+
) from exc
|
|
430
|
+
|
|
431
|
+
def _send(self, kind: str, points: Sequence[Tuple[float, float]]) -> None:
|
|
432
|
+
self._session.send(
|
|
433
|
+
"Input.dispatchTouchEvent",
|
|
434
|
+
{
|
|
435
|
+
"type": kind,
|
|
436
|
+
"touchPoints": [
|
|
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
|
+
)
|
|
452
|
+
|
|
453
|
+
def down(self, x: float, y: float) -> None:
|
|
454
|
+
self._send("touchStart", [(x, y)])
|
|
455
|
+
|
|
456
|
+
def move(self, path: Sequence[Tuple[float, float, float]]) -> None:
|
|
457
|
+
for x, y, dt_ms in path:
|
|
458
|
+
_delay(dt_ms)
|
|
459
|
+
self._send("touchMove", [(x, y)])
|
|
460
|
+
|
|
461
|
+
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
|
+
self._send("touchEnd", [])
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class AppiumTouchBackend(TouchBackend):
|
|
469
|
+
"""W3C pointer actions with `pointerType: touch`, for Appium and Selenium.
|
|
470
|
+
|
|
471
|
+
Batches a whole leg into ONE action chain with per-sample durations, so the
|
|
472
|
+
kinematics are reproduced BY THE DEVICE rather than by a Python loop
|
|
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.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
name = "appium"
|
|
496
|
+
|
|
497
|
+
def __init__(
|
|
498
|
+
self,
|
|
499
|
+
driver: Any,
|
|
500
|
+
scale: Optional[float] = None,
|
|
501
|
+
origin: Optional[Point] = None,
|
|
502
|
+
page: Any = None,
|
|
503
|
+
) -> None:
|
|
504
|
+
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
|
+
self._scale_given = scale is not None
|
|
509
|
+
self._scale = 1.0 if scale is None else float(scale)
|
|
510
|
+
self._origin = (0.0, 0.0) if origin is None else (float(origin[0]), float(origin[1]))
|
|
511
|
+
self._page = page
|
|
512
|
+
self._checked = False
|
|
513
|
+
|
|
514
|
+
def _check_scale(self) -> None:
|
|
515
|
+
"""Refuse an unset scale on a session that reports it is not 1.
|
|
516
|
+
|
|
517
|
+
A wrong transform fails SILENTLY on both sides of the wire: the chain is
|
|
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.
|
|
528
|
+
"""
|
|
529
|
+
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:
|
|
534
|
+
return
|
|
535
|
+
try:
|
|
536
|
+
dpr = float(evaluate("() => window.devicePixelRatio"))
|
|
537
|
+
except Exception: # noqa: BLE001 — cannot measure is not a mismatch
|
|
538
|
+
return
|
|
539
|
+
if abs(dpr - 1.0) < 1e-6:
|
|
540
|
+
return
|
|
541
|
+
raise RuntimeError(
|
|
542
|
+
f"the touch driver maps CSS pixels onto a device reporting "
|
|
543
|
+
f"devicePixelRatio {dpr:g}, and no scale was given. Every gesture "
|
|
544
|
+
f"would land at {1.0 / dpr:.2f}x the intended offset, and nothing "
|
|
545
|
+
f"would report it — the chain is valid, the device performs it, and "
|
|
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
|
+
)
|
|
552
|
+
|
|
553
|
+
def _map(self, x: float, y: float) -> Tuple[int, int]:
|
|
554
|
+
if not self._checked:
|
|
555
|
+
self._check_scale()
|
|
556
|
+
return (
|
|
557
|
+
int(round(self._origin[0] + x * self._scale)),
|
|
558
|
+
int(round(self._origin[1] + y * self._scale)),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def _perform(self, actions: Sequence[Dict[str, Any]]) -> None:
|
|
562
|
+
"""One W3C action chain on a single touch pointer.
|
|
563
|
+
|
|
564
|
+
Sent as the raw protocol payload rather than through Selenium's
|
|
565
|
+
`ActionBuilder`, so this needs no selenium import and works against any
|
|
566
|
+
client that speaks WebDriver — Appium's Python client, plain Selenium 4,
|
|
567
|
+
or a thin HTTP wrapper of someone's own.
|
|
568
|
+
"""
|
|
569
|
+
payload = {
|
|
570
|
+
"actions": [
|
|
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
|
+
)
|
|
597
|
+
|
|
598
|
+
def down(self, x: float, y: float) -> None:
|
|
599
|
+
mx, my = self._map(x, y)
|
|
600
|
+
self._perform([
|
|
601
|
+
{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": mx, "y": my},
|
|
602
|
+
{"type": "pointerDown", "button": 0},
|
|
603
|
+
])
|
|
604
|
+
|
|
605
|
+
def move(self, path: Sequence[Tuple[float, float, float]]) -> None:
|
|
606
|
+
actions = []
|
|
607
|
+
for x, y, dt_ms in path:
|
|
608
|
+
mx, my = self._map(x, y)
|
|
609
|
+
# The per-sample gap becomes the move's DURATION, not a pause before
|
|
610
|
+
# it: the device then interpolates over that window and reports
|
|
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
|
+
})
|
|
620
|
+
if actions:
|
|
621
|
+
self._perform(actions)
|
|
622
|
+
|
|
623
|
+
def up(self, x: float, y: float) -> None:
|
|
624
|
+
if not self._checked:
|
|
625
|
+
self._check_scale()
|
|
626
|
+
self._perform([{"type": "pointerUp", "button": 0}])
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class TouchscreenTouchBackend(TouchBackend):
|
|
630
|
+
"""Playwright's `page.touchscreen.tap()` — taps only, no travel.
|
|
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
|
+
"""
|
|
637
|
+
|
|
638
|
+
name = "touchscreen"
|
|
639
|
+
|
|
640
|
+
def __init__(self, page: Any) -> None:
|
|
641
|
+
self._page = page
|
|
642
|
+
self._pending: Optional[Point] = None
|
|
643
|
+
|
|
644
|
+
def down(self, x: float, y: float) -> None:
|
|
645
|
+
self._pending = (x, y)
|
|
646
|
+
|
|
647
|
+
def move(self, path: Sequence[Tuple[float, float, float]]) -> None:
|
|
648
|
+
raise RuntimeError(
|
|
649
|
+
"this browser exposes taps but not touch travel, so a drag/slide "
|
|
650
|
+
"puzzle cannot be driven on it. Use a Chromium-family browser (CDP "
|
|
651
|
+
"touch dispatch) or an Appium driver."
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def up(self, x: float, y: float) -> None:
|
|
655
|
+
at = self._pending or (x, y)
|
|
656
|
+
self._pending = None
|
|
657
|
+
self._page.touchscreen.tap(float(at[0]), float(at[1]))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
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
|
+
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)
|
|
672
|
+
try:
|
|
673
|
+
return CdpTouchBackend(page)
|
|
674
|
+
except RuntimeError:
|
|
675
|
+
if hasattr(page, "touchscreen"):
|
|
676
|
+
_log("no CDP session; falling back to tap-only touch dispatch")
|
|
677
|
+
return TouchscreenTouchBackend(page)
|
|
678
|
+
raise
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# ---------------------------------------------------------------------------
|
|
682
|
+
# Mobile
|
|
683
|
+
# ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
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
|
+
"""
|
|
705
|
+
|
|
706
|
+
name = "mobile"
|
|
707
|
+
hovers = False
|
|
708
|
+
|
|
709
|
+
PAUSES: Dict[str, Tuple[float, float]] = {
|
|
710
|
+
# A tap's press-to-release. Measured human touch dwell clusters at
|
|
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:
|
|
731
|
+
super().__init__(start)
|
|
732
|
+
self._backend: Optional[TouchBackend] = backend
|
|
733
|
+
self._driver = driver
|
|
734
|
+
self._backend_kwargs = backend_kwargs
|
|
735
|
+
self._frequency = frequency
|
|
736
|
+
self._down = False
|
|
737
|
+
|
|
738
|
+
def reset(self, page: Any) -> None:
|
|
739
|
+
# A solve that ended mid-gesture (a timeout inside the slider) leaves a
|
|
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.
|
|
742
|
+
if self._down:
|
|
743
|
+
try:
|
|
744
|
+
self._touch(page).up(*self.at)
|
|
745
|
+
except Exception: # noqa: BLE001 — best effort; a stale pointer must not fail a solve
|
|
746
|
+
pass
|
|
747
|
+
self._down = False
|
|
748
|
+
|
|
749
|
+
def _touch(self, page: Any) -> TouchBackend:
|
|
750
|
+
if self._backend is None:
|
|
751
|
+
self._backend = touch_backend_for(page, self._driver, **self._backend_kwargs)
|
|
752
|
+
return self._backend
|
|
753
|
+
|
|
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
|
+
def move(self, page: Any, to: Point) -> None:
|
|
759
|
+
to = (float(to[0]), float(to[1]))
|
|
760
|
+
if not self._down:
|
|
761
|
+
# No contact, no events. Just remember where the next touch lands.
|
|
762
|
+
self.at = to
|
|
763
|
+
return
|
|
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)
|
|
770
|
+
self.at = to
|
|
771
|
+
|
|
772
|
+
def press(self, page: Any) -> None:
|
|
773
|
+
self._touch(page).down(*self.at)
|
|
774
|
+
self._down = True
|
|
775
|
+
|
|
776
|
+
def release(self, page: Any) -> None:
|
|
777
|
+
self._touch(page).up(*self.at)
|
|
778
|
+
self._down = False
|
|
779
|
+
|
|
780
|
+
def click(self, page: Any, to: Point) -> None:
|
|
781
|
+
"""Tap. The wobble is the point — see the class docstring."""
|
|
782
|
+
self.move(page, to)
|
|
783
|
+
self.press(page)
|
|
784
|
+
held = self._pause_ms("tap")
|
|
785
|
+
_delay(held * 0.5)
|
|
786
|
+
try:
|
|
787
|
+
self._touch(page).move([
|
|
788
|
+
(self.at[0] + random.gauss(0.0, 0.9), self.at[1] + random.gauss(0.0, 0.9), 0.0)
|
|
789
|
+
])
|
|
790
|
+
except Exception: # noqa: BLE001 — a tap-only backend cannot wobble; the tap still lands
|
|
791
|
+
pass
|
|
792
|
+
_delay(held * 0.5)
|
|
793
|
+
self.release(page)
|
|
794
|
+
|
|
795
|
+
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
796
|
+
"""Into the field, at soft-keyboard pace.
|
|
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"):
|
|
803
|
+
fn = getattr(field, clear, None)
|
|
804
|
+
if fn is None:
|
|
805
|
+
continue
|
|
806
|
+
try:
|
|
807
|
+
fn() if clear == "clear" else fn("")
|
|
808
|
+
break
|
|
809
|
+
except Exception: # noqa: BLE001 — an uncleared box is recoverable; a crash is not
|
|
810
|
+
continue
|
|
811
|
+
|
|
812
|
+
send = getattr(field, "send_keys", None)
|
|
813
|
+
for ch in text:
|
|
814
|
+
try:
|
|
815
|
+
if send is not None:
|
|
816
|
+
send(ch) # Appium / Selenium WebElement
|
|
817
|
+
else:
|
|
818
|
+
page.keyboard.type(ch) # Playwright mobile emulation
|
|
819
|
+
except Exception as exc:
|
|
820
|
+
_log(f"could not type into the captcha field: {exc}")
|
|
821
|
+
return False
|
|
822
|
+
self.pause("key")
|
|
823
|
+
return True
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ---------------------------------------------------------------------------
|
|
827
|
+
# None
|
|
828
|
+
# ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class NullHumanizer(Humanizer):
|
|
832
|
+
"""No humanisation: the shortest legal path to the same DOM effect.
|
|
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.
|
|
839
|
+
|
|
840
|
+
Still moves the mouse and still presses and releases: the events are what
|
|
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.
|
|
843
|
+
"""
|
|
844
|
+
|
|
845
|
+
name = "none"
|
|
846
|
+
hovers = False
|
|
847
|
+
|
|
848
|
+
def reset(self, page: Any) -> None:
|
|
849
|
+
pass
|
|
850
|
+
|
|
851
|
+
def move(self, page: Any, to: Point) -> None:
|
|
852
|
+
if _same_point(self.at, to):
|
|
853
|
+
return
|
|
854
|
+
self.at = (float(to[0]), float(to[1]))
|
|
855
|
+
try:
|
|
856
|
+
page.mouse.move(self.at[0], self.at[1])
|
|
857
|
+
except Exception as exc: # noqa: BLE001
|
|
858
|
+
if "Target closed" in str(exc) or "Session closed" in str(exc):
|
|
859
|
+
_log("could not move mouse; page or session closed")
|
|
860
|
+
|
|
861
|
+
def press(self, page: Any) -> None:
|
|
862
|
+
page.mouse.down()
|
|
863
|
+
|
|
864
|
+
def release(self, page: Any) -> None:
|
|
865
|
+
page.mouse.up()
|
|
866
|
+
|
|
867
|
+
def type_text(self, page: Any, field: Any, text: str) -> bool:
|
|
868
|
+
try:
|
|
869
|
+
field.fill(text) # replaces, so no Control+A round is needed
|
|
870
|
+
return True
|
|
871
|
+
except Exception:
|
|
872
|
+
pass
|
|
873
|
+
try:
|
|
874
|
+
page.keyboard.press("Control+A")
|
|
875
|
+
except Exception:
|
|
876
|
+
pass
|
|
877
|
+
try:
|
|
878
|
+
page.keyboard.type(text)
|
|
879
|
+
return True
|
|
880
|
+
except Exception as exc:
|
|
881
|
+
_log(f"could not type into the captcha field: {exc}")
|
|
882
|
+
return False
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
# Resolution
|
|
887
|
+
# ---------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
MODES = {"mouse": MouseHumanizer, "mobile": MobileHumanizer, "none": NullHumanizer}
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def resolve(config: Any) -> Humanizer:
|
|
893
|
+
"""The humanizer one `PageSolver` will use, from its config.
|
|
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".
|
|
902
|
+
|
|
903
|
+
Note that the env var loses to code, which is the opposite of this package's
|
|
904
|
+
model-identity settings. Deliberate: which mode is right is a property of
|
|
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.
|
|
908
|
+
"""
|
|
909
|
+
custom = getattr(config, "humanizer", None)
|
|
910
|
+
if custom is not None:
|
|
911
|
+
return custom
|
|
912
|
+
|
|
913
|
+
mode = getattr(config, "humanization", None) or os.getenv("CAPTCHA_HUMANIZATION") or "mouse"
|
|
914
|
+
mode = str(mode).strip().lower()
|
|
915
|
+
if mode not in MODES:
|
|
916
|
+
raise ValueError(
|
|
917
|
+
f"unknown humanization mode {mode!r}; expected one of "
|
|
918
|
+
f"{', '.join(sorted(MODES))}, or pass your own object as "
|
|
919
|
+
f"PageSolverConfig.humanizer"
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
start = getattr(config, "starting_mouse_position", None) or (0.0, 0.0)
|
|
923
|
+
if mode == "mobile":
|
|
924
|
+
return MobileHumanizer(
|
|
925
|
+
start,
|
|
926
|
+
driver=getattr(config, "touch_driver", None),
|
|
927
|
+
**(getattr(config, "touch_transform", None) or {}),
|
|
928
|
+
)
|
|
929
|
+
return MODES[mode](start)
|