captchakraken 2.6.1 → 2.9.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 (38) hide show
  1. package/README.md +90 -7
  2. package/dist/cli-invocation.d.ts +16 -16
  3. package/dist/cli-invocation.js +33 -21
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/model-name.d.ts +1 -1
  10. package/dist/model-name.js +1 -1
  11. package/dist/playwright-types.d.ts +18 -0
  12. package/dist/solver.d.ts +314 -16
  13. package/dist/solver.js +1208 -278
  14. package/dist/timing.d.ts +48 -0
  15. package/dist/timing.js +103 -0
  16. package/dist/trajectory.d.ts +28 -1
  17. package/dist/trajectory.js +105 -1
  18. package/dist/types.d.ts +113 -2
  19. package/package.json +2 -1
  20. package/python/README.md +43 -0
  21. package/python/pyproject.toml +1 -1
  22. package/python/src/captchakraken/__init__.py +36 -1
  23. package/python/src/captchakraken/cli.py +66 -4
  24. package/python/src/captchakraken/config.py +28 -2
  25. package/python/src/captchakraken/errors.py +24 -0
  26. package/python/src/captchakraken/humanize.py +929 -0
  27. package/python/src/captchakraken/keyframes.py +184 -12
  28. package/python/src/captchakraken/models.json +180 -18
  29. package/python/src/captchakraken/page_solver.py +1132 -321
  30. package/python/src/captchakraken/pinned_model.json +3 -2
  31. package/python/src/captchakraken/planner.py +204 -16
  32. package/python/src/captchakraken/prompts.py +186 -4
  33. package/python/src/captchakraken/server_manager.py +9 -4
  34. package/python/src/captchakraken/solver.py +98 -58
  35. package/python/src/captchakraken/timing.py +8 -3
  36. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  37. package/python/src/captchakraken/trajectory.py +117 -0
  38. 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 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.
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)