captchakraken 2.9.0 → 3.1.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.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +131 -708
  28. package/dist/solver.js +1319 -3382
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +77 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -883
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1282 -3101
  60. package/python/src/captchakraken/planner.py +65 -487
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -1,48 +1,9 @@
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 a GeeTest v4 slider 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 `the training repo's humanizer parity tests` in the training repo 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 .trajectory import generate_swipe, generate_trajectory
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
- """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
- """
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
- #: 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")
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 `page_solver` drives. Subclass or duck-type.
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
- """Called once at the top of each `solve()`. Drop per-page caches."""
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: str) -> float:
138
- return 0.0
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
- # -- composed ----------------------------------------------------------
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("tap")
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("grab")
88
+ self.pause(PauseKind.GRAB)
154
89
  self.move(page, dst)
155
- self.pause("drop")
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
- """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"
95
+ name = HumanizationMode.MOUSE
174
96
  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
- }
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
- # 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.
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
- 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
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
- """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
- """
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: # noqa: BLE001 — an adapter without a mouse must not fail the solve
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 = generate_trajectory(self.at, to, self._frequency)
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
- # 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.
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 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.
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("key")
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 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
- """
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 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.
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
- 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
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
- 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
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
- 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
- )
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 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.
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
- self,
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 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.
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: # noqa: BLE001 — cannot measure is not a mismatch
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"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
- )
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
- """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
- )
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
- {"type": "pointerMove", "duration": 0, "origin": "viewport", "x": mx, "y": my},
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
- # 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
- })
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 `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
- """
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
- "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
- )
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 = "mobile"
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
- 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:
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 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.
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: # noqa: BLE001 — best effort; a stale pointer must not fail a solve
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 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)
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
- """Tap. The wobble is the point — see the class docstring."""
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("tap")
410
+ held = self._pause_ms(PauseKind.TAP)
785
411
  _delay(held * 0.5)
786
412
  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
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
- """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"):
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
- 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
-
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("key")
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: 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.
441
+ """No humanisation: one move per gesture and a single fill. Fast, and detectable.
839
442
 
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.
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 = "none"
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[0], self.at[1])
857
- except Exception as exc: # noqa: BLE001
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) # replaces, so no Control+A round is needed
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
- # Resolution
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
- """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".
488
+ """`config.humanizer`, else `config.humanization`, else CAPTCHA_HUMANIZATION, else mouse.
902
489
 
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.
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
- 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
-
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 == "mobile":
924
- return MobileHumanizer(
925
- start,
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)