captchakraken 2.4.0 → 2.6.1

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 (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +60 -13
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +217 -12
  19. package/dist/solver.js +1332 -172
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +158 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +12 -7
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +58 -13
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +24 -6
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1559 -105
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. package/python/src/captchakraken/planner_types.py +0 -74
@@ -41,14 +41,18 @@ import hashlib
41
41
  import os
42
42
  import random
43
43
  import re
44
+ import shutil
45
+ import sys
44
46
  import tempfile
45
47
  import time
46
48
  import uuid
49
+ from contextlib import contextmanager
47
50
  from dataclasses import dataclass, field
48
51
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
49
52
 
50
53
  from .action_types import CaptchaAction
51
54
  from .solver import CaptchaSolver, UnsupportedCaptchaError
55
+ from .timing import PhaseBudget, timings_enabled
52
56
  from .trajectory import generate_trajectory
53
57
 
54
58
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
@@ -104,7 +108,14 @@ class CaptchaSolveError(Exception):
104
108
 
105
109
 
106
110
  class AnimatedChallengeError(CaptchaSolveError):
107
- """The challenge never stops moving — a video/animated puzzle we can't read."""
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
+ """
108
119
 
109
120
 
110
121
  class UnsupportedChallengeError(CaptchaSolveError):
@@ -115,10 +126,32 @@ class NoCaptchaFoundError(CaptchaSolveError):
115
126
  """No interactive widget — reCAPTCHA v3/invisible, or a click-triggered challenge."""
116
127
 
117
128
 
129
+ class PageClosedError(CaptchaSolveError):
130
+ """The page, context or browser went away mid-solve.
131
+
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
+
139
+
118
140
  # Playwright surfaces these as messages, not types, so both drivers match on
119
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.
120
146
  _STALE_HANDLE_RE = re.compile(
121
- r"Timeout .*exceeded|not visible|not attached|detached|Target closed",
147
+ r"Timeout .*exceeded|not visible|not attached|detached",
148
+ re.IGNORECASE,
149
+ )
150
+
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",
122
155
  re.IGNORECASE,
123
156
  )
124
157
 
@@ -144,6 +177,31 @@ class _GridSession:
144
177
  screenshot_h: int
145
178
 
146
179
 
180
+ def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int) -> str:
181
+ """The pixel-settle rule, over `(elapsed_ms, moved)` polls.
182
+
183
+ Split out of the polling loop so the RULE can be tested against recorded
184
+ timelines without a browser — see `tests/test_animated_is_detected.py`,
185
+ which pins what it does to each shipped animated puzzle.
186
+
187
+ Note what it cannot do: 'animated' needs a poll that MOVED at or after
188
+ `animated_after_ms`, so any `settle_frames` run of stillness before then
189
+ ends the wait as 'settled' first. Every animated puzzle we ship rests
190
+ between screens for longer than that, which is why the caller also probes.
191
+ """
192
+ still_streak = 0
193
+ for elapsed_ms, moved in samples:
194
+ if moved:
195
+ still_streak = 0
196
+ if elapsed_ms >= animated_after_ms:
197
+ return "animated"
198
+ else:
199
+ still_streak += 1
200
+ if still_streak >= settle_frames:
201
+ return "settled"
202
+ return "timeout"
203
+
204
+
147
205
  @dataclass
148
206
  class PageSolverConfig:
149
207
  """
@@ -151,10 +209,65 @@ class PageSolverConfig:
151
209
  value tuned on one driver can be found on the other.
152
210
  """
153
211
 
154
- max_solve_loops: int = 10
155
- overall_solve_timeout_ms: int = 120_000
212
+ # 45s, and the loop count that fits inside it rather than one that needs
213
+ # policing by the clock. A round costs ~4-7s once the waits below are paid,
214
+ # so six rounds is the budget; ten never fitted and only ever expressed
215
+ # itself as a timeout. The cap is a BACKSTOP — `max_no_progress_rounds` is
216
+ # what is supposed to end a hopeless solve, and a solve that reaches this
217
+ # number is a bug report, not a normal outcome.
218
+ max_solve_loops: int = 6
219
+ overall_solve_timeout_ms: int = 45_000
220
+ #: Consecutive rounds producing the SAME answer before the solve is
221
+ #: abandoned.
222
+ #:
223
+ #: At temperature 0 the model is a function of the picture, so an identical
224
+ #: answer means an identical picture — and every answer this driver produces
225
+ #: is EXECUTED, so the previous one already ran and moved nothing. Repeating
226
+ #: it cannot do better; it just spends a round.
227
+ #:
228
+ #: Measured on recaptcha_grid_4x4 (fixture seed 20260730): the model answers
229
+ #: `[2,6,7,9,10]` on round 1 and then `[2,6,7,10]` on rounds 2 THROUGH 10 —
230
+ #: nine identical click sets, each one clicked, each one rejected, ending in
231
+ #: "still detected after 10 solve loops" at 66.1s. Of that, 39.0s was pure
232
+ #: waiting. Stopping on the second repeat ends it at round 4.
233
+ #:
234
+ #: 2 rather than 1: the first repeat also ARMS the animated probe, which is
235
+ #: the one recovery worth trying (a cycling board reads as a still and
236
+ #: answers the same way every round). One more round buys that chance.
237
+ max_no_progress_rounds: int = 2
156
238
  post_solve_delay_ms: int = 1_200
157
- post_solve_outcome_timeout_ms: int = 2_500
239
+ # How long to keep watching for a SUCCESS after submitting.
240
+ #
241
+ # That is the window's whole job, and it is why a wrong answer spends all of
242
+ # it: the vendors emit nothing on a wrong answer, they just re-deal. Sizing
243
+ # it therefore means asking how late a real success can arrive.
244
+ #
245
+ # MEASURED over 34 successful rounds across 20 puzzle types (fixture server,
246
+ # two seeds each): min 3ms, p50 360ms, p90 378ms, max 528ms.
247
+ #
248
+ # Read that with care — those values are quantized by `..._poll_ms`, not by
249
+ # any vendor. The clusters sit at one poll (~200ms), two polls (~370ms, the
250
+ # `widget_gone >= 2` confirmation) and three (~520ms). So the measurement
251
+ # mostly describes THIS loop, and the fixture's verdict is a local fetch,
252
+ # which means real hCaptcha/reCAPTCHA round-trips are absent from it
253
+ # entirely.
254
+ #
255
+ # 1000ms is therefore the measured worst case (528ms, ~264ms once the poll
256
+ # below is halved) plus deliberate headroom for a vendor round-trip nobody
257
+ # has measured yet. Down from 2500ms, which was spent in full on every
258
+ # unsuccessful round — 25.5s of one 66.1s recaptcha_grid_4x4 solve.
259
+ #
260
+ # To replace the headroom with a number: drive the real vendor pages via
261
+ # `tests/live-solve/src/demo-targets.ts` and re-read this distribution.
262
+ post_solve_outcome_timeout_ms: int = 1_000
263
+ #: Poll interval inside that window.
264
+ #:
265
+ #: The success signals are polled, so detection latency is a MULTIPLE of
266
+ #: this — and the one that matters most, "the widget is gone", needs two
267
+ #: consecutive polls to confirm, so it cannot be seen in less than 2x. At
268
+ #: 150ms that put a 300ms floor under every inline vendor's success. 75ms
269
+ #: halves it, and the poll is cheap: `detect_captcha` measures ~3ms.
270
+ post_solve_outcome_poll_ms: int = 75
158
271
  element_screenshot_timeout_ms: int = 8_000
159
272
  max_unsupported_resolves: int = 3
160
273
  max_stale_element_retries: int = 3
@@ -173,7 +286,77 @@ class PageSolverConfig:
173
286
  settle_diff_threshold: float = 0.01
174
287
  post_submit_change_timeout_ms: int = 4_000
175
288
 
289
+ # ── Animated challenges ────────────────────────────────────────────────
290
+ # A challenge that never settles is RECORDED and solved from keyframes rather
291
+ # than abandoned. Off switch for callers who would rather fail fast than spend
292
+ # the recording time.
293
+ video_solve_enabled: bool = True
294
+ # Whether a repeated answer may escalate to a RECORDING of the widget.
295
+ #
296
+ # A board that cycles reads as static to `_wait_for_element_settled` (it
297
+ # rests between screens for far longer than the 440ms that rule stops at),
298
+ # gets answered from whichever screen the screenshot caught, and then
299
+ # answers identically every round. That repeat is the signal, and the
300
+ # response is to record a burst and let the SLICER decide: a widget that
301
+ # really is static slices to one keyframe and is solved as the still it is,
302
+ # so the escalation costs one burst and can never produce a worse answer.
303
+ #
304
+ # Off restores the old behaviour for a caller who would rather re-read a
305
+ # still than spend `video_burst_duration_ms` finding out.
306
+ animated_probe_enabled: bool = True
307
+ # Burst geometry. Deliberately identical to the collector's
308
+ # (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the finetune repo), so a
309
+ # challenge recorded here is the same shape of artifact the model trained on —
310
+ # same clip length, same frame rate, therefore the same keyframe slicing.
311
+ video_burst_duration_ms: int = 4_000
312
+ video_burst_fps: int = 10
313
+ # How long to wait for the widget to return to the keyframe the model chose,
314
+ # before clicking anyway. Bounded because the alternative is worse: these
315
+ # puzzles cycle, so the state DOES come back — but if the recording caught a
316
+ # one-off transition it never will, and a click on the model's coordinates is
317
+ # still a better use of the remaining budget than a timeout.
318
+ keyframe_wait_timeout_ms: int = 6_000
319
+ keyframe_wait_poll_ms: int = 120
320
+ #: Extra wall clock granted ONCE, the first time a solve escalates to a
321
+ #: recording. NOT a looser `overall_solve_timeout_ms`.
322
+ #:
323
+ #: That budget is sized for ROUNDS — "a round costs ~4-7s, so six is the
324
+ #: budget". The recording path is not a round. It is a fixed extra stage
325
+ #: costing the burst, the slice, one MULTI-IMAGE inference (six keyframes,
326
+ #: several times a still's) and the wait for the widget to come back round
327
+ #: to the frame the model chose. Nothing in the 45s was ever set aside for
328
+ #: it, so an escalation started at ~35s ran the clock out mid-burst and
329
+ #: reported a TIMEOUT — which reads as a slow model rather than as a budget
330
+ #: with no room for the thing the solver had just decided to do.
331
+ #:
332
+ #: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on
333
+ #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
334
+ #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
335
+ #: recording the animated challenge", at 45.7-52.7s elapsed. The same three
336
+ #: fixtures solve in 11-20s on the rounds where the still path happens to
337
+ #: answer them, so it is the escalation that does not fit, not the puzzle.
338
+ #:
339
+ #: Granted only when `video_solve_enabled` — a caller who wants a hard
340
+ #: deadline turns recording off, which is the switch that already means
341
+ #: "fail fast rather than spend the recording time".
342
+ video_extra_inference_ms: int = 8_000
343
+
344
+ def video_budget_ms(self) -> int:
345
+ """What one escalation to recording is allowed to cost, on top.
346
+
347
+ DERIVED, so a longer burst or a longer keyframe wait carries its own
348
+ budget instead of quietly reintroducing the timeout this exists to fix.
349
+ """
350
+ return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms
351
+ + self.video_extra_inference_ms)
352
+
176
353
  # Grid load / dynamic-refresh timing.
354
+ # How long to wait for hCaptcha's task images to paint before screenshotting
355
+ # anyway. Best-effort by design — the screenshot happens either way — so it
356
+ # is bounded by what a loading tile plausibly costs, not by a generous
357
+ # default. 8s was two-thirds of what a whole solve is now allowed to take.
358
+ hcaptcha_images_timeout_ms: int = 3_000
359
+
177
360
  grid_load_poll_interval_ms: int = 250
178
361
  grid_load_timeout_ms: int = 8_000
179
362
  recaptcha_max_dynamic_rounds: int = 8
@@ -182,6 +365,125 @@ class PageSolverConfig:
182
365
  recaptcha_dynamic_fade_wait_ms: int = 6_000
183
366
  recaptcha_tile_hover_enabled: bool = True
184
367
 
368
+ # ── puzzle-piece sliders (see _execute_slide) ──────────────────────────
369
+ # How far to nudge the handle, in px, to learn the piece's width and how
370
+ # fast it follows. Two probes because two unknowns; far enough apart that
371
+ # the difference between the two widths is signal rather than rounding, and
372
+ # both small enough to stay on the shortest track observed (~250px).
373
+ slide_probe_offsets_px: Tuple[float, ...] = (24.0, 64.0)
374
+ # Stop steering once the piece is this close. Tighter than any vendor's
375
+ # accept window, so the limit on solving is the model's slot estimate.
376
+ slide_tolerance_px: float = 2.0
377
+ # Corrections after the probes. Each costs a screenshot with the mouse held
378
+ # down; two is enough for a linear system, and the third would only be
379
+ # chasing a measurement that is not going to converge.
380
+ slide_max_corrections: int = 2
381
+
382
+
383
+ # Vendors with no checkbox/challenge split — one container, one interactive
384
+ # surface. Checked in detect_captcha() after the five hard-coded reCAPTCHA /
385
+ # hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
386
+ # lifted from src/captchaCollection/sources.py, which already drives these 8
387
+ # vendors nightly in the collector. Mirror of VENDOR_WIDGET_LOCATORS in
388
+ # solver.ts — keep both in the same order with the same selectors.
389
+ VENDOR_WIDGET_LOCATORS = [
390
+ {"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
391
+ {"puzzle_source": "tencent", "selectors": ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
392
+ {"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
393
+ {"puzzle_source": "yandex", "selectors": [".CheckboxCaptcha"]},
394
+ {"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
395
+ {"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
396
+ {"puzzle_source": "mtcaptcha", "selectors": [".mtcap"]},
397
+ {"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
398
+ ]
399
+
400
+
401
+ # ── where the answer goes, when it is not a click ───────────────────────────
402
+ #
403
+ # Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
404
+ # first visible match. That order is the whole design: a named vendor selector
405
+ # is unambiguous, while the generic patterns are guesses that happen to be right
406
+ # most of the time. Trying the guess first would, on a page that hosts a captcha
407
+ # *and* a login form, type the captcha's answer into the username box.
408
+ #
409
+ # The generic tail is not a nicety either — it is what actually fires on most
410
+ # pages. Vendors rename these classes without notice (they are anti-bot
411
+ # surfaces, so churn is the point), and our own Tier 3 fixtures render neither
412
+ # vendor's DOM. Anything that only worked via the vendor list would be a feature
413
+ # that passes review and fails in the field.
414
+
415
+ # A distorted-text captcha's answer box. The three vendor entries are the three
416
+ # types in instructions.py::TEXT_TYPES.
417
+ TEXT_INPUT_SELECTORS = [
418
+ # BotDetect — the input is application-defined, so match the id fragment its
419
+ # own docs and samples use. These three are what the nightly collector
420
+ # already drives (src/captchaCollection/sources.py).
421
+ "input[id*=captchaCode]",
422
+ "input#captchaCode",
423
+ "input[id*=validateCaptcha]",
424
+ ".BDC_CaptchaDiv input[type=text]",
425
+ # MTCaptcha
426
+ "input.mtcap-inputtext",
427
+ ".mtcap input[type=text]",
428
+ # Yandex SmartCaptcha
429
+ ".AdvancedCaptcha-Input input",
430
+ "input.Textinput-Control",
431
+ 'input[name="rep"]',
432
+ # Generic — an input the page itself labels as the captcha answer.
433
+ 'input[name*="captcha" i]',
434
+ 'input[id*="captcha" i]',
435
+ 'input[aria-label*="captcha" i]',
436
+ 'input[placeholder*="code" i]',
437
+ 'input[autocomplete="off"][type=text]',
438
+ # Last resort: the only text box in the widget. Scoped to the challenge
439
+ # frame/container by the caller, never to the whole page — see _find_control.
440
+ "input[type=text]",
441
+ "input:not([type])",
442
+ "input[type=tel]",
443
+ "textarea",
444
+ ]
445
+
446
+ # The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
447
+ # these vendors the piece is inert decoration that the handle carries, so a
448
+ # drag starting on the piece moves nothing at all.
449
+ SLIDER_HANDLE_SELECTORS = [
450
+ # GeeTest v3 / v4
451
+ ".geetest_slider_button",
452
+ ".geetest_btn",
453
+ ".geetest_slider .geetest_arrow",
454
+ # Tencent
455
+ "#tcaptcha_drag_thumb",
456
+ ".tc-slider-normal",
457
+ "[id*=slideBlock]",
458
+ # Yidun (NetEase)
459
+ ".yidun_slider",
460
+ ".yidun_jigsaw",
461
+ # Lemin
462
+ ".lemin-slider-handle",
463
+ "#lemin-cropped-captcha .slider",
464
+ # Generic — an ARIA slider, or a class that says handle/thumb/button on a
465
+ # track. `[draggable=true]` is deliberately absent: it is the HTML5
466
+ # drag-and-drop opt-in, which fires dragstart rather than pointermove, and
467
+ # no slider captcha uses it.
468
+ '[role="slider"]',
469
+ "[aria-valuenow]",
470
+ '[class*="slider"][class*="btn"]',
471
+ '[class*="slider"][class*="button"]',
472
+ '[class*="slide"][class*="handle"]',
473
+ '[class*="drag"][class*="thumb"]',
474
+ ]
475
+
476
+ # Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
477
+ # has no track at all — you drag the piece itself onto the gap — and the model
478
+ # answers it with the same sourceless drag, because from the picture the two are
479
+ # indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
480
+ DRAGGABLE_PIECE_SELECTORS = [
481
+ ".lemin-cropped-puzzle-piece",
482
+ "#lemin-cropped-captcha canvas + canvas",
483
+ '[class*="puzzle"][class*="piece"]',
484
+ '[class*="jigsaw"]',
485
+ ]
486
+
185
487
 
186
488
  class PageSolver:
187
489
  """
@@ -208,12 +510,97 @@ class PageSolver:
208
510
  # accumulates token usage and holds the HTTP session to vLLM.
209
511
  self._solver = solver or CaptchaSolver(**solver_kwargs)
210
512
  self._last_mouse: Tuple[float, float] = (0.0, 0.0)
513
+ # See _seed_cursor: the (0, 0) origin wedges camoufox's humanised
514
+ # mouse, so the first move of each solve must step off it plainly.
515
+ self._cursor_seeded = False
211
516
  self._last_submit_frame_hash: Optional[str] = None
212
517
  # Absolute deadline for the current solve, in the same clock as
213
518
  # time.monotonic() * 1000. None outside a solve.
214
519
  self._deadline_ms: Optional[float] = None
215
520
  # Window size for clamping, resolved once per solve. See _viewport.
216
521
  self._viewport_cache: Optional[Dict[str, float]] = None
522
+ # Animation detection, all reset per solve by `_reset_animated_state`.
523
+ # `_known_animated` latches: once a widget has been SEEN to animate,
524
+ # every later round takes the keyframe path without re-probing.
525
+ self._known_animated = False
526
+ self._animated_probe_armed = False
527
+ self._animated_probe_done = False
528
+ # One-shot: the recording path buys its own budget the first time it is
529
+ # entered, and never again in the same solve. See video_budget_ms.
530
+ self._video_budget_granted = False
531
+ self._keyframe_mode: Optional[str] = None
532
+ # Repeat detection; see `max_no_progress_rounds`.
533
+ self._last_answer_sig: Optional[str] = None
534
+ self._no_progress_rounds = 0
535
+ # Per-solve phase accounting; see `_phase`.
536
+ self._budget: Optional[PhaseBudget] = None
537
+
538
+ @contextmanager
539
+ def _phase(self, name: str):
540
+ """Attribute the enclosed wall-clock to `name` for this solve's budget.
541
+
542
+ A no-op outside a solve, so helpers stay callable from tests and from
543
+ the CLI without a budget having been opened.
544
+ """
545
+ if self._budget is None:
546
+ yield
547
+ return
548
+ with self._budget.phase(name):
549
+ yield
550
+
551
+ @staticmethod
552
+ def _answer_signature(actions: Sequence[Any], retry_mode: Optional[str]) -> Optional[str]:
553
+ """A stable identity for "the answer this round is about to execute".
554
+
555
+ Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
556
+ about the same board and its answer legitimately overlaps the previous
557
+ one, so counting that as a repeat would abandon the one path built to
558
+ recover from an under-selection.
559
+
560
+ Returns None when the actions cannot be summarised, which is treated as
561
+ "not a repeat" — an unreadable answer must never be the reason a solve
562
+ is abandoned.
563
+ """
564
+ try:
565
+ parts = []
566
+ for raw in actions:
567
+ a = _as_dict(raw)
568
+ parts.append((
569
+ a.get("action"),
570
+ _round_pts(a.get("target_bounding_boxes")),
571
+ _round_pts(a.get("target_bounding_box")),
572
+ _round_pts(a.get("target_coordinates")),
573
+ _round_pts(a.get("source_bounding_box")),
574
+ a.get("text"),
575
+ ))
576
+ return repr((retry_mode, parts))
577
+ except Exception: # noqa: BLE001 — a signature is an optimisation, not a contract
578
+ return None
579
+
580
+ def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[str]) -> None:
581
+ """Count consecutive identical answers; see `max_no_progress_rounds`."""
582
+ sig = self._answer_signature(actions, retry_mode)
583
+ if sig is not None and sig == self._last_answer_sig:
584
+ self._no_progress_rounds += 1
585
+ _log(f"[no-progress] the model returned the same answer again "
586
+ f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds}) — "
587
+ f"the previous one already ran and changed nothing")
588
+ # A board that reads the same every round is the signature of a
589
+ # CYCLING challenge answered as a still. Give the recording path a
590
+ # chance before giving up on the solve entirely.
591
+ self._arm_animated_probe()
592
+ else:
593
+ self._no_progress_rounds = 0
594
+ self._last_answer_sig = sig
595
+
596
+ def _reset_animated_state(self) -> None:
597
+ self._known_animated = False
598
+ self._animated_probe_armed = False
599
+ self._animated_probe_done = False
600
+ self._video_budget_granted = False
601
+ self._keyframe_mode = None
602
+ self._last_answer_sig: Optional[str] = None
603
+ self._no_progress_rounds = 0
217
604
 
218
605
  def _check_deadline(self, where: str) -> None:
219
606
  """
@@ -283,7 +670,8 @@ class PageSolver:
283
670
  return None
284
671
 
285
672
  def _get_solution(
286
- self, image_path: str, puzzle_source: str, retry_mode: Optional[str]
673
+ self, image_path: str, puzzle_source: str, retry_mode: Optional[str],
674
+ text_mode: bool = False,
287
675
  ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
288
676
  """
289
677
  The model query. In-process where TS spawns the CLI.
@@ -295,13 +683,32 @@ class PageSolver:
295
683
  planner = self._solver.planner
296
684
  before = len(planner.token_usage)
297
685
  actions = self._solver.solve(
298
- image_path, puzzle_source=puzzle_source, retry_mode=retry_mode
686
+ image_path, puzzle_source=puzzle_source, retry_mode=retry_mode,
687
+ text_mode=text_mode,
299
688
  )
300
689
  usage = [dict(u) for u in planner.token_usage[before:]]
301
690
  if not isinstance(actions, list):
302
691
  actions = [actions]
303
692
  return actions, usage
304
693
 
694
+ def _get_keyframe_solution(
695
+ self, keyframe_paths: Sequence[str]
696
+ ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
697
+ """The model query for an animated challenge. Usage read by DELTA, as above.
698
+
699
+ One request for the whole keyframe set, not one per frame: the model has to
700
+ compare the frames to find what differs between them, which it can only do
701
+ with all of them in a single context. Per-frame queries would also cost N
702
+ billable rounds for one puzzle.
703
+ """
704
+ planner = self._solver.planner
705
+ before = len(planner.token_usage)
706
+ actions = self._solver.solve_keyframes(keyframe_paths)
707
+ usage = [dict(u) for u in planner.token_usage[before:]]
708
+ if not isinstance(actions, list):
709
+ actions = [actions]
710
+ return actions, usage
711
+
305
712
  # ------------------------------------------------------------------
306
713
  # Mouse
307
714
  # ------------------------------------------------------------------
@@ -388,9 +795,50 @@ class PageSolver:
388
795
  # Any other per-sample failure is skipped rather than fatal —
389
796
  # losing one mousemove must not lose the solve.
390
797
 
798
+ def _seed_cursor(self, page: Any) -> None:
799
+ """Move the cursor off its (0, 0) origin once per solve, in ONE plain
800
+ move, before any humanised trajectory runs.
801
+
802
+ Without this, the first trajectory of a solve begins at (0, 0) — the
803
+ exact window corner, because that is where the pointer starts and
804
+ nothing has moved it. Under camoufox's `humanize` juggler a short move
805
+ from that origin never returns: `page.mouse.move()` blocks forever at 0%
806
+ CPU, and since dispatch is serialised on a process-global activation
807
+ chain, every later input event blocks behind it too. The solve looks
808
+ hung with no error anywhere and the Tier 3 fixture run times out on all
809
+ three attempts.
810
+
811
+ Reduced to four lines against camoufox directly:
812
+
813
+ with Camoufox(headless=True, humanize=True) as b:
814
+ p = b.new_page(); p.goto("about:blank")
815
+ p.mouse.move(1.0, 1.0) # never returns
816
+
817
+ The same move after ANY interior move completes in ~1.1s, which is what
818
+ makes this the fix rather than a workaround: it is the ORIGIN that is
819
+ poisoned, not the destination. Same failure family as camoufox #225.
820
+
821
+ Deliberately not routed through `_smooth_move`: that would generate a
822
+ trajectory from (0, 0) and reintroduce exactly the move being avoided.
823
+ """
824
+ if self._cursor_seeded:
825
+ return
826
+ self._cursor_seeded = True
827
+ vp = self._viewport(page)
828
+ # Centre when the window is known, else a modest interior point — any
829
+ # coordinate comfortably off the corner will do.
830
+ cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
831
+ try:
832
+ page.mouse.move(cx, cy)
833
+ self._last_mouse = (cx, cy)
834
+ except Exception: # noqa: BLE001 — an adapter without a mouse must not fail the solve
835
+ pass
836
+
391
837
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
838
+ self._seed_cursor(page)
392
839
  points, timings = generate_trajectory(self._last_mouse, (x, y), 60)
393
- self._trace_path(page, points, timings)
840
+ with self._phase("mouse"):
841
+ self._trace_path(page, points, timings)
394
842
 
395
843
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
396
844
  # BOUNDED. Playwright's default timeout is 30s, and this is called once
@@ -472,6 +920,256 @@ class PageSolver:
472
920
  _delay(random.random() * 50 + 50)
473
921
  page.mouse.up()
474
922
 
923
+ # ------------------------------------------------------------------
924
+ # Typing and sliding
925
+ # ------------------------------------------------------------------
926
+
927
+ def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
928
+ """First VISIBLE match for `selectors`, tried in order.
929
+
930
+ `scope` is the challenge frame, or — for the vendors that render into
931
+ the host page rather than an iframe — the widget element itself. Never
932
+ the page: the generic tail of both selector tables would otherwise
933
+ happily match a login form's text box or a carousel's drag handle
934
+ somewhere else on the document, and the answer would go there.
935
+ """
936
+ for selector in selectors:
937
+ try:
938
+ element = scope.query_selector(selector)
939
+ except Exception:
940
+ continue # a selector this adapter can't parse must not end the search
941
+ if self._visible(element):
942
+ return element
943
+ return None
944
+
945
+ def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any]) -> bool:
946
+ """Put the model's reading of a distorted-text captcha into its box."""
947
+ text = str(action.get("text") or "")
948
+ if not text:
949
+ return False
950
+ field = self._find_control(scope, TEXT_INPUT_SELECTORS)
951
+ if field is None:
952
+ _log("type action, but no text box in the widget; skipping")
953
+ return False
954
+
955
+ self._move_and_click(page, field) # travel there, then press to focus
956
+ # A retry round arrives with the previous attempt still in the box, and
957
+ # typing would APPEND to it — submitting a string the model never read.
958
+ try:
959
+ page.keyboard.press("Control+A")
960
+ except Exception:
961
+ pass
962
+ # Per character rather than one `type(text, delay=…)` call: a constant
963
+ # inter-key delay is itself a signal, and these are the vendors that
964
+ # score typing cadence.
965
+ for ch in text:
966
+ try:
967
+ page.keyboard.type(ch)
968
+ except Exception as exc:
969
+ _log(f"could not type into the captcha field: {exc}")
970
+ return False
971
+ _delay(random.random() * 90 + 45)
972
+ _log(f"typed {len(text)} character(s) into the captcha field")
973
+ return True
974
+
975
+ def _track_piece(
976
+ self, element: Any, before: str, after: str, exclude: Sequence[float]
977
+ ) -> Optional[Sequence[int]]:
978
+ """`captchakraken track-piece` — box of what moved, handle masked out."""
979
+ from .tool_calls.track_piece import changed_bbox
980
+
981
+ try:
982
+ self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
983
+ return changed_bbox(before, after, exclude)
984
+ except Exception as exc:
985
+ _debug(f"track_piece failed: {exc}")
986
+ return None
987
+
988
+ def _execute_slide(
989
+ self,
990
+ page: Any,
991
+ element: Any,
992
+ scope: Any,
993
+ action: Dict[str, Any],
994
+ element_box: Dict[str, float],
995
+ ) -> bool:
996
+ """Drive a puzzle-piece slider until the PIECE reaches the model's slot.
997
+
998
+ The model is asked for one thing here — the centre of the gap — because
999
+ it is the only thing the picture can tell it. What it cannot know is how
1000
+ far the handle must travel to put the piece there: the handle is
1001
+ elsewhere on the widget, and the ratio between the two is a vendor
1002
+ implementation detail that several of them deliberately vary.
1003
+
1004
+ So this is closed-loop, not a calculation. Press the handle, nudge it
1005
+ twice by known amounts, and watch the screen:
1006
+
1007
+ union(before, after) spans the piece's ORIGINAL left edge to its
1008
+ CURRENT right edge, so its width is piece_width + ratio × nudge.
1009
+
1010
+ Two nudges, two widths, two unknowns — solve for both, then steer the
1011
+ remaining distance and re-measure. The mouse is not released until the
1012
+ piece is home, because on every one of these puzzles releasing IS the
1013
+ submit; there is no Verify button to reconsider at.
1014
+
1015
+ Returns False if there is nothing here to drag, leaving the caller's
1016
+ normal no-op handling to deal with it.
1017
+ """
1018
+ target_x = (
1019
+ (float(action["target_bounding_box"][0]) + float(action["target_bounding_box"][2])) / 2
1020
+ ) * element_box["width"]
1021
+
1022
+ handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
1023
+ if handle is None:
1024
+ # No track — the sliderless members of the family (Lemin's
1025
+ # "cropped") want the piece dragged directly. Same answer from the
1026
+ # model, because the two look identical; different gesture. Nothing
1027
+ # to close a loop on, since the piece is under the cursor and moves
1028
+ # with it one for one.
1029
+ piece = self._find_control(scope, DRAGGABLE_PIECE_SELECTORS)
1030
+ if piece is None:
1031
+ _log("slide action, but the widget has neither a slider nor a draggable piece")
1032
+ return False
1033
+ box = piece.bounding_box()
1034
+ if not box:
1035
+ return False
1036
+ # BOTH axes. The rail members travel horizontally and nothing else,
1037
+ # so the handle's own y is the only y there is — but a free drag
1038
+ # carries the piece across the card, and holding the piece's row
1039
+ # here slid it along the TRAY and released it there, 250 px below
1040
+ # the slot, on every attempt.
1041
+ target_y = (
1042
+ (float(action["target_bounding_box"][1])
1043
+ + float(action["target_bounding_box"][3])) / 2
1044
+ ) * element_box["height"]
1045
+ _log("no slider track; dragging the piece to the slot directly")
1046
+ self._smooth_move(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
1047
+ page.mouse.down()
1048
+ _delay(random.random() * 50 + 50)
1049
+ self._smooth_move(page, element_box["x"] + target_x, element_box["y"] + target_y)
1050
+ _delay(random.random() * 50 + 50)
1051
+ page.mouse.up()
1052
+ return True
1053
+
1054
+ hbox = handle.bounding_box()
1055
+ if not hbox:
1056
+ return False
1057
+ start_x = hbox["x"] + hbox["width"] / 2
1058
+ hold_y = hbox["y"] + hbox["height"] / 2
1059
+
1060
+ # Mask the whole horizontal BAND the handle runs in, not just where it
1061
+ # is now: it is about to move across that band, and most vendors fill
1062
+ # the track behind it as it goes. Either would otherwise be the largest
1063
+ # moving thing in frame, and we would track the handle instead of the
1064
+ # piece.
1065
+ pad = max(4.0, hbox["height"] * 0.35)
1066
+ exclude = [
1067
+ 0.0,
1068
+ hbox["y"] - element_box["y"] - pad,
1069
+ element_box["width"],
1070
+ hbox["y"] + hbox["height"] - element_box["y"] + pad,
1071
+ ]
1072
+
1073
+ shots = [_tmp_png("slide") for _ in range(4)]
1074
+ try:
1075
+ self._move_to_element(page, handle, padding_percentage=30.0)
1076
+ page.mouse.down()
1077
+ _delay(random.random() * 60 + 60)
1078
+ self._screenshot(element, shots[0],
1079
+ timeout_ms=self.config.element_screenshot_timeout_ms)
1080
+
1081
+ probes = self.config.slide_probe_offsets_px
1082
+ widths: List[Tuple[float, float]] = []
1083
+ last_box = None
1084
+ for offset, shot in zip(probes, shots[1:]):
1085
+ self._smooth_move(page, start_x + offset, hold_y)
1086
+ _delay(random.random() * 40 + 40)
1087
+ box = self._track_piece(element, shots[0], shot, exclude)
1088
+ if box is not None:
1089
+ widths.append((float(offset), float(box[2] - box[0])))
1090
+ last_box = box
1091
+
1092
+ piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
1093
+ if last_box is None or piece_w is None:
1094
+ # Never saw the piece — a canvas the screenshot cannot separate,
1095
+ # a widget that redraws wholesale, or a press the handle refused.
1096
+ # Fall back on the geometry every one of these puzzles shares:
1097
+ # piece and handle both start flush left, so the handle's travel
1098
+ # is the piece's travel.
1099
+ _log("slider: piece never resolved on screen; steering by handle travel alone")
1100
+ self._smooth_move(page, start_x + (target_x - (start_x - element_box["x"])), hold_y)
1101
+ else:
1102
+ # The offset `last_box` was MEASURED at — not `probes[-1]`, and
1103
+ # not indexed by how many measurements succeeded. If the first
1104
+ # probe failed to resolve and the second worked, those two
1105
+ # disagree, and steering from a base the reading does not belong
1106
+ # to sends the piece somewhere neither the model nor the screen
1107
+ # asked for.
1108
+ offset = float(widths[-1][0])
1109
+ for _ in range(self.config.slide_max_corrections):
1110
+ piece_center = (last_box[2] - piece_w / 2.0)
1111
+ error = target_x - piece_center
1112
+ if abs(error) <= self.config.slide_tolerance_px:
1113
+ break
1114
+ offset += error / ratio
1115
+ self._smooth_move(page, start_x + offset, hold_y)
1116
+ _delay(random.random() * 40 + 40)
1117
+ box = self._track_piece(element, shots[0], shots[3], exclude)
1118
+ if box is None:
1119
+ break # ran out of track; release where we are
1120
+ last_box = box
1121
+ _debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} "
1122
+ f"final_center={last_box[2] - piece_w / 2.0:.1f} target={target_x:.1f}")
1123
+
1124
+ # Settle before letting go. A release in the same tick as the last
1125
+ # move reads as a machine, and some vendors sample the final
1126
+ # milliseconds of the gesture.
1127
+ _delay(random.random() * 120 + 90)
1128
+ finally:
1129
+ try:
1130
+ page.mouse.up()
1131
+ except Exception:
1132
+ pass
1133
+ for shot in shots:
1134
+ _unlink(shot)
1135
+ return True
1136
+
1137
+ @staticmethod
1138
+ def _solve_slide_geometry(
1139
+ widths: Sequence[Tuple[float, float]], widget_width: float
1140
+ ) -> Tuple[Optional[float], float]:
1141
+ """Piece width and handle-to-piece travel ratio, from probe measurements.
1142
+
1143
+ Each measurement is (handle offset, width of what changed), and
1144
+ width = piece_width + ratio × offset. Two of them determine both.
1145
+
1146
+ With only one usable measurement the system is underdetermined, so ratio
1147
+ is ASSUMED to be 1 — true of every vendor observed, and the assumption
1148
+ is stated here rather than buried as a default. A ratio solved from
1149
+ implausible measurements (a redraw, a piece that hit the wall between
1150
+ probes) is rejected the same way: better a 1:1 guess that overshoots and
1151
+ gets corrected than a ratio of 0.02 that sends the handle off the track.
1152
+ """
1153
+ if not widths:
1154
+ return None, 1.0
1155
+ piece_w: Optional[float] = None
1156
+ ratio = 1.0
1157
+ if len(widths) >= 2:
1158
+ (o1, w1), (o2, w2) = widths[0], widths[-1]
1159
+ if o2 != o1:
1160
+ candidate = (w2 - w1) / (o2 - o1)
1161
+ if 0.2 <= candidate <= 3.0:
1162
+ ratio = candidate
1163
+ piece_w = w1 - ratio * o1
1164
+ if piece_w is None:
1165
+ o, w = widths[-1]
1166
+ piece_w = w - ratio * o
1167
+ # A piece narrower than a few pixels, or wider than half the widget, is
1168
+ # a measurement of something else.
1169
+ if not 3.0 <= piece_w <= widget_width * 0.6:
1170
+ return None, ratio
1171
+ return piece_w, ratio
1172
+
475
1173
  # ------------------------------------------------------------------
476
1174
  # Detection
477
1175
  # ------------------------------------------------------------------
@@ -580,6 +1278,14 @@ class PageSolver:
580
1278
  ):
581
1279
  return turnstile_container
582
1280
 
1281
+ # Vendors with one interactive surface (no checkbox/challenge split) —
1282
+ # GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
1283
+ for entry in VENDOR_WIDGET_LOCATORS:
1284
+ for selector in entry["selectors"]:
1285
+ el = page.query_selector(selector)
1286
+ if self._visible(el):
1287
+ return el
1288
+
583
1289
  return None
584
1290
 
585
1291
  def is_captcha_solved(self, page: Any) -> bool:
@@ -591,18 +1297,35 @@ class PageSolver:
591
1297
  a fresh puzzle burns ~18s re-running the pipeline on a closing frame.
592
1298
  """
593
1299
  try:
1300
+ # The token FIRST, and unconditionally. It is a hidden field on the
1301
+ # PAGE, not inside the widget, so it never needed the anchor iframe
1302
+ # to be on screen — and the moment it matters most is precisely when
1303
+ # the anchor is NOT on screen, because hCaptcha keeps its challenge
1304
+ # overlay up for a couple of seconds after the winning submit.
1305
+ # Gating this on `_visible(anchor)` meant the one signal that was
1306
+ # already true went unread, and the loop ground on against a frame
1307
+ # being torn down. See tests/test_solved_detection.py.
1308
+ if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
1309
+ return True
1310
+ if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
1311
+ return True
1312
+ # Turnstile. `detect_captcha` already reads this exact field to
1313
+ # decide a Turnstile widget is UNSOLVED, so the signal was known to
1314
+ # one half of the driver and ignored by the other: a solved
1315
+ # Turnstile reported unsolved here and the loop ground through the
1316
+ # readiness waits and a wasted inference until the widget happened
1317
+ # to disappear. Same defect the hCaptcha token had.
1318
+ if self._has_non_empty_field_value(page, '[name="cf-turnstile-response"]'):
1319
+ return True
1320
+
1321
+ # Anchor state is the fallback, and this one DOES need the iframe:
1322
+ # it is read out of the anchor's own document.
594
1323
  hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
595
- if self._visible(hc):
596
- if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
597
- return True
598
- if self._is_hcaptcha_anchor_checked(hc):
599
- return True
1324
+ if self._visible(hc) and self._is_hcaptcha_anchor_checked(hc):
1325
+ return True
600
1326
  rc = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
601
- if self._visible(rc):
602
- if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
603
- return True
604
- if self._is_recaptcha_anchor_checked(rc):
605
- return True
1327
+ if self._visible(rc) and self._is_recaptcha_anchor_checked(rc):
1328
+ return True
606
1329
  except Exception:
607
1330
  pass
608
1331
  return False
@@ -616,6 +1339,18 @@ class PageSolver:
616
1339
  try:
617
1340
  hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
618
1341
  if self._visible(hc):
1342
+ # "Prompt painted" alone does NOT mean a next round. hCaptcha
1343
+ # leaves the round you just answered on screen while it
1344
+ # verifies, so this fired on the CLOSING frame and broke the
1345
+ # post-submit poll out of its solved-check after ~0ms — which
1346
+ # then committed the solver to ~21s of readiness waits and a
1347
+ # full inference against a frame that was about to be destroyed.
1348
+ # The frame must have actually CHANGED since the submit; we
1349
+ # already snapshot exactly that at submit time.
1350
+ if self._last_submit_frame_hash:
1351
+ current = self._element_frame_hash(hc)
1352
+ if current and current == self._last_submit_frame_hash:
1353
+ return False
619
1354
  frame = hc.content_frame()
620
1355
  prompt = frame.query_selector(".prompt-text") if frame else None
621
1356
  if self._visible(prompt):
@@ -672,13 +1407,20 @@ class PageSolver:
672
1407
  return False
673
1408
 
674
1409
  def _get_verify_button(self, frame: Any) -> Optional[Any]:
1410
+ # `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
1411
+ # markup on the host page rather than a vendor iframe (every
1412
+ # distorted-text captcha), and a document-rooted `//button` does not
1413
+ # resolve against an element handle: the query returned None even with
1414
+ # the button sitting inside that very element, so a typed code was never
1415
+ # submitted. On a Frame the context node is the document, where `.//` and
1416
+ # `//` mean the same thing, so the vendor paths are unaffected.
675
1417
  for text in ("Verify", "Next", "Submit", "Skip"):
676
1418
  lowered = text.lower()
677
1419
  try:
678
1420
  button = frame.query_selector(
679
- f"xpath=//button[contains(translate(., "
1421
+ f"xpath=.//button[contains(translate(., "
680
1422
  f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
681
- f" | //div[@role='button' and contains(translate(., "
1423
+ f" | .//div[@role='button' and contains(translate(., "
682
1424
  f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
683
1425
  )
684
1426
  if self._visible(button):
@@ -692,6 +1434,26 @@ class PageSolver:
692
1434
  hcaptcha_verify = frame.query_selector(".button-submit")
693
1435
  if self._visible(hcaptcha_verify):
694
1436
  return hcaptcha_verify
1437
+ # GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
1438
+ #
1439
+ # Invisible to BOTH shapes above — a bare div carries no
1440
+ # role="button", and "OK" is on none of the four word lists. So
1441
+ # nothing was ever pressed on the puzzles that need pressing, and a
1442
+ # GeeTest board does not grade until you do: the solve loop re-read
1443
+ # the same unchanged panel and re-answered it identically until the
1444
+ # round cap. Ordered icon-click scored 0/31 and 0/13 that way while
1445
+ # the model was answering CORRECTLY (measured 2026-08-19). A driver
1446
+ # that discards a right answer is indistinguishable from a model
1447
+ # that cannot solve the puzzle, which is how this hid.
1448
+ #
1449
+ # Matched by CLASS, not by the word: `geetest_submit_tips` sits
1450
+ # beside it and also reads "OK", and pressing the tooltip does
1451
+ # nothing. `.geetest_submit` is a distinct token, so it cannot
1452
+ # match it. Mirrors getVerifyButton in js/src/solver.ts — CLAUDE.md
1453
+ # 1c, the two ports must behave the same.
1454
+ geetest_submit = frame.query_selector(".geetest_submit")
1455
+ if self._visible(geetest_submit):
1456
+ return geetest_submit
695
1457
  except Exception:
696
1458
  pass
697
1459
  return None
@@ -700,18 +1462,30 @@ class PageSolver:
700
1462
  # Frame readiness
701
1463
  # ------------------------------------------------------------------
702
1464
 
703
- def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None) -> None:
1465
+ def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
1466
+ animations: str = "disabled") -> None:
704
1467
  """
705
- Short timeout + animations disabled, always.
1468
+ Short timeout, and animations disabled unless the caller needs the motion.
706
1469
 
707
1470
  Playwright's default 30s stability wait hangs per-screenshot on a
708
1471
  closing/animating challenge element; that alone made a multi-round solve
709
1472
  take ~115s. Failing fast and skipping a frame is strictly better.
1473
+
1474
+ `animations` is 'disabled' everywhere the goal is a STABLE STILL, which
1475
+ is almost everywhere. It is not a formality: the flag fast-forwards
1476
+ finite animations and FREEZES infinite ones, which is exactly what a
1477
+ model-facing screenshot wants and exactly what a recording must not do.
1478
+ The burst and the animation probe pass 'allow' — see `solver.ts`
1479
+ `recordKeyframeBurst`, which learned this first: GeeTest's svg board
1480
+ cycles in CSS, so a burst taken with animations disabled captured the
1481
+ same picture forty times, the slicer honestly reported `mode=static`,
1482
+ and the solve went back to answering a single still. hCaptcha hid it,
1483
+ because it animates in canvas and the flag does not touch canvas.
710
1484
  """
711
1485
  element.screenshot(
712
1486
  path=path,
713
1487
  timeout=timeout_ms if timeout_ms is not None else 2_500,
714
- animations="disabled",
1488
+ animations=animations,
715
1489
  )
716
1490
 
717
1491
  def _element_frame_hash(self, element: Any) -> Optional[str]:
@@ -737,8 +1511,11 @@ class PageSolver:
737
1511
  cfg = self.config
738
1512
  start = time.monotonic() * 1000.0
739
1513
  previous: Optional[str] = None
740
- still_streak = 0
741
1514
  frames: List[str] = []
1515
+ # This loop only COLLECTS polls; the verdict is `settle_verdict`'s, so
1516
+ # the recorded timelines in tests/test_animated_is_detected.py exercise
1517
+ # the same rule that runs here rather than a copy of it that can drift.
1518
+ samples: List[Tuple[float, bool]] = []
742
1519
  try:
743
1520
  while (time.monotonic() * 1000.0) - start < cfg.settle_timeout_ms:
744
1521
  self._check_deadline("waiting for the challenge to settle")
@@ -752,14 +1529,16 @@ class PageSolver:
752
1529
  frames.append(path)
753
1530
  if previous:
754
1531
  moved = self._has_movement(previous, path, cfg.settle_diff_threshold)
755
- still_streak = 0 if moved else still_streak + 1
756
1532
  if len(frames) > 1:
757
1533
  _unlink(frames.pop(0))
758
- if still_streak >= cfg.settle_frames:
759
- return "settled"
760
- # Still moving this late means it is not merely loading.
761
- if moved and (time.monotonic() * 1000.0) - start >= cfg.animated_challenge_after_ms:
762
- return "animated"
1534
+ samples.append(((time.monotonic() * 1000.0) - start, moved))
1535
+ verdict = settle_verdict(
1536
+ samples,
1537
+ settle_frames=cfg.settle_frames,
1538
+ animated_after_ms=cfg.animated_challenge_after_ms,
1539
+ )
1540
+ if verdict != "timeout":
1541
+ return verdict
763
1542
  previous = frames[-1]
764
1543
  _delay(cfg.settle_poll_ms)
765
1544
  return "timeout"
@@ -767,6 +1546,271 @@ class PageSolver:
767
1546
  for path in frames:
768
1547
  _unlink(path)
769
1548
 
1549
+ # ------------------------------------------------------------------
1550
+ # Animated challenges
1551
+ # ------------------------------------------------------------------
1552
+
1553
+ def _settle_or_animated(self, element: Any) -> bool:
1554
+ """Wait for the widget to settle; return whether it is animated instead.
1555
+
1556
+ "Never settles" stopped being a failure. hCaptcha's "select the odd animal"
1557
+ fades its sprites on independent cycles and its "unique motion pattern"
1558
+ puzzle spins identical meshes — those challenges are animated BY DESIGN, and
1559
+ the answer only exists across frames. True here routes the caller to the
1560
+ recording path.
1561
+
1562
+ `video_solve_enabled=False` restores the old behaviour for callers who would
1563
+ rather fail fast than spend the recording time.
1564
+ """
1565
+ # Already established for this widget — don't re-probe every round.
1566
+ if self._known_animated:
1567
+ return True
1568
+
1569
+ with self._phase("settle"):
1570
+ verdict = self._wait_for_element_settled(element)
1571
+ if verdict != "animated":
1572
+ # 'settled' is not proof of static. The settle rule stops at the
1573
+ # first `settle_frames` of stillness, and every animated puzzle we
1574
+ # ship rests between screens for longer than that, so it calls all
1575
+ # of them settled (tests/test_animated_is_detected.py has the
1576
+ # measured timelines).
1577
+ #
1578
+ # What decides it instead is a RECORDING, taken once a round has
1579
+ # already answered the same thing twice — the signature of a
1580
+ # cycling board read as a still. The recording is self-checking: if
1581
+ # the widget really is static it slices to one keyframe and the
1582
+ # caller solves that frame as the still it is. So there is nothing
1583
+ # to be careful about here, and nothing to observe first.
1584
+ if not self._animated_probe_armed or self._animated_probe_done:
1585
+ return False
1586
+ self._animated_probe_done = True
1587
+ self._animated_probe_armed = False
1588
+ _log("[animated] the same answer came back twice — recording the "
1589
+ "challenge to see whether it is cycling")
1590
+
1591
+ # Latch either route. Re-deciding every round would re-record a widget
1592
+ # we have already SEEN animate, and on a puzzle that rests between
1593
+ # screens a later recording can land inside a hold, slice to one frame,
1594
+ # and flip the solve back onto the still path halfway through.
1595
+ self._known_animated = True
1596
+
1597
+ if not self.config.video_solve_enabled:
1598
+ raise AnimatedChallengeError(
1599
+ "the challenge never settles and video_solve_enabled is off"
1600
+ )
1601
+ _log("[animated] challenge is animated — solving it from keyframes")
1602
+ return True
1603
+
1604
+ def _arm_animated_probe(self) -> None:
1605
+ """Round N finished without finishing the CHALLENGE — probe next round.
1606
+
1607
+ Deliberately not armed for round 1. A static puzzle solves there and
1608
+ never pays anything, which is what keeps the common case at the ~530ms
1609
+ settle it has today. Only a widget that survived a round is worth
1610
+ spending a cycle of observation on.
1611
+ """
1612
+ if self.config.animated_probe_enabled and not self._animated_probe_done:
1613
+ self._animated_probe_armed = True
1614
+
1615
+ def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
1616
+ """Record the widget and return `(keyframe_paths, temp_dir)`.
1617
+
1618
+ Screenshots the element at the collector's burst geometry (4 s @ 10 fps),
1619
+ then hands the frames to the SAME slicer the training data was cut with
1620
+ (`keyframes.extract_keyframes`). That shared code path is the whole point:
1621
+ the model answers with a frame NUMBER, and a number only means something if
1622
+ the live set was sliced the way the trained set was.
1623
+
1624
+ Frames are kept in memory and sliced from there — no intermediate mp4. The
1625
+ old pipeline encoded one, and every clip it produced was mp4v, a codec the
1626
+ serving side may or may not decode. Skipping the encode removes that whole
1627
+ class of silent failure along with the disk round-trip.
1628
+
1629
+ The caller owns `temp_dir` and must remove it once the actions are done
1630
+ with: the returned paths are read back by the wait gate on every poll, so
1631
+ cleaning up any earlier would break the click.
1632
+
1633
+ Raises AnimatedChallengeError if nothing could be captured.
1634
+ """
1635
+ from .keyframes import extract_keyframes, write_keyframes
1636
+
1637
+ cfg = self.config
1638
+ fps = max(1, int(cfg.video_burst_fps))
1639
+ total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
1640
+ interval = 1.0 / fps
1641
+
1642
+ # THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
1643
+ #
1644
+ # `overall_solve_timeout_ms` counts rounds, and this is not a round —
1645
+ # see `video_budget_ms` for the arithmetic and for what it cost not to
1646
+ # have it. Granted here rather than where the probe arms because this is
1647
+ # the one place every path into a recording goes through.
1648
+ if cfg.video_solve_enabled and not self._video_budget_granted:
1649
+ self._video_budget_granted = True
1650
+ if self._deadline_ms is not None:
1651
+ self._deadline_ms += cfg.video_budget_ms()
1652
+ _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
1653
+
1654
+ # Checked ONCE, before the first frame — never per frame.
1655
+ #
1656
+ # A HALF-RECORDED BURST IS WORTHLESS: the slicer reads a clip's temporal
1657
+ # structure, so stopping at frame 27 of 40 does not produce a shorter
1658
+ # answer, it produces a recording that may not contain the screen the
1659
+ # answer is on. Aborting mid-way therefore threw away the whole burst
1660
+ # AND the ~3s already spent making it, to report a timeout. The burst is
1661
+ # also fixed-length and short, so it is not one of the places that "can
1662
+ # legitimately spin for a long time" that `_check_deadline` is for.
1663
+ #
1664
+ # If the budget cannot fit one, say THAT — a caller who has tightened
1665
+ # `overall_solve_timeout_ms` below what a recording costs has asked for
1666
+ # something impossible, and should hear it rather than watch a burst die
1667
+ # partway through every time.
1668
+ if self._deadline_ms is not None:
1669
+ left = self._deadline_ms - time.monotonic() * 1000.0
1670
+ if left < cfg.video_burst_duration_ms:
1671
+ raise CaptchaSolveError(
1672
+ f"only {left:.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
1673
+ f"budget is left and an animated recording needs "
1674
+ f"{cfg.video_burst_duration_ms}ms — not starting one that would "
1675
+ f"be cut off mid-way. Raise overall_solve_timeout_ms or "
1676
+ f"video_extra_inference_ms, or set video_solve_enabled=False."
1677
+ )
1678
+
1679
+ frames: List[Any] = []
1680
+
1681
+ remaining = max(0, total - len(frames))
1682
+ shot = _tmp_png("burst")
1683
+ # A burst that runs far past its own length is a hung screenshot, not a
1684
+ # tight budget — bounded separately so the two cannot be confused.
1685
+ burst_deadline = (time.monotonic() * 1000.0
1686
+ + 3 * cfg.video_burst_duration_ms + 5_000)
1687
+ try:
1688
+ for i in range(remaining):
1689
+ if time.monotonic() * 1000.0 > burst_deadline:
1690
+ raise CaptchaSolveError(
1691
+ f"the animated recording stalled: {i} of {remaining} frames "
1692
+ f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
1693
+ f"is not screenshotting."
1694
+ )
1695
+ start = time.monotonic()
1696
+ try:
1697
+ # animations ALLOWED — see `_screenshot`. The JS port has
1698
+ # done this since the GeeTest svg burst came back as 40
1699
+ # copies of one picture; this port never did, so every
1700
+ # CSS-animated vendor was recorded frozen.
1701
+ self._screenshot(element, shot, animations="allow")
1702
+ except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
1703
+ _debug(f"burst frame {i} failed: {exc}")
1704
+ else:
1705
+ import cv2
1706
+
1707
+ img = cv2.imread(shot)
1708
+ if img is not None:
1709
+ frames.append(img)
1710
+ # Drift-corrected: a slow screenshot must not stretch the clip, or
1711
+ # the recording covers more wall-clock than the model trained on and
1712
+ # a cycle's period lands differently across the frames.
1713
+ wait = interval - (time.monotonic() - start)
1714
+ if wait > 0 and i < remaining - 1:
1715
+ time.sleep(wait)
1716
+ finally:
1717
+ _unlink(shot)
1718
+
1719
+ if not frames:
1720
+ raise AnimatedChallengeError(
1721
+ "could not record the animated challenge (no frame screenshotted)"
1722
+ )
1723
+ _log(f"[animated] recorded {len(frames)} frames at {fps}fps")
1724
+
1725
+ kfset = extract_keyframes(frames, fps=float(fps))
1726
+ temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
1727
+ paths = write_keyframes(kfset, temp_dir, stem="challenge")
1728
+ _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1729
+ # The wait gate consults this: `even` means the extractor found no state
1730
+ # that RECURS, so there is nothing for the page to come back to.
1731
+ self._keyframe_mode = kfset.mode
1732
+ return [str(p) for p in paths], temp_dir
1733
+
1734
+ def _wait_for_keyframe(self, element: Any, keyframe_path: str,
1735
+ point_norm: Tuple[float, float]) -> bool:
1736
+ """Hold until the widget looks like `keyframe_path` around `point_norm`.
1737
+
1738
+ This is the reason an animated answer names a frame. The model picked the
1739
+ moment its target was visible; the coordinates are only correct at that
1740
+ moment. Clicking as soon as the answer arrives lands on whatever the sprite
1741
+ happens to be doing — for a cross-fade, usually background.
1742
+
1743
+ Compares only the neighbourhood of the action point, with the same box and
1744
+ the same metric the training label was chosen with
1745
+ (`keyframes.region_box` / `region_diff_ratio`). Local rather than
1746
+ whole-frame because everything ELSE in these puzzles is also moving: a
1747
+ whole-frame match would need every unrelated sprite to align too, and would
1748
+ essentially never open.
1749
+
1750
+ Returns whether the state was reached. On timeout the caller clicks anyway
1751
+ — see `keyframe_wait_timeout_ms`.
1752
+
1753
+ NOT ATTEMPTED on an `even` clip. `keyframes.py` picks that mode precisely
1754
+ when the clip never revisits a picture it has already shown — a slow
1755
+ one-way change, a rotation, a cross-fade — so there is no state for the
1756
+ page to come BACK to and the wait can only ever run out. It is not a
1757
+ cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
1758
+ PER CLICK before giving up and clicking anyway.
1759
+ And `even` is not the rare case. All 116 real clips under
1760
+ `cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
1761
+ fired on real footage, which keyframes.py itself records ("real state
1762
+ separations top out around 0.007, well under this"). So on real traffic
1763
+ this gate was 6s of dead time on every animated click, always followed by
1764
+ the same click it would have made immediately. Measured on
1765
+ `hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
1766
+ 0.0721 against a 0.05 tolerance, then it clicked and solved.
1767
+
1768
+ The gate stays for `cycle`/`static`, where a state genuinely does recur
1769
+ and waiting is the difference between clicking the sprite and clicking
1770
+ the background.
1771
+ """
1772
+ import cv2
1773
+
1774
+ from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1775
+
1776
+ if self._keyframe_mode == "even":
1777
+ _log("[animated] clip is mode=even (no state recurs); "
1778
+ "acting on the model's frame without waiting")
1779
+ return False
1780
+
1781
+ ref = cv2.imread(keyframe_path)
1782
+ if ref is None:
1783
+ _debug(f"keyframe {keyframe_path} unreadable; not waiting")
1784
+ return False
1785
+ box = region_box(ref.shape[1::-1], point_norm)
1786
+
1787
+ cfg = self.config
1788
+ deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
1789
+ probe = _tmp_png("kfwait")
1790
+ best = 1.0
1791
+ try:
1792
+ while (time.monotonic() * 1000.0) < deadline:
1793
+ self._check_deadline("waiting for the challenge keyframe")
1794
+ try:
1795
+ self._screenshot(element, probe)
1796
+ live = cv2.imread(probe)
1797
+ except Exception as exc: # noqa: BLE001
1798
+ _debug(f"keyframe probe failed: {exc}")
1799
+ live = None
1800
+ if live is not None:
1801
+ d = region_diff_ratio(ref, live, box)
1802
+ best = min(best, d)
1803
+ if d <= MATCH_REGION_TOLERANCE:
1804
+ _log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
1805
+ return True
1806
+ _delay(cfg.keyframe_wait_poll_ms)
1807
+ finally:
1808
+ _unlink(probe)
1809
+ _log(f"[animated] widget never matched the chosen keyframe within "
1810
+ f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
1811
+ f"clicking on the model's coordinates anyway")
1812
+ return False
1813
+
770
1814
  def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
771
1815
  """After a submit the frame MUST change (next round, or closing)."""
772
1816
  cfg = self.config
@@ -786,12 +1830,24 @@ class PageSolver:
786
1830
  ships an empty `url("")` placeholder before it arrives. Best-effort: a
787
1831
  timeout falls through to the screenshot, where the existing fail-fast
788
1832
  path still covers a genuinely unsupported puzzle.
1833
+
1834
+ "NOTHING TO WAIT FOR" IS READY. The last clause used to be
1835
+ `return !!(example && ...)`, which is false when there is no example
1836
+ image — so a challenge with no tile grid, no canvas and no example
1837
+ polled until the timeout and then carried on regardless. Measured on
1838
+ `hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
1839
+ full 8s three times over, more than half the budget spent asking a
1840
+ question about elements that were not on the page. A readiness gate can
1841
+ only report on what it can see; with nothing to check it has no opinion,
1842
+ and "no opinion" must not read as "not ready".
789
1843
  """
1844
+ cfg = self.config
790
1845
  try:
791
1846
  frame = challenge_iframe.content_frame()
792
1847
  if not frame:
793
1848
  return
794
- frame.wait_for_selector(".prompt-text", state="visible", timeout=8_000)
1849
+ frame.wait_for_selector(".prompt-text", state="visible",
1850
+ timeout=cfg.hcaptcha_images_timeout_ms)
795
1851
  frame.wait_for_function(
796
1852
  """() => {
797
1853
  const tiles = Array.from(
@@ -806,9 +1862,10 @@ class PageSolver:
806
1862
  if (canvas && canvas.width > 0 && canvas.height > 0) return true;
807
1863
  const example = document.querySelector(
808
1864
  '.challenge-example img, .image-wrapper img');
809
- return !!(example && example.complete && example.naturalWidth > 0);
1865
+ if (example) return example.complete && example.naturalWidth > 0;
1866
+ return true; // nothing here to be waited for
810
1867
  }""",
811
- timeout=8_000,
1868
+ timeout=cfg.hcaptcha_images_timeout_ms,
812
1869
  )
813
1870
  except Exception:
814
1871
  pass # timed out or detached mid-load; screenshot anyway
@@ -981,11 +2038,35 @@ class PageSolver:
981
2038
  ordered.extend(sorted(remaining))
982
2039
  return ordered
983
2040
 
984
- def _current_loading_cells(
2041
+ def _watch_clicked_tiles(
985
2042
  self, page: Any, element: Any, session: _GridSession, priority: Sequence[int] = ()
986
- ) -> List[int]:
2043
+ ) -> Tuple[List[int], bool]:
987
2044
  """
988
- Watch for the ONSET of the refresh over a grace window.
2045
+ Watch the just-clicked tiles until the widget says what it did with them.
2046
+
2047
+ Returns `(loading, chipped)`. Two answers because reCAPTCHA gives a click
2048
+ one of exactly two replies, and they are the two kinds of board:
2049
+
2050
+ * the small blue CHIP in the tile's top-left corner — the photo was
2051
+ KEPT. Nothing is on its way in, the selection is the answer, and the
2052
+ caller should press Verify (`chipped`).
2053
+ * the photo blanking or dissolving under a large centred check — the
2054
+ tile is being SWAPPED, and what lands may match too, so the board has
2055
+ to be read again (`loading`).
2056
+
2057
+ A widget that swaps one clicked cell swaps them all, so the two never
2058
+ share a board and one look at the tiles we just clicked settles it. The
2059
+ chip is what `detect_selected_cells` reports as `selected` — it looks for
2060
+ it in the top-left corner only, behind a circularity and a centroid test
2061
+ a centred check fails — and we already read it every poll.
2062
+
2063
+ `chipped` needs EVERY watched tile, because a partial reading is a
2064
+ misread and the two mistakes do not cost the same: calling a swapping
2065
+ board finished submits half an answer and burns the attempt, while
2066
+ calling a chipped board unfinished costs one inference. It is also only
2067
+ ever OUR click that a chip can be reporting: tiles already wearing one
2068
+ are filtered out of the model's answer before we click, so a chip on a
2069
+ watched tile arrived in response to this round.
989
2070
 
990
2071
  The blank/fade transition LAGS the click by a beat: reCAPTCHA holds a
991
2072
  clicked tile selected (old image visible) for ~1-3s and only then blanks
@@ -1024,17 +2105,23 @@ class PageSolver:
1024
2105
  frames.append(path)
1025
2106
 
1026
2107
  states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
2108
+ # Chip first: a chip landing on a tile ZOOMS its photo out, which
2109
+ # reads as `changing` on the same frame that shows the chip. Test
2110
+ # the swap first and every chipped board looks like a swapping
2111
+ # one for as long as the animation runs.
2112
+ if priority and set((states or {}).get("selected", [])).issuperset(priority):
2113
+ return [], True
1027
2114
  in_scope = lambda c: watch is None or c in watch # noqa: E731
1028
2115
  empty = [c for c in (states or {}).get("empty", []) if in_scope(c)]
1029
2116
  changing = [c for c in (states or {}).get("changing", []) if in_scope(c)]
1030
2117
  loading = sorted(set(empty) | set(changing))
1031
2118
  if loading:
1032
- return self._order_by_priority(loading, priority)
2119
+ return self._order_by_priority(loading, priority), False
1033
2120
  _unlink(frames.pop(0))
1034
- return []
2121
+ return [], False
1035
2122
  except Exception as exc:
1036
2123
  _debug(f"fade-onset error: {exc}")
1037
- return []
2124
+ return [], False
1038
2125
  finally:
1039
2126
  for path in frames:
1040
2127
  _unlink(path)
@@ -1101,8 +2188,14 @@ class PageSolver:
1101
2188
  tiles and waits for at least one to finish reloading before re-solving,
1102
2189
  so we never burn a model call on a grid that is still mid-fade.
1103
2190
 
1104
- Submits ONLY on `done` — never on a round-cap exit, which is left to the
1105
- outer loop to re-detect and decide.
2191
+ Only a board that SWAPS a clicked tile out is worth those extra rounds.
2192
+ One that ticks the tile and keeps the photo has been fully answered by
2193
+ the round that clicked it — same as the 4x4 — so `_watch_clicked_tiles`
2194
+ reports the chip and this submits there and then. Rounds 2..N exist for
2195
+ the fading board and nothing else.
2196
+
2197
+ Submits on `done`, and on a board that ticked our clicks — never on a
2198
+ round-cap exit, which is left to the outer loop to re-detect and decide.
1106
2199
  """
1107
2200
  cfg = self.config
1108
2201
  session = _GridSession(
@@ -1124,7 +2217,8 @@ class PageSolver:
1124
2217
  # Each round can legitimately spend ~10s waiting on fades, so eight
1125
2218
  # of them plus the model calls can outlast the whole solve budget.
1126
2219
  self._check_deadline(f"recaptcha grid round {round_index}")
1127
- self._wait_for_grid_cells_loaded(element)
2220
+ with self._phase("grid-load"):
2221
+ self._wait_for_grid_cells_loaded(element)
1128
2222
  shot = _tmp_png("recap")
1129
2223
  action: Optional[Dict[str, Any]] = None
1130
2224
  try:
@@ -1148,7 +2242,7 @@ class PageSolver:
1148
2242
 
1149
2243
  if action.get("action") == "wait":
1150
2244
  _log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
1151
- loading = self._current_loading_cells(page, element, session, clicked_order)
2245
+ loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
1152
2246
  self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
1153
2247
  continue
1154
2248
 
@@ -1179,11 +2273,18 @@ class PageSolver:
1179
2273
  f"-> cells {clicked_this_round}."
1180
2274
  )
1181
2275
 
1182
- loading = self._current_loading_cells(page, element, session, clicked_this_round)
1183
- if not loading:
1184
- # Nothing faded within the grace window → the model fully
1185
- # solved it; submit rather than burning another round.
1186
- _log(f"[recaptcha-grid] round {round_index}: nothing loading; submitting.")
2276
+ loading, chipped = self._watch_clicked_tiles(
2277
+ page, element, session, clicked_this_round
2278
+ )
2279
+ if chipped or not loading:
2280
+ # Either the widget ticked our clicks and kept the photos
2281
+ # (a board that does that is answered), or nothing faded
2282
+ # within the grace window. Submit rather than paying for
2283
+ # another round to be told the same thing.
2284
+ _log(
2285
+ f"[recaptcha-grid] round {round_index}: "
2286
+ f"{'tiles chipped' if chipped else 'nothing loading'}; submitting."
2287
+ )
1187
2288
  should_submit = True
1188
2289
  break
1189
2290
  self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
@@ -1225,55 +2326,145 @@ class PageSolver:
1225
2326
  else:
1226
2327
  puzzle_source = "unknown"
1227
2328
 
2329
+ # Everything the answer might have to be delivered INTO — a text box, a
2330
+ # slider handle — is looked up against this, never against the page.
2331
+ # For the iframed vendors it is the challenge document; for the ones
2332
+ # that render into the host page (GeeTest, Yidun, BotDetect, …) it is
2333
+ # the widget element, whose subtree is the same boundary.
2334
+ scope = element.content_frame() or element
2335
+
2336
+ # Does this puzzle want a STRING rather than a place to click? Only the
2337
+ # DOM can say. The picture cannot: BotDetect's warped code and
2338
+ # hCaptcha's "click the matching character" are the same genre of image
2339
+ # and want opposite answers. Restricted to `unknown` because neither
2340
+ # hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
2341
+ # inside one of their frames would be a false positive by definition.
2342
+ text_mode = puzzle_source == "unknown" and self._find_control(
2343
+ scope, TEXT_INPUT_SELECTORS
2344
+ ) is not None
2345
+ if text_mode:
2346
+ _log("widget has a text box; solving as a distorted-text captcha")
2347
+
1228
2348
  # hCaptcha REUSES the challenge iframe across rounds: after a submit it
1229
2349
  # briefly shows the previous round, then a spinner, then the next one.
1230
2350
  # Screenshotting any of those transitional frames feeds the model a
1231
2351
  # blank/stale grid it correctly calls "unsupported" — which used to abort
1232
2352
  # the whole solve on round 2.
2353
+ is_animated = False
1233
2354
  if puzzle_source == "hcaptcha" and "frame=challenge" in src:
1234
2355
  if self._last_submit_frame_hash:
1235
- self._wait_for_change_since(element, self._last_submit_frame_hash)
2356
+ with self._phase("await-next-round"):
2357
+ self._wait_for_change_since(element, self._last_submit_frame_hash)
1236
2358
  self._last_submit_frame_hash = None
1237
- self._wait_for_hcaptcha_challenge_images(element)
1238
- if self._wait_for_element_settled(element) == "animated":
1239
- raise AnimatedChallengeError(
1240
- "the challenge never settles (likely a video/animated puzzle)"
1241
- )
2359
+ with self._phase("hcaptcha-images"):
2360
+ self._wait_for_hcaptcha_challenge_images(element)
2361
+ is_animated = self._settle_or_animated(element)
2362
+ # Those three waits total up to ~21s and none of them watches for
2363
+ # success, so the vendor's token routinely lands DURING them. Ask
2364
+ # once more before spending an inference: this is the last free
2365
+ # moment to notice the captcha is already accepted, and the
2366
+ # inference is the single most expensive thing in the loop.
2367
+ if self.is_captcha_solved(page):
2368
+ _log("solved while waiting for the next round; skipping inference.")
2369
+ return False, []
2370
+ elif puzzle_source == "unknown":
2371
+ # Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
2372
+ # probe was never run for these, so an animated one — GeeTest's svg board
2373
+ # cycles its glyph set — was screenshotted mid-cycle and answered from
2374
+ # whatever single moment we happened to catch. reCAPTCHA is excluded on
2375
+ # purpose: it has its own readiness gate below, its grids are never
2376
+ # animated, and a second probe would only add latency to a path that
2377
+ # already works.
2378
+ is_animated = self._settle_or_animated(element)
1242
2379
 
1243
2380
  # Only the image-challenge frame holds a grid. Running grid detection on
1244
2381
  # the anchor checkbox just burns an 8s timeout before the click.
1245
2382
  is_recaptcha_challenge = puzzle_source == "recaptcha" and "recaptcha/api2/bframe" in src
1246
- is_recaptcha_one_shot = False
1247
2383
  if is_recaptcha_challenge:
1248
- self._wait_for_grid_cells_loaded(element)
2384
+ with self._phase("grid-load"):
2385
+ self._wait_for_grid_cells_loaded(element)
1249
2386
  grid = self._get_grid_boxes(element)
1250
2387
  if grid and grid["size"] == 3:
1251
- # 3x3 refreshes tiles in place, so it needs the multi-round
1252
- # driver. 4x4 only ever returns `checked` and is one-shot.
2388
+ # Only a 3x3 ever refreshes its tiles in place, so only a 3x3
2389
+ # can need more than one round — and whether THIS one does is
2390
+ # decided inside the driver, by what the widget does with the
2391
+ # first click. A 4x4 never refreshes: it falls through to the
2392
+ # ordinary click-then-submit path below.
1253
2393
  element_box = element.bounding_box()
1254
2394
  if element_box:
1255
2395
  return self._solve_recaptcha_grid(
1256
2396
  page, element, retry_mode, grid, element_box
1257
2397
  )
1258
- elif grid and grid["size"] == 4:
1259
- is_recaptcha_one_shot = True
1260
2398
 
1261
2399
  shot = _tmp_png("captcha")
1262
2400
  performed_action = False
2401
+ slid = False
2402
+ placed = False
2403
+ clicked = False
2404
+ typed = False
1263
2405
  all_usage: List[Dict[str, Any]] = []
2406
+ keyframe_dir: Optional[str] = None
2407
+ have_shot = False
1264
2408
  try:
1265
- self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
1266
-
1267
- actions, all_usage = self._solve_frame_freshness_guarded(
1268
- element,
1269
- shot,
1270
- lambda image_path: self._get_solution(image_path, puzzle_source, retry_mode),
1271
- )
2409
+ if is_animated:
2410
+ # The freshness guard is deliberately SKIPPED here. It re-solves when
2411
+ # the frame changes during inference, and an animated challenge
2412
+ # changes by definition — every attempt would be judged stale and the
2413
+ # whole re-solve budget would burn without ever acting. The frame
2414
+ # number in the answer is the real guard: it names the state to act
2415
+ # in, and `_execute_click` waits for it.
2416
+ with self._phase("burst"):
2417
+ keyframes, keyframe_dir = self._record_keyframes(element)
2418
+ # THE RECORDING IS THE TEST. A burst of a widget that turns out
2419
+ # not to move slices to a single keyframe (`mode=static`), and
2420
+ # that frame is exactly the still this round would have taken
2421
+ # anyway — so a wrong guess about "is it animated" costs the
2422
+ # burst and nothing else, and cannot produce a wrong answer.
2423
+ #
2424
+ # This replaced a separate probe that watched the widget FIRST
2425
+ # and only recorded if it saw motion. That paid for the
2426
+ # observation twice over: up to `animated_probe_ms` to decide,
2427
+ # then the burst to act, and it still had to be long enough to
2428
+ # outlast the longest hold (8.2s measured on
2429
+ # number_with_highest_value_video) or it answered "static" from
2430
+ # inside one — which it did, live, burning 9s of a 45s budget to
2431
+ # reach the wrong conclusion about a puzzle that was animating.
2432
+ if len(keyframes) < 2:
2433
+ _log("[animated] the recording shows one picture; "
2434
+ "solving it as a still")
2435
+ is_animated = False
2436
+ # Reuse the frame we just recorded rather than taking
2437
+ # another screenshot of the same motionless widget.
2438
+ shutil.copyfile(keyframes[0], shot)
2439
+ have_shot = True
2440
+ shutil.rmtree(keyframe_dir, ignore_errors=True)
2441
+ keyframe_dir = None
2442
+ else:
2443
+ with self._phase("inference"):
2444
+ actions, all_usage = self._get_keyframe_solution(keyframes)
2445
+
2446
+ if not is_animated:
2447
+ if not have_shot:
2448
+ self._screenshot(element, shot,
2449
+ timeout_ms=self.config.element_screenshot_timeout_ms)
2450
+ with self._phase("inference"):
2451
+ actions, all_usage = self._solve_frame_freshness_guarded(
2452
+ element,
2453
+ shot,
2454
+ lambda image_path: self._get_solution(
2455
+ image_path, puzzle_source, retry_mode, text_mode=text_mode
2456
+ ),
2457
+ )
1272
2458
 
1273
2459
  element_box = element.bounding_box()
1274
2460
  if not element_box:
1275
2461
  raise CaptchaSolveError("could not get bounding box of captcha element")
1276
2462
 
2463
+ # Recorded at the moment of EXECUTION, which is what makes a repeat
2464
+ # mean something: this exact answer is about to be performed, so if
2465
+ # it matches the last one, the last one already ran and the page is
2466
+ # still asking the same question.
2467
+ self._note_answer(actions, retry_mode)
1277
2468
  _log(f"executing {len(actions)} action(s)")
1278
2469
  frame = element.content_frame()
1279
2470
  verify_button = None
@@ -1291,43 +2482,132 @@ class PageSolver:
1291
2482
  if not bboxes and not action.get("target_coordinates"):
1292
2483
  _log("click action has no bboxes or coordinates; skipping")
1293
2484
  continue
2485
+ # On an animated challenge, hold each click until the widget is
2486
+ # back in the state the model answered about. Per-click, not once
2487
+ # per action: these puzzles keep cycling, so by the time click 2
2488
+ # comes round the state has moved on again.
2489
+ await_kf = action.get("await_keyframe")
1294
2490
  if bboxes:
1295
2491
  for bbox in bboxes:
2492
+ if await_kf:
2493
+ self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
1296
2494
  self._execute_click(page, {"target_bounding_box": bbox}, element_box)
1297
2495
  _delay(random.random() * 80 + 80)
1298
2496
  else:
1299
2497
  self._execute_click(page, action, element_box)
1300
2498
  performed_action = True
2499
+ clicked = True
2500
+ elif kind == "drag" and not action.get("source_bounding_box"):
2501
+ # No source — a puzzle-piece slider. What you grab is not
2502
+ # what has to arrive, so this cannot go through
2503
+ # _execute_drag: pressing the gap the model named and
2504
+ # dragging from there picks up nothing at all.
2505
+ if self._execute_slide(page, element, scope, action, element_box):
2506
+ performed_action = True
2507
+ slid = True
1301
2508
  elif kind == "drag":
2509
+ # Wait on the SOURCE: the piece has to be there to be picked up.
2510
+ # The destination is not gated — by the time the mouse arrives the
2511
+ # animation has moved on regardless, and a drop is judged by where
2512
+ # it lands, not by what the slot looked like on pickup.
2513
+ await_kf = action.get("await_keyframe")
2514
+ if await_kf and action.get("source_bounding_box"):
2515
+ self._wait_for_keyframe(
2516
+ element, await_kf, _bbox_center(action["source_bounding_box"])
2517
+ )
1302
2518
  self._execute_drag(page, action, element_box)
1303
2519
  performed_action = True
2520
+ placed = True
2521
+ elif kind == "type":
2522
+ if self._execute_type(page, scope, action):
2523
+ performed_action = True
2524
+ typed = True
1304
2525
  elif kind == "wait":
1305
2526
  duration = int(action.get("duration_ms") or 0)
1306
2527
  if duration > 0:
1307
2528
  _delay(duration)
1308
2529
  performed_action = True
1309
2530
 
1310
- if frame:
1311
- verify_button = self._get_verify_button(frame)
1312
- if verify_button:
1313
- self._move_to_element(page, verify_button)
1314
-
1315
- # Submit policy:
1316
- # hCaptcha — every puzzle is one-shot; Verify submits it.
1317
- # reCAPTCHA 4x4 — one-shot too (never fades), so submit now.
1318
- # no action/done — submit to advance.
1319
- # (reCAPTCHA 3x3 never reaches here; it returned above.)
1320
- should_submit = (
1321
- not performed_action or puzzle_source == "hcaptcha" or is_recaptcha_one_shot
1322
- )
1323
- if should_submit and frame and verify_button:
2531
+
2532
+ # Resolve the widget's submit control AFTER the action loop, not
2533
+ # inside it. An empty plan — the correct answer to reCAPTCHA 3x3's
2534
+ # `none_present` variation, where nothing matches and the control
2535
+ # reads SKIP — never enters that loop, so the lookup never ran, the
2536
+ # press below found `verify_button` None, and the round aborted on
2537
+ # 'performed no interactions'. The finder was never the problem:
2538
+ # 'Skip' has always been in its list. It was simply never called for
2539
+ # the one answer shape that performs no other action.
2540
+ # `scope` when there is no vendor iframe. Eight vendors render
2541
+ # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2542
+ # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2543
+ # for all of them and the button was never even SEARCHED FOR,
2544
+ # while the text box and the slider handle it sits beside were
2545
+ # both found through `scope` a few lines above. Two containers
2546
+ # for two halves of one interaction.
2547
+ #
2548
+ # This used to be gated on `typed`, for fear of turning up the
2549
+ # submit of the FORM the captcha guards. `scope` is the widget
2550
+ # container and the xpaths are RELATIVE, so that button is out
2551
+ # of reach by construction; what the gate actually did was make
2552
+ # every non-typed inline puzzle unsubmittable. Measured on the
2553
+ # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2554
+ # burning all ten solve loops on a puzzle they had answered on
2555
+ # the first one. The press itself is still bounded by
2556
+ # `should_submit` below, which is where the hazard belongs.
2557
+ lookup = frame or (scope if not slid else None)
2558
+ if lookup is not None:
2559
+ verify_button = self._get_verify_button(lookup)
2560
+ if verify_button:
2561
+ self._move_to_element(page, verify_button)
2562
+
2563
+ # Submit policy: press the widget's own submit control whenever we
2564
+ # have put an ANSWER into it — a selection, a placed piece, a typed
2565
+ # code — or when we had nothing to do and want the round to advance.
2566
+ #
2567
+ # Two exclusions, and they are the whole rule:
2568
+ #
2569
+ # a completed SLIDE has already submitted. Letting go of the handle
2570
+ # is the gesture these puzzles grade; none of them ships a Verify
2571
+ # button, so anything the generic finder turns up afterwards
2572
+ # belongs to the host page, and pressing it would submit the form
2573
+ # the captcha guards while the verdict is still in flight.
2574
+ # a round that only WAITED has answered nothing. Submitting an
2575
+ # empty board spends the attempt on a puzzle we were about to
2576
+ # solve.
2577
+ #
2578
+ # hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
2579
+ # special cases; they are ordinary click rounds and this covers them.
2580
+ # (reCAPTCHA 3x3 never reaches here — it returned above, to the
2581
+ # driver that owns its fade-and-re-round rounds.)
2582
+ #
2583
+ # A click round DID used to be excluded, on the reasoning that these
2584
+ # boards re-round and a half-made selection spends the attempt. They
2585
+ # do not: the ones that grade themselves mid-selection draw no submit
2586
+ # control at all, so `verify_button` is None and nothing is pressed
2587
+ # either way. What the exclusion actually bought was a whole extra
2588
+ # model call per puzzle, spent asking a board we had already answered
2589
+ # correctly whether it was `done` — 6.2 s of prosopo_grid_3x3's 13.8 s,
2590
+ # on a selection that scored 1.0 on the first call.
2591
+ answered = clicked or placed or typed
2592
+ should_submit = not slid and (answered or not performed_action)
2593
+ if should_submit and verify_button:
1324
2594
  _log(f"clicking Verify to submit ({puzzle_source}).")
1325
2595
  self._move_and_click(page, verify_button)
2596
+ # The press IS an interaction, and saying so is load-bearing:
2597
+ # the caller aborts a round that reports none, so submitting a
2598
+ # `done` answer and then returning False re-arms the very guard
2599
+ # this satisfies — the puzzle is sent and the solve gives up on
2600
+ # it one line later, which is what `prosopo_grid_3x3` did.
2601
+ performed_action = True
1326
2602
  # Snapshot at submit time so the NEXT attempt waits for the real
1327
2603
  # transition before treating whatever is on screen as fresh.
1328
2604
  self._last_submit_frame_hash = self._element_frame_hash(element)
1329
2605
  finally:
1330
2606
  _unlink(shot)
2607
+ # Only now: the wait gate re-reads the keyframe PNG on every poll, so
2608
+ # removing the directory any earlier would break the click it is gating.
2609
+ if keyframe_dir:
2610
+ shutil.rmtree(keyframe_dir, ignore_errors=True)
1331
2611
 
1332
2612
  return performed_action, all_usage
1333
2613
 
@@ -1335,6 +2615,27 @@ class PageSolver:
1335
2615
  # Public entry point
1336
2616
  # ------------------------------------------------------------------
1337
2617
 
2618
+ def watch(self, page: Any, **options: Any) -> "CaptchaWatcher":
2619
+ """
2620
+ A watcher that solves captchas on `page` as they appear.
2621
+
2622
+ Mirrors the TS `solver.watch(page)` in intent, but NOT in whether it
2623
+ blocks: this returns an idle watcher and you choose how to drive it,
2624
+ because a sync Playwright handle cannot be driven from a worker thread.
2625
+
2626
+ solver.watch(page).run() # blocking; hold the page clean
2627
+ w = solver.watch(page)
2628
+ while my_loop():
2629
+ w.poll_once() # cooperative; you own the cadence
2630
+
2631
+ Options are `CaptchaWatcher`'s fields: `interval_ms`, `max_solves`,
2632
+ `error_backoff_ms`, `on_solved`, `on_error`. Nothing is injected into
2633
+ the page; see watcher.py.
2634
+ """
2635
+ from .watcher import CaptchaWatcher
2636
+
2637
+ return CaptchaWatcher(solver=self, page=page, **options)
2638
+
1338
2639
  def solve(self, page: Any) -> SolveResult:
1339
2640
  """
1340
2641
  Solve whatever captcha is on `page`.
@@ -1348,6 +2649,9 @@ class PageSolver:
1348
2649
  self._last_submit_frame_hash = None
1349
2650
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
1350
2651
  self._viewport_cache = None
2652
+ self._cursor_seeded = False
2653
+ self._reset_animated_state()
2654
+ self._budget = PhaseBudget()
1351
2655
 
1352
2656
  # Mint one session id for the WHOLE solve, exactly as the TS driver does
1353
2657
  # per `solve()`. The planner turns it into `X-CK-Session`, which is what
@@ -1364,6 +2668,10 @@ class PageSolver:
1364
2668
  try:
1365
2669
  return self._solve_impl(page, start, cumulative_usage)
1366
2670
  finally:
2671
+ # Printed on the way out of every solve, success or failure — a
2672
+ # solve that FAILED is exactly the one whose time you want itemised.
2673
+ if timings_enabled() and self._budget is not None:
2674
+ print(self._budget.report(), file=sys.stderr)
1367
2675
  self._deadline_ms = None
1368
2676
  if previous_session is None:
1369
2677
  os.environ.pop(_SESSION_ENV, None)
@@ -1384,16 +2692,47 @@ class PageSolver:
1384
2692
  stale_element_retries = 0
1385
2693
  has_interacted = False
1386
2694
  render_waits = 0
1387
- max_render_waits = 6
2695
+ # Strictly FEWER than the solve loops. A render wait consumes an
2696
+ # attempt, so at parity the loop runs out first and the branch below
2697
+ # never fires — "no interactive captcha widget", which is the correct
2698
+ # and benign answer for a reCAPTCHA v3 / invisible page, is reported
2699
+ # instead as "still detected after N solve loops". That is a hard error
2700
+ # for a caller who catches NoCaptchaFoundError to mean "nothing here",
2701
+ # and it appeared the moment max_solve_loops came down from 10 to 6.
2702
+ max_render_waits = min(6, cfg.max_solve_loops - 1)
1388
2703
 
1389
2704
  for attempt in range(1, cfg.max_solve_loops + 1):
2705
+ # Round 1 did not finish the challenge. Before spending another
2706
+ # still inference on it, let this round's settle check probe whether
2707
+ # the widget is animated — the settle rule alone cannot tell, and
2708
+ # answering an animated puzzle from one still is how a solve burns
2709
+ # all ten loops without ever being able to succeed.
2710
+ if attempt >= 2:
2711
+ self._arm_animated_probe()
1390
2712
  if (time.monotonic() * 1000.0) - start > cfg.overall_solve_timeout_ms:
1391
2713
  raise CaptchaSolveError(
1392
2714
  f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms "
1393
2715
  f"(attempt {attempt}/{cfg.max_solve_loops})"
1394
2716
  )
1395
2717
 
1396
- element = self.detect_captcha(page)
2718
+ # Ask the DOM whether we are DONE before asking what to solve next.
2719
+ # Only once we have interacted: before that a populated token is the
2720
+ # "already satisfied" case handled below, and this ordering would
2721
+ # skip the render-wait a fresh widget needs.
2722
+ #
2723
+ # `detect_captcha` is the expensive call — it settles pixels and
2724
+ # screenshots the element — and after the winning submit it returns
2725
+ # the challenge frame hCaptcha is TEARING DOWN. Solving that frame
2726
+ # cannot succeed; it just runs until the handle goes stale, which
2727
+ # measured 19-33s of dead time at the end of every run while the
2728
+ # answer had already been accepted. `is_captcha_solved` is a couple
2729
+ # of cheap DOM reads and is authoritative, so it goes first.
2730
+ if has_interacted and self.is_captcha_solved(page):
2731
+ _log("captcha reports solved; finishing.")
2732
+ return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
2733
+
2734
+ with self._phase("detect"):
2735
+ element = self.detect_captcha(page)
1397
2736
  if not element:
1398
2737
  # Two-stage. A null detection splits into two very different
1399
2738
  # cases and treating them alike is how you either hang on a
@@ -1432,47 +2771,105 @@ class PageSolver:
1432
2771
  did_interact, usage = self._solve_single(page, element, retry_mode_this_loop)
1433
2772
  except AnimatedChallengeError:
1434
2773
  raise
1435
- except UnsupportedCaptchaError:
2774
+ except UnsupportedCaptchaError as unsupported:
1436
2775
  # A settled frame the model cannot solve is normally definitive.
1437
2776
  # BUT mid-solve, a transitional blank frame produces the same
1438
2777
  # verdict — that was the "solves round 1, dies on round 2" bug.
1439
2778
  if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
1440
2779
  unsupported_retries += 1
1441
2780
  current = self.detect_captcha(page)
1442
- if current and self._wait_for_element_settled(current) == "animated":
1443
- raise AnimatedChallengeError(
1444
- "the challenge never settles (likely a video puzzle)"
1445
- )
2781
+ with self._phase("settle"):
2782
+ _settled = self._wait_for_element_settled(current)
2783
+ if current and _settled == "animated":
2784
+ # Used to be terminal. Now it just means the next round is an
2785
+ # animated puzzle: retry the loop and `_solve_single` takes the
2786
+ # recording path. `unsupported_retries` still bounds it, so a
2787
+ # widget that is animated AND unsolvable cannot spin here.
2788
+ if not cfg.video_solve_enabled:
2789
+ raise AnimatedChallengeError(
2790
+ "the challenge never settles and video_solve_enabled is off"
2791
+ )
2792
+ _log('"unsupported" mid-solve and the next round is animated; '
2793
+ "retrying into the recording path.")
2794
+ continue
1446
2795
  _log(
1447
2796
  f'"unsupported" mid-solve; settled and retrying '
1448
2797
  f"({unsupported_retries}/{cfg.max_unsupported_resolves})."
1449
2798
  )
1450
2799
  continue
2800
+ # The solver's OWN message, not a guess about what it saw.
2801
+ # This used to substitute "likely an hCaptcha click/drag
2802
+ # puzzle" for every unsupported verdict, including the ones
2803
+ # that already said exactly what was wrong — "prompt
2804
+ # generation 1 has no distorted-text prompt", say, which names
2805
+ # both the cause and the fix. Reporting a wrong guess in place
2806
+ # of a right answer costs whoever reads the gate an
2807
+ # investigation, every time.
1451
2808
  raise UnsupportedChallengeError(
1452
- "cannot solve this kind of captcha — the rendered puzzle is not a "
1453
- "supported grid or checkbox (likely an hCaptcha click/drag puzzle)"
1454
- )
2809
+ f"cannot solve this kind of captcha — {unsupported}"
2810
+ ) from unsupported
1455
2811
  except Exception as exc:
1456
2812
  # A stale/detached handle after a submit is a TRANSITION, not a
1457
2813
  # dead puzzle: hCaptcha swapped in the next round while we held
1458
2814
  # the old iframe. Only after interacting — a first-frame failure
1459
2815
  # is a genuine problem worth surfacing.
1460
2816
  message = str(exc)
1461
- if (
1462
- has_interacted
1463
- and stale_element_retries < cfg.max_stale_element_retries
1464
- and _STALE_HANDLE_RE.search(message)
1465
- ):
1466
- stale_element_retries += 1
1467
- _log(
1468
- f"stale challenge handle after submit; re-detecting next round "
1469
- f"({stale_element_retries}/{cfg.max_stale_element_retries})."
1470
- )
1471
- _delay(cfg.stale_element_backoff_ms)
1472
- continue
2817
+ closed = bool(_CLOSED_TARGET_RE.search(message))
2818
+ if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
2819
+ # ASK THE VENDOR FIRST. The handle most often went stale
2820
+ # BECAUSE the answer was accepted: reCAPTCHA tears the
2821
+ # challenge iframe down the instant it takes an answer, and
2822
+ # hCaptcha swaps the frame out. Re-entering the pipeline
2823
+ # here spends a whole round — detect, screenshot, infer — on
2824
+ # a puzzle that no longer exists, and races the teardown.
2825
+ # Headless usually won that race; headed usually lost it and
2826
+ # surfaced `TargetClosedError` from whatever ran next.
2827
+ #
2828
+ # The top-of-loop check is not enough on its own: it runs
2829
+ # only after the backoff, and it does DOM reads that throw
2830
+ # on a target that is already gone.
2831
+ try:
2832
+ if self.is_captcha_solved(page):
2833
+ _log("captcha reports solved; finishing.")
2834
+ return SolveResult(
2835
+ True, self._last_mouse, _aggregate(cumulative_usage)
2836
+ )
2837
+ except Exception: # noqa: BLE001
2838
+ # Can't consult the vendor — the page is gone. Fall
2839
+ # through; `closed` decides between a named error and a
2840
+ # retry, and neither wants this exception in its place.
2841
+ pass
2842
+
2843
+ if closed:
2844
+ raise PageClosedError(
2845
+ "the page, context or browser closed mid-solve, after the "
2846
+ "answer had been submitted but before the vendor's verdict "
2847
+ "could be read — the solve may in fact have succeeded"
2848
+ ) from exc
2849
+
2850
+ if stale_element_retries < cfg.max_stale_element_retries:
2851
+ stale_element_retries += 1
2852
+ _log(
2853
+ f"stale challenge handle after submit; re-detecting next round "
2854
+ f"({stale_element_retries}/{cfg.max_stale_element_retries})."
2855
+ )
2856
+ _delay(cfg.stale_element_backoff_ms)
2857
+ continue
1473
2858
  raise
1474
2859
 
1475
2860
  has_interacted = has_interacted or did_interact
2861
+
2862
+ # Give up on a solve that is repeating itself, rather than letting
2863
+ # the clock do it. The alternative is not "one more chance" — it is
2864
+ # the same click, again, until `overall_solve_timeout_ms`, which is
2865
+ # how a hopeless captcha came to cost 66s and how half of Tier 3's
2866
+ # wall-clock was being spent on attempts that could not succeed.
2867
+ if self._no_progress_rounds >= cfg.max_no_progress_rounds:
2868
+ raise CaptchaSolveError(
2869
+ f"no progress: the model returned the same answer "
2870
+ f"{self._no_progress_rounds + 1} times running and the challenge "
2871
+ f"is still up (attempt {attempt}/{cfg.max_solve_loops})"
2872
+ )
1476
2873
  render_waits = 0
1477
2874
  cumulative_usage.extend(usage)
1478
2875
 
@@ -1484,17 +2881,52 @@ class PageSolver:
1484
2881
  # definitive signal, so it cannot loop.
1485
2882
  deadline = time.monotonic() * 1000.0 + cfg.post_solve_outcome_timeout_ms
1486
2883
  solved = False
2884
+ widget_gone = 0
2885
+ _verdict_t0 = time.perf_counter()
1487
2886
  while time.monotonic() * 1000.0 < deadline:
1488
2887
  if self.is_captcha_solved(page):
1489
2888
  solved = True
1490
2889
  break
2890
+ # The eight inline vendors have no response token, so
2891
+ # `is_captcha_solved` — which reads only the hCaptcha and
2892
+ # reCAPTCHA anchors — can never fire for them and this loop
2893
+ # ran out its whole 2.5s budget on EVERY round, waiting for
2894
+ # a signal that cannot arrive. Measured on geetest_v4_slide:
2895
+ # 5.2s of a 12.3s solve, spent after the puzzle was already
2896
+ # answered, with the widget sitting there visibly solved.
2897
+ #
2898
+ # "The widget is gone" is the completion signal for those
2899
+ # vendors and is already the authority immediately after
2900
+ # this loop, so this only reaches the same verdict sooner —
2901
+ # confirmed over two polls so a frame caught mid-swap
2902
+ # between rounds cannot read as a solve.
2903
+ if self.detect_captcha(page) is None:
2904
+ widget_gone += 1
2905
+ if widget_gone >= 2:
2906
+ solved = True
2907
+ break
2908
+ else:
2909
+ widget_gone = 0
1491
2910
  if self._is_challenge_freshly_rendered(page):
1492
2911
  break # next round is up; go solve it now
1493
- _delay(200)
2912
+ _delay(cfg.post_solve_outcome_poll_ms)
2913
+ _verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
2914
+ if self._budget is not None:
2915
+ self._budget.add("await-verdict", _verdict_ms)
2916
+ # How long a SUCCESS actually took to show itself. This is the
2917
+ # only number that can size `post_solve_outcome_timeout_ms`: the
2918
+ # window exists to catch a late success, so it needs to cover
2919
+ # the slowest real one and nothing beyond it. A round that ends
2920
+ # any other way spends the whole window by construction — there
2921
+ # is no signal on a wrong answer — so its duration says nothing
2922
+ # about how long the window ought to be.
2923
+ if solved:
2924
+ _log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
1494
2925
  if solved:
1495
2926
  return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
1496
2927
  else:
1497
- _delay(cfg.post_solve_delay_ms + random.random() * 300)
2928
+ with self._phase("post-submit-delay"):
2929
+ _delay(cfg.post_solve_delay_ms + random.random() * 300)
1498
2930
 
1499
2931
  if self._has_recaptcha_underselect_error(page):
1500
2932
  if already_retried_underselect:
@@ -1525,6 +2957,28 @@ class PageSolver:
1525
2957
  # --------------------------------------------------------------------------
1526
2958
 
1527
2959
 
2960
+ def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]:
2961
+ """Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the keyframe
2962
+ wait gate compares around. The solver builds these boxes as a small square
2963
+ around the model's point, so the centre recovers that point exactly."""
2964
+ x1, y1, x2, y2 = (float(v) for v in bbox)
2965
+ return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
2966
+
2967
+
2968
+ def _round_pts(value: Any, places: int = 3) -> Any:
2969
+ """Coordinates rounded, for comparing two answers for sameness.
2970
+
2971
+ Rounded rather than compared exactly because the same tile chosen twice can
2972
+ differ in the last float digit after the normalise/clamp round-trip, and a
2973
+ repeat that reads as "different" is a repeat that costs a round.
2974
+ """
2975
+ if isinstance(value, (int, float)):
2976
+ return round(float(value), places)
2977
+ if isinstance(value, (list, tuple)):
2978
+ return [_round_pts(v, places) for v in value]
2979
+ return value
2980
+
2981
+
1528
2982
  def _as_dict(action: Any) -> Dict[str, Any]:
1529
2983
  """Actions arrive as pydantic models in-process, or dicts from JSON."""
1530
2984
  if isinstance(action, dict):