captchakraken 2.8.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +9 -40
  4. package/dist/cli-invocation.js +16 -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 +112 -539
  28. package/dist/solver.js +1088 -2950
  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 -483
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +12 -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 -879
  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 +69 -434
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +79 -21
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1123 -2564
  60. package/python/src/captchakraken/planner.py +70 -428
  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,43 +1,10 @@
1
- """
2
- Browser page driver — the Python mirror of `js/src/solver.ts`.
3
-
4
- WHAT THIS IS
5
- The Python port used to be image-in / actions-out: you handed `CaptchaSolver` a
6
- PNG and got back click/drag actions, and something else had to own the browser.
7
- Everything that actually drives a page — finding the challenge iframe, waiting
8
- for tiles to paint, clicking, submitting, deciding whether the vendor accepted
9
- — lived only in the TypeScript driver. This module closes that gap so a Python
10
- caller (camoufox's Python API, plain Playwright, patchright) can solve a captcha
11
- end to end without a Node process.
12
-
13
- THE SPLIT, WHICH IS THE SAME ON BOTH SIDES
14
- vision / CV / prompting -> Python (`solver.py`, `planner.py`, `tool_calls/`)
15
- page driving + clicking -> the driver (this file, or solver.ts)
16
-
17
- The TS driver reaches the Python half by spawning the CLI and talking JSON over
18
- a pipe. This driver calls the very same functions in-process — no subprocess, no
19
- serialisation, no persistent CV worker to leak. That is the only intended
20
- difference between the two drivers: everything about WHAT to click, WHEN a frame
21
- is settled, and WHETHER a puzzle is supported is shared code, so the two cannot
22
- drift on the parts that decide accuracy.
23
-
24
- STRUCTURAL TYPING, NO BROWSER DEPENDENCY
25
- Like `playwright-types.ts`, this module imports no browser package. The caller
26
- passes whatever Playwright-compatible `page` they have; we duck-type the slice
27
- we use. Importing `playwright` here would force it into every consumer's tree
28
- and break across version skew, and camoufox users already have their own.
29
-
30
- SYNC ONLY, FOR NOW
31
- This mirrors the synchronous Playwright API (`Camoufox()`, `sync_playwright()`),
32
- which is camoufox's headline Python interface. An async mirror is mechanical but
33
- is NOT written yet — see `solve_captcha_on_page`'s docstring. Calling this from
34
- an async event loop will not work, because sync Playwright handles cannot be
35
- driven from one.
36
- """
1
+ """Browser page driver: the Python mirror of js/src/solver.ts. Sync Playwright only."""
37
2
 
38
3
  from __future__ import annotations
39
4
 
40
5
  import hashlib
6
+ import itertools
7
+ import json
41
8
  import os
42
9
  import random
43
10
  import re
@@ -46,29 +13,27 @@ import sys
46
13
  import tempfile
47
14
  import time
48
15
  import uuid
16
+ from concurrent.futures import ThreadPoolExecutor
49
17
  from contextlib import contextmanager
50
18
  from dataclasses import dataclass, field
51
- from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
19
+ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple
52
20
 
21
+ from . import planner
53
22
  from .action_types import CaptchaAction
23
+ from .humanize import Humanizer, resolve as resolve_humanizer
24
+ from .kinds import (ActionKind, FrameRole, HumanizationMode, KeyframeMode, PauseKind, Phase, PromptFamily,
25
+ RecaptchaBanner, RetryMode, SettleVerdict, Vendor)
26
+ from .selectors import (ACCEPTED_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
27
+ SUBMIT_SELECTORS, TEXT_INPUT_SELECTORS, TEXT_INPUT_VENDOR_SELECTORS, VENDORS, WIDGET_PROBES,
28
+ VendorSelectors, WidgetProbe)
54
29
  from .solver import CaptchaSolver, UnsupportedCaptchaError
55
30
  from .timing import PhaseBudget, timings_enabled
56
- from .humanize import Humanizer, resolve as resolve_humanizer
57
31
 
58
32
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
59
-
60
- # Read by planner.py, which turns it into the X-CK-Session header. Kept as a
61
- # module constant so the name is defined in exactly one place per port.
62
33
  _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
63
34
 
64
35
 
65
36
  def _log(message: str) -> None:
66
- # flush=True is load-bearing, not tidiness. A solve is a minutes-long
67
- # sequence of slow steps, and Python block-buffers stdout whenever it is not
68
- # a TTY — so piped or redirected (CI logs, `> run.log`, a supervisor) the
69
- # progress lines all appear at once when the process exits. A run that is
70
- # working then looks indistinguishable from one that is hung, which is
71
- # exactly the wrong signal from the one output people watch.
72
37
  print(f"[captchakraken] {message}", flush=True)
73
38
 
74
39
 
@@ -82,6 +47,10 @@ def _delay(ms: float) -> None:
82
47
  time.sleep(ms / 1000.0)
83
48
 
84
49
 
50
+ def _now() -> float:
51
+ return time.monotonic() * 1000.0
52
+
53
+
85
54
  def _tmp_png(prefix: str) -> str:
86
55
  fd, path = tempfile.mkstemp(prefix=f"{prefix}_", suffix=".png")
87
56
  os.close(fd)
@@ -93,90 +62,56 @@ def _unlink(path: Optional[str]) -> None:
93
62
  try:
94
63
  os.unlink(path)
95
64
  except OSError:
96
- pass # best-effort; a leaked temp frame is not worth failing a solve
65
+ pass
97
66
 
98
67
 
99
- # --------------------------------------------------------------------------
100
- # Errors — the taxonomy the outer loop branches on. The TS driver tags plain
101
- # Errors with `.animated` / `.unsupported` booleans; Python gets real types,
102
- # which is the same distinction expressed properly.
103
- # --------------------------------------------------------------------------
68
+ def _sha1(path: str) -> str:
69
+ with open(path, "rb") as fh:
70
+ return hashlib.sha1(fh.read()).hexdigest()
104
71
 
105
72
 
106
73
  class CaptchaSolveError(Exception):
107
- """Base for every failure this driver raises."""
74
+ pass
108
75
 
109
76
 
110
77
  class AnimatedChallengeError(CaptchaSolveError):
111
- """An animated challenge we could not RECORD.
112
-
113
- Note the narrowed meaning. "The challenge never stops moving" is no longer a
114
- failure: `video_solve_enabled` records the widget, slices the recording into
115
- keyframes and solves those. This is raised only when that path cannot get an
116
- artifact to work with — the element refuses to screenshot, or the recording
117
- decodes to nothing.
118
- """
78
+ pass
119
79
 
120
80
 
121
81
  class UnsupportedChallengeError(CaptchaSolveError):
122
- """A settled frame the model reports it cannot solve (e.g. hCaptcha click/drag)."""
82
+ pass
123
83
 
124
84
 
125
85
  class NoCaptchaFoundError(CaptchaSolveError):
126
- """No interactive widget — reCAPTCHA v3/invisible, or a click-triggered challenge."""
86
+ pass
127
87
 
128
88
 
129
89
  class PageClosedError(CaptchaSolveError):
130
- """The page, context or browser went away mid-solve.
90
+ pass
131
91
 
132
- Split out from staleness deliberately. These two used to share one pattern,
133
- which meant a DEAD target was retried three times as if it were a live one
134
- mid-transition, and the error that finally escaped named whichever call ran
135
- last — `ElementHandle.screenshot`, usually — instead of saying the page had
136
- closed. That cost a real debugging session on the headed reCAPTCHA run.
137
- """
138
92
 
93
+ _STALE_HANDLE_RE = re.compile(r"Timeout .*exceeded|not visible|not attached|detached", re.IGNORECASE)
94
+ # A closed target is not a stale handle: retried as one, a dead page was re-detected three times and the
95
+ # error that finally escaped named whichever call ran last.
96
+ _CLOSED_TARGET_RE = re.compile(
97
+ r"Target (?:page, context or browser )?(?:has been )?closed|Session closed", re.IGNORECASE)
139
98
 
140
- # Playwright surfaces these as messages, not types, so both drivers match on
141
- # text. Kept as one pattern so the two lists can be diffed against each other.
142
- #
143
- # "Target closed" is NOT here on purpose — see `_CLOSED_TARGET_RE`. Everything in
144
- # this list describes a handle that went stale while the PAGE IS STILL ALIVE, so
145
- # re-detecting next round is the right response.
146
- _STALE_HANDLE_RE = re.compile(
147
- r"Timeout .*exceeded|not visible|not attached|detached",
148
- re.IGNORECASE,
149
- )
150
99
 
151
- # A target that no longer exists. Nothing can be re-detected on it, so this is
152
- # terminal rather than retryable.
153
- _CLOSED_TARGET_RE = re.compile(
154
- r"Target (?:page, context or browser )?(?:has been )?closed|Session closed",
155
- re.IGNORECASE,
156
- )
100
+ def answer_needs_element_box(actions: List[Dict[str, Any]]) -> bool:
101
+ """An allow-list of what needs nothing: the other direction raises instead of clicking at the origin."""
102
+ return any((a or {}).get("action") != ActionKind.DONE for a in actions)
157
103
 
158
104
 
159
105
  @dataclass
160
106
  class SolveResult:
161
- """Mirror of the TS `SolveResult`."""
162
-
163
107
  is_solved: bool
164
108
  final_mouse_position: Tuple[float, float]
165
109
  token_usage: List[Dict[str, Any]] = field(default_factory=list)
166
- #: Where this solve's wall-clock went, in milliseconds per phase — the same
167
- #: partition `CAPTCHA_TIMINGS=1` prints, handed to the caller instead of to
168
- #: stderr. `{}` when the result was built outside a solve.
169
- #:
170
- #: Returned rather than only logged because "the solve took 12s" is not
171
- #: actionable and "the settle monitor spent 4s of it" is, and a caller
172
- #: measuring a fleet cannot scrape stderr from inside its own process.
173
110
  phases: Dict[str, float] = field(default_factory=dict)
174
111
 
175
112
 
176
113
  @dataclass
177
114
  class _GridSession:
178
- """Cached geometry for one reCAPTCHA grid puzzle. Mirror of `GridSession`."""
179
-
180
115
  grid_boxes: List[Sequence[int]]
181
116
  element_box: Dict[str, float]
182
117
  scale_x: float
@@ -185,508 +120,210 @@ class _GridSession:
185
120
  screenshot_h: int
186
121
 
187
122
 
188
- def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int) -> str:
189
- """The pixel-settle rule, over `(elapsed_ms, moved)` polls.
123
+ # Mirrors the JS port: past the floor window, this many distinct screens still arriving is an animation
124
+ # even though no screen has come back yet.
125
+ BURST_ANIMATED_SCREENS = 6
126
+
190
127
 
191
- Split out of the polling loop so the RULE can be tested against recorded
192
- timelines without a browser — see `tests/test_animated_is_detected.py`,
193
- which pins what it does to each shipped animated puzzle.
128
+ def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int,
129
+ motion_streak: int = 0) -> SettleVerdict:
130
+ """The pixel-settle rule over `(elapsed_ms, moved)` polls.
194
131
 
195
- Note what it cannot do: 'animated' needs a poll that MOVED at or after
196
- `animated_after_ms`, so any `settle_frames` run of stillness before then
197
- ends the wait as 'settled' first. Every animated puzzle we ship rests
198
- between screens for longer than that, which is why the caller also probes.
132
+ `motion_streak` exits early: rotating_obj_video changes every 133-171ms, and without it 4.5s went on
133
+ proving that before a fresh 4s burst filmed it again.
199
134
  """
200
- still_streak = 0
201
- for elapsed_ms, moved in samples:
202
- if moved:
203
- still_streak = 0
204
- if elapsed_ms >= animated_after_ms:
205
- return "animated"
135
+ still = moved = 0
136
+ for elapsed_ms, did_move in samples:
137
+ if did_move:
138
+ still, moved = 0, moved + 1
139
+ if elapsed_ms >= animated_after_ms or (motion_streak and moved >= motion_streak):
140
+ return SettleVerdict.ANIMATED
206
141
  else:
207
- still_streak += 1
208
- if still_streak >= settle_frames:
209
- return "settled"
210
- return "timeout"
142
+ moved, still = 0, still + 1
143
+ if still >= settle_frames:
144
+ return SettleVerdict.SETTLED
145
+ return SettleVerdict.TIMEOUT
146
+
147
+
148
+ def burst_hang_deadline_ms(cfg) -> float:
149
+ return 3.0 * float(cfg.video_burst_max_ms) + 5_000.0
150
+
151
+
152
+ def measured_fps(frames: int, elapsed_ms: float, nominal_fps: float) -> float:
153
+ if frames <= 0 or elapsed_ms <= 0:
154
+ return float(nominal_fps)
155
+ return frames / (elapsed_ms / 1000.0)
156
+
157
+
158
+ # Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
159
+ # 0.002 sits above the 0.001 noise floor; reusing stale_frame_diff_threshold (0.02) was blind to GeeTest svg.
160
+ _MOVED_DURING_INFERENCE_DIFF = 0.002
161
+ _NOT_THIS_BOARD_DIFF = 0.5
162
+ _NOT_THIS_BOARD_POLLS = 3
211
163
 
212
164
 
213
165
  @dataclass
214
166
  class PageSolverConfig:
215
- """
216
- Tunables, named to match the TS `CaptchaKrakenConfig` keys (snake_cased) so a
217
- value tuned on one driver can be found on the other.
218
- """
219
-
220
- # ── how the driver moves ───────────────────────────────────────────────
221
- #: "mouse" (default), "mobile" or "none". See `humanize.py`.
222
- #:
223
- #: Not a realism dial — a choice of INPUT DEVICE. "mobile" dispatches touch
224
- #: events with finger kinematics, and on a touch-only widget that is the
225
- #: difference between the page's handlers firing and not. "none" is the
226
- #: shortest legal path to the same DOM effect, for fixtures and for callers
227
- #: who humanise somewhere else in their stack.
228
- #:
229
- #: None means unset: `CAPTCHA_HUMANIZATION`, else "mouse".
230
- humanization: Optional[str] = None
231
- #: Your own `humanize.Humanizer`. Overrides `humanization` entirely — the
232
- #: driver then makes no decisions about pointer motion at all.
167
+ humanization: Optional[HumanizationMode] = None
233
168
  humanizer: Optional[Humanizer] = None
234
- #: mobile only. The thing that is actually TOUCHED, when it is not the page
235
- #: object — an Appium/Selenium WebDriver on a real handset. Left None, the
236
- #: mode dispatches CDP touch events at the page it was given.
237
169
  touch_driver: Optional[Any] = None
238
- #: mobile only. CSS-pixel -> device-pixel transform for `touch_driver`,
239
- #: e.g. `{"scale": 3.0, "origin": (0, 132)}`. See AppiumTouchBackend; the
240
- #: default identity is right for browser emulation.
241
170
  touch_transform: Optional[Dict[str, Any]] = None
242
- #: Where the pointer starts. Setting it stops the first gesture of a solve
243
- #: crossing the whole window from the origin.
244
171
  starting_mouse_position: Optional[Tuple[float, float]] = None
245
-
246
- # ── which model answers ────────────────────────────────────────────────
247
- #: Force ONE expert of a routed model — "pixel", "grid", "video" or "text".
248
- #:
249
- #: A routed model (Abyss) is four adapters behind one endpoint, and the
250
- #: router is the prompt family the request is about to send, so the default
251
- #: (None) already reaches the right one with no help from the caller. This
252
- #: is the override: serve one arm, drive only the puzzles it owns, which is
253
- #: what a per-arm benchmark needs and what a licence holder pinning a single
254
- #: expert wants.
255
- #:
256
- #: Refused at construction against a model that serves one adapter — every
257
- #: model published so far — because a run that quietly measured the
258
- #: generalist and reported it as the expert is a number nobody can catch.
259
- #: None means unset: `CAPTCHA_EXPERT`, else route by family.
260
- expert: Optional[str] = None
261
-
262
- # 45s, and the loop count that fits inside it rather than one that needs
263
- # policing by the clock. A round costs ~4-7s once the waits below are paid,
264
- # so six rounds is the budget; ten never fitted and only ever expressed
265
- # itself as a timeout. The cap is a BACKSTOP — `max_no_progress_rounds` is
266
- # what is supposed to end a hopeless solve, and a solve that reaches this
267
- # number is a bug report, not a normal outcome.
172
+ expert: Optional[PromptFamily] = None
268
173
  max_solve_loops: int = 6
269
174
  overall_solve_timeout_ms: int = 45_000
270
- #: Consecutive rounds producing the SAME answer before the solve is
271
- #: abandoned.
272
- #:
273
- #: At temperature 0 the model is a function of the picture, so an identical
274
- #: answer means an identical picture — and every answer this driver produces
275
- #: is EXECUTED, so the previous one already ran and moved nothing. Repeating
276
- #: it cannot do better; it just spends a round.
277
- #:
278
- #: Measured on recaptcha_grid_4x4 (fixture seed 20260730): the model answers
279
- #: `[2,6,7,9,10]` on round 1 and then `[2,6,7,10]` on rounds 2 THROUGH 10 —
280
- #: nine identical click sets, each one clicked, each one rejected, ending in
281
- #: "still detected after 10 solve loops" at 66.1s. Of that, 39.0s was pure
282
- #: waiting. Stopping on the second repeat ends it at round 4.
283
- #:
284
- #: 2 rather than 1: the first repeat also ARMS the animated probe, which is
285
- #: the one recovery worth trying (a cycling board reads as a still and
286
- #: answers the same way every round). One more round buys that chance.
287
175
  max_no_progress_rounds: int = 2
288
176
  post_solve_delay_ms: int = 1_200
289
- # How long to keep watching for a SUCCESS after submitting.
290
- #
291
- # That is the window's whole job, and it is why a wrong answer spends all of
292
- # it: the vendors emit nothing on a wrong answer, they just re-deal. Sizing
293
- # it therefore means asking how late a real success can arrive.
294
- #
295
- # MEASURED over 34 successful rounds across 20 puzzle types (fixture server,
296
- # two seeds each): min 3ms, p50 360ms, p90 378ms, max 528ms.
297
- #
298
- # Read that with care — those values are quantized by `..._poll_ms`, not by
299
- # any vendor. The clusters sit at one poll (~200ms), two polls (~370ms, the
300
- # `widget_gone >= 2` confirmation) and three (~520ms). So the measurement
301
- # mostly describes THIS loop, and the fixture's verdict is a local fetch,
302
- # which means real hCaptcha/reCAPTCHA round-trips are absent from it
303
- # entirely.
304
- #
305
- # 1000ms is therefore the measured worst case (528ms, ~264ms once the poll
306
- # below is halved) plus deliberate headroom for a vendor round-trip nobody
307
- # has measured yet. Down from 2500ms, which was spent in full on every
308
- # unsuccessful round — 25.5s of one 66.1s recaptcha_grid_4x4 solve.
309
- #
310
- # To replace the headroom with a number: drive the real vendor pages via
311
- # `tests/live-solve/src/demo-targets.ts` and re-read this distribution.
312
177
  post_solve_outcome_timeout_ms: int = 1_000
313
- #: Poll interval inside that window.
314
- #:
315
- #: The success signals are polled, so detection latency is a MULTIPLE of
316
- #: this — and the one that matters most, "the widget is gone", needs two
317
- #: consecutive polls to confirm, so it cannot be seen in less than 2x. At
318
- #: 150ms that put a 300ms floor under every inline vendor's success. 75ms
319
- #: halves it, and the poll is cheap: `detect_captcha` measures ~3ms.
320
178
  post_solve_outcome_poll_ms: int = 75
321
179
  element_screenshot_timeout_ms: int = 8_000
322
180
  max_unsupported_resolves: int = 3
323
181
  max_stale_element_retries: int = 3
324
182
  stale_element_backoff_ms: int = 900
325
-
326
- # Freshness guard — re-solve if the frame moved during inference.
327
183
  stale_frame_resolve_enabled: bool = True
328
184
  stale_frame_diff_threshold: float = 0.02
329
185
  max_stale_frame_resolves: int = 2
330
-
331
- # Pixel-settle monitor.
332
186
  settle_poll_ms: int = 220
333
187
  settle_frames: int = 2
334
188
  settle_timeout_ms: int = 9_000
335
189
  animated_challenge_after_ms: int = 4_500
190
+ animated_motion_streak: int = 5
336
191
  settle_diff_threshold: float = 0.01
337
192
  post_submit_change_timeout_ms: int = 4_000
338
-
339
- # ── Animated challenges ────────────────────────────────────────────────
340
- # A challenge that never settles is RECORDED and solved from keyframes rather
341
- # than abandoned. Off switch for callers who would rather fail fast than spend
342
- # the recording time.
343
193
  video_solve_enabled: bool = True
344
- # Whether a repeated answer may escalate to a RECORDING of the widget.
345
- #
346
- # A board that cycles reads as static to `_wait_for_element_settled` (it
347
- # rests between screens for far longer than the 440ms that rule stops at),
348
- # gets answered from whichever screen the screenshot caught, and then
349
- # answers identically every round. That repeat is the signal, and the
350
- # response is to record a burst and let the SLICER decide: a widget that
351
- # really is static slices to one keyframe and is solved as the still it is,
352
- # so the escalation costs one burst and can never produce a worse answer.
353
- #
354
- # Off restores the old behaviour for a caller who would rather re-read a
355
- # still than spend `video_burst_duration_ms` finding out.
356
194
  animated_probe_enabled: bool = True
357
- # Burst geometry. Deliberately identical to the collector's
358
- # (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the finetune repo), so a
359
- # challenge recorded here is the same shape of artifact the model trained on —
360
- # same clip length, same frame rate, therefore the same keyframe slicing.
361
195
  video_burst_duration_ms: int = 4_000
196
+ video_burst_max_ms: int = 12_000
197
+ speculative_burst_enabled: bool = True
362
198
  video_burst_fps: int = 10
363
- # How long to wait for the widget to return to the keyframe the model chose,
364
- # before clicking anyway. Bounded because the alternative is worse: these
365
- # puzzles cycle, so the state DOES come back — but if the recording caught a
366
- # one-off transition it never will, and a click on the model's coordinates is
367
- # still a better use of the remaining budget than a timeout.
368
- keyframe_wait_timeout_ms: int = 6_000
199
+ # GeeTest svg dwells up to 2.7s a screen, so a 3-screen cycle is 8.1s; 6s gave up one screen short.
200
+ keyframe_wait_timeout_ms: int = 9_000
369
201
  keyframe_wait_poll_ms: int = 120
370
- #: Extra wall clock granted ONCE, the first time a solve escalates to a
371
- #: recording. NOT a looser `overall_solve_timeout_ms`.
372
- #:
373
- #: That budget is sized for ROUNDS — "a round costs ~4-7s, so six is the
374
- #: budget". The recording path is not a round. It is a fixed extra stage
375
- #: costing the burst, the slice, one MULTI-IMAGE inference (six keyframes,
376
- #: several times a still's) and the wait for the widget to come back round
377
- #: to the frame the model chose. Nothing in the 45s was ever set aside for
378
- #: it, so an escalation started at ~35s ran the clock out mid-burst and
379
- #: reported a TIMEOUT — which reads as a slow model rather than as a budget
380
- #: with no room for the thing the solver had just decided to do.
381
- #:
382
- #: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on
383
- #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
384
- #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
385
- #: recording the animated challenge", at 45.7-52.7s elapsed. The same three
386
- #: fixtures solve in 11-20s on the rounds where the still path happens to
387
- #: answer them, so it is the escalation that does not fit, not the puzzle.
388
- #:
389
- #: Granted only when `video_solve_enabled` — a caller who wants a hard
390
- #: deadline turns recording off, which is the switch that already means
391
- #: "fail fast rather than spend the recording time".
392
202
  video_extra_inference_ms: int = 8_000
393
-
394
- def video_budget_ms(self) -> int:
395
- """What one escalation to recording is allowed to cost, on top.
396
-
397
- DERIVED, so a longer burst or a longer keyframe wait carries its own
398
- budget instead of quietly reintroducing the timeout this exists to fix.
399
- """
400
- return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms
401
- + self.video_extra_inference_ms)
402
-
403
- # Grid load / dynamic-refresh timing.
404
- # How long to wait for hCaptcha's task images to paint before screenshotting
405
- # anyway. Best-effort by design — the screenshot happens either way — so it
406
- # is bounded by what a loading tile plausibly costs, not by a generous
407
- # default. 8s was two-thirds of what a whole solve is now allowed to take.
408
203
  hcaptcha_images_timeout_ms: int = 3_000
409
-
410
204
  grid_load_poll_interval_ms: int = 250
411
205
  grid_load_timeout_ms: int = 8_000
206
+ board_paint_poll_ms: int = 180
207
+ board_paint_timeout_ms: int = 2_500
208
+ board_paint_floor: Optional[float] = None
412
209
  recaptcha_max_dynamic_rounds: int = 8
413
210
  recaptcha_fade_onset_grace_ms: int = 4_000
414
211
  recaptcha_dynamic_fade_poll_ms: int = 250
415
212
  recaptcha_dynamic_fade_wait_ms: int = 6_000
416
213
  recaptcha_tile_hover_enabled: bool = True
417
-
418
- # ── puzzle-piece sliders (see _execute_slide) ──────────────────────────
419
- # How far to nudge the handle, in px, to learn the piece's width and how
420
- # fast it follows. Two probes because two unknowns; far enough apart that
421
- # the difference between the two widths is signal rather than rounding, and
422
- # both small enough to stay on the shortest track observed (~250px).
423
- slide_probe_offsets_px: Tuple[float, ...] = (24.0, 64.0)
424
- # Stop steering once the piece is this close. Tighter than any vendor's
425
- # accept window, so the limit on solving is the model's slot estimate.
426
214
  slide_tolerance_px: float = 2.0
427
- # Corrections after the probes. Each costs a screenshot with the mouse held
428
- # down; two is enough for a linear system, and the third would only be
429
- # chasing a measurement that is not going to converge.
430
- slide_max_corrections: int = 2
431
-
432
-
433
- # Vendors with no checkbox/challenge split — one container, one interactive
434
- # surface. Checked in detect_captcha() after the five hard-coded reCAPTCHA /
435
- # hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
436
- # lifted from src/captchaCollection/sources.py, which already drives these 8
437
- # vendors nightly in the collector. Mirror of VENDOR_WIDGET_LOCATORS in
438
- # solver.ts — keep both in the same order with the same selectors.
439
- VENDOR_WIDGET_LOCATORS = [
440
- {"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
441
- # Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
442
- # `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first,
443
- # because the iframe build is still deployed on sites pinning an older
444
- # widget and costs nothing to keep. Dropping the in-page selectors is what
445
- # made the client blind to every live Tencent captcha for twelve days.
446
- #
447
- # `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
448
- # MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
449
- # Tencent's entry was silently the only thing detecting MTCaptcha at all,
450
- # which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
451
- # matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
452
- {"puzzle_source": "tencent", "selectors": ['#tcaptcha_transform_dy', '#tCaptchaDyContent', '.tencent-captcha-dy__content', 'iframe#tcaptcha_iframe_dy', 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
453
- {"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
454
- # Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
455
- # that frame's document — `query_selector` does not cross a frame boundary,
456
- # so the host page never carried it and this entry matched nothing, ever.
457
- # Challenge frame first, anchor second, exactly like reCAPTCHA above.
458
- # `.CheckboxCaptcha` stays for an inline embed that renders it directly.
459
- {"puzzle_source": "yandex", "selectors": ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', ".CheckboxCaptcha"]},
460
- {"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
461
- {"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
462
- # MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
463
- # id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
464
- # is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
465
- # class selector matches whole tokens, so it never matched the host page's
466
- # `class="mtcaptcha"` either. Kept last for an inline embed.
467
- {"puzzle_source": "mtcaptcha", "selectors": ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', ".mtcaptcha", ".mtcap"]},
468
- {"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
469
- ]
470
-
471
-
472
- # ── which vendor's code the page LOADED, as opposed to what it rendered ─────
473
- #
474
- # A selector is a claim about markup, and markup is the half of a captcha the
475
- # vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
476
- # the point. The REQUEST is far more stable: Tencent moved its widget out of an
477
- # iframe and renamed every class on 2026-08-11, and went on fetching from
478
- # `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
479
- # substring `captcha.gtimg.com` — already in the table above, on a selector that
480
- # stopped matching — never left the wire.
481
- #
482
- # This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
483
- # page; it cannot say where the widget is, and the solver needs an ELEMENT to
484
- # screenshot, to scope its controls against, and to drag. What it buys is the
485
- # difference between the two things a null detection can mean:
486
- #
487
- # · no captcha here (reCAPTCHA v3 / invisible / not triggered yet) — fine, and
488
- # what the message used to guess unconditionally;
489
- # · the vendor's code is loaded and running and NOTHING WE LOOK FOR MATCHES —
490
- # i.e. the DOM moved under us. That is a bug report, and it took twelve days
491
- # to notice the last time because the client said the first thing while
492
- # meaning the second.
493
- #
494
- # Hosts measured 2026-08-24 by loading each vendor's demo page and recording
495
- # every third-party request (see scripts/check_vendor_selectors.py --hosts).
496
- #: The two vendors with bespoke handling in this file. EVERYTHING else shares the
497
- #: generic path, and the two behaviours below are gated on "not one of these" —
498
- #: spelled as this set rather than as `== "unknown"`, which is what they used to
499
- #: say. The two were identical only while `unknown` was the sole third value, so
500
- #: the day anyone reports a vendor by name — to constrain which grid shapes it may
501
- #: be solved as, say — `== "unknown"` silently turns OFF typed-challenge detection
502
- #: for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
503
- #: animated settle probe for GeeTest and Tencent. Neither failure raises; the text
504
- #: captcha just becomes unsolvable and the animated board gets answered from one
505
- #: arbitrary frame.
506
- VENDORS_WITH_BESPOKE_HANDLING = frozenset({"hcaptcha", "recaptcha"})
507
-
508
- VENDOR_URL_MARKERS = [
509
- {"puzzle_source": "hcaptcha", "hosts": ["hcaptcha.com"]},
510
- {"puzzle_source": "recaptcha", "hosts": ["google.com/recaptcha", "recaptcha.net"]},
511
- {"puzzle_source": "turnstile", "hosts": ["challenges.cloudflare.com"]},
512
- {"puzzle_source": "geetest", "hosts": ["geetest.com"]},
513
- {"puzzle_source": "tencent", "hosts": ["captcha.gtimg.com", "captcha.qcloud.com"]},
514
- {"puzzle_source": "yidun", "hosts": ["dun.163.com", "cstaticdun.126.net",
515
- "necaptcha.nosdn.127.net"]},
516
- {"puzzle_source": "yandex", "hosts": ["smartcaptcha.yandexcloud.net"]},
517
- {"puzzle_source": "lemin", "hosts": ["leminnow.com"]},
518
- {"puzzle_source": "prosopo", "hosts": ["prosopo.io"]},
519
- {"puzzle_source": "mtcaptcha", "hosts": ["mtcaptcha.com"]},
520
- # BotDetect is deliberately absent and must stay absent. It is a SELF-HOSTED
521
- # library — the image comes from the application's own origin
522
- # (`BotDetectCaptcha.ashx`), so there is no vendor host to see and a URL
523
- # tripwire cannot cover it. Recording that is the point: an empty result for
524
- # a BotDetect page means "this check does not apply", not "no captcha".
525
- ]
526
-
527
-
528
- # ── where the answer goes, when it is not a click ───────────────────────────
529
- #
530
- # Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
531
- # first visible match. That order is the whole design: a named vendor selector
532
- # is unambiguous, while the generic patterns are guesses that happen to be right
533
- # most of the time. Trying the guess first would, on a page that hosts a captcha
534
- # *and* a login form, type the captcha's answer into the username box.
535
- #
536
- # The generic tail is not a nicety either — it is what actually fires on most
537
- # pages. Vendors rename these classes without notice (they are anti-bot
538
- # surfaces, so churn is the point), and our own Tier 3 fixtures render neither
539
- # vendor's DOM. Anything that only worked via the vendor list would be a feature
540
- # that passes review and fails in the field.
541
-
542
- # A distorted-text captcha's answer box. The three vendor entries are the three
543
- # types in instructions.py::TEXT_TYPES.
544
- #
545
- # Split in two because the two halves are safe in different places. The NAMED
546
- # half identifies a captcha's answer box wherever it sits; the GENERIC half only
547
- # means "the text box in this widget" and is trustworthy only when the scope
548
- # already is the widget. `_answer_box` widens past the widget for BotDetect and
549
- # may take the named half with it, never the generic one.
550
- TEXT_INPUT_VENDOR_SELECTORS = [
551
- # BotDetect — the input is application-defined, so match the id fragment its
552
- # own docs and samples use. These three are what the nightly collector
553
- # already drives (src/captchaCollection/sources.py).
554
- "input[id*=captchaCode]",
555
- "input#captchaCode",
556
- "input[id*=validateCaptcha]",
557
- ".BDC_CaptchaDiv input[type=text]",
558
- # MTCaptcha
559
- "input.mtcap-inputtext",
560
- ".mtcap input[type=text]",
561
- # Yandex SmartCaptcha
562
- ".AdvancedCaptcha-Input input",
563
- "input.Textinput-Control",
564
- 'input[name="rep"]',
565
- ]
566
-
567
- TEXT_INPUT_GENERIC_SELECTORS = [
568
- # Generic — an input the page itself labels as the captcha answer.
569
- 'input[name*="captcha" i]',
570
- 'input[id*="captcha" i]',
571
- 'input[aria-label*="captcha" i]',
572
- 'input[placeholder*="code" i]',
573
- 'input[autocomplete="off"][type=text]',
574
- # Last resort: the only text box in the widget. Scoped to the challenge
575
- # frame/container by the caller, never to the whole page — see _find_control.
576
- "input[type=text]",
577
- "input:not([type])",
578
- "input[type=tel]",
579
- "textarea",
580
- ]
581
-
582
- TEXT_INPUT_SELECTORS = TEXT_INPUT_VENDOR_SELECTORS + TEXT_INPUT_GENERIC_SELECTORS
583
-
584
- # The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
585
- # these vendors the piece is inert decoration that the handle carries, so a
586
- # drag starting on the piece moves nothing at all.
587
- SLIDER_HANDLE_SELECTORS = [
588
- # GeeTest v3 / v4
589
- ".geetest_slider_button",
590
- ".geetest_btn",
591
- ".geetest_slider .geetest_arrow",
592
- # Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
593
- # no aria-valuenow — so every generic fallback below misses it and it has to
594
- # be named. The three after it are the pre-redesign widget.
595
- ".tencent-captcha-dy__slider-block",
596
- "#tcaptcha_drag_thumb",
597
- ".tc-slider-normal",
598
- "[id*=slideBlock]",
599
- # Yidun (NetEase)
600
- ".yidun_slider",
601
- ".yidun_jigsaw",
602
- # Lemin
603
- ".lemin-slider-handle",
604
- "#lemin-cropped-captcha .slider",
605
- # Generic — an ARIA slider, or a class that says handle/thumb/button on a
606
- # track. `[draggable=true]` is deliberately absent: it is the HTML5
607
- # drag-and-drop opt-in, which fires dragstart rather than pointermove, and
608
- # no slider captcha uses it.
609
- '[role="slider"]',
610
- "[aria-valuenow]",
611
- '[class*="slider"][class*="btn"]',
612
- '[class*="slider"][class*="button"]',
613
- '[class*="slide"][class*="handle"]',
614
- '[class*="drag"][class*="thumb"]',
615
- ]
616
-
617
- # Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
618
- # has no track at all — you drag the piece itself onto the gap — and the model
619
- # answers it with the same sourceless drag, because from the picture the two are
620
- # indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
621
- DRAGGABLE_PIECE_SELECTORS = [
622
- ".lemin-cropped-puzzle-piece",
623
- "#lemin-cropped-captcha canvas + canvas",
624
- '[class*="puzzle"][class*="piece"]',
625
- '[class*="jigsaw"]',
626
- ]
215
+ slide_max_corrections: int = 3
627
216
 
217
+ def video_budget_ms(self) -> int:
218
+ return (self.video_burst_max_ms + self.keyframe_wait_timeout_ms
219
+ + self.video_extra_inference_ms)
628
220
 
629
- class PageSolver:
630
- """
631
- Drives one Playwright-compatible `page` through a captcha.
632
221
 
633
- from playwright.sync_api import sync_playwright
634
- from captchakraken.page_solver import PageSolver
222
+ class Widget(NamedTuple):
223
+ """What detection found: the handle to photograph, the locator to search inside, and which table entry named it."""
635
224
 
636
- solver = PageSolver()
637
- result = solver.solve(page)
638
- if result.is_solved: ...
225
+ element: Any
226
+ at: Any
227
+ vendor: Vendor
228
+ role: FrameRole
229
+
230
+
231
+ # A named set rather than `== UNKNOWN`: naming a new vendor must not silently switch off typed-challenge
232
+ # detection for the text vendors and the animated probe for GeeTest and Tencent.
233
+ VENDORS_WITH_BESPOKE_HANDLING: frozenset[Vendor] = frozenset({Vendor.HCAPTCHA, Vendor.RECAPTCHA})
234
+
235
+ # Long enough to resolve a locator `all()` just returned, short enough that a widget gone in between reads as stale, not hung.
236
+ HANDLE_TIMEOUT_MS = 1000
237
+
238
+
239
+ def _visible(scope: Any, selectors: Sequence[str]) -> List[Any]:
240
+ """Every visible match of `selectors`, in selector order. A selector this adapter can't parse is skipped, not fatal."""
241
+ found: List[Any] = []
242
+ for selector in selectors:
243
+ try:
244
+ found.extend(scope.locator(selector).filter(visible=True).all())
245
+ except Exception:
246
+ continue
247
+ return found
248
+
249
+
250
+ def _handle_of(at: Any) -> Optional[Any]:
251
+ try:
252
+ return at.element_handle(timeout=HANDLE_TIMEOUT_MS)
253
+ except Exception:
254
+ return None
255
+
256
+
257
+ def _handles(ats: Sequence[Any]) -> List[Any]:
258
+ return [h for h in map(_handle_of, ats) if h is not None]
259
+
260
+
261
+ def _frame_of(at: Any) -> Optional[Any]:
262
+ handle = _handle_of(at)
263
+ try:
264
+ return handle.content_frame() if handle else None
265
+ except Exception:
266
+ return None
267
+
268
+
269
+ def _input_value(handle: Any) -> str:
270
+ try:
271
+ return str(handle.input_value() or "")
272
+ except Exception:
273
+ return ""
274
+
275
+
276
+ def _has_text(handle: Any) -> bool:
277
+ try:
278
+ return bool((handle.text_content() or "").strip())
279
+ except Exception:
280
+ return False
639
281
 
640
- One instance is reusable across solves; state is reset per `solve()`.
641
- """
642
282
 
643
- def __init__(
644
- self,
645
- config: Optional[PageSolverConfig] = None,
646
- solver: Optional[CaptchaSolver] = None,
647
- **solver_kwargs: Any,
648
- ) -> None:
283
+ SLIDE_PIECE_MIN_PX = 3.0
284
+ SLIDE_PIECE_MAX_FRACTION = 0.6
285
+
286
+ # No prompt and no pictures both read as ready: waiting for `.prompt-text` to exist once paid the whole timeout
287
+ # before every board of a challenge that draws none (tower_stack: 18s a round, 2/2 -> 0/2).
288
+ _BOARD_IMAGES_READY_JS = """({ prompt, images }) => {
289
+ const vis = (el) => !!el && el.getClientRects().length > 0
290
+ && getComputedStyle(el).visibility !== 'hidden';
291
+ const p = prompt && document.querySelector(prompt);
292
+ if (p && !vis(p)) return false;
293
+ return Array.from(document.querySelectorAll(images)).every((el) => {
294
+ if (el instanceof HTMLImageElement) return el.complete && el.naturalWidth > 0;
295
+ const bg = getComputedStyle(el).backgroundImage;
296
+ return !!bg && bg !== 'none' && !/url\\(["']?["']?\\)/.test(bg);
297
+ });
298
+ }"""
299
+ _RESOURCES_JS = """() => {
300
+ const out = [];
301
+ try { for (const e of performance.getEntriesByType('resource')) out.push(e.name); } catch (err) {}
302
+ for (const el of document.querySelectorAll('script[src],iframe[src],link[href],img[src]')) {
303
+ out.push(el.getAttribute('src') || el.getAttribute('href') || '');
304
+ }
305
+ return out;
306
+ }"""
307
+
308
+
309
+ class PageSolver:
310
+ """Drives one Playwright-compatible `page` through a captcha. Reusable across solves."""
311
+
312
+ def __init__(self, config: Optional[PageSolverConfig] = None,
313
+ solver: Optional[CaptchaSolver] = None, **solver_kwargs: Any) -> None:
649
314
  self.config = config or PageSolverConfig()
650
- # One CaptchaSolver for the whole driver: it owns the planner, which
651
- # accumulates token usage and holds the HTTP session to vLLM.
652
- #
653
- # `expert` reaches the solver from the config, so the knob is in the
654
- # same object as every other tunable and matches the TS port's
655
- # `CaptchaKrakenConfig.expert`. An explicit kwarg still wins — a caller
656
- # who built the argument list themselves has said the more specific
657
- # thing — and a caller who passed their OWN solver has already made
658
- # this decision inside it.
659
315
  if self.config.expert is not None:
660
316
  solver_kwargs.setdefault("expert", self.config.expert)
661
317
  self._solver = solver or CaptchaSolver(**solver_kwargs)
662
- #: Every gesture goes through here, and it owns the pointer position.
663
- #: See humanize.py — the driver names gestures, this decides what
664
- #: events they are and how long they take.
665
318
  self._human: Humanizer = resolve_humanizer(self.config)
666
319
  self._last_submit_frame_hash: Optional[str] = None
667
- # Absolute deadline for the current solve, in the same clock as
668
- # time.monotonic() * 1000. None outside a solve.
669
320
  self._deadline_ms: Optional[float] = None
670
- # Animation detection, all reset per solve by `_reset_animated_state`.
671
- # `_known_animated` latches: once a widget has been SEEN to animate,
672
- # every later round takes the keyframe path without re-probing.
673
- self._known_animated = False
674
- self._animated_probe_armed = False
675
- self._animated_probe_done = False
676
- # One-shot: the recording path buys its own budget the first time it is
677
- # entered, and never again in the same solve. See video_budget_ms.
678
- self._video_budget_granted = False
679
- self._keyframe_mode: Optional[str] = None
680
- # Repeat detection; see `max_no_progress_rounds`.
681
- self._last_answer_sig: Optional[str] = None
682
- self._no_progress_rounds = 0
683
- # Per-solve phase accounting; see `_phase`.
684
321
  self._budget: Optional[PhaseBudget] = None
322
+ self._animated_plan: Optional[Tuple[List[str], str, Any, Any]] = None
323
+ self._reset_animated_state()
685
324
 
686
325
  @property
687
326
  def _last_mouse(self) -> Tuple[float, float]:
688
- """Where the pointer is. Owned by the humanizer, because a mode that
689
- dispatches no motion (mobile, between taps) still has to answer this."""
690
327
  return self._human.at
691
328
 
692
329
  @_last_mouse.setter
@@ -694,111 +331,99 @@ class PageSolver:
694
331
  self._human.at = (float(at[0]), float(at[1]))
695
332
 
696
333
  @contextmanager
697
- def _phase(self, name: str):
698
- """Attribute the enclosed wall-clock to `name` for this solve's budget.
699
-
700
- A no-op outside a solve, so helpers stay callable from tests and from
701
- the CLI without a budget having been opened.
702
- """
334
+ def _phase(self, name: Phase):
703
335
  if self._budget is None:
704
336
  yield
705
337
  return
706
338
  with self._budget.phase(name):
707
339
  yield
708
340
 
709
- @staticmethod
710
- def _answer_signature(actions: Sequence[Any], retry_mode: Optional[str]) -> Optional[str]:
711
- """A stable identity for "the answer this round is about to execute".
341
+ # ── per-solve state ──────────────────────────────────────────────────
712
342
 
713
- Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
714
- about the same board and its answer legitimately overlaps the previous
715
- one, so counting that as a repeat would abandon the one path built to
716
- recover from an under-selection.
343
+ def _reset_animated_state(self) -> None:
344
+ self._acted_on_board = False
345
+ self._known_animated = False
346
+ self._animated_probe_armed = False
347
+ self._animated_probe_done = False
348
+ self._video_budget_granted = False
349
+ self._discard_animated_plan()
350
+ self._keyframe_mode: Optional[KeyframeMode] = None
351
+ self._keyframe_steady_screens = 0
352
+ self._last_answer_sig: Optional[str] = None
353
+ self._no_progress_rounds = 0
354
+ self._resample_level = 0
355
+ self._apply_sampling()
717
356
 
718
- Returns None when the actions cannot be summarised, which is treated as
719
- "not a repeat" — an unreadable answer must never be the reason a solve
720
- is abandoned.
721
- """
357
+ @staticmethod
358
+ def _answer_signature(actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> Optional[str]:
359
+ """Keyed on retry mode: the missed-tiles answer legitimately overlaps the previous one."""
722
360
  try:
723
- parts = []
724
- for raw in actions:
725
- a = _as_dict(raw)
726
- parts.append((
727
- a.get("action"),
728
- _round_pts(a.get("target_bounding_boxes")),
729
- _round_pts(a.get("target_bounding_box")),
730
- _round_pts(a.get("target_coordinates")),
731
- _round_pts(a.get("source_bounding_box")),
732
- a.get("text"),
733
- ))
361
+ parts = [(a.get("action"), _round_pts(a.get("target_bounding_boxes")),
362
+ _round_pts(a.get("target_bounding_box")), _round_pts(a.get("target_coordinates")),
363
+ _round_pts(a.get("source_bounding_box")), a.get("text"))
364
+ for a in map(_as_dict, actions)]
734
365
  return repr((retry_mode, parts))
735
- except Exception: # noqa: BLE001 — a signature is an optimisation, not a contract
366
+ except Exception:
736
367
  return None
737
368
 
738
- def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[str]) -> None:
739
- """Count consecutive identical answers; see `max_no_progress_rounds`."""
369
+ def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> bool:
370
+ """True when this answer already ran and changed nothing: resample, re-ask, and arm the probe."""
740
371
  sig = self._answer_signature(actions, retry_mode)
741
372
  if sig is not None and sig == self._last_answer_sig:
742
373
  self._no_progress_rounds += 1
743
374
  _log(f"[no-progress] the model returned the same answer again "
744
- f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds}) — "
745
- f"the previous one already ran and changed nothing")
746
- # A board that reads the same every round is the signature of a
747
- # CYCLING challenge answered as a still. Give the recording path a
748
- # chance before giving up on the solve entirely.
375
+ f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds})")
376
+ self._resample_level += 1
377
+ self._apply_sampling()
378
+ self._invalidate_animated_answer()
749
379
  self._arm_animated_probe()
750
- else:
751
- self._no_progress_rounds = 0
752
- self._last_answer_sig = sig
753
-
754
- def _reset_animated_state(self) -> None:
755
- self._known_animated = False
756
- self._animated_probe_armed = False
757
- self._animated_probe_done = False
758
- self._video_budget_granted = False
759
- self._keyframe_mode = None
760
- self._last_answer_sig: Optional[str] = None
380
+ return True
761
381
  self._no_progress_rounds = 0
382
+ self._last_answer_sig = sig
383
+ return False
762
384
 
763
- def _check_deadline(self, where: str) -> None:
764
- """
765
- Enforce `overall_solve_timeout_ms` from INSIDE the long-running loops.
385
+ def _apply_sampling(self) -> None:
386
+ target = getattr(self._solver, "planner", None)
387
+ if target is None:
388
+ return
389
+ target.sampling = sampling = planner.sampling_for_level(self._resample_level)
390
+ if sampling:
391
+ _log(f"[resample] temperature {sampling['temperature']}, seed {sampling['seed']}")
766
392
 
767
- The TypeScript driver checks its budget only at the top of each attempt,
768
- which means the budget is not really a budget: one slow attempt overruns
769
- it without bound, because nothing looks at the clock again until the
770
- attempt returns. Observed in practice — a camoufox session ran past ten
771
- minutes against a nominal 120 s timeout, and the check at the top of the
772
- loop never got a turn to fire.
393
+ def _fresh_board(self) -> None:
394
+ self._resample_level = 0
395
+ self._acted_on_board = False
396
+ self._apply_sampling()
773
397
 
774
- Called at the points that can legitimately spin for a long time: each
775
- action executed, and each round of the dynamic grid driver.
776
- """
777
- if self._deadline_ms is None:
778
- return
779
- if time.monotonic() * 1000.0 > self._deadline_ms:
398
+ def _check_deadline(self, where: str) -> None:
399
+ # Called inside loops too: a camoufox session once ran ten minutes past a 120s timeout because only
400
+ # the loop head checked.
401
+ if self._deadline_ms is not None and _now() > self._deadline_ms:
780
402
  raise CaptchaSolveError(
781
403
  f"captcha solve exceeded overall_solve_timeout_ms "
782
- f"({self.config.overall_solve_timeout_ms}ms) during {where}"
783
- )
404
+ f"({self.config.overall_solve_timeout_ms}ms) during {where}")
784
405
 
785
- # ------------------------------------------------------------------
786
- # Shared-half bridges. Each of these is the in-process equivalent of one
787
- # CLI subcommand the TS driver shells out for.
788
- # ------------------------------------------------------------------
406
+ def _grant_video_budget(self) -> None:
407
+ """The recording path buys its own budget once per solve."""
408
+ cfg = self.config
409
+ if cfg.video_solve_enabled and not self._video_budget_granted:
410
+ self._video_budget_granted = True
411
+ if self._deadline_ms is not None:
412
+ self._deadline_ms += cfg.video_budget_ms()
413
+ _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
414
+
415
+ # ── the shared half, in-process ──────────────────────────────────────
789
416
 
790
417
  def _find_grid(self, image_path: str) -> Optional[List[Sequence[int]]]:
791
- """`captchakraken find-grid` — row-major cell boxes in screenshot pixels."""
792
418
  from .tool_calls.find_grid import find_grid
793
419
 
794
420
  try:
795
421
  return find_grid(image_path)
796
- except Exception as exc: # pragma: no cover - CV failure is never fatal
422
+ except Exception as exc:
797
423
  _debug(f"find_grid failed: {exc}")
798
424
  return None
799
425
 
800
426
  def _has_movement(self, path_a: str, path_b: str, threshold: float) -> bool:
801
- """`captchakraken check-movement`."""
802
427
  from .image_processor import ImageProcessor
803
428
 
804
429
  try:
@@ -807,90 +432,51 @@ class PageSolver:
807
432
  _debug(f"detect_movement failed: {exc}")
808
433
  return False
809
434
 
810
- def _grid_cell_states(
811
- self, path_a: str, path_b: str, grid_boxes: Sequence[Sequence[int]]
812
- ) -> Optional[Dict[str, List[int]]]:
813
- """
814
- `captchakraken grid-cell-states-fixed`.
815
-
816
- Always the FIXED variant: the dynamic refresh blanks tiles to near-white,
817
- which makes find_grid fail on that frame, and a self-detecting call would
818
- then report "no grid" — which a naive caller misreads as "nothing
819
- loading", i.e. solved. Passing the cached boxes keeps empty/changing/
820
- selected correct while tiles are blank.
821
- """
822
- from .cli import _compute_grid_cell_states
435
+ def _grid_cell_states(self, path_a: str, path_b: str,
436
+ grid_boxes: Sequence[Sequence[int]]) -> Optional[Dict[str, List[int]]]:
437
+ from .cli import grid_cell_states
823
438
 
824
439
  try:
825
- return _compute_grid_cell_states(path_a, path_b, list(grid_boxes))
440
+ return grid_cell_states(path_a, path_b, list(grid_boxes))
826
441
  except Exception as exc:
827
442
  _debug(f"grid_cell_states failed: {exc}")
828
443
  return None
829
444
 
830
- def _get_solution(
831
- self, image_path: str, puzzle_source: str, retry_mode: Optional[str],
832
- text_mode: bool = False,
833
- ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
834
- """
835
- The model query. In-process where TS spawns the CLI.
445
+ def _board_painted(self, path: str) -> Optional[bool]:
446
+ from .tool_calls.board_painted import board_is_painted
836
447
 
837
- Token usage is read off the planner by DELTA rather than reset, because
838
- the planner is shared across the whole solve and the caller wants a
839
- per-round figure that still sums to the session total.
840
- """
841
- planner = self._solver.planner
842
- before = len(planner.token_usage)
843
- actions = self._solver.solve(
844
- image_path, puzzle_source=puzzle_source, retry_mode=retry_mode,
845
- text_mode=text_mode,
846
- )
847
- usage = [dict(u) for u in planner.token_usage[before:]]
848
- if not isinstance(actions, list):
849
- actions = [actions]
850
- return actions, usage
851
-
852
- def _get_keyframe_solution(
853
- self, keyframe_paths: Sequence[str]
854
- ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
855
- """The model query for an animated challenge. Usage read by DELTA, as above.
856
-
857
- One request for the whole keyframe set, not one per frame: the model has to
858
- compare the frames to find what differs between them, which it can only do
859
- with all of them in a single context. Per-frame queries would also cost N
860
- billable rounds for one puzzle.
861
- """
862
- planner = self._solver.planner
863
- before = len(planner.token_usage)
864
- actions = self._solver.solve_keyframes(keyframe_paths)
865
- usage = [dict(u) for u in planner.token_usage[before:]]
866
- if not isinstance(actions, list):
867
- actions = [actions]
868
- return actions, usage
869
-
870
- # ------------------------------------------------------------------
871
- # Mouse
872
- # ------------------------------------------------------------------
448
+ try:
449
+ return board_is_painted(path, self.config.board_paint_floor)["painted"]
450
+ except Exception:
451
+ return None
452
+
453
+ def _ask(self, query: Callable[[], Any]) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
454
+ """One model query; usage is read off the shared planner by delta."""
455
+ p = self._solver.planner
456
+ before = len(p.token_usage)
457
+ actions = query()
458
+ usage = [dict(u) for u in p.token_usage[before:]]
459
+ return (actions if isinstance(actions, list) else [actions]), usage
460
+
461
+ def _get_solution(self, image_path: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
462
+ text_mode: bool = False):
463
+ return self._ask(lambda: self._solver.solve(
464
+ image_path, puzzle_source=puzzle_source, retry_mode=retry_mode, text_mode=text_mode))
465
+
466
+ def _get_keyframe_solution(self, keyframe_paths: Sequence[str]):
467
+ return self._ask(lambda: self._solver.solve_keyframes(keyframe_paths))
468
+
469
+ # ── gestures ─────────────────────────────────────────────────────────
873
470
 
874
471
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
875
- """Travel to a point. What that MEANS is the humanizer's business — on
876
- a touchscreen with no finger down it is a bookkeeping update and emits
877
- nothing at all."""
878
- with self._phase("mouse"):
472
+ with self._phase(Phase.MOUSE):
879
473
  self._human.move(page, (x, y))
880
474
 
881
475
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
882
- # BOUNDED. Playwright's default timeout is 30s, and this is called once
883
- # per action plus once per submit — on a challenge iframe that is
884
- # mid-animation, scrolling "waits for stability" and burns the full 30s
885
- # every time, which turned a ~5s solve loop into minutes during live
886
- # testing. The element is already on screen in every real case here (we
887
- # just screenshotted it), so a short bound loses nothing: on timeout we
888
- # move to wherever it currently is.
476
+ # Bounded: Playwright's default 30s stability wait hangs on an animating frame.
889
477
  try:
890
478
  element.scroll_into_view_if_needed(timeout=2_000)
891
479
  except TypeError:
892
- # An adapter whose signature takes no timeout (e.g. the Puppeteer
893
- # bridge). Fall back rather than fail the solve.
894
480
  try:
895
481
  element.scroll_into_view_if_needed()
896
482
  except Exception:
@@ -901,319 +487,202 @@ class PageSolver:
901
487
  if not box:
902
488
  raise CaptchaSolveError("element has no bounding box")
903
489
  pad = padding_percentage / 100.0
904
- pad_x, pad_y = box["width"] * pad, box["height"] * pad
905
- target_x = box["x"] + pad_x + random.random() * (box["width"] - 2 * pad_x)
906
- target_y = box["y"] + pad_y + random.random() * (box["height"] - 2 * pad_y)
907
- self._smooth_move(page, target_x, target_y)
490
+ self._smooth_move(
491
+ page,
492
+ box["x"] + box["width"] * (pad + random.random() * (1 - 2 * pad)),
493
+ box["y"] + box["height"] * (pad + random.random() * (1 - 2 * pad)))
908
494
 
909
495
  def _move_and_click(self, page: Any, element: Any) -> None:
910
496
  self._move_to_element(page, element)
911
- with self._phase("mouse"):
497
+ with self._phase(Phase.MOUSE):
912
498
  self._human.click(page, self._last_mouse)
913
499
 
914
- def _execute_click(
915
- self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
916
- ) -> None:
500
+ @staticmethod
501
+ def _click_point_for(action: Dict[str, Any],
502
+ element_box: Dict[str, float]) -> Optional[Tuple[float, float]]:
503
+ """Element-relative click point: a random spot inside the box, inset 10% off its border."""
917
504
  bbox = action.get("target_bounding_box")
918
505
  coords = action.get("target_coordinates")
919
506
  if bbox:
920
- min_x, min_y, max_x, max_y = (float(v) for v in bbox)
921
- px_min_x = min_x * element_box["width"]
922
- px_max_x = max_x * element_box["width"]
923
- px_min_y = min_y * element_box["height"]
924
- px_max_y = max_y * element_box["height"]
925
- # 10% inset, so a click never lands on a tile's border.
926
- pad_x = (px_max_x - px_min_x) * 0.1
927
- pad_y = (px_max_y - px_min_y) * 0.1
928
- rel_x = (px_min_x + pad_x) + random.random() * ((px_max_x - pad_x) - (px_min_x + pad_x))
929
- rel_y = (px_min_y + pad_y) + random.random() * ((px_max_y - pad_y) - (px_min_y + pad_y))
930
- elif coords:
931
- rel_x = float(coords[0]) * element_box["width"]
932
- rel_y = float(coords[1]) * element_box["height"]
933
- else:
507
+ x1, y1, x2, y2 = (float(v) for v in bbox)
508
+ w, h = element_box["width"], element_box["height"]
509
+ px, py = (x2 - x1) * w * 0.1, (y2 - y1) * h * 0.1
510
+ return (x1 * w + px + random.random() * ((x2 - x1) * w - 2 * px),
511
+ y1 * h + py + random.random() * ((y2 - y1) * h - 2 * py))
512
+ if coords:
513
+ return float(coords[0]) * element_box["width"], float(coords[1]) * element_box["height"]
514
+ return None
515
+
516
+ def _execute_click(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float],
517
+ element: Any = None, await_keyframe: Optional[str] = None) -> None:
518
+ # Every gesture marks the board acted on so the speculative burst is never re-run over a touched board.
519
+ self._acted_on_board = True
520
+ rel = self._click_point_for(action, element_box)
521
+ if rel is None:
934
522
  _log("click action without coordinates or bounding box; skipping")
935
523
  return
936
-
937
- with self._phase("mouse"):
938
- self._human.click(page, (element_box["x"] + rel_x, element_box["y"] + rel_y))
939
-
940
- def _execute_drag(
941
- self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
942
- ) -> None:
943
- def center(bbox: Sequence[float]) -> Tuple[float, float]:
944
- return (
945
- element_box["x"] + ((float(bbox[0]) + float(bbox[2])) / 2) * element_box["width"],
946
- element_box["y"] + ((float(bbox[1]) + float(bbox[3])) / 2) * element_box["height"],
947
- )
948
-
949
- src = center(action["source_bounding_box"])
950
- dst = center(action["target_bounding_box"])
951
- with self._phase("mouse"):
952
- self._human.drag(page, src, dst)
953
-
954
- # ------------------------------------------------------------------
955
- # Typing and sliding
956
- # ------------------------------------------------------------------
524
+ at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
525
+ if await_keyframe:
526
+ # Park on the target first, so only a mouse-down separates the right screen from the click.
527
+ with self._phase(Phase.MOUSE):
528
+ self._human.move(page, at)
529
+ self._wait_for_keyframe(element, await_keyframe,
530
+ (rel[0] / element_box["width"], rel[1] / element_box["height"]))
531
+ with self._phase(Phase.MOUSE):
532
+ self._human.click(page, at)
533
+
534
+ def _click_when_frame_matches(self, page: Any, element: Any, action: Dict[str, Any],
535
+ element_box: Dict[str, float], await_keyframe: str) -> None:
536
+ self._execute_click(page, action, element_box, element, await_keyframe)
537
+
538
+ def _execute_drag(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]) -> None:
539
+ self._acted_on_board = True
540
+
541
+ def center(bbox):
542
+ return (element_box["x"] + (float(bbox[0]) + float(bbox[2])) / 2 * element_box["width"],
543
+ element_box["y"] + (float(bbox[1]) + float(bbox[3])) / 2 * element_box["height"])
544
+
545
+ with self._phase(Phase.MOUSE):
546
+ self._human.drag(page, center(action["source_bounding_box"]), center(action["target_bounding_box"]))
957
547
 
958
548
  def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
959
- """First VISIBLE match for `selectors`, tried in order.
549
+ first = next(iter(_visible(scope, selectors)), None)
550
+ return _handle_of(first) if first is not None else None
551
+
552
+ def _measure_piece_box(self, scope: Any, widget_width: float) -> Optional[Dict[str, float]]:
553
+ """The slider piece's box: the first visible match small enough to be a piece.
960
554
 
961
- `scope` is the challenge frame, or — for the vendors that render into
962
- the host page rather than an iframe — the widget element itself. Never
963
- the page: the generic tail of both selector tables would otherwise
964
- happily match a login form's text box or a carousel's drag handle
965
- somewhere else on the document, and the answer would go there.
555
+ Runs during detection, so it must not mark the board acted on: doing so once disabled the
556
+ speculative burst on every slide solve.
966
557
  """
967
- for selector in selectors:
558
+ for candidate in _handles(_visible(scope, PIECE_SELECTORS)):
968
559
  try:
969
- element = scope.query_selector(selector)
560
+ b = candidate.bounding_box()
970
561
  except Exception:
971
- continue # a selector this adapter can't parse must not end the search
972
- if self._visible(element):
973
- return element
562
+ continue
563
+ if b and SLIDE_PIECE_MIN_PX <= b["width"] <= widget_width * SLIDE_PIECE_MAX_FRACTION:
564
+ return b
974
565
  return None
975
566
 
976
- def _answer_box(self, scope: Any, element: Any = None) -> Optional[Any]:
977
- """Where a distorted-text captcha's answer goes.
978
-
979
- Inside the widget first. If the widget holds no text box at all, widen
980
- ONCE to its enclosing <fieldset>/<form> and look again for a
981
- VENDOR-NAMED box only.
982
-
983
- BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
984
- LIBRARY, so the host application owns the layout: measured on
985
- captcha.com 2026-08-24, `.BDC_CaptchaDiv` — the element `detect_captcha`
986
- returns — is 280x50 and holds the image alone, while `#captchaCode` sits
987
- in a sibling `<div class="validationDiv">` under the enclosing
988
- <fieldset>. Scoped to the widget the solver found no box, `text_mode`
989
- stayed False, and it read the code correctly and never typed it. A clean
990
- solve thrown away, and indistinguishable in any report from a model that
991
- cannot read warped text.
992
-
993
- The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on
994
- purpose. The generic tail (`input[type=text]`, `textarea`,
995
- `input:not([type])`) is what makes the in-widget lookup work for every
996
- other vendor, and OUTSIDE the widget it is exactly how a captcha's
997
- answer ends up in a login form's username box — the failure
998
- `_find_control`'s docstring already warns about. One level, named
999
- selectors, and never the page.
567
+ def _answer_box(self, scope: Any, at: Any = None) -> Optional[Any]:
568
+ """The text box inside the widget, else a vendor-named one in its enclosing fieldset/form.
569
+
570
+ Only vendor-named selectors widen: BotDetect's `.BDC_CaptchaDiv` is 280x50 and holds only the image,
571
+ with `#captchaCode` in a sibling div. The generic tail there would take a login form's own box.
1000
572
  """
1001
573
  inside = self._find_control(scope, TEXT_INPUT_SELECTORS)
1002
- if inside is not None or element is None:
574
+ if inside is not None or at is None:
1003
575
  return inside
1004
- # Nearest first: a <fieldset> inside a <form> is the tighter box, and
1005
- # `query_selector` on a union would hand back whichever comes first in
1006
- # document order — the outer one.
1007
- for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"):
1008
- try:
1009
- host = element.query_selector(f"xpath={axis}")
1010
- except Exception:
1011
- continue
1012
- if host is None:
1013
- continue
1014
- found = self._find_control(host, TEXT_INPUT_VENDOR_SELECTORS)
1015
- if found is not None:
1016
- _log("widget holds no answer box; using the vendor-named one "
1017
- "in its enclosing form")
1018
- return found
1019
- return None
576
+ around = (self._find_control(at.locator(f"xpath={axis}"), TEXT_INPUT_VENDOR_SELECTORS)
577
+ for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"))
578
+ return next((found for found in around if found is not None), None)
1020
579
 
1021
- def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any],
1022
- element: Any = None) -> bool:
1023
- """Put the model's reading of a distorted-text captcha into its box."""
580
+ def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any], at: Any = None) -> bool:
581
+ self._acted_on_board = True
1024
582
  text = str(action.get("text") or "")
1025
- if not text:
1026
- return False
1027
- field = self._answer_box(scope, element)
1028
- if field is None:
583
+ field_el = text and self._answer_box(scope, at)
584
+ if not field_el:
1029
585
  _log("type action, but no text box in the widget; skipping")
1030
586
  return False
1031
-
1032
- # Tapping the box is what focuses it — and on a phone it is also what
1033
- # raises the keyboard, so this is not decoration on either device.
1034
- self._move_and_click(page, field)
1035
- if not self._human.type_text(page, field, text):
587
+ self._move_and_click(page, field_el)
588
+ if not self._human.type_text(page, field_el, text):
1036
589
  return False
1037
590
  _log(f"typed {len(text)} character(s) into the captcha field")
1038
591
  return True
1039
592
 
1040
- def _track_piece(
1041
- self, element: Any, before: str, after: str, exclude: Sequence[float]
1042
- ) -> Optional[Sequence[int]]:
1043
- """`captchakraken track-piece` — box of what moved, handle masked out."""
1044
- from .tool_calls.track_piece import changed_bbox
593
+ def _track_piece(self, element: Any, before: str, after: str, exclude: Sequence[float],
594
+ travel: float = 0.0) -> Optional[Dict[str, Any]]:
595
+ from .tool_calls.track_piece import changed_bbox, locate_piece
1045
596
 
1046
597
  try:
1047
598
  self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
1048
- return changed_bbox(before, after, exclude)
599
+ bbox = changed_bbox(before, after, exclude)
600
+ if bbox is None:
601
+ return None
602
+ return {"bbox": bbox, "piece": locate_piece(before, after, travel, exclude)}
1049
603
  except Exception as exc:
1050
604
  _debug(f"track_piece failed: {exc}")
1051
605
  return None
1052
606
 
1053
607
  @staticmethod
1054
608
  def _shot_scale(shot: str, css_width: float) -> float:
1055
- """Device pixels per CSS pixel, read off the shot we are about to
1056
- measure. 1.0 when the image cannot be read — an unreadable shot is
1057
- already the `_track_piece` returns-None path, and guessing a ratio
1058
- would steer the handle by it."""
1059
609
  dims = _read_png_dimensions(shot)
1060
- if not dims or css_width <= 0:
1061
- return 1.0
1062
- return dims[0] / css_width
1063
-
1064
- def _execute_slide(
1065
- self,
1066
- page: Any,
1067
- element: Any,
1068
- scope: Any,
1069
- action: Dict[str, Any],
1070
- element_box: Dict[str, float],
1071
- ) -> bool:
1072
- """Drive a puzzle-piece slider until the PIECE reaches the model's slot.
1073
-
1074
- The model is asked for one thing here — the centre of the gap — because
1075
- it is the only thing the picture can tell it. What it cannot know is how
1076
- far the handle must travel to put the piece there: the handle is
1077
- elsewhere on the widget, and the ratio between the two is a vendor
1078
- implementation detail that several of them deliberately vary.
1079
-
1080
- So this is closed-loop, not a calculation. Press the handle, nudge it
1081
- twice by known amounts, and watch the screen:
1082
-
1083
- union(before, after) spans the piece's ORIGINAL left edge to its
1084
- CURRENT right edge, so its width is piece_width + ratio × nudge.
1085
-
1086
- Two nudges, two widths, two unknowns — solve for both, then steer the
1087
- remaining distance and re-measure. The mouse is not released until the
1088
- piece is home, because on every one of these puzzles releasing IS the
1089
- submit; there is no Verify button to reconsider at.
1090
-
1091
- Returns False if there is nothing here to drag, leaving the caller's
1092
- normal no-op handling to deal with it.
1093
- """
1094
- target_x = (
1095
- (float(action["target_bounding_box"][0]) + float(action["target_bounding_box"][2])) / 2
1096
- ) * element_box["width"]
610
+ return dims[0] / css_width if dims and css_width > 0 else 1.0
611
+
612
+ def _execute_slide(self, page: Any, element: Any, scope: Any, action: Dict[str, Any],
613
+ element_box: Dict[str, float]) -> bool:
614
+ """Aim the handle at the slot once, then look and correct until the piece is home."""
615
+ self._acted_on_board = True
616
+ tb = action["target_bounding_box"]
617
+ target_x = (float(tb[0]) + float(tb[2])) / 2 * element_box["width"]
1097
618
 
1098
619
  handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
1099
620
  if handle is None:
1100
- # No track — the sliderless members of the family (Lemin's
1101
- # "cropped") want the piece dragged directly. Same answer from the
1102
- # model, because the two look identical; different gesture. Nothing
1103
- # to close a loop on, since the piece is under the cursor and moves
1104
- # with it one for one.
1105
- piece = self._find_control(scope, DRAGGABLE_PIECE_SELECTORS)
1106
- if piece is None:
1107
- _log("slide action, but the widget has neither a slider nor a draggable piece")
1108
- return False
1109
- box = piece.bounding_box()
621
+ piece = self._find_control(scope, PIECE_SELECTORS)
622
+ box = piece and piece.bounding_box()
1110
623
  if not box:
624
+ _log("slide action, but the widget has neither a slider nor a draggable piece")
1111
625
  return False
1112
- # BOTH axes. The rail members travel horizontally and nothing else,
1113
- # so the handle's own y is the only y there is — but a free drag
1114
- # carries the piece across the card, and holding the piece's row
1115
- # here slid it along the TRAY and released it there, 250 px below
1116
- # the slot, on every attempt.
1117
- target_y = (
1118
- (float(action["target_bounding_box"][1])
1119
- + float(action["target_bounding_box"][3])) / 2
1120
- ) * element_box["height"]
1121
- _log("no slider track; dragging the piece to the slot directly")
1122
- with self._phase("mouse"):
1123
- self._human.drag(
1124
- page,
1125
- (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
1126
- (element_box["x"] + target_x, element_box["y"] + target_y),
1127
- )
626
+ target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
627
+ with self._phase(Phase.MOUSE):
628
+ self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
629
+ (element_box["x"] + target_x, element_box["y"] + target_y))
1128
630
  return True
1129
631
 
1130
632
  hbox = handle.bounding_box()
1131
633
  if not hbox:
1132
634
  return False
1133
- start_x = hbox["x"] + hbox["width"] / 2
1134
- hold_y = hbox["y"] + hbox["height"] / 2
1135
-
1136
- # Mask the whole horizontal BAND the handle runs in, not just where it
1137
- # is now: it is about to move across that band, and most vendors fill
1138
- # the track behind it as it goes. Either would otherwise be the largest
1139
- # moving thing in frame, and we would track the handle instead of the
1140
- # piece.
635
+ start_x, hold_y = hbox["x"] + hbox["width"] / 2, hbox["y"] + hbox["height"] / 2
636
+ # Mask from the handle's band to the widget's bottom: the filled track moves too.
1141
637
  pad = max(4.0, hbox["height"] * 0.35)
1142
- band = [
1143
- 0.0,
1144
- hbox["y"] - element_box["y"] - pad,
1145
- element_box["width"],
1146
- hbox["y"] + hbox["height"] - element_box["y"] + pad,
1147
- ]
1148
-
1149
- shots = [_tmp_png("slide") for _ in range(4)]
638
+ band = [0.0, hbox["y"] - element_box["y"] - pad, element_box["width"], element_box["height"]]
639
+ shots = [_tmp_png("slide") for _ in range(2)]
1150
640
  try:
1151
641
  self._move_to_element(page, handle, padding_percentage=30.0)
1152
642
  self._human.press(page)
1153
- self._human.pause("grab")
1154
- self._screenshot(element, shots[0],
1155
- timeout_ms=self.config.element_screenshot_timeout_ms)
1156
-
1157
- # CSS pixels out here, DEVICE pixels inside the shots. The same
1158
- # number on a 1x desktop and 2.625x apart on a phone, which is why
1159
- # this loop measured a slider correctly for a year and then missed
1160
- # every attempt the moment Tier 3 grew a mobile arm: the mask landed
1161
- # above the handle, the widths came back 2.6x too wide, and
1162
- # `_solve_slide_geometry` threw them out as wider than the widget.
1163
- # Measured from the shot rather than asked of the page, for the same
1164
- # reason the grid path does it (`_GridSession.scale_x`): what the CV
1165
- # reads is the image, whatever the window thinks its ratio is.
643
+ self._human.pause(PauseKind.GRAB)
644
+ self._screenshot(element, shots[0], timeout_ms=self.config.element_screenshot_timeout_ms)
1166
645
  scale = self._shot_scale(shots[0], element_box["width"])
1167
646
  exclude = [v * scale for v in band]
1168
647
 
1169
- probes = self.config.slide_probe_offsets_px
648
+ def live_center():
649
+ b = self._measure_piece_box(scope, element_box["width"])
650
+ return None if b is None else b["x"] + b["width"] / 2.0 - element_box["x"]
651
+
652
+ rest = live_center()
653
+ offset = target_x - (start_x - element_box["x"] if rest is None else rest)
654
+ self._smooth_move(page, start_x + offset, hold_y)
655
+
1170
656
  widths: List[Tuple[float, float]] = []
1171
- last_box = None
1172
- for offset, shot in zip(probes, shots[1:]):
657
+ last_box = last_piece = None
658
+ ratio, steered = 1.0, False
659
+ for _ in range(self.config.slide_max_corrections):
660
+ self._human.pause(PauseKind.PROBE)
661
+ seen = self._track_piece(element, shots[0], shots[1], exclude, travel=offset * ratio * scale)
662
+ if seen is not None:
663
+ box = seen["bbox"]
664
+ widths.append((offset, float(box[2] - box[0]) / scale))
665
+ last_box, last_piece = box, seen["piece"]
666
+ piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
667
+ live = live_center()
668
+ if live is not None:
669
+ center = live
670
+ elif last_piece is not None:
671
+ center = last_piece["centre"] / scale
672
+ elif last_box is not None and piece_w is not None:
673
+ center = last_box[2] / scale - piece_w / 2.0
674
+ else:
675
+ continue
676
+ steered = True
677
+ error = target_x - center
678
+ _debug(f"slider: center={center:.1f} target={target_x:.1f} error={error:+.1f} ratio={ratio:.3f}")
679
+ if abs(error) <= self.config.slide_tolerance_px:
680
+ break
681
+ offset += error / ratio
1173
682
  self._smooth_move(page, start_x + offset, hold_y)
1174
- self._human.pause("probe")
1175
- box = self._track_piece(element, shots[0], shot, exclude)
1176
- if box is not None:
1177
- widths.append((float(offset), float(box[2] - box[0]) / scale))
1178
- last_box = box
1179
-
1180
- piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
1181
- if last_box is None or piece_w is None:
1182
- # Never saw the piece — a canvas the screenshot cannot separate,
1183
- # a widget that redraws wholesale, or a press the handle refused.
1184
- # Fall back on the geometry every one of these puzzles shares:
1185
- # piece and handle both start flush left, so the handle's travel
1186
- # is the piece's travel.
1187
- _log("slider: piece never resolved on screen; steering by handle travel alone")
1188
- self._smooth_move(page, start_x + (target_x - (start_x - element_box["x"])), hold_y)
1189
- else:
1190
- # The offset `last_box` was MEASURED at — not `probes[-1]`, and
1191
- # not indexed by how many measurements succeeded. If the first
1192
- # probe failed to resolve and the second worked, those two
1193
- # disagree, and steering from a base the reading does not belong
1194
- # to sends the piece somewhere neither the model nor the screen
1195
- # asked for.
1196
- offset = float(widths[-1][0])
1197
- for _ in range(self.config.slide_max_corrections):
1198
- piece_center = (last_box[2] / scale - piece_w / 2.0)
1199
- error = target_x - piece_center
1200
- if abs(error) <= self.config.slide_tolerance_px:
1201
- break
1202
- offset += error / ratio
1203
- self._smooth_move(page, start_x + offset, hold_y)
1204
- self._human.pause("probe")
1205
- box = self._track_piece(element, shots[0], shots[3], exclude)
1206
- if box is None:
1207
- break # ran out of track; release where we are
1208
- last_box = box
1209
- _debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} scale={scale:.3f} "
1210
- f"final_center={last_box[2] / scale - piece_w / 2.0:.1f} "
1211
- f"target={target_x:.1f}")
1212
-
1213
- # Settle before letting go. A release in the same tick as the last
1214
- # move reads as a machine, and some vendors sample the final
1215
- # milliseconds of the gesture.
1216
- self._human.pause("settle")
683
+ if not steered:
684
+ _log("slider: the piece never resolved on screen; released where the opening sweep put it")
685
+ self._human.pause(PauseKind.SETTLE)
1217
686
  finally:
1218
687
  try:
1219
688
  self._human.release(page)
@@ -1224,857 +693,280 @@ class PageSolver:
1224
693
  return True
1225
694
 
1226
695
  @staticmethod
1227
- def _solve_slide_geometry(
1228
- widths: Sequence[Tuple[float, float]], widget_width: float
1229
- ) -> Tuple[Optional[float], float]:
1230
- """Piece width and handle-to-piece travel ratio, from probe measurements.
1231
-
1232
- Each measurement is (handle offset, width of what changed), and
1233
- width = piece_width + ratio × offset. Two of them determine both.
1234
-
1235
- With only one usable measurement the system is underdetermined, so ratio
1236
- is ASSUMED to be 1 — true of every vendor observed, and the assumption
1237
- is stated here rather than buried as a default. A ratio solved from
1238
- implausible measurements (a redraw, a piece that hit the wall between
1239
- probes) is rejected the same way: better a 1:1 guess that overshoots and
1240
- gets corrected than a ratio of 0.02 that sends the handle off the track.
696
+ def _solve_slide_geometry(widths: Sequence[Tuple[float, float]],
697
+ widget_width: float) -> Tuple[Optional[float], float]:
698
+ """Piece width and handle-to-piece ratio from `(offset, changed width)` readings.
699
+
700
+ The widest-apart pair over a 16px spread: changed widths are ±1px, so the ratio error is ±0.13 at the
701
+ floor and ±0.5 at 4px. An implausible ratio falls back to 1:1 (true of every vendor observed) rather
702
+ than a 0.02 that sends the handle off the track and camoufox into a move that never returns.
1241
703
  """
1242
704
  if not widths:
1243
705
  return None, 1.0
1244
- piece_w: Optional[float] = None
1245
- ratio = 1.0
1246
- if len(widths) >= 2:
1247
- (o1, w1), (o2, w2) = widths[0], widths[-1]
1248
- if o2 != o1:
1249
- candidate = (w2 - w1) / (o2 - o1)
1250
- if 0.2 <= candidate <= 3.0:
1251
- ratio = candidate
1252
- piece_w = w1 - ratio * o1
706
+ piece_w, ratio = None, 1.0
707
+ (o1, w1), (o2, w2) = min(widths, key=lambda m: m[0]), max(widths, key=lambda m: m[0])
708
+ if o2 - o1 >= 16.0 and 0.2 <= (w2 - w1) / (o2 - o1) <= 3.0:
709
+ ratio = (w2 - w1) / (o2 - o1)
710
+ piece_w = w1 - ratio * o1
1253
711
  if piece_w is None:
1254
712
  o, w = widths[-1]
1255
713
  piece_w = w - ratio * o
1256
- # A piece narrower than a few pixels, or wider than half the widget, is
1257
- # a measurement of something else.
1258
- if not 3.0 <= piece_w <= widget_width * 0.6:
714
+ if not (SLIDE_PIECE_MIN_PX <= piece_w <= widget_width * SLIDE_PIECE_MAX_FRACTION):
1259
715
  return None, ratio
1260
716
  return piece_w, ratio
1261
717
 
1262
- # ------------------------------------------------------------------
1263
- # Detection
1264
- # ------------------------------------------------------------------
1265
-
1266
- @staticmethod
1267
- def _visible(element: Any) -> bool:
1268
- try:
1269
- return bool(element and element.is_visible())
1270
- except Exception:
1271
- return False
718
+ # ── detection ────────────────────────────────────────────────────────
1272
719
 
1273
- def _has_non_empty_field_value(self, page: Any, selector: str) -> bool:
720
+ def _has_value(self, page: Any, selector: str) -> bool:
721
+ """Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter."""
1274
722
  try:
1275
- element = page.query_selector(selector)
1276
- if not element:
1277
- return False
1278
- value = element.get_attribute("value")
1279
- if value is None:
1280
- value = page.eval_on_selector(
1281
- selector, "node => (typeof node.value === 'string' ? node.value : '')"
1282
- )
1283
- return bool(value and str(value).strip())
723
+ fields = _handles(page.locator(selector).all())
1284
724
  except Exception:
1285
725
  return False
726
+ return any(_input_value(f).strip() for f in fields)
1286
727
 
1287
- def _is_recaptcha_anchor_checked(self, anchor_iframe: Any) -> bool:
1288
- try:
1289
- frame = anchor_iframe.content_frame()
1290
- if not frame:
1291
- return False
1292
- checked = frame.query_selector(".recaptcha-checkbox-checked")
1293
- return self._visible(checked)
1294
- except Exception:
1295
- return False
728
+ def _visible_with_text(self, scope: Any, selector: str) -> bool:
729
+ """A visible match carrying text; an empty banner or prompt is a placeholder, not a signal."""
730
+ return any(map(_has_text, _handles(_visible(scope, (selector,)))))
1296
731
 
1297
- def _is_hcaptcha_anchor_checked(self, anchor_iframe: Any) -> bool:
1298
- # hCaptcha sets <div id="checkbox" aria-checked="true"> on success. Demo
1299
- # pages don't always populate h-captcha-response, so this visual state is
1300
- # the necessary tie-breaker rather than a nicety.
1301
- try:
1302
- frame = anchor_iframe.content_frame()
1303
- if not frame:
1304
- return False
1305
- return self._visible(frame.query_selector('#checkbox[aria-checked="true"]'))
1306
- except Exception:
1307
- return False
732
+ def _checked_in(self, checkbox: Any, checked: str) -> bool:
733
+ frame = _frame_of(checkbox)
734
+ return bool(frame) and bool(_visible(frame, (checked,)))
1308
735
 
1309
736
  def has_interactive_widget_in_dom(self, page: Any) -> bool:
737
+ """Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector.
738
+
739
+ The inline vendors count: without them the JS port failed fast in under a second on every GeeTest/Yidun page.
1310
740
  """
1311
- Broader than `detect_captcha`: is a widget in the DOM AT ALL, even if it
1312
- has not rendered? Distinguishes "still loading, wait for it" from
1313
- "reCAPTCHA v3 / invisible, fail fast" — two cases a null detection
1314
- cannot tell apart.
1315
- """
1316
- try:
1317
- for anchor in page.query_selector_all('iframe[src*="recaptcha/api2/anchor"]'):
1318
- src = anchor.get_attribute("src") or ""
1319
- # v3 / invisible-v2 injects only an anchor, with size=invisible,
1320
- # and never a challenge frame. Excluded here so we fail fast.
1321
- if not re.search(r"[?&]size=invisible", src):
1322
- return True
1323
- if page.query_selector('iframe[src*="recaptcha/api2/bframe"]'):
1324
- return True
1325
- if page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]'):
1326
- return True
1327
- if page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]'):
1328
- return True
1329
- except Exception:
1330
- pass
1331
- return False
741
+ def count(selector: str) -> int:
742
+ try:
743
+ return page.locator(selector).count()
744
+ except Exception:
745
+ return 0
1332
746
 
1333
- def detect_captcha(self, page: Any) -> Optional[Any]:
1334
- """Open challenges first, then unsolved checkboxes. Mirror of `detectCaptcha`."""
1335
- recaptcha_challenge = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
1336
- if self._visible(recaptcha_challenge):
1337
- return recaptcha_challenge
1338
-
1339
- # Match the URL fragment, not the title: hCaptcha's ANCHOR title also
1340
- # says "hCaptcha security challenge", so a title match mis-classifies it.
1341
- hcaptcha_challenge = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
1342
- if self._visible(hcaptcha_challenge):
1343
- return hcaptcha_challenge
1344
-
1345
- recaptcha_checkbox = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
1346
- if self._visible(recaptcha_checkbox) and not self._is_recaptcha_anchor_checked(
1347
- recaptcha_checkbox
1348
- ):
1349
- return recaptcha_checkbox
1350
-
1351
- hcaptcha_checkbox = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
1352
- if self._visible(hcaptcha_checkbox):
1353
- has_token = self._has_non_empty_field_value(page, '[name="h-captcha-response"]')
1354
- if not has_token and not self._is_hcaptcha_anchor_checked(hcaptcha_checkbox):
1355
- return hcaptcha_checkbox
1356
-
1357
- turnstile_iframe = page.query_selector('iframe[src*="challenges.cloudflare.com"]')
1358
- if self._visible(turnstile_iframe) and not self._has_non_empty_field_value(
1359
- page, '[name="cf-turnstile-response"]'
1360
- ):
1361
- return turnstile_iframe
1362
-
1363
- # Closed shadow roots hide the iframe; the container is still findable.
1364
- turnstile_container = page.query_selector(".cf-turnstile")
1365
- if self._visible(turnstile_container) and not self._has_non_empty_field_value(
1366
- page, '[name="cf-turnstile-response"]'
1367
- ):
1368
- return turnstile_container
1369
-
1370
- # Vendors with one interactive surface (no checkbox/challenge split) —
1371
- # GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
1372
- for entry in VENDOR_WIDGET_LOCATORS:
1373
- for selector in entry["selectors"]:
1374
- el = page.query_selector(selector)
1375
- if self._visible(el):
1376
- return el
747
+ return any(count(p.selector) > 0 for p in WIDGET_PROBES)
1377
748
 
749
+ def detect_captcha(self, page: Any) -> Optional[Widget]:
750
+ """Open challenges first, then unsolved checkboxes, then the inline vendors."""
751
+ for probe in WIDGET_PROBES:
752
+ at = next(iter(_visible(page, (probe.selector,))), None)
753
+ if at is None or self._already_accepted(page, at, probe):
754
+ continue
755
+ element = _handle_of(at)
756
+ return Widget(element, at, probe.vendor, probe.role) if element is not None else None
1378
757
  return None
1379
758
 
1380
- def vendors_on_the_wire(self, page: Any) -> List[str]:
1381
- """Which vendors' code this page actually loaded.
1382
-
1383
- Read out of the page's own resource timing plus the `src`/`href` of
1384
- everything it linked, so it works whenever it is called — a request
1385
- listener would have to have been attached before navigation, and by the
1386
- time a solve fails that ship has sailed.
759
+ def _already_accepted(self, page: Any, at: Any, probe: WidgetProbe) -> bool:
760
+ """A checkbox the vendor has already accepted is not a captcha to solve."""
761
+ s = SELECTORS[probe.vendor]
762
+ if probe.role != FrameRole.CHECKBOX:
763
+ return False
764
+ return bool(s.response and self._has_value(page, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
1387
765
 
1388
- Best-effort by construction: the resource-timing buffer is finite and a
1389
- page may have cleared it. An empty answer is "nothing seen", never "no
1390
- captcha here".
1391
- """
766
+ def vendors_on_the_wire(self, page: Any) -> List[Vendor]:
767
+ """Which vendors' code the page loaded. Resource timing, because a request listener would have to have been attached before navigation."""
1392
768
  try:
1393
- names = page.evaluate(
1394
- """() => {
1395
- const out = [];
1396
- try {
1397
- for (const e of performance.getEntriesByType('resource')) out.push(e.name);
1398
- } catch (err) {}
1399
- for (const el of document.querySelectorAll('script[src],iframe[src],link[href],img[src]')) {
1400
- out.push(el.getAttribute('src') || el.getAttribute('href') || '');
1401
- }
1402
- return out;
1403
- }"""
1404
- )
769
+ names = page.evaluate(_RESOURCES_JS)
1405
770
  except Exception:
1406
771
  return []
1407
772
  blob = " ".join(str(n) for n in (names or []))
1408
- return [entry["puzzle_source"] for entry in VENDOR_URL_MARKERS
1409
- if any(host in blob for host in entry["hosts"])]
773
+ return [vendor for vendor, s in VENDORS if any(h in blob for h in s.hosts)]
1410
774
 
1411
775
  def _no_widget_message(self, page: Any) -> str:
1412
- """Why nothing was found — the two cases told apart.
1413
-
1414
- The old text guessed "reCAPTCHA v3 / invisible" every time, including
1415
- the times a vendor had simply moved its DOM. See VENDOR_URL_MARKERS.
1416
- """
1417
776
  base = "no interactive captcha widget detected"
1418
777
  loaded = self.vendors_on_the_wire(page)
1419
778
  if not loaded:
1420
- return (f"{base} (no vendor captcha code loaded on this page — "
1421
- "likely reCAPTCHA v3 / invisible, or a click-triggered "
1422
- "challenge that has not been triggered)")
1423
- return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on "
1424
- "this page. The vendor's markup no longer matches anything in "
1425
- "VENDOR_WIDGET_LOCATORS — re-measure with "
1426
- "scripts/check_vendor_selectors.py and update BOTH solver ports")
779
+ return (f"{base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / "
780
+ "invisible, or a click-triggered challenge that has not been triggered)")
781
+ return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on this page. The "
782
+ "vendor's markup no longer matches anything in SELECTORS — the table needs "
783
+ "re-measuring against the vendor's current markup, in both solver ports")
1427
784
 
1428
785
  def is_captcha_solved(self, page: Any) -> bool:
1429
- """
1430
- The vendor's definitive DONE signal — anchor checked or token populated.
1431
-
1432
- Needed because after the final submit hCaptcha keeps the challenge iframe
1433
- VISIBLE for a couple of seconds while it verifies. Treating that frame as
1434
- a fresh puzzle burns ~18s re-running the pipeline on a closing frame.
1435
- """
786
+ """The vendor's own done signal: a response token, a painted success state, or a checked box."""
1436
787
  try:
1437
- # The token FIRST, and unconditionally. It is a hidden field on the
1438
- # PAGE, not inside the widget, so it never needed the anchor iframe
1439
- # to be on screen — and the moment it matters most is precisely when
1440
- # the anchor is NOT on screen, because hCaptcha keeps its challenge
1441
- # overlay up for a couple of seconds after the winning submit.
1442
- # Gating this on `_visible(anchor)` meant the one signal that was
1443
- # already true went unread, and the loop ground on against a frame
1444
- # being torn down. See tests/test_solved_detection.py.
1445
- if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
1446
- return True
1447
- if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
1448
- return True
1449
- # Turnstile. `detect_captcha` already reads this exact field to
1450
- # decide a Turnstile widget is UNSOLVED, so the signal was known to
1451
- # one half of the driver and ignored by the other: a solved
1452
- # Turnstile reported unsolved here and the loop ground through the
1453
- # readiness waits and a wasted inference until the widget happened
1454
- # to disappear. Same defect the hCaptcha token had.
1455
- if self._has_non_empty_field_value(page, '[name="cf-turnstile-response"]'):
1456
- return True
1457
-
1458
- # Anchor state is the fallback, and this one DOES need the iframe:
1459
- # it is read out of the anchor's own document.
1460
- hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
1461
- if self._visible(hc) and self._is_hcaptcha_anchor_checked(hc):
1462
- return True
1463
- rc = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
1464
- if self._visible(rc) and self._is_recaptcha_anchor_checked(rc):
788
+ if any(self._has_value(page, s) for s in RESPONSE_SELECTORS) or _visible(page, ACCEPTED_SELECTORS):
1465
789
  return True
790
+ for probe in WIDGET_PROBES:
791
+ checked = SELECTORS[probe.vendor].checked
792
+ if probe.role != FrameRole.CHECKBOX or not checked:
793
+ continue
794
+ at = next(iter(_visible(page, (probe.selector,))), None)
795
+ if at is not None and self._checked_in(at, checked):
796
+ return True
1466
797
  except Exception:
1467
798
  pass
1468
799
  return False
1469
800
 
1470
801
  def _is_challenge_freshly_rendered(self, page: Any) -> bool:
1471
- """
1472
- A NEXT ROUND (prompt painted) as opposed to a frame animating closed
1473
- (prompt already gone). Lets a multi-round solve move on immediately
1474
- instead of waiting out the full post-submit window.
1475
- """
802
+ """A next round has painted, as opposed to the answered frame animating closed."""
1476
803
  try:
1477
- hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
1478
- if self._visible(hc):
1479
- # "Prompt painted" alone does NOT mean a next round. hCaptcha
1480
- # leaves the round you just answered on screen while it
1481
- # verifies, so this fired on the CLOSING frame and broke the
1482
- # post-submit poll out of its solved-check after ~0ms — which
1483
- # then committed the solver to ~21s of readiness waits and a
1484
- # full inference against a frame that was about to be destroyed.
1485
- # The frame must have actually CHANGED since the submit; we
1486
- # already snapshot exactly that at submit time.
1487
- if self._last_submit_frame_hash:
1488
- current = self._element_frame_hash(hc)
1489
- if current and current == self._last_submit_frame_hash:
1490
- return False
1491
- frame = hc.content_frame()
1492
- prompt = frame.query_selector(".prompt-text") if frame else None
1493
- if self._visible(prompt):
1494
- text = prompt.text_content() or ""
1495
- if text.strip():
1496
- return True
1497
- rc = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
1498
- if self._visible(rc):
1499
- frame = rc.content_frame()
1500
- instructions = (
1501
- frame.query_selector(".rc-imageselect-instructions, #rc-imageselect")
1502
- if frame
1503
- else None
1504
- )
1505
- if self._visible(instructions):
804
+ for probe in WIDGET_PROBES:
805
+ fresh = SELECTORS[probe.vendor].fresh
806
+ if probe.role != FrameRole.CHALLENGE or not fresh:
807
+ continue
808
+ at = next(iter(_visible(page, (probe.selector,))), None)
809
+ element = _handle_of(at) if at is not None else None
810
+ if element is None:
811
+ continue
812
+ if self._last_submit_frame_hash and self._element_frame_hash(element) == self._last_submit_frame_hash:
813
+ continue
814
+ frame = element.content_frame()
815
+ if frame and self._visible_with_text(frame, fresh):
1506
816
  return True
1507
817
  except Exception:
1508
818
  pass
1509
819
  return False
1510
820
 
1511
- def _has_recaptcha_underselect_error(self, page: Any) -> bool:
1512
- """
1513
- reCAPTCHA's "select all matching images" banner, shown after Verify with
1514
- an incomplete selection.
821
+ @staticmethod
822
+ def _banner_is_fatal_after_retry(kind: Optional[RecaptchaBanner]) -> bool:
823
+ return kind in (RecaptchaBanner.SELECT_MORE, RecaptchaBanner.REJECTED)
1515
824
 
1516
- Critically, the tiles do NOT refresh on this error: without special
1517
- handling the model sees the same image, answers `done` (to it, everything
1518
- matching IS selected), we click Verify again, and the solve loops until
1519
- it times out. Detecting it switches the next call into missed-tiles mode.
1520
- """
825
+ def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
826
+ """Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
1521
827
  try:
1522
- bframe = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
1523
- if not bframe:
1524
- return False
1525
- frame = bframe.content_frame()
1526
- if not frame:
1527
- return False
1528
- for selector in (
1529
- ".rc-imageselect-error-select-more",
1530
- ".rc-imageselect-error-dynamic-more",
1531
- ".rc-imageselect-incorrect-response",
1532
- ):
1533
- element = frame.query_selector(selector)
1534
- if not element:
828
+ for probe in WIDGET_PROBES:
829
+ banners = SELECTORS[probe.vendor].banners
830
+ if probe.role != FrameRole.CHALLENGE or not banners:
1535
831
  continue
1536
- # reCAPTCHA toggles these via aria-hidden on a wrapper, so
1537
- # presence + non-empty text is the reliable test.
1538
- if self._visible(element):
1539
- text = element.text_content() or ""
1540
- if text.strip():
1541
- return True
1542
- except Exception:
1543
- pass
1544
- return False
1545
-
1546
- def _get_verify_button(self, frame: Any) -> Optional[Any]:
1547
- # `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
1548
- # markup on the host page rather than a vendor iframe (every
1549
- # distorted-text captcha), and a document-rooted `//button` does not
1550
- # resolve against an element handle: the query returned None even with
1551
- # the button sitting inside that very element, so a typed code was never
1552
- # submitted. On a Frame the context node is the document, where `.//` and
1553
- # `//` mean the same thing, so the vendor paths are unaffected.
1554
- for text in ("Verify", "Next", "Submit", "Skip"):
1555
- lowered = text.lower()
1556
- try:
1557
- button = frame.query_selector(
1558
- f"xpath=.//button[contains(translate(., "
1559
- f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
1560
- f" | .//div[@role='button' and contains(translate(., "
1561
- f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
1562
- )
1563
- if self._visible(button):
1564
- return button
1565
- except Exception:
1566
- pass
1567
- try:
1568
- recaptcha_verify = frame.query_selector("#recaptcha-verify-button")
1569
- if self._visible(recaptcha_verify):
1570
- return recaptcha_verify
1571
- hcaptcha_verify = frame.query_selector(".button-submit")
1572
- if self._visible(hcaptcha_verify):
1573
- return hcaptcha_verify
1574
- # GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
1575
- #
1576
- # Invisible to BOTH shapes above — a bare div carries no
1577
- # role="button", and "OK" is on none of the four word lists. So
1578
- # nothing was ever pressed on the puzzles that need pressing, and a
1579
- # GeeTest board does not grade until you do: the solve loop re-read
1580
- # the same unchanged panel and re-answered it identically until the
1581
- # round cap. Ordered icon-click scored 0/31 and 0/13 that way while
1582
- # the model was answering CORRECTLY (measured 2026-08-19). A driver
1583
- # that discards a right answer is indistinguishable from a model
1584
- # that cannot solve the puzzle, which is how this hid.
1585
- #
1586
- # Matched by CLASS, not by the word: `geetest_submit_tips` sits
1587
- # beside it and also reads "OK", and pressing the tooltip does
1588
- # nothing. `.geetest_submit` is a distinct token, so it cannot
1589
- # match it. Mirrors getVerifyButton in js/src/solver.ts — CLAUDE.md
1590
- # 1c, the two ports must behave the same.
1591
- geetest_submit = frame.query_selector(".geetest_submit")
1592
- if self._visible(geetest_submit):
1593
- return geetest_submit
832
+ at = next(iter(_visible(page, (probe.selector,))), None)
833
+ frame = _frame_of(at) if at is not None else None
834
+ if not frame:
835
+ continue
836
+ shown = next((kind for selector, kind in banners if self._visible_with_text(frame, selector)), None)
837
+ if shown is not None:
838
+ return shown
1594
839
  except Exception:
1595
840
  pass
1596
841
  return None
1597
842
 
1598
- # ------------------------------------------------------------------
1599
- # Frame readiness
1600
- # ------------------------------------------------------------------
843
+ def _get_verify_button(self, scope: Any) -> Optional[Any]:
844
+ return self._find_control(scope, SUBMIT_SELECTORS)
845
+
846
+ # ── frames and polling ───────────────────────────────────────────────
1601
847
 
1602
848
  def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
1603
849
  animations: str = "disabled") -> None:
1604
- """
1605
- Short timeout, and animations disabled unless the caller needs the motion.
1606
-
1607
- Playwright's default 30s stability wait hangs per-screenshot on a
1608
- closing/animating challenge element; that alone made a multi-round solve
1609
- take ~115s. Failing fast and skipping a frame is strictly better.
1610
-
1611
- `animations` is 'disabled' everywhere the goal is a STABLE STILL, which
1612
- is almost everywhere. It is not a formality: the flag fast-forwards
1613
- finite animations and FREEZES infinite ones, which is exactly what a
1614
- model-facing screenshot wants and exactly what a recording must not do.
1615
- The burst and the animation probe pass 'allow' — see `solver.ts`
1616
- `recordKeyframeBurst`, which learned this first: GeeTest's svg board
1617
- cycles in CSS, so a burst taken with animations disabled captured the
1618
- same picture forty times, the slicer honestly reported `mode=static`,
1619
- and the solve went back to answering a single still. hCaptcha hid it,
1620
- because it animates in canvas and the flag does not touch canvas.
1621
- """
1622
- element.screenshot(
1623
- path=path,
1624
- timeout=timeout_ms if timeout_ms is not None else 2_500,
1625
- animations=animations,
1626
- )
850
+ """Short timeout; `disabled` freezes CSS animation, so bursts and the keyframe gate pass `allow`."""
851
+ element.screenshot(path=path, timeout=2_500 if timeout_ms is None else timeout_ms,
852
+ animations=animations)
1627
853
 
1628
854
  def _element_frame_hash(self, element: Any) -> Optional[str]:
1629
855
  path = _tmp_png("fh")
1630
856
  try:
1631
857
  self._screenshot(element, path)
1632
- with open(path, "rb") as handle:
1633
- return hashlib.sha1(handle.read()).hexdigest()
858
+ return _sha1(path)
1634
859
  except Exception:
1635
860
  return None
1636
861
  finally:
1637
862
  _unlink(path)
1638
863
 
1639
- def _wait_for_element_settled(self, element: Any) -> str:
1640
- """
1641
- Poll until the element's pixels stop changing.
1642
-
1643
- Returns 'settled' | 'animated' | 'timeout'. This is a PIXEL settle; the
1644
- caller pairs it with the DOM-level image wait so a static loading frame
1645
- (a spinner on grey, below the diff threshold) isn't mistaken for painted
1646
- tiles.
1647
- """
1648
- cfg = self.config
1649
- start = time.monotonic() * 1000.0
1650
- previous: Optional[str] = None
864
+ def _poll(self, element: Any, timeout_ms: float, interval_ms: float,
865
+ judge: Callable[[List[str], float], Any], where: str,
866
+ before: Optional[Callable[[], None]] = None) -> Any:
867
+ """Screenshot `element` every `interval_ms` until `judge(last_two_frames, elapsed_ms)` answers."""
868
+ start = _now()
1651
869
  frames: List[str] = []
1652
- # This loop only COLLECTS polls; the verdict is `settle_verdict`'s, so
1653
- # the recorded timelines in tests/test_animated_is_detected.py exercise
1654
- # the same rule that runs here rather than a copy of it that can drift.
1655
- samples: List[Tuple[float, bool]] = []
1656
870
  try:
1657
- while (time.monotonic() * 1000.0) - start < cfg.settle_timeout_ms:
1658
- self._check_deadline("waiting for the challenge to settle")
1659
- path = _tmp_png("settle")
871
+ while _now() - start < timeout_ms:
872
+ self._check_deadline(where)
873
+ t0 = _now()
874
+ if before is not None:
875
+ before()
876
+ path = _tmp_png("poll")
1660
877
  try:
1661
878
  self._screenshot(element, path)
1662
879
  except Exception:
1663
880
  _unlink(path)
1664
- _delay(cfg.settle_poll_ms)
881
+ _delay(interval_ms)
1665
882
  continue
1666
883
  frames.append(path)
1667
- if previous:
1668
- moved = self._has_movement(previous, path, cfg.settle_diff_threshold)
1669
- if len(frames) > 1:
1670
- _unlink(frames.pop(0))
1671
- samples.append(((time.monotonic() * 1000.0) - start, moved))
1672
- verdict = settle_verdict(
1673
- samples,
1674
- settle_frames=cfg.settle_frames,
1675
- animated_after_ms=cfg.animated_challenge_after_ms,
1676
- )
1677
- if verdict != "timeout":
1678
- return verdict
1679
- previous = frames[-1]
1680
- _delay(cfg.settle_poll_ms)
1681
- return "timeout"
884
+ verdict = judge(frames, _now() - start)
885
+ if verdict is not None:
886
+ return verdict
887
+ if len(frames) > 1:
888
+ _unlink(frames.pop(0))
889
+ _delay(interval_ms - (_now() - t0))
890
+ return None
1682
891
  finally:
1683
892
  for path in frames:
1684
893
  _unlink(path)
1685
894
 
1686
- # ------------------------------------------------------------------
1687
- # Animated challenges
1688
- # ------------------------------------------------------------------
1689
-
1690
- def _settle_or_animated(self, element: Any) -> bool:
1691
- """Wait for the widget to settle; return whether it is animated instead.
895
+ def _wait_for_element_settled(self, element: Any) -> SettleVerdict:
896
+ cfg = self.config
897
+ samples: List[Tuple[float, bool]] = []
1692
898
 
1693
- "Never settles" stopped being a failure. hCaptcha's "select the odd animal"
1694
- fades its sprites on independent cycles and its "unique motion pattern"
1695
- puzzle spins identical meshes — those challenges are animated BY DESIGN, and
1696
- the answer only exists across frames. True here routes the caller to the
1697
- recording path.
899
+ def judge(frames, elapsed):
900
+ if len(frames) < 2:
901
+ return None
902
+ samples.append((elapsed, self._has_movement(frames[0], frames[1], cfg.settle_diff_threshold)))
903
+ verdict = settle_verdict(samples, settle_frames=cfg.settle_frames,
904
+ animated_after_ms=cfg.animated_challenge_after_ms,
905
+ motion_streak=cfg.animated_motion_streak)
906
+ return None if verdict == SettleVerdict.TIMEOUT else verdict
1698
907
 
1699
- `video_solve_enabled=False` restores the old behaviour for callers who would
1700
- rather fail fast than spend the recording time.
1701
- """
1702
- # Already established for this widget — don't re-probe every round.
1703
- if self._known_animated:
1704
- return True
908
+ return self._poll(element, cfg.settle_timeout_ms, cfg.settle_poll_ms, judge,
909
+ "waiting for the challenge to settle") or SettleVerdict.TIMEOUT
1705
910
 
1706
- with self._phase("settle"):
1707
- verdict = self._wait_for_element_settled(element)
1708
- if verdict != "animated":
1709
- # 'settled' is not proof of static. The settle rule stops at the
1710
- # first `settle_frames` of stillness, and every animated puzzle we
1711
- # ship rests between screens for longer than that, so it calls all
1712
- # of them settled (tests/test_animated_is_detected.py has the
1713
- # measured timelines).
1714
- #
1715
- # What decides it instead is a RECORDING, taken once a round has
1716
- # already answered the same thing twice — the signature of a
1717
- # cycling board read as a still. The recording is self-checking: if
1718
- # the widget really is static it slices to one keyframe and the
1719
- # caller solves that frame as the still it is. So there is nothing
1720
- # to be careful about here, and nothing to observe first.
1721
- if not self._animated_probe_armed or self._animated_probe_done:
1722
- return False
1723
- self._animated_probe_done = True
1724
- self._animated_probe_armed = False
1725
- _log("[animated] the same answer came back twice — recording the "
1726
- "challenge to see whether it is cycling")
1727
-
1728
- # Latch either route. Re-deciding every round would re-record a widget
1729
- # we have already SEEN animate, and on a puzzle that rests between
1730
- # screens a later recording can land inside a hold, slice to one frame,
1731
- # and flip the solve back onto the still path halfway through.
1732
- self._known_animated = True
911
+ def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
912
+ cfg = self.config
1733
913
 
1734
- if not self.config.video_solve_enabled:
1735
- raise AnimatedChallengeError(
1736
- "the challenge never settles and video_solve_enabled is off"
1737
- )
1738
- _log("[animated] challenge is animated — solving it from keyframes")
1739
- return True
914
+ def judge(frames, _elapsed):
915
+ if len(frames) < 2:
916
+ return None
917
+ boxes = self._find_grid(frames[1])
918
+ states = boxes and self._grid_cell_states(frames[0], frames[1], boxes)
919
+ return True if states and not states["empty"] and not states["changing"] and states["loaded"] else None
1740
920
 
1741
- def _arm_animated_probe(self) -> None:
1742
- """Round N finished without finishing the CHALLENGE — probe next round.
921
+ return bool(self._poll(element, cfg.grid_load_timeout_ms, cfg.grid_load_poll_interval_ms,
922
+ judge, "waiting for grid cells to load"))
1743
923
 
1744
- Deliberately not armed for round 1. A static puzzle solves there and
1745
- never pays anything, which is what keeps the common case at the ~530ms
1746
- settle it has today. Only a widget that survived a round is worth
1747
- spending a cycle of observation on.
1748
- """
1749
- if self.config.animated_probe_enabled and not self._animated_probe_done:
1750
- self._animated_probe_armed = True
1751
-
1752
- def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
1753
- """Record the widget and return `(keyframe_paths, temp_dir)`.
1754
-
1755
- Screenshots the element at the collector's burst geometry (4 s @ 10 fps),
1756
- then hands the frames to the SAME slicer the training data was cut with
1757
- (`keyframes.extract_keyframes`). That shared code path is the whole point:
1758
- the model answers with a frame NUMBER, and a number only means something if
1759
- the live set was sliced the way the trained set was.
1760
-
1761
- Frames are kept in memory and sliced from there — no intermediate mp4. The
1762
- old pipeline encoded one, and every clip it produced was mp4v, a codec the
1763
- serving side may or may not decode. Skipping the encode removes that whole
1764
- class of silent failure along with the disk round-trip.
1765
-
1766
- The caller owns `temp_dir` and must remove it once the actions are done
1767
- with: the returned paths are read back by the wait gate on every poll, so
1768
- cleaning up any earlier would break the click.
1769
-
1770
- Raises AnimatedChallengeError if nothing could be captured.
1771
- """
1772
- from .keyframes import extract_keyframes, write_keyframes
1773
-
1774
- cfg = self.config
1775
- fps = max(1, int(cfg.video_burst_fps))
1776
- total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
1777
- interval = 1.0 / fps
1778
-
1779
- # THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
1780
- #
1781
- # `overall_solve_timeout_ms` counts rounds, and this is not a round —
1782
- # see `video_budget_ms` for the arithmetic and for what it cost not to
1783
- # have it. Granted here rather than where the probe arms because this is
1784
- # the one place every path into a recording goes through.
1785
- if cfg.video_solve_enabled and not self._video_budget_granted:
1786
- self._video_budget_granted = True
1787
- if self._deadline_ms is not None:
1788
- self._deadline_ms += cfg.video_budget_ms()
1789
- _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
1790
-
1791
- # Checked ONCE, before the first frame — never per frame.
1792
- #
1793
- # A HALF-RECORDED BURST IS WORTHLESS: the slicer reads a clip's temporal
1794
- # structure, so stopping at frame 27 of 40 does not produce a shorter
1795
- # answer, it produces a recording that may not contain the screen the
1796
- # answer is on. Aborting mid-way therefore threw away the whole burst
1797
- # AND the ~3s already spent making it, to report a timeout. The burst is
1798
- # also fixed-length and short, so it is not one of the places that "can
1799
- # legitimately spin for a long time" that `_check_deadline` is for.
1800
- #
1801
- # If the budget cannot fit one, say THAT — a caller who has tightened
1802
- # `overall_solve_timeout_ms` below what a recording costs has asked for
1803
- # something impossible, and should hear it rather than watch a burst die
1804
- # partway through every time.
1805
- if self._deadline_ms is not None:
1806
- left = self._deadline_ms - time.monotonic() * 1000.0
1807
- if left < cfg.video_burst_duration_ms:
1808
- raise CaptchaSolveError(
1809
- f"only {left:.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
1810
- f"budget is left and an animated recording needs "
1811
- f"{cfg.video_burst_duration_ms}ms — not starting one that would "
1812
- f"be cut off mid-way. Raise overall_solve_timeout_ms or "
1813
- f"video_extra_inference_ms, or set video_solve_enabled=False."
1814
- )
1815
-
1816
- frames: List[Any] = []
1817
-
1818
- remaining = max(0, total - len(frames))
1819
- shot = _tmp_png("burst")
1820
- # A burst that runs far past its own length is a hung screenshot, not a
1821
- # tight budget — bounded separately so the two cannot be confused.
1822
- burst_deadline = (time.monotonic() * 1000.0
1823
- + 3 * cfg.video_burst_duration_ms + 5_000)
1824
- try:
1825
- for i in range(remaining):
1826
- if time.monotonic() * 1000.0 > burst_deadline:
1827
- raise CaptchaSolveError(
1828
- f"the animated recording stalled: {i} of {remaining} frames "
1829
- f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
1830
- f"is not screenshotting."
1831
- )
1832
- start = time.monotonic()
1833
- try:
1834
- # animations ALLOWED — see `_screenshot`. The JS port has
1835
- # done this since the GeeTest svg burst came back as 40
1836
- # copies of one picture; this port never did, so every
1837
- # CSS-animated vendor was recorded frozen.
1838
- self._screenshot(element, shot, animations="allow")
1839
- except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
1840
- _debug(f"burst frame {i} failed: {exc}")
1841
- else:
1842
- import cv2
1843
-
1844
- img = cv2.imread(shot)
1845
- if img is not None:
1846
- frames.append(img)
1847
- # Drift-corrected: a slow screenshot must not stretch the clip, or
1848
- # the recording covers more wall-clock than the model trained on and
1849
- # a cycle's period lands differently across the frames.
1850
- wait = interval - (time.monotonic() - start)
1851
- if wait > 0 and i < remaining - 1:
1852
- time.sleep(wait)
1853
- finally:
1854
- _unlink(shot)
1855
-
1856
- if not frames:
1857
- raise AnimatedChallengeError(
1858
- "could not record the animated challenge (no frame screenshotted)"
1859
- )
1860
- _log(f"[animated] recorded {len(frames)} frames at {fps}fps")
1861
-
1862
- kfset = extract_keyframes(frames, fps=float(fps))
1863
- temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
1864
- paths = write_keyframes(kfset, temp_dir, stem="challenge")
1865
- _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1866
- # The wait gate consults this: `even` means the extractor found no state
1867
- # that RECURS, so there is nothing for the page to come back to.
1868
- self._keyframe_mode = kfset.mode
1869
- return [str(p) for p in paths], temp_dir
1870
-
1871
- def _wait_for_keyframe(self, element: Any, keyframe_path: str,
1872
- point_norm: Tuple[float, float]) -> bool:
1873
- """Hold until the widget looks like `keyframe_path` around `point_norm`.
1874
-
1875
- This is the reason an animated answer names a frame. The model picked the
1876
- moment its target was visible; the coordinates are only correct at that
1877
- moment. Clicking as soon as the answer arrives lands on whatever the sprite
1878
- happens to be doing — for a cross-fade, usually background.
1879
-
1880
- Compares only the neighbourhood of the action point, with the same box and
1881
- the same metric the training label was chosen with
1882
- (`keyframes.region_box` / `region_diff_ratio`). Local rather than
1883
- whole-frame because everything ELSE in these puzzles is also moving: a
1884
- whole-frame match would need every unrelated sprite to align too, and would
1885
- essentially never open.
1886
-
1887
- Returns whether the state was reached. On timeout the caller clicks anyway
1888
- — see `keyframe_wait_timeout_ms`.
1889
-
1890
- NOT ATTEMPTED on an `even` clip. `keyframes.py` picks that mode precisely
1891
- when the clip never revisits a picture it has already shown — a slow
1892
- one-way change, a rotation, a cross-fade — so there is no state for the
1893
- page to come BACK to and the wait can only ever run out. It is not a
1894
- cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
1895
- PER CLICK before giving up and clicking anyway.
1896
- And `even` is not the rare case. All 116 real clips under
1897
- `cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
1898
- fired on real footage, which keyframes.py itself records ("real state
1899
- separations top out around 0.007, well under this"). So on real traffic
1900
- this gate was 6s of dead time on every animated click, always followed by
1901
- the same click it would have made immediately. Measured on
1902
- `hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
1903
- 0.0721 against a 0.05 tolerance, then it clicked and solved.
1904
-
1905
- The gate stays for `cycle`/`static`, where a state genuinely does recur
1906
- and waiting is the difference between clicking the sprite and clicking
1907
- the background.
1908
- """
1909
- import cv2
1910
-
1911
- from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1912
-
1913
- if self._keyframe_mode == "even":
1914
- _log("[animated] clip is mode=even (no state recurs); "
1915
- "acting on the model's frame without waiting")
1916
- return False
1917
-
1918
- ref = cv2.imread(keyframe_path)
1919
- if ref is None:
1920
- _debug(f"keyframe {keyframe_path} unreadable; not waiting")
1921
- return False
1922
- box = region_box(ref.shape[1::-1], point_norm)
1923
-
1924
- cfg = self.config
1925
- deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
1926
- probe = _tmp_png("kfwait")
1927
- best = 1.0
1928
- try:
1929
- while (time.monotonic() * 1000.0) < deadline:
1930
- self._check_deadline("waiting for the challenge keyframe")
1931
- try:
1932
- self._screenshot(element, probe)
1933
- live = cv2.imread(probe)
1934
- except Exception as exc: # noqa: BLE001
1935
- _debug(f"keyframe probe failed: {exc}")
1936
- live = None
1937
- if live is not None:
1938
- d = region_diff_ratio(ref, live, box)
1939
- best = min(best, d)
1940
- if d <= MATCH_REGION_TOLERANCE:
1941
- _log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
1942
- return True
1943
- _delay(cfg.keyframe_wait_poll_ms)
1944
- finally:
1945
- _unlink(probe)
1946
- _log(f"[animated] widget never matched the chosen keyframe within "
1947
- f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
1948
- f"clicking on the model's coordinates anyway")
1949
- return False
924
+ def _wait_for_board_painted(self, element: Any) -> int:
925
+ """A still board is not a loaded board: hold until the panel's centre carries structure."""
926
+ cfg = self.config
927
+ start = _now()
928
+ painted = self._poll(element, cfg.board_paint_timeout_ms, cfg.board_paint_poll_ms,
929
+ lambda frames, _e: True if self._board_painted(frames[-1]) is not False else None,
930
+ "waiting for the board to paint")
931
+ waited = int(_now() - start)
932
+ if not painted:
933
+ _log(f"[board] the widget never painted a puzzle in {cfg.board_paint_timeout_ms}ms")
934
+ elif waited >= cfg.board_paint_poll_ms:
935
+ _log(f"[board] waited {waited}ms for the widget to paint its puzzle")
936
+ return waited
1950
937
 
1951
938
  def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
1952
- """After a submit the frame MUST change (next round, or closing)."""
1953
- cfg = self.config
1954
- start = time.monotonic() * 1000.0
1955
- while (time.monotonic() * 1000.0) - start < cfg.post_submit_change_timeout_ms:
939
+ start = _now()
940
+ while _now() - start < self.config.post_submit_change_timeout_ms:
1956
941
  current = self._element_frame_hash(element)
1957
942
  if current and current != since_hash:
1958
943
  return True
1959
- _delay(cfg.settle_poll_ms)
944
+ _delay(self.config.settle_poll_ms)
1960
945
  return False
1961
946
 
1962
- def _wait_for_hcaptcha_challenge_images(self, challenge_iframe: Any) -> None:
1963
- """
1964
- Block until the challenge's task images have actually painted.
1965
-
1966
- hCaptcha sets each tile's background-image once the asset loads, and
1967
- ships an empty `url("")` placeholder before it arrives. Best-effort: a
1968
- timeout falls through to the screenshot, where the existing fail-fast
1969
- path still covers a genuinely unsupported puzzle.
1970
-
1971
- "NOTHING TO WAIT FOR" IS READY. The last clause used to be
1972
- `return !!(example && ...)`, which is false when there is no example
1973
- image — so a challenge with no tile grid, no canvas and no example
1974
- polled until the timeout and then carried on regardless. Measured on
1975
- `hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
1976
- full 8s three times over, more than half the budget spent asking a
1977
- question about elements that were not on the page. A readiness gate can
1978
- only report on what it can see; with nothing to check it has no opinion,
1979
- and "no opinion" must not read as "not ready".
1980
- """
1981
- cfg = self.config
1982
- try:
1983
- frame = challenge_iframe.content_frame()
1984
- if not frame:
1985
- return
1986
- frame.wait_for_selector(".prompt-text", state="visible",
1987
- timeout=cfg.hcaptcha_images_timeout_ms)
1988
- frame.wait_for_function(
1989
- """() => {
1990
- const tiles = Array.from(
1991
- document.querySelectorAll('.task-image .image, .task .image'));
1992
- if (tiles.length > 0) {
1993
- return tiles.every((el) => {
1994
- const bg = getComputedStyle(el).backgroundImage;
1995
- return bg && bg !== 'none' && !/url\\(["']?["']?\\)/.test(bg);
1996
- });
1997
- }
1998
- const canvas = document.querySelector('canvas');
1999
- if (canvas && canvas.width > 0 && canvas.height > 0) return true;
2000
- const example = document.querySelector(
2001
- '.challenge-example img, .image-wrapper img');
2002
- if (example) return example.complete && example.naturalWidth > 0;
2003
- return true; // nothing here to be waited for
2004
- }""",
2005
- timeout=cfg.hcaptcha_images_timeout_ms,
2006
- )
2007
- except Exception:
2008
- pass # timed out or detached mid-load; screenshot anyway
2009
-
2010
- def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
2011
- """
2012
- reCAPTCHA fades new tiles in over ~1s, on first load and on every
2013
- in-place refresh. Screenshotting mid-fade feeds the model a partial grid.
2014
- """
2015
- cfg = self.config
2016
- start = time.monotonic() * 1000.0
2017
- frames: List[str] = []
947
+ def _wait_for_board_images(self, frame: Any, selectors: VendorSelectors) -> None:
948
+ """Best-effort: hold until the board's pictures have painted."""
2018
949
  try:
2019
- while (time.monotonic() * 1000.0) - start < cfg.grid_load_timeout_ms:
2020
- self._check_deadline("waiting for grid cells to load")
2021
- path = _tmp_png("gridpoll")
2022
- try:
2023
- self._screenshot(element, path)
2024
- except Exception:
2025
- _unlink(path)
2026
- _delay(cfg.grid_load_poll_interval_ms)
2027
- continue
2028
- frames.append(path)
2029
- if len(frames) >= 2:
2030
- boxes = self._find_grid(frames[-1])
2031
- if boxes:
2032
- states = self._grid_cell_states(frames[-2], frames[-1], boxes)
2033
- if (
2034
- states
2035
- and not states["empty"]
2036
- and not states["changing"]
2037
- and states["loaded"]
2038
- ):
2039
- return True
2040
- _unlink(frames.pop(0))
2041
- _delay(cfg.grid_load_poll_interval_ms)
2042
- return False
950
+ frame.wait_for_function(_BOARD_IMAGES_READY_JS, arg={"prompt": selectors.fresh or "", "images": ",".join(selectors.images)},
951
+ timeout=self.config.hcaptcha_images_timeout_ms)
2043
952
  except Exception:
2044
- return False
2045
- finally:
2046
- for path in frames:
2047
- _unlink(path)
953
+ pass
2048
954
 
2049
955
  def _get_grid_boxes(self, element: Any) -> Optional[Dict[str, Any]]:
2050
- """
2051
- Detect the grid ONCE per puzzle session. Geometry is stable across the
2052
- in-place dynamic refresh (only tile images change), so callers cache it.
2053
- """
2054
956
  path = _tmp_png("findgrid")
2055
957
  try:
2056
958
  self._screenshot(element, path, timeout_ms=self.config.element_screenshot_timeout_ms)
2057
959
  boxes = self._find_grid(path)
2058
- if not boxes or len(boxes) not in (9, 16):
2059
- return None
2060
960
  dims = _read_png_dimensions(path)
2061
- if not dims:
961
+ if not boxes or len(boxes) not in (9, 16) or not dims:
2062
962
  return None
2063
- return {
2064
- "boxes": [list(b) for b in boxes],
2065
- "size": 4 if len(boxes) == 16 else 3,
2066
- "screenshot_w": dims[0],
2067
- "screenshot_h": dims[1],
2068
- }
963
+ return {"boxes": [list(b) for b in boxes], "size": 4 if len(boxes) == 16 else 3,
964
+ "screenshot_w": dims[0], "screenshot_h": dims[1]}
2069
965
  except Exception:
2070
966
  return None
2071
967
  finally:
2072
968
  _unlink(path)
2073
969
 
2074
- # ------------------------------------------------------------------
2075
- # Freshness guard
2076
- # ------------------------------------------------------------------
2077
-
2078
970
  def _frame_changed_since(self, element: Any, prior_path: str, threshold: float) -> bool:
2079
971
  probe = _tmp_png("freshcheck")
2080
972
  try:
@@ -2085,1099 +977,781 @@ class PageSolver:
2085
977
  finally:
2086
978
  _unlink(probe)
2087
979
 
2088
- def _solve_frame_freshness_guarded(
2089
- self,
2090
- element: Any,
2091
- initial_shot: str,
2092
- run_query: Callable[[str], Tuple[List[CaptchaAction], List[Dict[str, Any]]]],
2093
- ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
2094
- """
2095
- Never act on a stale frame.
2096
-
2097
- If the frame changed WHILE the model was generating, its answer describes
2098
- an undeveloped frame whose tiles no longer line up — so re-screenshot and
2099
- re-solve on the developed one. Token usage from every attempt is merged,
2100
- because every attempt was really billed.
2101
- """
980
+ def _solve_frame_freshness_guarded(self, element: Any, initial_shot: str,
981
+ run_query: Callable[[str], Tuple[List[CaptchaAction], List[Dict[str, Any]]]]):
982
+ """Re-solve when the frame changed during inference; twice means the board cycles."""
2102
983
  cfg = self.config
2103
984
  owned: List[str] = []
2104
- merged_usage: List[Dict[str, Any]] = []
985
+ merged: List[Dict[str, Any]] = []
2105
986
  try:
2106
987
  current = initial_shot
2107
988
  actions, usage = run_query(current)
2108
- merged_usage.extend(usage)
989
+ merged.extend(usage)
2109
990
  if not cfg.stale_frame_resolve_enabled:
2110
- return actions, merged_usage
2111
-
991
+ return actions, merged
992
+ if self._frame_changed_since(element, current, _MOVED_DURING_INFERENCE_DIFF):
993
+ self._arm_animated_probe()
994
+ _log("[freshness] the widget moved while the model was reading it")
995
+ changes = 0
2112
996
  for attempt in range(cfg.max_stale_frame_resolves):
2113
997
  if not self._frame_changed_since(element, current, cfg.stale_frame_diff_threshold):
2114
- break # frame held still through inference — answer is valid
998
+ break
999
+ changes += 1
1000
+ if changes >= 2:
1001
+ self._arm_animated_probe()
1002
+ _log("[freshness] the frame changed twice during inference — this board cycles")
1003
+ return actions, merged
1004
+ self._wait_for_board_painted(element)
2115
1005
  fresh = _tmp_png("freshsolve")
2116
1006
  try:
2117
1007
  self._screenshot(element, fresh)
2118
1008
  except Exception:
2119
1009
  _unlink(fresh)
2120
- break # can't grab a fresh frame — act on what we have
1010
+ break
2121
1011
  owned.append(fresh)
2122
- _log(
2123
- f"[freshness] frame changed during inference "
2124
- f"(re-solve {attempt + 1}/{cfg.max_stale_frame_resolves})"
2125
- )
1012
+ _log(f"[freshness] frame changed during inference (re-solve {attempt + 1}/{cfg.max_stale_frame_resolves})")
2126
1013
  current = fresh
2127
1014
  actions, usage = run_query(current)
2128
- merged_usage.extend(usage)
2129
- return actions, merged_usage
1015
+ merged.extend(usage)
1016
+ return actions, merged
2130
1017
  finally:
2131
1018
  for path in owned:
2132
1019
  _unlink(path)
2133
1020
 
2134
- # ------------------------------------------------------------------
2135
- # reCAPTCHA 3x3 dynamic driver
2136
- # ------------------------------------------------------------------
1021
+ # ── animated challenges ──────────────────────────────────────────────
1022
+
1023
+ def _settle_or_animated(self, element: Any) -> bool:
1024
+ """Wait for the widget to settle; True routes the caller to the recording path."""
1025
+ if self._known_animated:
1026
+ return True
1027
+ with self._phase(Phase.SETTLE):
1028
+ verdict = self._wait_for_element_settled(element)
1029
+ if verdict != SettleVerdict.ANIMATED:
1030
+ # 'settled' is not proof of static; a repeated answer arms one recording to find out.
1031
+ if not self._animated_probe_armed or self._animated_probe_done:
1032
+ return False
1033
+ self._animated_probe_done, self._animated_probe_armed = True, False
1034
+ _log("[animated] a second look at a board that did not solve as a still — recording it")
1035
+ self._known_animated = True
1036
+ if not self.config.video_solve_enabled:
1037
+ raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
1038
+ _log("[animated] challenge is animated — solving it from keyframes")
1039
+ return True
1040
+
1041
+ def _arm_animated_probe(self) -> None:
1042
+ if self.config.animated_probe_enabled and not self._animated_probe_done:
1043
+ self._animated_probe_armed = True
1044
+
1045
+ def _should_speculate(self, puzzle_source: Vendor, text_mode: bool) -> bool:
1046
+ # reCAPTCHA is excluded: its dynamic 3x3 replaces tiles in place, so a burst would film a fade and call
1047
+ # it a cycle. A text round has nothing to film.
1048
+ cfg = self.config
1049
+ return (cfg.video_solve_enabled and cfg.speculative_burst_enabled and not self._acted_on_board
1050
+ and puzzle_source != Vendor.RECAPTCHA and not text_mode)
1051
+
1052
+ def _burst(self, element: Any) -> Tuple[List[Any], List[str], bool, float]:
1053
+ """Film the widget until a screen comes back (a cycle) or nothing new appears for a floor window.
1054
+
1055
+ Returns `(frames, distinct_digests, moved, elapsed_ms)`. `moved` is a closed cycle OR a board still
1056
+ producing new screens past the floor with more than BURST_ANIMATED_SCREENS seen: a continuous animation
1057
+ never repeats and never settles, so without that verdict it read as a still and was clicked as one.
1058
+ Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
1059
+ number_with_highest_value_video and failed every seed either way.
1060
+ """
1061
+ import cv2
1062
+
1063
+ cfg = self.config
1064
+ floor_ms = float(cfg.video_burst_duration_ms)
1065
+ max_ms = max(floor_ms, float(cfg.video_burst_max_ms))
1066
+ interval = 1.0 / max(1, int(cfg.video_burst_fps))
1067
+ frames: List[Any] = []
1068
+ order: List[str] = []
1069
+ last_digest: Optional[str] = None
1070
+ cycle_closed = False
1071
+ last_new_ms = 0.0
1072
+ shot = _tmp_png("burst")
1073
+ t0 = time.monotonic()
1074
+ next_at = t0
1075
+ hang_deadline = t0 * 1000.0 + burst_hang_deadline_ms(cfg)
1076
+ try:
1077
+ while (time.monotonic() - t0) * 1000.0 < max_ms:
1078
+ if time.monotonic() * 1000.0 > hang_deadline:
1079
+ raise CaptchaSolveError(
1080
+ f"the animated recording stalled: {len(frames)} frames in "
1081
+ f"{burst_hang_deadline_ms(cfg):.0f}ms. The widget is not screenshotting.")
1082
+ try:
1083
+ self._screenshot(element, shot, animations="allow")
1084
+ img = cv2.imread(shot)
1085
+ except Exception as exc:
1086
+ _debug(f"burst frame failed: {exc}")
1087
+ img = None
1088
+ if img is not None:
1089
+ frames.append(img)
1090
+ d = _sha1(shot)
1091
+ if d != last_digest:
1092
+ if d in order and len(order) >= 2:
1093
+ cycle_closed = True
1094
+ elif d not in order:
1095
+ order.append(d)
1096
+ last_new_ms = (time.monotonic() - t0) * 1000.0
1097
+ last_digest = d
1098
+ elapsed_ms = (time.monotonic() - t0) * 1000.0
1099
+ if cycle_closed and elapsed_ms >= floor_ms:
1100
+ _log(f"[animated] cycle closed after {elapsed_ms / 1000:.1f}s ({len(order)} screens)")
1101
+ break
1102
+ if elapsed_ms >= floor_ms and elapsed_ms - last_new_ms >= floor_ms:
1103
+ _log(f"[animated] no new screen for {floor_ms / 1000:.1f}s ({len(order)} seen) — settled")
1104
+ break
1105
+ # Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
1106
+ # loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
1107
+ next_at = max(next_at + interval, time.monotonic())
1108
+ wait = next_at - time.monotonic()
1109
+ if wait > 0 and (next_at - t0) * 1000.0 < max_ms:
1110
+ time.sleep(wait)
1111
+ finally:
1112
+ _unlink(shot)
1113
+ elapsed_ms = (time.monotonic() - t0) * 1000.0
1114
+ animating = len(order) > BURST_ANIMATED_SCREENS and elapsed_ms - last_new_ms < floor_ms
1115
+ if cycle_closed:
1116
+ why = "a screen came back"
1117
+ elif animating:
1118
+ why = f"{len(order)} screens and still arriving — animating continuously"
1119
+ else:
1120
+ why = "no new screen for a full floor window — it moved once and settled"
1121
+ _log(f"[animated] burst verdict after {elapsed_ms / 1000:.1f}s: "
1122
+ f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
1123
+ return frames, order, cycle_closed or animating, elapsed_ms
1124
+
1125
+ def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[List[str], str]:
1126
+ """Cut a burst with the training-side slicer; the model answers with a frame number into it."""
1127
+ from .keyframes import extract_keyframes, write_keyframes
1128
+
1129
+ kfset = extract_keyframes(frames, fps=measured_fps(len(frames), burst_ms, self.config.video_burst_fps))
1130
+ self._keyframe_mode = kfset.mode
1131
+ self._keyframe_steady_screens = kfset.steady_screens
1132
+ temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
1133
+ paths = [str(p) for p in write_keyframes(kfset, temp_dir, stem="challenge")]
1134
+ _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1135
+ return paths, temp_dir
1136
+
1137
+ def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
1138
+ """Record the widget and return `(keyframe_paths, temp_dir)`; the caller removes the dir.
1139
+
1140
+ Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
1141
+ """
1142
+ cfg = self.config
1143
+ self._grant_video_budget()
1144
+ if self._deadline_ms is not None and self._deadline_ms - _now() < cfg.video_burst_duration_ms:
1145
+ raise CaptchaSolveError(
1146
+ f"only {self._deadline_ms - _now():.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
1147
+ f"budget is left and an animated recording needs {cfg.video_burst_duration_ms}ms — not "
1148
+ "starting one that would be cut off mid-way. Raise overall_solve_timeout_ms or "
1149
+ "video_extra_inference_ms, or set video_solve_enabled=False.")
1150
+ frames, _order, _closed, burst_ms = self._burst(element)
1151
+ if not frames:
1152
+ raise AnimatedChallengeError("could not record the animated challenge (no frame screenshotted)")
1153
+ _log(f"[animated] recorded {len(frames)} frames in {burst_ms / 1000:.1f}s "
1154
+ f"({measured_fps(len(frames), burst_ms, cfg.video_burst_fps):.1f}fps)")
1155
+ return self._slice(frames, burst_ms)
1156
+
1157
+ def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
1158
+ text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
1159
+ """Ask the still and film the widget at once; `keyframe_dir` is None when the board is still.
1160
+
1161
+ Only the HTTP request crosses to the worker thread: sync Playwright is thread-affine.
1162
+ """
1163
+ pool = ThreadPoolExecutor(max_workers=1)
1164
+ try:
1165
+ fut = pool.submit(self._get_solution, shot, puzzle_source, retry_mode, text_mode)
1166
+ with self._phase(Phase.BURST):
1167
+ frames, order, moved, burst_ms = self._burst(element)
1168
+ if not moved:
1169
+ with self._phase(Phase.INFERENCE):
1170
+ actions, usage = fut.result()
1171
+ if len(order) > 1:
1172
+ # Moved once and settled: re-read the screen it came to rest on.
1173
+ try:
1174
+ self._screenshot(element, shot)
1175
+ actions, extra = self._get_solution(shot, puzzle_source, retry_mode, text_mode)
1176
+ usage = list(usage) + list(extra)
1177
+ except Exception as exc:
1178
+ _debug(f"settled re-read failed: {exc}")
1179
+ return actions, usage, None
1180
+ _log("[animated] the widget moved while the model was reading it — finishing the recording")
1181
+ fut.result()
1182
+ self._grant_video_budget()
1183
+ paths, keyframe_dir = self._slice(frames, burst_ms)
1184
+ with self._phase(Phase.INFERENCE):
1185
+ actions, usage = self._get_keyframe_solution(paths)
1186
+ self._animated_plan = (paths, keyframe_dir, actions, usage)
1187
+ return actions, usage, keyframe_dir
1188
+ finally:
1189
+ pool.shutdown(wait=True)
1190
+
1191
+ def _invalidate_animated_answer(self) -> None:
1192
+ """Keep the frames and drop the answer, unless the clip has no steady screen to re-ask."""
1193
+ plan = self._animated_plan
1194
+ if plan is None or plan[2] is None:
1195
+ return
1196
+ if self._keyframe_steady_screens < 2:
1197
+ self._discard_animated_plan()
1198
+ self._keyframe_mode, self._keyframe_steady_screens = None, 0
1199
+ return
1200
+ self._animated_plan = (plan[0], plan[1], None, None)
1201
+
1202
+ def _discard_animated_plan(self) -> None:
1203
+ plan = self._animated_plan
1204
+ self._animated_plan = None
1205
+ if plan and plan[1]:
1206
+ shutil.rmtree(plan[1], ignore_errors=True)
1207
+
1208
+ def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
1209
+ """Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
1210
+ import cv2
1211
+
1212
+ from .keyframes import MATCH_REGION_TOLERANCE, region_diff_ratio
1213
+
1214
+ folder, base = os.path.dirname(keyframe_path), os.path.basename(keyframe_path)
1215
+ prefix = re.sub(r"_\d+\.png$", "_", base, flags=re.IGNORECASE)
1216
+ if prefix == base:
1217
+ return False
1218
+ try:
1219
+ siblings = [os.path.join(folder, f) for f in sorted(os.listdir(folder))
1220
+ if f.startswith(prefix) and f.lower().endswith(".png") and f != base]
1221
+ except OSError:
1222
+ return False
1223
+ for other in siblings:
1224
+ img = cv2.imread(other)
1225
+ if img is not None and img.shape == ref.shape and region_diff_ratio(ref, img, box) <= MATCH_REGION_TOLERANCE:
1226
+ return True
1227
+ return False
1228
+
1229
+ def _wait_for_keyframe(self, element: Any, keyframe_path: str, point_norm: Tuple[float, float]) -> bool:
1230
+ """Hold until the widget looks like the keyframe around the action point, or the wait is spent."""
1231
+ import cv2
1232
+
1233
+ from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1234
+
1235
+ ref = cv2.imread(keyframe_path)
1236
+ if ref is None:
1237
+ return False
1238
+ box = region_box(ref.shape[1::-1], point_norm)
1239
+ steady = self._keyframe_steady_screens >= 2
1240
+ if not steady and not self._answer_region_recurs(keyframe_path, ref, box):
1241
+ _log("[animated] the answer area is unique to the chosen frame; acting without waiting")
1242
+ return False
1243
+ cfg = self.config
1244
+ wait_ms = cfg.keyframe_wait_timeout_ms if steady else min(cfg.keyframe_wait_timeout_ms, cfg.video_burst_duration_ms)
1245
+ deadline = _now() + wait_ms
1246
+ probe = _tmp_png("kfwait")
1247
+ best, polls = 1.0, 0
1248
+ try:
1249
+ while _now() < deadline:
1250
+ self._check_deadline("waiting for the challenge keyframe")
1251
+ try:
1252
+ self._screenshot(element, probe, animations="allow")
1253
+ live = cv2.imread(probe)
1254
+ except Exception:
1255
+ live = None
1256
+ if live is not None:
1257
+ d = region_diff_ratio(ref, live, box)
1258
+ best = min(best, d)
1259
+ if d <= MATCH_REGION_TOLERANCE:
1260
+ _log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
1261
+ return True
1262
+ polls += 1
1263
+ if polls >= _NOT_THIS_BOARD_POLLS and best > _NOT_THIS_BOARD_DIFF:
1264
+ _log("[animated] the widget no longer resembles the recorded board")
1265
+ self._discard_animated_plan()
1266
+ return False
1267
+ _delay(cfg.keyframe_wait_poll_ms)
1268
+ finally:
1269
+ _unlink(probe)
1270
+ self._discard_animated_plan()
1271
+ _log(f"[animated] widget never matched the chosen keyframe within {wait_ms}ms (closest diff={best:.4f})")
1272
+ return False
1273
+
1274
+ # ── reCAPTCHA 3x3 dynamic driver ─────────────────────────────────────
2137
1275
 
2138
1276
  def _cell_center_page(self, cell: int, session: _GridSession) -> Tuple[float, float]:
2139
1277
  x1, y1, x2, y2 = session.grid_boxes[cell - 1]
2140
- return (
2141
- session.element_box["x"] + ((x1 + x2) / 2) * session.scale_x,
2142
- session.element_box["y"] + ((y1 + y2) / 2) * session.scale_y,
2143
- )
1278
+ return (session.element_box["x"] + (x1 + x2) / 2 * session.scale_x,
1279
+ session.element_box["y"] + (y1 + y2) / 2 * session.scale_y)
2144
1280
 
2145
1281
  def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
2146
- # A hover is mimicry of a resting CURSOR, so on a device that has none
2147
- # it is not weaker mimicry — it is a mousemove at a touch-only widget.
2148
1282
  if not self._human.hovers:
2149
1283
  return
2150
1284
  first = session.grid_boxes[0]
2151
- cell_w = (first[2] - first[0]) * session.scale_x
2152
- cell_h = (first[3] - first[1]) * session.scale_y
2153
1285
  cx, cy = self._cell_center_page(cell, session)
2154
- self._smooth_move(
2155
- page,
2156
- cx + (random.random() - 0.5) * cell_w * 0.4,
2157
- cy + (random.random() - 0.5) * cell_h * 0.4,
2158
- )
1286
+ self._smooth_move(page,
1287
+ cx + (random.random() - 0.5) * (first[2] - first[0]) * session.scale_x * 0.4,
1288
+ cy + (random.random() - 0.5) * (first[3] - first[1]) * session.scale_y * 0.4)
2159
1289
 
2160
1290
  @staticmethod
2161
- def _bbox_to_cell(
2162
- bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]], width: int, height: int
2163
- ) -> Optional[int]:
2164
- cx = ((float(bbox[0]) + float(bbox[2])) / 2) * width
2165
- cy = ((float(bbox[1]) + float(bbox[3])) / 2) * height
1291
+ def _bbox_to_cell(bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]],
1292
+ width: int, height: int) -> Optional[int]:
1293
+ cx = (float(bbox[0]) + float(bbox[2])) / 2 * width
1294
+ cy = (float(bbox[1]) + float(bbox[3])) / 2 * height
2166
1295
  for index, (x1, y1, x2, y2) in enumerate(grid_boxes):
2167
1296
  if x1 <= cx <= x2 and y1 <= cy <= y2:
2168
- return index + 1 # 1-indexed, row-major, matching find_grid
1297
+ return index + 1
2169
1298
  return None
2170
1299
 
2171
1300
  @staticmethod
2172
1301
  def _order_by_priority(loading: Sequence[int], priority: Sequence[int]) -> List[int]:
2173
1302
  remaining = set(loading)
2174
- ordered: List[int] = []
2175
- for cell in priority:
2176
- if cell in remaining:
2177
- ordered.append(cell)
2178
- remaining.discard(cell)
2179
- ordered.extend(sorted(remaining))
2180
- return ordered
2181
-
2182
- def _watch_clicked_tiles(
2183
- self, page: Any, element: Any, session: _GridSession, priority: Sequence[int] = ()
2184
- ) -> Tuple[List[int], bool]:
2185
- """
2186
- Watch the just-clicked tiles until the widget says what it did with them.
2187
-
2188
- Returns `(loading, chipped)`. Two answers because reCAPTCHA gives a click
2189
- one of exactly two replies, and they are the two kinds of board:
2190
-
2191
- * the small blue CHIP in the tile's top-left corner — the photo was
2192
- KEPT. Nothing is on its way in, the selection is the answer, and the
2193
- caller should press Verify (`chipped`).
2194
- * the photo blanking or dissolving under a large centred check — the
2195
- tile is being SWAPPED, and what lands may match too, so the board has
2196
- to be read again (`loading`).
2197
-
2198
- A widget that swaps one clicked cell swaps them all, so the two never
2199
- share a board and one look at the tiles we just clicked settles it. The
2200
- chip is what `detect_selected_cells` reports as `selected` — it looks for
2201
- it in the top-left corner only, behind a circularity and a centroid test
2202
- a centred check fails — and we already read it every poll.
2203
-
2204
- `chipped` needs EVERY watched tile, because a partial reading is a
2205
- misread and the two mistakes do not cost the same: calling a swapping
2206
- board finished submits half an answer and burns the attempt, while
2207
- calling a chipped board unfinished costs one inference. It is also only
2208
- ever OUR click that a chip can be reporting: tiles already wearing one
2209
- are filtered out of the model's answer before we click, so a chip on a
2210
- watched tile arrived in response to this round.
2211
-
2212
- The blank/fade transition LAGS the click by a beat: reCAPTCHA holds a
2213
- clicked tile selected (old image visible) for ~1-3s and only then blanks
2214
- it. A single snapshot right after clicking therefore sees nothing and
2215
- concludes the puzzle is finished. Hovers a clicked tile each poll so the
2216
- cursor keeps moving during the wait.
1303
+ ordered = [c for c in priority if c in remaining and not remaining.discard(c)]
1304
+ return ordered + sorted(remaining)
1305
+
1306
+ def _hover_loop(self, page: Any, session: _GridSession, cells: Sequence[int]) -> Optional[Callable[[], None]]:
1307
+ if not cells:
1308
+ return None
1309
+ cycle = itertools.cycle(cells)
1310
+
1311
+ def hover():
1312
+ try:
1313
+ self._hover_cell(page, session, next(cycle))
1314
+ except Exception:
1315
+ pass
1316
+
1317
+ return hover
1318
+
1319
+ def _watch_clicked_tiles(self, page: Any, element: Any, session: _GridSession,
1320
+ priority: Sequence[int] = ()) -> Tuple[List[int], bool]:
1321
+ """`(loading, chipped)`: a chip means the photo was kept, a blank or fade means it is being swapped.
1322
+
1323
+ A grace window, not a snapshot: reCAPTCHA holds a clicked tile selected for ~1-3s and only then blanks
1324
+ it. The chip is tested first, because a chip zooms the photo and reads as `changing` on the frame that
1325
+ shows it. `chipped` needs every clicked tile, since the two misreads cost differently: calling a
1326
+ swapping board finished submits half an answer, calling a chipped board unfinished costs one inference.
2217
1327
  """
2218
1328
  cfg = self.config
2219
- watch = list(priority) if priority else None
2220
- start = time.monotonic() * 1000.0
2221
- frames: List[str] = []
2222
- hover_index = 0
2223
- try:
2224
- first = _tmp_png("loadchk")
2225
- self._screenshot(element, first)
2226
- frames.append(first)
2227
-
2228
- while (time.monotonic() * 1000.0) - start < cfg.recaptcha_fade_onset_grace_ms:
2229
- self._check_deadline("watching for the tile refresh")
2230
- iteration_start = time.monotonic() * 1000.0
2231
- if priority:
2232
- try:
2233
- self._hover_cell(page, session, priority[hover_index % len(priority)])
2234
- except Exception:
2235
- pass
2236
- hover_index += 1
2237
- # Enforce a minimum inter-frame gap: the in-process CV call is
2238
- # near-instant, so without this the polls fire back-to-back on
2239
- # near-identical frames and miss a slow fade.
2240
- elapsed = (time.monotonic() * 1000.0) - iteration_start
2241
- if elapsed < cfg.recaptcha_dynamic_fade_poll_ms:
2242
- _delay(cfg.recaptcha_dynamic_fade_poll_ms - elapsed)
2243
-
2244
- path = _tmp_png("loadchk")
2245
- self._screenshot(element, path)
2246
- frames.append(path)
2247
-
2248
- states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
2249
- # Chip first: a chip landing on a tile ZOOMS its photo out, which
2250
- # reads as `changing` on the same frame that shows the chip. Test
2251
- # the swap first and every chipped board looks like a swapping
2252
- # one for as long as the animation runs.
2253
- if priority and set((states or {}).get("selected", [])).issuperset(priority):
2254
- return [], True
2255
- in_scope = lambda c: watch is None or c in watch # noqa: E731
2256
- empty = [c for c in (states or {}).get("empty", []) if in_scope(c)]
2257
- changing = [c for c in (states or {}).get("changing", []) if in_scope(c)]
2258
- loading = sorted(set(empty) | set(changing))
2259
- if loading:
2260
- return self._order_by_priority(loading, priority), False
2261
- _unlink(frames.pop(0))
2262
- return [], False
2263
- except Exception as exc:
2264
- _debug(f"fade-onset error: {exc}")
2265
- return [], False
2266
- finally:
2267
- for path in frames:
2268
- _unlink(path)
1329
+ watch = set(priority) or None
2269
1330
 
2270
- def _wait_for_any_clicked_tile_loaded(
2271
- self, page: Any, element: Any, session: _GridSession, fading_cells: Sequence[int]
2272
- ) -> bool:
2273
- """Wait for at least one blank/fading cell to finish reloading."""
1331
+ def judge(frames, _elapsed):
1332
+ if len(frames) < 2:
1333
+ return None
1334
+ states = self._grid_cell_states(frames[0], frames[1], session.grid_boxes) or {}
1335
+ if priority and set(states.get("selected", [])).issuperset(priority):
1336
+ return [], True
1337
+ loading = sorted({c for c in states.get("empty", []) + states.get("changing", [])
1338
+ if watch is None or c in watch})
1339
+ return (self._order_by_priority(loading, priority), False) if loading else None
1340
+
1341
+ return self._poll(element, cfg.recaptcha_fade_onset_grace_ms, cfg.recaptcha_dynamic_fade_poll_ms,
1342
+ judge, "watching for the tile refresh",
1343
+ self._hover_loop(page, session, priority)) or ([], False)
1344
+
1345
+ def _wait_for_any_clicked_tile_loaded(self, page: Any, element: Any, session: _GridSession,
1346
+ fading_cells: Sequence[int]) -> bool:
2274
1347
  if not fading_cells:
2275
1348
  return True
2276
1349
  cfg = self.config
2277
- start = time.monotonic() * 1000.0
2278
- frames: List[str] = []
2279
- hover_index = 0
2280
- try:
2281
- while (time.monotonic() * 1000.0) - start < cfg.recaptcha_dynamic_fade_wait_ms:
2282
- self._check_deadline("waiting for a tile to reload")
2283
- iteration_start = time.monotonic() * 1000.0
2284
- if cfg.recaptcha_tile_hover_enabled:
2285
- try:
2286
- self._hover_cell(
2287
- page, session, fading_cells[hover_index % len(fading_cells)]
2288
- )
2289
- except Exception:
2290
- pass
2291
- hover_index += 1
2292
- elapsed = (time.monotonic() * 1000.0) - iteration_start
2293
- if elapsed < cfg.recaptcha_dynamic_fade_poll_ms:
2294
- _delay(cfg.recaptcha_dynamic_fade_poll_ms - elapsed)
2295
1350
 
2296
- path = _tmp_png("fadepoll")
2297
- self._screenshot(element, path)
2298
- frames.append(path)
1351
+ def judge(frames, _elapsed):
1352
+ if len(frames) < 2:
1353
+ return None
1354
+ states = self._grid_cell_states(frames[0], frames[1], session.grid_boxes)
1355
+ return True if states and any(c in states["loaded"] for c in fading_cells) else None
2299
1356
 
2300
- if len(frames) >= 2:
2301
- states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
2302
- if states and [c for c in fading_cells if c in states["loaded"]]:
2303
- return True
2304
- _unlink(frames.pop(0))
2305
- return False
2306
- except Exception as exc:
2307
- _debug(f"wait-load error: {exc}")
2308
- return False
2309
- finally:
2310
- for path in frames:
2311
- _unlink(path)
1357
+ hover = self._hover_loop(page, session, fading_cells) if cfg.recaptcha_tile_hover_enabled else None
1358
+ return bool(self._poll(element, cfg.recaptcha_dynamic_fade_wait_ms, cfg.recaptcha_dynamic_fade_poll_ms,
1359
+ judge, "waiting for a tile to reload", hover))
2312
1360
 
2313
- def _solve_recaptcha_grid(
2314
- self,
2315
- page: Any,
2316
- element: Any,
2317
- retry_mode: Optional[str],
2318
- grid: Dict[str, Any],
2319
- element_box: Dict[str, float],
2320
- ) -> Tuple[bool, List[Dict[str, Any]]]:
2321
- """
2322
- Multi-round driver for reCAPTCHA 3x3 dynamic puzzles. One call = one
2323
- puzzle session.
2324
-
2325
- The shared Python half is authoritative about WHAT to do — it runs the
2326
- blue-badge detector, filters already-selected and still-loading tiles,
2327
- and returns `click` / `wait` / `done`. This driver owns the human-like
2328
- WAITING that logic cannot: after a click round it hovers the just-clicked
2329
- tiles and waits for at least one to finish reloading before re-solving,
2330
- so we never burn a model call on a grid that is still mid-fade.
2331
-
2332
- Only a board that SWAPS a clicked tile out is worth those extra rounds.
2333
- One that ticks the tile and keeps the photo has been fully answered by
2334
- the round that clicked it — same as the 4x4 — so `_watch_clicked_tiles`
2335
- reports the chip and this submits there and then. Rounds 2..N exist for
2336
- the fading board and nothing else.
2337
-
2338
- Submits on `done`, and on a board that ticked our clicks — never on a
2339
- round-cap exit, which is left to the outer loop to re-detect and decide.
2340
- """
1361
+ def _solve_recaptcha_grid(self, page: Any, element: Any, retry_mode: Optional[RetryMode],
1362
+ grid: Dict[str, Any], element_box: Dict[str, float]) -> Tuple[bool, List[Dict[str, Any]]]:
1363
+ """Click, watch the clicked tiles, re-solve while they swap, submit on `done` or a chipped board."""
2341
1364
  cfg = self.config
2342
- session = _GridSession(
2343
- grid_boxes=grid["boxes"],
2344
- element_box=element_box,
2345
- scale_x=element_box["width"] / grid["screenshot_w"],
2346
- scale_y=element_box["height"] / grid["screenshot_h"],
2347
- screenshot_w=grid["screenshot_w"],
2348
- screenshot_h=grid["screenshot_h"],
2349
- )
2350
-
1365
+ session = _GridSession(grid["boxes"], element_box,
1366
+ element_box["width"] / grid["screenshot_w"],
1367
+ element_box["height"] / grid["screenshot_h"],
1368
+ grid["screenshot_w"], grid["screenshot_h"])
2351
1369
  clicked_order: List[int] = []
2352
- performed_action = False
2353
- should_submit = False
1370
+ performed = should_submit = False
2354
1371
  all_usage: List[Dict[str, Any]] = []
2355
1372
  pending_retry = retry_mode
2356
1373
 
2357
1374
  for round_index in range(1, cfg.recaptcha_max_dynamic_rounds + 1):
2358
- # Each round can legitimately spend ~10s waiting on fades, so eight
2359
- # of them plus the model calls can outlast the whole solve budget.
2360
1375
  self._check_deadline(f"recaptcha grid round {round_index}")
2361
- # Round 1 skips it: `_solve_single` has just waited for these cells,
2362
- # read the grid boxes off the loaded board and handed over, and
2363
- # nothing has touched the widget in between. Rounds 2..N still wait,
2364
- # because by then THIS driver has clicked and the tiles really are
2365
- # reloading. Measured 2148ms x2 on one solve.
2366
1376
  if round_index > 1:
2367
- with self._phase("grid-load"):
1377
+ with self._phase(Phase.GRID_LOAD):
2368
1378
  self._wait_for_grid_cells_loaded(element)
2369
1379
  shot = _tmp_png("recap")
2370
- action: Optional[Dict[str, Any]] = None
2371
1380
  try:
2372
- with self._phase("screenshot"):
2373
- self._screenshot(element, shot,
2374
- timeout_ms=cfg.element_screenshot_timeout_ms)
2375
- retry_for_round = pending_retry
2376
- pending_retry = None # only round 1 carries the inbound hint
2377
- with self._phase("inference"):
1381
+ with self._phase(Phase.SCREENSHOT):
1382
+ self._screenshot(element, shot, timeout_ms=cfg.element_screenshot_timeout_ms)
1383
+ retry_for_round, pending_retry = pending_retry, None
1384
+ with self._phase(Phase.INFERENCE):
2378
1385
  actions, usage = self._solve_frame_freshness_guarded(
2379
- element,
2380
- shot,
2381
- lambda image_path: self._get_solution(
2382
- image_path, "recaptcha", retry_for_round),
2383
- )
1386
+ element, shot, lambda p: self._get_solution(p, Vendor.RECAPTCHA, retry_for_round))
2384
1387
  all_usage.extend(usage)
2385
1388
  action = _as_dict(actions[0]) if actions else None
2386
1389
  finally:
2387
1390
  _unlink(shot)
2388
1391
 
2389
- if not action or action.get("action") == "done":
1392
+ kind = (action or {}).get("action")
1393
+ if not action or kind == ActionKind.DONE:
2390
1394
  _log(f"[recaptcha-grid] round {round_index}: done; submitting.")
2391
1395
  should_submit = True
2392
1396
  break
2393
-
2394
- if action.get("action") == "wait":
2395
- _log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
2396
- with self._phase("fade-wait"):
2397
- loading, _ = self._watch_clicked_tiles(
2398
- page, element, session, clicked_order)
2399
- self._wait_for_any_clicked_tile_loaded(
2400
- page, element, session, loading)
1397
+ if kind == ActionKind.WAIT:
1398
+ with self._phase(Phase.FADE_WAIT):
1399
+ loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
1400
+ self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
2401
1401
  continue
2402
-
2403
- if action.get("action") == "click":
2404
- bboxes = action.get("target_bounding_boxes") or (
2405
- [action["target_bounding_box"]] if action.get("target_bounding_box") else []
2406
- )
2407
- if not bboxes:
2408
- # Malformed click: treat as a soft wait so we never submit
2409
- # prematurely, and re-solve next round.
2410
- _log(f"[recaptcha-grid] round {round_index}: click with no bboxes; re-solving.")
2411
- _delay(500)
2412
- continue
2413
-
2414
- clicked_this_round: List[int] = []
2415
- for bbox in bboxes:
2416
- cell = self._bbox_to_cell(
2417
- bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h
2418
- )
2419
- self._execute_click(page, {"target_bounding_box": bbox}, element_box)
2420
- if cell is not None:
2421
- clicked_order.append(cell)
2422
- clicked_this_round.append(cell)
2423
- self._human.pause("between")
2424
- performed_action = True
2425
- _log(
2426
- f"[recaptcha-grid] round {round_index}: clicked {len(bboxes)} tile(s) "
2427
- f"-> cells {clicked_this_round}."
2428
- )
2429
-
2430
- with self._phase("fade-wait"):
2431
- loading, chipped = self._watch_clicked_tiles(
2432
- page, element, session, clicked_this_round
2433
- )
2434
- if chipped or not loading:
2435
- # Either the widget ticked our clicks and kept the photos
2436
- # (a board that does that is answered), or nothing faded
2437
- # within the grace window. Submit rather than paying for
2438
- # another round to be told the same thing.
2439
- _log(
2440
- f"[recaptcha-grid] round {round_index}: "
2441
- f"{'tiles chipped' if chipped else 'nothing loading'}; submitting."
2442
- )
2443
- should_submit = True
2444
- break
2445
- with self._phase("fade-wait"):
2446
- self._wait_for_any_clicked_tile_loaded(
2447
- page, element, session, loading)
1402
+ if kind != ActionKind.CLICK:
1403
+ _log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
2448
1404
  continue
2449
-
2450
- _log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
1405
+ bboxes = action.get("target_bounding_boxes") or (
1406
+ [action["target_bounding_box"]] if action.get("target_bounding_box") else [])
1407
+ if not bboxes:
1408
+ _delay(500)
1409
+ continue
1410
+ clicked_this_round: List[int] = []
1411
+ for bbox in bboxes:
1412
+ cell = self._bbox_to_cell(bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h)
1413
+ self._execute_click(page, {"target_bounding_box": bbox}, element_box)
1414
+ if cell is not None:
1415
+ clicked_order.append(cell)
1416
+ clicked_this_round.append(cell)
1417
+ self._human.pause(PauseKind.BETWEEN)
1418
+ performed = True
1419
+ _log(f"[recaptcha-grid] round {round_index}: clicked cells {clicked_this_round}.")
1420
+ with self._phase(Phase.FADE_WAIT):
1421
+ loading, chipped = self._watch_clicked_tiles(page, element, session, clicked_this_round)
1422
+ if chipped or not loading:
1423
+ _log(f"[recaptcha-grid] round {round_index}: {'tiles chipped' if chipped else 'nothing loading'}; submitting.")
1424
+ should_submit = True
1425
+ break
1426
+ with self._phase(Phase.FADE_WAIT):
1427
+ self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
2451
1428
 
2452
1429
  if should_submit:
2453
1430
  frame = element.content_frame()
2454
- if frame:
2455
- verify = self._get_verify_button(frame)
2456
- if verify:
2457
- _log("[recaptcha-grid] clicking Verify to submit.")
2458
- self._move_and_click(page, verify)
2459
- # The press IS an interaction, and saying so is load-bearing
2460
- # — the same rule `_solve_single` learned on
2461
- # prosopo_grid_3x3, in the other driver, on another day. A
2462
- # `done` round clicks no tile, so without this the one
2463
- # answer shape that submits and does nothing else reports
2464
- # having done nothing: the caller then takes the
2465
- # no-interaction wait instead of polling for the verdict
2466
- # (~1s of dead time on every static reCAPTCHA), and raises
2467
- # "performed no interactions" if the widget has not
2468
- # finished tearing down — on an answer correctly sent.
2469
- performed_action = True
2470
-
2471
- return performed_action, all_usage
2472
-
2473
- # ------------------------------------------------------------------
2474
- # One pass over a rendered challenge
2475
- # ------------------------------------------------------------------
2476
-
2477
- def _solve_single(
2478
- self, page: Any, element: Any, retry_mode: Optional[str]
2479
- ) -> Tuple[bool, List[Dict[str, Any]]]:
2480
- src = None
2481
- try:
2482
- src = element.get_attribute("src")
2483
- except Exception:
2484
- pass
2485
- src = src or ""
2486
-
2487
- # The vendor hint routes to the right pipeline. It matters: hCaptcha
2488
- # click puzzles must never go through grid detection, because find_grid
2489
- # false-positives on the header/footer bands.
2490
- if "hcaptcha.com" in src:
2491
- puzzle_source = "hcaptcha"
2492
- elif "recaptcha/api2" in src:
2493
- puzzle_source = "recaptcha"
2494
- else:
2495
- puzzle_source = "unknown"
2496
-
2497
- # Everything the answer might have to be delivered INTO — a text box, a
2498
- # slider handle — is looked up against this, never against the page.
2499
- # For the iframed vendors it is the challenge document; for the ones
2500
- # that render into the host page (GeeTest, Yidun, BotDetect, …) it is
2501
- # the widget element, whose subtree is the same boundary.
2502
- scope = element.content_frame() or element
2503
-
2504
- # Does this puzzle want a STRING rather than a place to click? Only the
2505
- # DOM can say. The picture cannot: BotDetect's warped code and
2506
- # hCaptcha's "click the matching character" are the same genre of image
2507
- # and want opposite answers. Restricted to `unknown` because neither
2508
- # hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
2509
- # inside one of their frames would be a false positive by definition.
2510
- text_mode = (
2511
- puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING
2512
- and self._answer_box(scope, element) is not None
2513
- )
1431
+ verify = frame and self._get_verify_button(frame)
1432
+ if verify:
1433
+ _log("[recaptcha-grid] clicking Verify to submit.")
1434
+ self._move_and_click(page, verify)
1435
+ performed = True
1436
+ return performed, all_usage
1437
+
1438
+ # ── one pass over a rendered challenge ───────────────────────────────
1439
+
1440
+ def _solve_single(self, page: Any, widget: Widget, retry_mode: Optional[RetryMode]) -> Tuple[bool, List[Dict[str, Any]]]:
1441
+ element, puzzle_source, role = widget.element, widget.vendor, widget.role
1442
+ frame = element.content_frame()
1443
+ scope = frame or widget.at
1444
+ # Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
1445
+ text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
2514
1446
  if text_mode:
2515
1447
  _log("widget has a text box; solving as a distorted-text captcha")
2516
1448
 
2517
- # hCaptcha REUSES the challenge iframe across rounds: after a submit it
2518
- # briefly shows the previous round, then a spinner, then the next one.
2519
- # Screenshotting any of those transitional frames feeds the model a
2520
- # blank/stale grid it correctly calls "unsupported" — which used to abort
2521
- # the whole solve on round 2.
2522
- is_animated = False
2523
- if puzzle_source == "hcaptcha" and "frame=challenge" in src:
1449
+ if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
2524
1450
  if self._last_submit_frame_hash:
2525
- with self._phase("await-next-round"):
1451
+ with self._phase(Phase.AWAIT_NEXT_ROUND):
2526
1452
  self._wait_for_change_since(element, self._last_submit_frame_hash)
2527
1453
  self._last_submit_frame_hash = None
2528
- with self._phase("hcaptcha-images"):
2529
- self._wait_for_hcaptcha_challenge_images(element)
2530
- is_animated = self._settle_or_animated(element)
2531
- # Those three waits total up to ~21s and none of them watches for
2532
- # success, so the vendor's token routinely lands DURING them. Ask
2533
- # once more before spending an inference: this is the last free
2534
- # moment to notice the captcha is already accepted, and the
2535
- # inference is the single most expensive thing in the loop.
2536
- if self.is_captcha_solved(page):
2537
- _log("solved while waiting for the next round; skipping inference.")
2538
- return False, []
2539
- elif puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING:
2540
- # Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
2541
- # probe was never run for these, so an animated one — GeeTest's svg board
2542
- # cycles its glyph set — was screenshotted mid-cycle and answered from
2543
- # whatever single moment we happened to catch. reCAPTCHA is excluded on
2544
- # purpose: it has its own readiness gate below, its grids are never
2545
- # animated, and a second probe would only add latency to a path that
2546
- # already works.
2547
- is_animated = self._settle_or_animated(element)
2548
-
2549
- # Only the image-challenge frame holds a grid. Running grid detection on
2550
- # the anchor checkbox just burns an 8s timeout before the click.
2551
- is_recaptcha_challenge = puzzle_source == "recaptcha" and "recaptcha/api2/bframe" in src
2552
- if is_recaptcha_challenge:
2553
- with self._phase("grid-load"):
1454
+ with self._phase(Phase.HCAPTCHA_IMAGES):
1455
+ self._wait_for_board_images(frame, SELECTORS[puzzle_source])
1456
+
1457
+ # A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
1458
+ filmable = role != FrameRole.CHECKBOX and puzzle_source != Vendor.RECAPTCHA and not text_mode
1459
+ is_animated = filmable and self._settle_or_animated(element)
1460
+ # hCaptcha keeps its challenge iframe visible ~2s after the final submit; read as a fresh puzzle it burned ~18s.
1461
+ if role == FrameRole.CHALLENGE and self.is_captcha_solved(page):
1462
+ _log("solved while waiting for the next round; skipping inference.")
1463
+ return False, []
1464
+
1465
+ if puzzle_source == Vendor.RECAPTCHA and role == FrameRole.CHALLENGE:
1466
+ with self._phase(Phase.GRID_LOAD):
2554
1467
  self._wait_for_grid_cells_loaded(element)
2555
1468
  grid = self._get_grid_boxes(element)
2556
1469
  if grid and grid["size"] == 3:
2557
- # Only a 3x3 ever refreshes its tiles in place, so only a 3x3
2558
- # can need more than one round — and whether THIS one does is
2559
- # decided inside the driver, by what the widget does with the
2560
- # first click. A 4x4 never refreshes: it falls through to the
2561
- # ordinary click-then-submit path below.
2562
1470
  element_box = element.bounding_box()
2563
1471
  if element_box:
2564
- return self._solve_recaptcha_grid(
2565
- page, element, retry_mode, grid, element_box
2566
- )
1472
+ return self._solve_recaptcha_grid(page, element, retry_mode, grid, element_box)
1473
+
1474
+ with self._phase(Phase.BOARD_PAINT):
1475
+ self._wait_for_board_painted(element)
2567
1476
 
2568
1477
  shot = _tmp_png("captcha")
2569
- performed_action = False
2570
- slid = False
2571
- placed = False
2572
- clicked = False
2573
- typed = False
1478
+ performed = slid = answered = have_shot = False
2574
1479
  all_usage: List[Dict[str, Any]] = []
2575
1480
  keyframe_dir: Optional[str] = None
2576
- have_shot = False
2577
1481
  try:
2578
1482
  if is_animated:
2579
- # The freshness guard is deliberately SKIPPED here. It re-solves when
2580
- # the frame changes during inference, and an animated challenge
2581
- # changes by definition — every attempt would be judged stale and the
2582
- # whole re-solve budget would burn without ever acting. The frame
2583
- # number in the answer is the real guard: it names the state to act
2584
- # in, and `_execute_click` waits for it.
2585
- with self._phase("burst"):
2586
- keyframes, keyframe_dir = self._record_keyframes(element)
2587
- # THE RECORDING IS THE TEST. A burst of a widget that turns out
2588
- # not to move slices to a single keyframe (`mode=static`), and
2589
- # that frame is exactly the still this round would have taken
2590
- # anyway — so a wrong guess about "is it animated" costs the
2591
- # burst and nothing else, and cannot produce a wrong answer.
2592
- #
2593
- # This replaced a separate probe that watched the widget FIRST
2594
- # and only recorded if it saw motion. That paid for the
2595
- # observation twice over: up to `animated_probe_ms` to decide,
2596
- # then the burst to act, and it still had to be long enough to
2597
- # outlast the longest hold (8.2s measured on
2598
- # number_with_highest_value_video) or it answered "static" from
2599
- # inside one — which it did, live, burning 9s of a 45s budget to
2600
- # reach the wrong conclusion about a puzzle that was animating.
1483
+ if self._animated_plan is not None:
1484
+ keyframes, keyframe_dir, actions, all_usage = self._animated_plan
1485
+ reused = actions is not None
1486
+ _log("[animated] reusing the recorded answer" if reused
1487
+ else "[animated] re-asking on the frames already recorded")
1488
+ else:
1489
+ reused = False
1490
+ with self._phase(Phase.BURST):
1491
+ keyframes, keyframe_dir = self._record_keyframes(element)
2601
1492
  if len(keyframes) < 2:
2602
- _log("[animated] the recording shows one picture; "
2603
- "solving it as a still")
1493
+ _log("[animated] the recording shows one picture; solving it as a still")
2604
1494
  is_animated = False
2605
- # Reuse the frame we just recorded rather than taking
2606
- # another screenshot of the same motionless widget.
2607
1495
  shutil.copyfile(keyframes[0], shot)
2608
1496
  have_shot = True
1497
+ self._discard_animated_plan()
2609
1498
  shutil.rmtree(keyframe_dir, ignore_errors=True)
2610
1499
  keyframe_dir = None
2611
- else:
2612
- with self._phase("inference"):
1500
+ elif not reused:
1501
+ with self._phase(Phase.INFERENCE):
2613
1502
  actions, all_usage = self._get_keyframe_solution(keyframes)
1503
+ self._animated_plan = (keyframes, keyframe_dir, actions, all_usage)
2614
1504
 
2615
1505
  if not is_animated:
2616
1506
  if not have_shot:
2617
- with self._phase("screenshot"):
2618
- self._screenshot(
1507
+ with self._phase(Phase.SCREENSHOT):
1508
+ self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
1509
+ if self._should_speculate(puzzle_source, text_mode):
1510
+ actions, all_usage, keyframe_dir = self._speculate(element, shot, puzzle_source, retry_mode, text_mode)
1511
+ is_animated = keyframe_dir is not None
1512
+ else:
1513
+ with self._phase(Phase.INFERENCE):
1514
+ actions, all_usage = self._solve_frame_freshness_guarded(
2619
1515
  element, shot,
2620
- timeout_ms=self.config.element_screenshot_timeout_ms)
2621
- with self._phase("inference"):
2622
- actions, all_usage = self._solve_frame_freshness_guarded(
2623
- element,
2624
- shot,
2625
- lambda image_path: self._get_solution(
2626
- image_path, puzzle_source, retry_mode, text_mode=text_mode
2627
- ),
2628
- )
1516
+ lambda p: self._get_solution(p, puzzle_source, retry_mode, text_mode=text_mode))
2629
1517
 
2630
1518
  element_box = element.bounding_box()
2631
- if not element_box:
1519
+ if not element_box and answer_needs_element_box(actions):
2632
1520
  raise CaptchaSolveError("could not get bounding box of captcha element")
2633
1521
 
2634
- # Recorded at the moment of EXECUTION, which is what makes a repeat
2635
- # mean something: this exact answer is about to be performed, so if
2636
- # it matches the last one, the last one already ran and the page is
2637
- # still asking the same question.
2638
- self._note_answer(actions, retry_mode)
1522
+ _log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
1523
+ # A repeated answer is not re-performed: the widget already refused it, and every extra press is
1524
+ # behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
1525
+ if self._note_answer(actions, retry_mode):
1526
+ return False, all_usage
2639
1527
  _log(f"executing {len(actions)} action(s)")
2640
- frame = element.content_frame()
2641
- verify_button = None
2642
1528
 
2643
1529
  for raw_action in actions:
2644
1530
  self._check_deadline("action execution")
2645
1531
  action = _as_dict(raw_action)
2646
1532
  kind = action.get("action")
2647
- if kind == "click":
1533
+ await_kf = action.get("await_keyframe")
1534
+ if kind == ActionKind.CLICK:
2648
1535
  bboxes = action.get("target_bounding_boxes") or (
2649
- [action["target_bounding_box"]]
2650
- if action.get("target_bounding_box")
2651
- else []
2652
- )
1536
+ [action["target_bounding_box"]] if action.get("target_bounding_box") else [])
2653
1537
  if not bboxes and not action.get("target_coordinates"):
2654
1538
  _log("click action has no bboxes or coordinates; skipping")
2655
1539
  continue
2656
- # On an animated challenge, hold each click until the widget is
2657
- # back in the state the model answered about. Per-click, not once
2658
- # per action: these puzzles keep cycling, so by the time click 2
2659
- # comes round the state has moved on again.
2660
- await_kf = action.get("await_keyframe")
2661
- if bboxes:
2662
- for bbox in bboxes:
2663
- if await_kf:
2664
- self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
2665
- self._execute_click(page, {"target_bounding_box": bbox}, element_box)
2666
- self._human.pause("between")
2667
- else:
2668
- self._execute_click(page, action, element_box)
2669
- performed_action = True
2670
- clicked = True
2671
- elif kind == "drag" and not action.get("source_bounding_box"):
2672
- # No source — a puzzle-piece slider. What you grab is not
2673
- # what has to arrive, so this cannot go through
2674
- # _execute_drag: pressing the gap the model named and
2675
- # dragging from there picks up nothing at all.
1540
+ for bbox in bboxes or [None]:
1541
+ one = {"target_bounding_box": bbox} if bbox else action
1542
+ self._execute_click(page, one, element_box, element, await_kf)
1543
+ if bbox:
1544
+ self._human.pause(PauseKind.BETWEEN)
1545
+ performed = answered = True
1546
+ elif kind == ActionKind.DRAG and not action.get("source_bounding_box"):
2676
1547
  if self._execute_slide(page, element, scope, action, element_box):
2677
- performed_action = True
2678
- slid = True
2679
- elif kind == "drag":
2680
- # Wait on the SOURCE: the piece has to be there to be picked up.
2681
- # The destination is not gated — by the time the mouse arrives the
2682
- # animation has moved on regardless, and a drop is judged by where
2683
- # it lands, not by what the slot looked like on pickup.
2684
- await_kf = action.get("await_keyframe")
2685
- if await_kf and action.get("source_bounding_box"):
2686
- self._wait_for_keyframe(
2687
- element, await_kf, _bbox_center(action["source_bounding_box"])
2688
- )
1548
+ performed = slid = True
1549
+ elif kind == ActionKind.DRAG:
1550
+ if await_kf:
1551
+ self._wait_for_keyframe(element, await_kf, _bbox_center(action["source_bounding_box"]))
2689
1552
  self._execute_drag(page, action, element_box)
2690
- performed_action = True
2691
- placed = True
2692
- elif kind == "type":
2693
- if self._execute_type(page, scope, action, element):
2694
- performed_action = True
2695
- typed = True
2696
- elif kind == "wait":
1553
+ performed = answered = True
1554
+ elif kind == ActionKind.TYPE:
1555
+ if self._execute_type(page, scope, action, widget.at):
1556
+ performed = answered = True
1557
+ elif kind == ActionKind.WAIT:
2697
1558
  duration = int(action.get("duration_ms") or 0)
2698
1559
  if duration > 0:
2699
1560
  _delay(duration)
2700
- performed_action = True
2701
-
2702
-
2703
- # Resolve the widget's submit control AFTER the action loop, not
2704
- # inside it. An empty plan — the correct answer to reCAPTCHA 3x3's
2705
- # `none_present` variation, where nothing matches and the control
2706
- # reads SKIP — never enters that loop, so the lookup never ran, the
2707
- # press below found `verify_button` None, and the round aborted on
2708
- # 'performed no interactions'. The finder was never the problem:
2709
- # 'Skip' has always been in its list. It was simply never called for
2710
- # the one answer shape that performs no other action.
2711
- # `scope` when there is no vendor iframe. Eight vendors render
2712
- # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2713
- # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2714
- # for all of them and the button was never even SEARCHED FOR,
2715
- # while the text box and the slider handle it sits beside were
2716
- # both found through `scope` a few lines above. Two containers
2717
- # for two halves of one interaction.
2718
- #
2719
- # This used to be gated on `typed`, for fear of turning up the
2720
- # submit of the FORM the captcha guards. `scope` is the widget
2721
- # container and the xpaths are RELATIVE, so that button is out
2722
- # of reach by construction; what the gate actually did was make
2723
- # every non-typed inline puzzle unsubmittable. Measured on the
2724
- # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2725
- # burning all ten solve loops on a puzzle they had answered on
2726
- # the first one. The press itself is still bounded by
2727
- # `should_submit` below, which is where the hazard belongs.
2728
- # RESOLVED, not travelled to. `_move_and_click` below moves to the
2729
- # button itself and picks its own random point inside it, so moving
2730
- # here first bought a second humanised trajectory that ended a few
2731
- # dozen pixels from where the first one stopped, plus a second
2732
- # bounded scroll-into-view. Two hops onto one button is slower AND
2733
- # is not something a hand does. A round that decides not to submit
2734
- # no longer walks to the control either.
2735
- lookup = frame or (scope if not slid else None)
2736
- if lookup is not None:
2737
- verify_button = self._get_verify_button(lookup)
2738
-
2739
- # Submit policy: press the widget's own submit control whenever we
2740
- # have put an ANSWER into it — a selection, a placed piece, a typed
2741
- # code — or when we had nothing to do and want the round to advance.
2742
- #
2743
- # Two exclusions, and they are the whole rule:
2744
- #
2745
- # a completed SLIDE has already submitted. Letting go of the handle
2746
- # is the gesture these puzzles grade; none of them ships a Verify
2747
- # button, so anything the generic finder turns up afterwards
2748
- # belongs to the host page, and pressing it would submit the form
2749
- # the captcha guards while the verdict is still in flight.
2750
- # a round that only WAITED has answered nothing. Submitting an
2751
- # empty board spends the attempt on a puzzle we were about to
2752
- # solve.
2753
- #
2754
- # hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
2755
- # special cases; they are ordinary click rounds and this covers them.
2756
- # (reCAPTCHA 3x3 never reaches here — it returned above, to the
2757
- # driver that owns its fade-and-re-round rounds.)
2758
- #
2759
- # A click round DID used to be excluded, on the reasoning that these
2760
- # boards re-round and a half-made selection spends the attempt. They
2761
- # do not: the ones that grade themselves mid-selection draw no submit
2762
- # control at all, so `verify_button` is None and nothing is pressed
2763
- # either way. What the exclusion actually bought was a whole extra
2764
- # model call per puzzle, spent asking a board we had already answered
2765
- # correctly whether it was `done` — 6.2 s of prosopo_grid_3x3's 13.8 s,
2766
- # on a selection that scored 1.0 on the first call.
2767
- answered = clicked or placed or typed
2768
- should_submit = not slid and (answered or not performed_action)
2769
- if should_submit and verify_button:
1561
+ performed = True
1562
+
1563
+ # A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
1564
+ # submit the guarded form while the verdict is in flight. An empty or `done` plan still presses Verify/Skip.
1565
+ verify_button = self._get_verify_button(scope) if frame or not slid else None
1566
+ if not slid and (answered or not performed) and verify_button:
2770
1567
  _log(f"clicking Verify to submit ({puzzle_source}).")
2771
1568
  self._move_and_click(page, verify_button)
2772
- # The press IS an interaction, and saying so is load-bearing:
2773
- # the caller aborts a round that reports none, so submitting a
2774
- # `done` answer and then returning False re-arms the very guard
2775
- # this satisfies — the puzzle is sent and the solve gives up on
2776
- # it one line later, which is what `prosopo_grid_3x3` did.
2777
- performed_action = True
2778
- # Snapshot at submit time so the NEXT attempt waits for the real
2779
- # transition before treating whatever is on screen as fresh.
1569
+ performed = True
2780
1570
  self._last_submit_frame_hash = self._element_frame_hash(element)
2781
1571
  finally:
2782
1572
  _unlink(shot)
2783
- # Only now: the wait gate re-reads the keyframe PNG on every poll, so
2784
- # removing the directory any earlier would break the click it is gating.
2785
- if keyframe_dir:
1573
+ held = self._animated_plan[1] if self._animated_plan else None
1574
+ if keyframe_dir and keyframe_dir != held:
2786
1575
  shutil.rmtree(keyframe_dir, ignore_errors=True)
1576
+ return performed, all_usage
2787
1577
 
2788
- return performed_action, all_usage
2789
-
2790
- # ------------------------------------------------------------------
2791
- # Public entry point
2792
- # ------------------------------------------------------------------
1578
+ # ── public entry point ───────────────────────────────────────────────
2793
1579
 
2794
1580
  def watch(self, page: Any, **options: Any) -> "CaptchaWatcher":
2795
- """
2796
- A watcher that solves captchas on `page` as they appear.
2797
-
2798
- Mirrors the TS `solver.watch(page)` in intent, but NOT in whether it
2799
- blocks: this returns an idle watcher and you choose how to drive it,
2800
- because a sync Playwright handle cannot be driven from a worker thread.
2801
-
2802
- solver.watch(page).run() # blocking; hold the page clean
2803
- w = solver.watch(page)
2804
- while my_loop():
2805
- w.poll_once() # cooperative; you own the cadence
2806
-
2807
- Options are `CaptchaWatcher`'s fields: `interval_ms`, `max_solves`,
2808
- `error_backoff_ms`, `on_solved`, `on_error`. Nothing is injected into
2809
- the page; see watcher.py.
2810
- """
1581
+ """A watcher over `page`; `.run()` blocks, `.poll_once()` is cooperative."""
2811
1582
  from .watcher import CaptchaWatcher
2812
1583
 
2813
1584
  return CaptchaWatcher(solver=self, page=page, **options)
2814
1585
 
2815
1586
  def solve(self, page: Any) -> SolveResult:
2816
- """
2817
- Solve whatever captcha is on `page`.
2818
-
2819
- Returns a `SolveResult`; raises `NoCaptchaFoundError`,
2820
- `UnsupportedChallengeError`, `AnimatedChallengeError`, or
2821
- `CaptchaSolveError` on the failure modes named in each type.
2822
- """
2823
- start = time.monotonic() * 1000.0
2824
- cumulative_usage: List[Dict[str, Any]] = []
1587
+ start = _now()
1588
+ usage: List[Dict[str, Any]] = []
2825
1589
  self._last_submit_frame_hash = None
2826
1590
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
2827
1591
  self._human.reset(page)
2828
1592
  self._reset_animated_state()
2829
1593
  self._budget = PhaseBudget()
2830
-
2831
- # Mint one session id for the WHOLE solve, exactly as the TS driver does
2832
- # per `solve()`. The planner turns it into `X-CK-Session`, which is what
2833
- # lets the hosted gateway group this captcha's 1..N inference rounds into
2834
- # a single billable attempt. Without it a multi-round dynamic 3x3 bills
2835
- # as several attempts instead of one, so this is a billing-correctness
2836
- # requirement, not telemetry.
2837
- #
2838
- # Restored rather than left set: a stale id leaking into the NEXT solve
2839
- # would merge two separate captchas into one attempt — the opposite
2840
- # error, and the one that under-bills.
1594
+ # One session id per solve groups its inference rounds into one billable attempt.
2841
1595
  previous_session = os.environ.get(_SESSION_ENV)
2842
- session_id = str(uuid.uuid4())
2843
- os.environ[_SESSION_ENV] = session_id
2844
- # The verdict this driver reports back, and it starts FALSE. A solve
2845
- # that raises never sets it, which is the right default: the widget did
2846
- # not accept, and the boards that made it raise are exactly the ones
2847
- # worth keeping. See planner.report_outcome.
2848
- solved_for_report = False
1596
+ session_id = os.environ[_SESSION_ENV] = str(uuid.uuid4())
1597
+ solved = False
2849
1598
  try:
2850
- result = self._solve_impl(page, start, cumulative_usage)
2851
- # Attached HERE rather than at each of the seven `return
2852
- # SolveResult(...)` sites, so a new early exit cannot forget it.
2853
- result.phases = dict(self._budget.totals)
2854
- result.phases["total"] = self._budget.elapsed_ms()
2855
- solved_for_report = bool(getattr(result, "is_solved", False))
1599
+ result = self._solve_impl(page, start, usage)
1600
+ result.phases = dict(self._budget.totals, total=self._budget.elapsed_ms())
1601
+ solved = bool(result.is_solved)
2856
1602
  return result
2857
1603
  finally:
2858
- # Printed on the way out of every solve, success or failure — a
2859
- # solve that FAILED is exactly the one whose time you want itemised.
2860
- if timings_enabled() and self._budget is not None:
1604
+ if timings_enabled():
2861
1605
  print(self._budget.report(), file=sys.stderr)
2862
1606
  self._deadline_ms = None
2863
- # BEFORE the env is restored, so the id reported is this solve's.
2864
- # Best-effort and never raised out of a `finally` — an exception
2865
- # here would replace the caller's real error, or their real result,
2866
- # with one about telemetry.
2867
1607
  try:
2868
- self._solver.planner.report_outcome(session_id, solved_for_report)
2869
- except Exception as exc: # noqa: BLE001
1608
+ # On by default: it is the only way a hosted account's failures can be found at all.
1609
+ self._solver.planner.report_outcome(session_id, solved)
1610
+ except Exception as exc:
2870
1611
  _log(f"[outcome] could not report: {exc}")
2871
1612
  if previous_session is None:
2872
1613
  os.environ.pop(_SESSION_ENV, None)
2873
1614
  else:
2874
1615
  os.environ[_SESSION_ENV] = previous_session
2875
1616
 
2876
- def _solve_impl(
2877
- self,
2878
- page: Any,
2879
- start: float,
2880
- cumulative_usage: List[Dict[str, Any]],
2881
- ) -> SolveResult:
1617
+ def _solve_impl(self, page: Any, start: float, usage: List[Dict[str, Any]]) -> SolveResult:
2882
1618
  cfg = self.config
2883
-
2884
- pending_retry_mode: Optional[str] = None
2885
- already_retried_underselect = False
2886
- unsupported_retries = 0
2887
- stale_element_retries = 0
1619
+ pending_retry_mode: Optional[RetryMode] = None
1620
+ retried_underselect = False
1621
+ unsupported_retries = stale_retries = render_waits = 0
2888
1622
  has_interacted = False
2889
- render_waits = 0
2890
- # Strictly FEWER than the solve loops. A render wait consumes an
2891
- # attempt, so at parity the loop runs out first and the branch below
2892
- # never fires — "no interactive captcha widget", which is the correct
2893
- # and benign answer for a reCAPTCHA v3 / invisible page, is reported
2894
- # instead as "still detected after N solve loops". That is a hard error
2895
- # for a caller who catches NoCaptchaFoundError to mean "nothing here",
2896
- # and it appeared the moment max_solve_loops came down from 10 to 6.
1623
+ # Strictly fewer than the loops, else the no-widget branch for invisible reCAPTCHA never fires.
2897
1624
  max_render_waits = min(6, cfg.max_solve_loops - 1)
2898
1625
 
1626
+ def done() -> SolveResult:
1627
+ return SolveResult(True, self._last_mouse, _aggregate(usage))
1628
+
2899
1629
  for attempt in range(1, cfg.max_solve_loops + 1):
2900
- # Round 1 did not finish the challenge. Before spending another
2901
- # still inference on it, let this round's settle check probe whether
2902
- # the widget is animated — the settle rule alone cannot tell, and
2903
- # answering an animated puzzle from one still is how a solve burns
2904
- # all ten loops without ever being able to succeed.
2905
1630
  if attempt >= 2:
2906
1631
  self._arm_animated_probe()
2907
- if (time.monotonic() * 1000.0) - start > cfg.overall_solve_timeout_ms:
1632
+ if _now() - start > cfg.overall_solve_timeout_ms:
2908
1633
  raise CaptchaSolveError(
2909
- f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms "
2910
- f"(attempt {attempt}/{cfg.max_solve_loops})"
2911
- )
2912
-
2913
- # Ask the DOM whether we are DONE before asking what to solve next.
2914
- # Only once we have interacted: before that a populated token is the
2915
- # "already satisfied" case handled below, and this ordering would
2916
- # skip the render-wait a fresh widget needs.
2917
- #
2918
- # `detect_captcha` is the expensive call — it settles pixels and
2919
- # screenshots the element — and after the winning submit it returns
2920
- # the challenge frame hCaptcha is TEARING DOWN. Solving that frame
2921
- # cannot succeed; it just runs until the handle goes stale, which
2922
- # measured 19-33s of dead time at the end of every run while the
2923
- # answer had already been accepted. `is_captcha_solved` is a couple
2924
- # of cheap DOM reads and is authoritative, so it goes first.
1634
+ f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms (attempt {attempt}/{cfg.max_solve_loops})")
2925
1635
  if has_interacted and self.is_captcha_solved(page):
2926
1636
  _log("captcha reports solved; finishing.")
2927
- return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
2928
-
2929
- with self._phase("detect"):
2930
- element = self.detect_captcha(page)
2931
- if not element:
2932
- # Two-stage. A null detection splits into two very different
2933
- # cases and treating them alike is how you either hang on a
2934
- # v3 page or give up on a slow-rendering widget.
1637
+ return done()
1638
+
1639
+ with self._phase(Phase.DETECT):
1640
+ widget = self.detect_captcha(page)
1641
+ if not widget:
2935
1642
  if has_interacted:
2936
1643
  _log("no supported captcha remains after interaction; considering solved.")
2937
- return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
2938
- # Already satisfied before we touched anything — the widget is
2939
- # present but the vendor has passed it (anchor checked / token
2940
- # populated). Common with a good stealth browser: camoufox often
2941
- # clears reCAPTCHA on the checkbox alone, with no challenge ever
2942
- # shown. Without this, the render-wait branch below sits for
2943
- # ~6s and then raises "no interactive captcha widget", turning
2944
- # the BEST outcome into an exception the caller has to catch.
1644
+ return done()
2945
1645
  if self.is_captcha_solved(page):
2946
1646
  _log("captcha already satisfied; nothing to solve.")
2947
- return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
1647
+ return done()
2948
1648
  if self.has_interactive_widget_in_dom(page) and render_waits < max_render_waits:
2949
1649
  render_waits += 1
2950
- _log(
2951
- f"widget in DOM but not yet rendered; waiting "
2952
- f"({render_waits}/{max_render_waits})."
2953
- )
1650
+ _log(f"widget in DOM but not yet rendered; waiting ({render_waits}/{max_render_waits}).")
2954
1651
  _delay(800 + random.random() * 300)
2955
1652
  continue
2956
1653
  raise NoCaptchaFoundError(self._no_widget_message(page))
2957
1654
 
2958
1655
  _log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
2959
- retry_mode_this_loop = pending_retry_mode
2960
- pending_retry_mode = None
2961
-
1656
+ retry_mode, pending_retry_mode = pending_retry_mode, None
2962
1657
  try:
2963
- did_interact, usage = self._solve_single(page, element, retry_mode_this_loop)
1658
+ did_interact, round_usage = self._solve_single(page, widget, retry_mode)
2964
1659
  except AnimatedChallengeError:
2965
1660
  raise
2966
1661
  except UnsupportedCaptchaError as unsupported:
2967
- # A settled frame the model cannot solve is normally definitive.
2968
- # BUT mid-solve, a transitional blank frame produces the same
2969
- # verdict — that was the "solves round 1, dies on round 2" bug.
1662
+ # Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
2970
1663
  if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
2971
1664
  unsupported_retries += 1
2972
- current = self.detect_captcha(page)
2973
- with self._phase("settle"):
2974
- _settled = self._wait_for_element_settled(current)
2975
- if current and _settled == "animated":
2976
- # Used to be terminal. Now it just means the next round is an
2977
- # animated puzzle: retry the loop and `_solve_single` takes the
2978
- # recording path. `unsupported_retries` still bounds it, so a
2979
- # widget that is animated AND unsolvable cannot spin here.
2980
- if not cfg.video_solve_enabled:
2981
- raise AnimatedChallengeError(
2982
- "the challenge never settles and video_solve_enabled is off"
2983
- )
2984
- _log('"unsupported" mid-solve and the next round is animated; '
2985
- "retrying into the recording path.")
2986
- continue
2987
- _log(
2988
- f'"unsupported" mid-solve; settled and retrying '
2989
- f"({unsupported_retries}/{cfg.max_unsupported_resolves})."
2990
- )
1665
+ again = self.detect_captcha(page)
1666
+ with self._phase(Phase.SETTLE):
1667
+ settled = again and self._wait_for_element_settled(again.element)
1668
+ if settled == SettleVerdict.ANIMATED and not cfg.video_solve_enabled:
1669
+ raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
1670
+ _log(f'"unsupported" mid-solve; retrying ({unsupported_retries}/{cfg.max_unsupported_resolves}).')
2991
1671
  continue
2992
- # The solver's OWN message, not a guess about what it saw.
2993
- # This used to substitute "likely an hCaptcha click/drag
2994
- # puzzle" for every unsupported verdict, including the ones
2995
- # that already said exactly what was wrong — "prompt
2996
- # generation 1 has no distorted-text prompt", say, which names
2997
- # both the cause and the fix. Reporting a wrong guess in place
2998
- # of a right answer costs whoever reads the gate an
2999
- # investigation, every time.
3000
- raise UnsupportedChallengeError(
3001
- f"cannot solve this kind of captcha — {unsupported}"
3002
- ) from unsupported
1672
+ raise UnsupportedChallengeError(f"cannot solve this kind of captcha — {unsupported}") from unsupported
3003
1673
  except Exception as exc:
3004
- # A stale/detached handle after a submit is a TRANSITION, not a
3005
- # dead puzzle: hCaptcha swapped in the next round while we held
3006
- # the old iframe. Only after interacting — a first-frame failure
3007
- # is a genuine problem worth surfacing.
3008
1674
  message = str(exc)
3009
1675
  closed = bool(_CLOSED_TARGET_RE.search(message))
3010
1676
  if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
3011
- # ASK THE VENDOR FIRST. The handle most often went stale
3012
- # BECAUSE the answer was accepted: reCAPTCHA tears the
3013
- # challenge iframe down the instant it takes an answer, and
3014
- # hCaptcha swaps the frame out. Re-entering the pipeline
3015
- # here spends a whole round — detect, screenshot, infer — on
3016
- # a puzzle that no longer exists, and races the teardown.
3017
- # Headless usually won that race; headed usually lost it and
3018
- # surfaced `TargetClosedError` from whatever ran next.
3019
- #
3020
- # The top-of-loop check is not enough on its own: it runs
3021
- # only after the backoff, and it does DOM reads that throw
3022
- # on a target that is already gone.
1677
+ # The handle most often went stale because the answer was accepted.
3023
1678
  try:
3024
1679
  if self.is_captcha_solved(page):
3025
1680
  _log("captcha reports solved; finishing.")
3026
- return SolveResult(
3027
- True, self._last_mouse, _aggregate(cumulative_usage)
3028
- )
3029
- except Exception: # noqa: BLE001
3030
- # Can't consult the vendor — the page is gone. Fall
3031
- # through; `closed` decides between a named error and a
3032
- # retry, and neither wants this exception in its place.
1681
+ return done()
1682
+ except Exception:
3033
1683
  pass
3034
-
3035
1684
  if closed:
3036
1685
  raise PageClosedError(
3037
- "the page, context or browser closed mid-solve, after the "
3038
- "answer had been submitted but before the vendor's verdict "
3039
- "could be read — the solve may in fact have succeeded"
3040
- ) from exc
3041
-
3042
- if stale_element_retries < cfg.max_stale_element_retries:
3043
- stale_element_retries += 1
3044
- _log(
3045
- f"stale challenge handle after submit; re-detecting next round "
3046
- f"({stale_element_retries}/{cfg.max_stale_element_retries})."
3047
- )
1686
+ "the page, context or browser closed mid-solve, after the answer had been "
1687
+ "submitted but before the vendor's verdict could be read — the solve may in "
1688
+ "fact have succeeded") from exc
1689
+ if stale_retries < cfg.max_stale_element_retries:
1690
+ stale_retries += 1
1691
+ _log(f"stale challenge handle after submit; re-detecting ({stale_retries}/{cfg.max_stale_element_retries}).")
3048
1692
  _delay(cfg.stale_element_backoff_ms)
3049
1693
  continue
3050
1694
  raise
3051
1695
 
3052
1696
  has_interacted = has_interacted or did_interact
3053
-
3054
- # Give up on a solve that is repeating itself, rather than letting
3055
- # the clock do it. The alternative is not "one more chance" — it is
3056
- # the same click, again, until `overall_solve_timeout_ms`, which is
3057
- # how a hopeless captcha came to cost 66s and how half of Tier 3's
3058
- # wall-clock was being spent on attempts that could not succeed.
3059
1697
  if self._no_progress_rounds >= cfg.max_no_progress_rounds:
3060
1698
  raise CaptchaSolveError(
3061
- f"no progress: the model returned the same answer "
3062
- f"{self._no_progress_rounds + 1} times running and the challenge "
3063
- f"is still up (attempt {attempt}/{cfg.max_solve_loops})"
3064
- )
1699
+ f"no progress: the model returned the same answer {self._no_progress_rounds + 1} times "
1700
+ f"running and the challenge is still up (attempt {attempt}/{cfg.max_solve_loops})")
3065
1701
  render_waits = 0
3066
- cumulative_usage.extend(usage)
3067
-
3068
- # ONE wait after a round, polled, whichever kind of round it was.
3069
- #
3070
- # There used to be two: this poll when the round interacted, and a
3071
- # flat `_delay(post_solve_delay_ms + jitter)` when it did not. The
3072
- # flat sleep observed NOTHING — it ran to the end and only then
3073
- # asked whether the widget was still there — so a round that had in
3074
- # fact finished the captcha paid 1200-1500ms to find that out. The
3075
- # poll below already asks exactly that question and answers it the
3076
- # moment it becomes true.
3077
- #
3078
- # The two windows keep different LENGTHS, because different things
3079
- # size them: `post_solve_outcome_timeout_ms` covers how late a
3080
- # vendor's success signal can arrive (measured — see the constant),
3081
- # while `post_solve_delay_ms` is the dwell a round that answered
3082
- # nothing takes before deciding the page has settled.
1702
+ usage.extend(round_usage)
1703
+
1704
+ # One polled wait per round: the vendor's verdict, the widget going away, or a fresh board. The flat
1705
+ # sleep it replaced observed nothing and cost 1200-1500ms per finished round.
3083
1706
  window_ms = (cfg.post_solve_outcome_timeout_ms if did_interact
3084
1707
  else cfg.post_solve_delay_ms + random.random() * 300)
3085
- # Poll for the vendor's SOLVED signal before re-entering the
3086
- # pipeline. hCaptcha keeps the challenge visible for a couple of
3087
- # seconds while verifying; without this the loop re-solves that
3088
- # closing frame and burns ~18s. Only ever early-RETURNS on a
3089
- # definitive signal, so it cannot loop.
3090
- deadline = time.monotonic() * 1000.0 + window_ms
1708
+ deadline = _now() + window_ms
1709
+ t0 = time.perf_counter()
3091
1710
  solved = False
3092
1711
  widget_gone = 0
3093
- _verdict_t0 = time.perf_counter()
3094
- while time.monotonic() * 1000.0 < deadline:
1712
+ while _now() < deadline:
3095
1713
  if self.is_captcha_solved(page):
3096
1714
  solved = True
3097
1715
  break
3098
- # The eight inline vendors have no response token, so
3099
- # `is_captcha_solved` — which reads only the hCaptcha and
3100
- # reCAPTCHA anchors — can never fire for them and this loop
3101
- # ran out its whole 2.5s budget on EVERY round, waiting for
3102
- # a signal that cannot arrive. Measured on geetest_v4_slide:
3103
- # 5.2s of a 12.3s solve, spent after the puzzle was already
3104
- # answered, with the widget sitting there visibly solved.
3105
- #
3106
- # "The widget is gone" is the completion signal for those
3107
- # vendors and is already the authority immediately after
3108
- # this loop, so this only reaches the same verdict sooner —
3109
- # confirmed over two polls so a frame caught mid-swap
3110
- # between rounds cannot read as a solve.
3111
- if self.detect_captcha(page) is None:
3112
- widget_gone += 1
3113
- if widget_gone >= 2:
3114
- solved = True
3115
- break
3116
- else:
3117
- widget_gone = 0
1716
+ widget_gone = widget_gone + 1 if self.detect_captcha(page) is None else 0
1717
+ if widget_gone >= 2:
1718
+ solved = True
1719
+ break
3118
1720
  if self._is_challenge_freshly_rendered(page):
3119
- break # next round is up; go solve it now
1721
+ self._fresh_board()
1722
+ break
3120
1723
  _delay(cfg.post_solve_outcome_poll_ms)
3121
- _verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
1724
+ verdict_ms = (time.perf_counter() - t0) * 1000.0
3122
1725
  if self._budget is not None:
3123
- self._budget.add(
3124
- "await-verdict" if did_interact else "post-submit-delay",
3125
- _verdict_ms)
3126
- # How long a SUCCESS actually took to show itself. This is the
3127
- # only number that can size `post_solve_outcome_timeout_ms`: the
3128
- # window exists to catch a late success, so it needs to cover
3129
- # the slowest real one and nothing beyond it. A round that ends
3130
- # any other way spends the whole window by construction — there
3131
- # is no signal on a wrong answer — so its duration says nothing
3132
- # about how long the window ought to be.
1726
+ self._budget.add(Phase.AWAIT_VERDICT if did_interact else Phase.POST_SUBMIT_DELAY, verdict_ms)
3133
1727
  if solved:
3134
- _log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
3135
- return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
1728
+ _log(f"[verdict] success signal arrived after {verdict_ms:.0f}ms")
1729
+ return done()
3136
1730
 
3137
- if self._has_recaptcha_underselect_error(page):
3138
- if already_retried_underselect:
1731
+ if self._banner_is_fatal_after_retry(self._banner_kind(page)):
1732
+ if retried_underselect:
3139
1733
  raise CaptchaSolveError(
3140
- "reCAPTCHA still showing the under-selection error after retry; "
3141
- "aborting (model unable to identify the missed tile)"
3142
- )
1734
+ "reCAPTCHA still showing the under-selection error after retry; aborting "
1735
+ "(model unable to identify the missed tile)")
3143
1736
  _log("reCAPTCHA under-selection error; retrying with missed-tiles prompt.")
3144
- pending_retry_mode = "missed-tiles"
3145
- already_retried_underselect = True
1737
+ pending_retry_mode, retried_underselect = RetryMode.MISSED_TILES, True
3146
1738
 
3147
1739
  if not self.detect_captcha(page):
3148
- return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
3149
-
3150
- if not did_interact:
1740
+ return done()
1741
+ if not did_interact and not self._no_progress_rounds:
3151
1742
  raise CaptchaSolveError(
3152
- "captcha still detected but the solver performed no interactions; "
3153
- "aborting to avoid an infinite loop"
3154
- )
1743
+ "captcha still detected but the solver performed no interactions; aborting to avoid an infinite loop")
3155
1744
 
3156
- raise CaptchaSolveError(
3157
- f"captcha still detected after {cfg.max_solve_loops} solve loops"
3158
- )
3159
-
3160
-
3161
- # --------------------------------------------------------------------------
3162
- # Helpers
3163
- # --------------------------------------------------------------------------
1745
+ raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops")
3164
1746
 
3165
1747
 
3166
1748
  def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]:
3167
- """Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the keyframe
3168
- wait gate compares around. The solver builds these boxes as a small square
3169
- around the model's point, so the centre recovers that point exactly."""
3170
1749
  x1, y1, x2, y2 = (float(v) for v in bbox)
3171
- return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
1750
+ return (x1 + x2) / 2.0, (y1 + y2) / 2.0
3172
1751
 
3173
1752
 
3174
1753
  def _round_pts(value: Any, places: int = 3) -> Any:
3175
- """Coordinates rounded, for comparing two answers for sameness.
3176
-
3177
- Rounded rather than compared exactly because the same tile chosen twice can
3178
- differ in the last float digit after the normalise/clamp round-trip, and a
3179
- repeat that reads as "different" is a repeat that costs a round.
3180
- """
1754
+ # Float last-digit drift would make a repeated answer look new and cost a round.
3181
1755
  if isinstance(value, (int, float)):
3182
1756
  return round(float(value), places)
3183
1757
  if isinstance(value, (list, tuple)):
@@ -3186,7 +1760,6 @@ def _round_pts(value: Any, places: int = 3) -> Any:
3186
1760
 
3187
1761
 
3188
1762
  def _as_dict(action: Any) -> Dict[str, Any]:
3189
- """Actions arrive as pydantic models in-process, or dicts from JSON."""
3190
1763
  if isinstance(action, dict):
3191
1764
  return action
3192
1765
  for method in ("model_dump", "dict"):
@@ -3199,7 +1772,6 @@ def _as_dict(action: Any) -> Dict[str, Any]:
3199
1772
 
3200
1773
 
3201
1774
  def _aggregate(usage: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
3202
- """Mirror of `aggregateTokenUsage`: one summed row, plus the raw rounds."""
3203
1775
  if not usage:
3204
1776
  return []
3205
1777
  total = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
@@ -3210,30 +1782,17 @@ def _aggregate(usage: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
3210
1782
 
3211
1783
 
3212
1784
  def _read_png_dimensions(path: str) -> Optional[Tuple[int, int]]:
3213
- """
3214
- Width/height from the IHDR chunk, so no image-size dependency is needed.
3215
- PNG signature is 8 bytes, IHDR length+type another 8, then two big-endian
3216
- uint32s at offsets 16 and 20.
3217
- """
1785
+ """Width and height from the IHDR chunk, so no image-size dependency is needed."""
3218
1786
  try:
3219
1787
  with open(path, "rb") as handle:
3220
1788
  header = handle.read(24)
3221
1789
  if len(header) < 24 or header[1:4] != b"PNG":
3222
1790
  return None
3223
- width = int.from_bytes(header[16:20], "big")
3224
- height = int.from_bytes(header[20:24], "big")
1791
+ width, height = int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big")
3225
1792
  return (width, height) if width and height else None
3226
1793
  except OSError:
3227
1794
  return None
3228
1795
 
3229
1796
 
3230
1797
  def solve_captcha_on_page(page: Any, **kwargs: Any) -> SolveResult:
3231
- """
3232
- One-shot convenience wrapper mirroring the TS `new CaptchaKrakenSolver().solve(page)`.
3233
-
3234
- Synchronous only. The async Playwright API needs a parallel implementation
3235
- (`await` at every call site) rather than a wrapper — sync Playwright handles
3236
- cannot be driven from inside an event loop, so `asyncio.to_thread` would not
3237
- save this. Async support is not yet written.
3238
- """
3239
1798
  return PageSolver(**kwargs).solve(page)