captchakraken 2.3.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/errors.d.ts +45 -0
  6. package/dist/errors.js +74 -0
  7. package/dist/index.d.ts +50 -0
  8. package/dist/index.js +52 -1
  9. package/dist/limits.d.ts +51 -0
  10. package/dist/limits.js +54 -0
  11. package/dist/model-name.d.ts +23 -0
  12. package/dist/model-name.js +70 -0
  13. package/dist/playwright-types.d.ts +28 -0
  14. package/dist/puppeteer-adapter.d.ts +11 -0
  15. package/dist/puppeteer-adapter.js +23 -0
  16. package/dist/python-command.d.ts +50 -0
  17. package/dist/python-command.js +60 -0
  18. package/dist/slide-geometry.d.ts +33 -0
  19. package/dist/slide-geometry.js +64 -0
  20. package/dist/solver.d.ts +214 -12
  21. package/dist/solver.js +1297 -165
  22. package/dist/trajectory.d.ts +31 -0
  23. package/dist/trajectory.js +162 -0
  24. package/dist/types.d.ts +128 -11
  25. package/dist/watcher.d.ts +99 -0
  26. package/dist/watcher.js +129 -0
  27. package/package.json +2 -5
  28. package/python/LICENSE +47 -7
  29. package/python/README.md +25 -8
  30. package/python/examples/README.md +36 -0
  31. package/python/examples/_harness.py +182 -94
  32. package/python/examples/demoHcaptcha.py +2 -1
  33. package/python/examples/demoRecaptcha.py +2 -1
  34. package/python/examples/watch_playwright.py +56 -0
  35. package/python/examples/with_playwright.py +43 -0
  36. package/python/pyproject.toml +4 -3
  37. package/python/src/captchakraken/__init__.py +5 -3
  38. package/python/src/captchakraken/action_types.py +24 -3
  39. package/python/src/captchakraken/cli.py +224 -2
  40. package/python/src/captchakraken/config.py +119 -17
  41. package/python/src/captchakraken/errors.py +195 -0
  42. package/python/src/captchakraken/keyframes.py +690 -0
  43. package/python/src/captchakraken/models.json +120 -0
  44. package/python/src/captchakraken/page_solver.py +1465 -102
  45. package/python/src/captchakraken/pinned_model.json +5 -7
  46. package/python/src/captchakraken/planner.py +328 -73
  47. package/python/src/captchakraken/prompts.py +485 -0
  48. package/python/src/captchakraken/solver.py +182 -11
  49. package/python/src/captchakraken/timing.py +70 -0
  50. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  51. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  52. package/python/src/captchakraken/trajectory.py +2 -7
  53. package/python/src/captchakraken/watcher.py +193 -0
  54. 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,45 @@ 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
+
176
321
  # Grid load / dynamic-refresh timing.
322
+ # How long to wait for hCaptcha's task images to paint before screenshotting
323
+ # anyway. Best-effort by design — the screenshot happens either way — so it
324
+ # is bounded by what a loading tile plausibly costs, not by a generous
325
+ # default. 8s was two-thirds of what a whole solve is now allowed to take.
326
+ hcaptcha_images_timeout_ms: int = 3_000
327
+
177
328
  grid_load_poll_interval_ms: int = 250
178
329
  grid_load_timeout_ms: int = 8_000
179
330
  recaptcha_max_dynamic_rounds: int = 8
@@ -182,6 +333,125 @@ class PageSolverConfig:
182
333
  recaptcha_dynamic_fade_wait_ms: int = 6_000
183
334
  recaptcha_tile_hover_enabled: bool = True
184
335
 
336
+ # ── puzzle-piece sliders (see _execute_slide) ──────────────────────────
337
+ # How far to nudge the handle, in px, to learn the piece's width and how
338
+ # fast it follows. Two probes because two unknowns; far enough apart that
339
+ # the difference between the two widths is signal rather than rounding, and
340
+ # both small enough to stay on the shortest track observed (~250px).
341
+ slide_probe_offsets_px: Tuple[float, ...] = (24.0, 64.0)
342
+ # Stop steering once the piece is this close. Tighter than any vendor's
343
+ # accept window, so the limit on solving is the model's slot estimate.
344
+ slide_tolerance_px: float = 2.0
345
+ # Corrections after the probes. Each costs a screenshot with the mouse held
346
+ # down; two is enough for a linear system, and the third would only be
347
+ # chasing a measurement that is not going to converge.
348
+ slide_max_corrections: int = 2
349
+
350
+
351
+ # Vendors with no checkbox/challenge split — one container, one interactive
352
+ # surface. Checked in detect_captcha() after the five hard-coded reCAPTCHA /
353
+ # hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
354
+ # lifted from src/captchaCollection/sources.py, which already drives these 8
355
+ # vendors nightly in the collector. Mirror of VENDOR_WIDGET_LOCATORS in
356
+ # solver.ts — keep both in the same order with the same selectors.
357
+ VENDOR_WIDGET_LOCATORS = [
358
+ {"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
359
+ {"puzzle_source": "tencent", "selectors": ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
360
+ {"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
361
+ {"puzzle_source": "yandex", "selectors": [".CheckboxCaptcha"]},
362
+ {"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
363
+ {"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
364
+ {"puzzle_source": "mtcaptcha", "selectors": [".mtcap"]},
365
+ {"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
366
+ ]
367
+
368
+
369
+ # ── where the answer goes, when it is not a click ───────────────────────────
370
+ #
371
+ # Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
372
+ # first visible match. That order is the whole design: a named vendor selector
373
+ # is unambiguous, while the generic patterns are guesses that happen to be right
374
+ # most of the time. Trying the guess first would, on a page that hosts a captcha
375
+ # *and* a login form, type the captcha's answer into the username box.
376
+ #
377
+ # The generic tail is not a nicety either — it is what actually fires on most
378
+ # pages. Vendors rename these classes without notice (they are anti-bot
379
+ # surfaces, so churn is the point), and our own Tier 3 fixtures render neither
380
+ # vendor's DOM. Anything that only worked via the vendor list would be a feature
381
+ # that passes review and fails in the field.
382
+
383
+ # A distorted-text captcha's answer box. The three vendor entries are the three
384
+ # types in instructions.py::TEXT_TYPES.
385
+ TEXT_INPUT_SELECTORS = [
386
+ # BotDetect — the input is application-defined, so match the id fragment its
387
+ # own docs and samples use. These three are what the nightly collector
388
+ # already drives (src/captchaCollection/sources.py).
389
+ "input[id*=captchaCode]",
390
+ "input#captchaCode",
391
+ "input[id*=validateCaptcha]",
392
+ ".BDC_CaptchaDiv input[type=text]",
393
+ # MTCaptcha
394
+ "input.mtcap-inputtext",
395
+ ".mtcap input[type=text]",
396
+ # Yandex SmartCaptcha
397
+ ".AdvancedCaptcha-Input input",
398
+ "input.Textinput-Control",
399
+ 'input[name="rep"]',
400
+ # Generic — an input the page itself labels as the captcha answer.
401
+ 'input[name*="captcha" i]',
402
+ 'input[id*="captcha" i]',
403
+ 'input[aria-label*="captcha" i]',
404
+ 'input[placeholder*="code" i]',
405
+ 'input[autocomplete="off"][type=text]',
406
+ # Last resort: the only text box in the widget. Scoped to the challenge
407
+ # frame/container by the caller, never to the whole page — see _find_control.
408
+ "input[type=text]",
409
+ "input:not([type])",
410
+ "input[type=tel]",
411
+ "textarea",
412
+ ]
413
+
414
+ # The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
415
+ # these vendors the piece is inert decoration that the handle carries, so a
416
+ # drag starting on the piece moves nothing at all.
417
+ SLIDER_HANDLE_SELECTORS = [
418
+ # GeeTest v3 / v4
419
+ ".geetest_slider_button",
420
+ ".geetest_btn",
421
+ ".geetest_slider .geetest_arrow",
422
+ # Tencent
423
+ "#tcaptcha_drag_thumb",
424
+ ".tc-slider-normal",
425
+ "[id*=slideBlock]",
426
+ # Yidun (NetEase)
427
+ ".yidun_slider",
428
+ ".yidun_jigsaw",
429
+ # Lemin
430
+ ".lemin-slider-handle",
431
+ "#lemin-cropped-captcha .slider",
432
+ # Generic — an ARIA slider, or a class that says handle/thumb/button on a
433
+ # track. `[draggable=true]` is deliberately absent: it is the HTML5
434
+ # drag-and-drop opt-in, which fires dragstart rather than pointermove, and
435
+ # no slider captcha uses it.
436
+ '[role="slider"]',
437
+ "[aria-valuenow]",
438
+ '[class*="slider"][class*="btn"]',
439
+ '[class*="slider"][class*="button"]',
440
+ '[class*="slide"][class*="handle"]',
441
+ '[class*="drag"][class*="thumb"]',
442
+ ]
443
+
444
+ # Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
445
+ # has no track at all — you drag the piece itself onto the gap — and the model
446
+ # answers it with the same sourceless drag, because from the picture the two are
447
+ # indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
448
+ DRAGGABLE_PIECE_SELECTORS = [
449
+ ".lemin-cropped-puzzle-piece",
450
+ "#lemin-cropped-captcha canvas + canvas",
451
+ '[class*="puzzle"][class*="piece"]',
452
+ '[class*="jigsaw"]',
453
+ ]
454
+
185
455
 
186
456
  class PageSolver:
187
457
  """
@@ -208,12 +478,93 @@ class PageSolver:
208
478
  # accumulates token usage and holds the HTTP session to vLLM.
209
479
  self._solver = solver or CaptchaSolver(**solver_kwargs)
210
480
  self._last_mouse: Tuple[float, float] = (0.0, 0.0)
481
+ # See _seed_cursor: the (0, 0) origin wedges camoufox's humanised
482
+ # mouse, so the first move of each solve must step off it plainly.
483
+ self._cursor_seeded = False
211
484
  self._last_submit_frame_hash: Optional[str] = None
212
485
  # Absolute deadline for the current solve, in the same clock as
213
486
  # time.monotonic() * 1000. None outside a solve.
214
487
  self._deadline_ms: Optional[float] = None
215
488
  # Window size for clamping, resolved once per solve. See _viewport.
216
489
  self._viewport_cache: Optional[Dict[str, float]] = None
490
+ # Animation detection, all reset per solve by `_reset_animated_state`.
491
+ # `_known_animated` latches: once a widget has been SEEN to animate,
492
+ # every later round takes the keyframe path without re-probing.
493
+ self._known_animated = False
494
+ self._animated_probe_armed = False
495
+ self._animated_probe_done = False
496
+ self._keyframe_mode: Optional[str] = None
497
+ # Repeat detection; see `max_no_progress_rounds`.
498
+ self._last_answer_sig: Optional[str] = None
499
+ self._no_progress_rounds = 0
500
+ # Per-solve phase accounting; see `_phase`.
501
+ self._budget: Optional[PhaseBudget] = None
502
+
503
+ @contextmanager
504
+ def _phase(self, name: str):
505
+ """Attribute the enclosed wall-clock to `name` for this solve's budget.
506
+
507
+ A no-op outside a solve, so helpers stay callable from tests and from
508
+ the CLI without a budget having been opened.
509
+ """
510
+ if self._budget is None:
511
+ yield
512
+ return
513
+ with self._budget.phase(name):
514
+ yield
515
+
516
+ @staticmethod
517
+ def _answer_signature(actions: Sequence[Any], retry_mode: Optional[str]) -> Optional[str]:
518
+ """A stable identity for "the answer this round is about to execute".
519
+
520
+ Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
521
+ about the same board and its answer legitimately overlaps the previous
522
+ one, so counting that as a repeat would abandon the one path built to
523
+ recover from an under-selection.
524
+
525
+ Returns None when the actions cannot be summarised, which is treated as
526
+ "not a repeat" — an unreadable answer must never be the reason a solve
527
+ is abandoned.
528
+ """
529
+ try:
530
+ parts = []
531
+ for raw in actions:
532
+ a = _as_dict(raw)
533
+ parts.append((
534
+ a.get("action"),
535
+ _round_pts(a.get("target_bounding_boxes")),
536
+ _round_pts(a.get("target_bounding_box")),
537
+ _round_pts(a.get("target_coordinates")),
538
+ _round_pts(a.get("source_bounding_box")),
539
+ a.get("text"),
540
+ ))
541
+ return repr((retry_mode, parts))
542
+ except Exception: # noqa: BLE001 — a signature is an optimisation, not a contract
543
+ return None
544
+
545
+ def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[str]) -> None:
546
+ """Count consecutive identical answers; see `max_no_progress_rounds`."""
547
+ sig = self._answer_signature(actions, retry_mode)
548
+ if sig is not None and sig == self._last_answer_sig:
549
+ self._no_progress_rounds += 1
550
+ _log(f"[no-progress] the model returned the same answer again "
551
+ f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds}) — "
552
+ f"the previous one already ran and changed nothing")
553
+ # A board that reads the same every round is the signature of a
554
+ # CYCLING challenge answered as a still. Give the recording path a
555
+ # chance before giving up on the solve entirely.
556
+ self._arm_animated_probe()
557
+ else:
558
+ self._no_progress_rounds = 0
559
+ self._last_answer_sig = sig
560
+
561
+ def _reset_animated_state(self) -> None:
562
+ self._known_animated = False
563
+ self._animated_probe_armed = False
564
+ self._animated_probe_done = False
565
+ self._keyframe_mode = None
566
+ self._last_answer_sig: Optional[str] = None
567
+ self._no_progress_rounds = 0
217
568
 
218
569
  def _check_deadline(self, where: str) -> None:
219
570
  """
@@ -283,7 +634,8 @@ class PageSolver:
283
634
  return None
284
635
 
285
636
  def _get_solution(
286
- self, image_path: str, puzzle_source: str, retry_mode: Optional[str]
637
+ self, image_path: str, puzzle_source: str, retry_mode: Optional[str],
638
+ text_mode: bool = False,
287
639
  ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
288
640
  """
289
641
  The model query. In-process where TS spawns the CLI.
@@ -295,13 +647,32 @@ class PageSolver:
295
647
  planner = self._solver.planner
296
648
  before = len(planner.token_usage)
297
649
  actions = self._solver.solve(
298
- image_path, puzzle_source=puzzle_source, retry_mode=retry_mode
650
+ image_path, puzzle_source=puzzle_source, retry_mode=retry_mode,
651
+ text_mode=text_mode,
299
652
  )
300
653
  usage = [dict(u) for u in planner.token_usage[before:]]
301
654
  if not isinstance(actions, list):
302
655
  actions = [actions]
303
656
  return actions, usage
304
657
 
658
+ def _get_keyframe_solution(
659
+ self, keyframe_paths: Sequence[str]
660
+ ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
661
+ """The model query for an animated challenge. Usage read by DELTA, as above.
662
+
663
+ One request for the whole keyframe set, not one per frame: the model has to
664
+ compare the frames to find what differs between them, which it can only do
665
+ with all of them in a single context. Per-frame queries would also cost N
666
+ billable rounds for one puzzle.
667
+ """
668
+ planner = self._solver.planner
669
+ before = len(planner.token_usage)
670
+ actions = self._solver.solve_keyframes(keyframe_paths)
671
+ usage = [dict(u) for u in planner.token_usage[before:]]
672
+ if not isinstance(actions, list):
673
+ actions = [actions]
674
+ return actions, usage
675
+
305
676
  # ------------------------------------------------------------------
306
677
  # Mouse
307
678
  # ------------------------------------------------------------------
@@ -388,9 +759,50 @@ class PageSolver:
388
759
  # Any other per-sample failure is skipped rather than fatal —
389
760
  # losing one mousemove must not lose the solve.
390
761
 
762
+ def _seed_cursor(self, page: Any) -> None:
763
+ """Move the cursor off its (0, 0) origin once per solve, in ONE plain
764
+ move, before any humanised trajectory runs.
765
+
766
+ Without this, the first trajectory of a solve begins at (0, 0) — the
767
+ exact window corner, because that is where the pointer starts and
768
+ nothing has moved it. Under camoufox's `humanize` juggler a short move
769
+ from that origin never returns: `page.mouse.move()` blocks forever at 0%
770
+ CPU, and since dispatch is serialised on a process-global activation
771
+ chain, every later input event blocks behind it too. The solve looks
772
+ hung with no error anywhere and the Tier 3 fixture run times out on all
773
+ three attempts.
774
+
775
+ Reduced to four lines against camoufox directly:
776
+
777
+ with Camoufox(headless=True, humanize=True) as b:
778
+ p = b.new_page(); p.goto("about:blank")
779
+ p.mouse.move(1.0, 1.0) # never returns
780
+
781
+ The same move after ANY interior move completes in ~1.1s, which is what
782
+ makes this the fix rather than a workaround: it is the ORIGIN that is
783
+ poisoned, not the destination. Same failure family as camoufox #225.
784
+
785
+ Deliberately not routed through `_smooth_move`: that would generate a
786
+ trajectory from (0, 0) and reintroduce exactly the move being avoided.
787
+ """
788
+ if self._cursor_seeded:
789
+ return
790
+ self._cursor_seeded = True
791
+ vp = self._viewport(page)
792
+ # Centre when the window is known, else a modest interior point — any
793
+ # coordinate comfortably off the corner will do.
794
+ cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
795
+ try:
796
+ page.mouse.move(cx, cy)
797
+ self._last_mouse = (cx, cy)
798
+ except Exception: # noqa: BLE001 — an adapter without a mouse must not fail the solve
799
+ pass
800
+
391
801
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
802
+ self._seed_cursor(page)
392
803
  points, timings = generate_trajectory(self._last_mouse, (x, y), 60)
393
- self._trace_path(page, points, timings)
804
+ with self._phase("mouse"):
805
+ self._trace_path(page, points, timings)
394
806
 
395
807
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
396
808
  # BOUNDED. Playwright's default timeout is 30s, and this is called once
@@ -472,6 +884,256 @@ class PageSolver:
472
884
  _delay(random.random() * 50 + 50)
473
885
  page.mouse.up()
474
886
 
887
+ # ------------------------------------------------------------------
888
+ # Typing and sliding
889
+ # ------------------------------------------------------------------
890
+
891
+ def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
892
+ """First VISIBLE match for `selectors`, tried in order.
893
+
894
+ `scope` is the challenge frame, or — for the vendors that render into
895
+ the host page rather than an iframe — the widget element itself. Never
896
+ the page: the generic tail of both selector tables would otherwise
897
+ happily match a login form's text box or a carousel's drag handle
898
+ somewhere else on the document, and the answer would go there.
899
+ """
900
+ for selector in selectors:
901
+ try:
902
+ element = scope.query_selector(selector)
903
+ except Exception:
904
+ continue # a selector this adapter can't parse must not end the search
905
+ if self._visible(element):
906
+ return element
907
+ return None
908
+
909
+ def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any]) -> bool:
910
+ """Put the model's reading of a distorted-text captcha into its box."""
911
+ text = str(action.get("text") or "")
912
+ if not text:
913
+ return False
914
+ field = self._find_control(scope, TEXT_INPUT_SELECTORS)
915
+ if field is None:
916
+ _log("type action, but no text box in the widget; skipping")
917
+ return False
918
+
919
+ self._move_and_click(page, field) # travel there, then press to focus
920
+ # A retry round arrives with the previous attempt still in the box, and
921
+ # typing would APPEND to it — submitting a string the model never read.
922
+ try:
923
+ page.keyboard.press("Control+A")
924
+ except Exception:
925
+ pass
926
+ # Per character rather than one `type(text, delay=…)` call: a constant
927
+ # inter-key delay is itself a signal, and these are the vendors that
928
+ # score typing cadence.
929
+ for ch in text:
930
+ try:
931
+ page.keyboard.type(ch)
932
+ except Exception as exc:
933
+ _log(f"could not type into the captcha field: {exc}")
934
+ return False
935
+ _delay(random.random() * 90 + 45)
936
+ _log(f"typed {len(text)} character(s) into the captcha field")
937
+ return True
938
+
939
+ def _track_piece(
940
+ self, element: Any, before: str, after: str, exclude: Sequence[float]
941
+ ) -> Optional[Sequence[int]]:
942
+ """`captchakraken track-piece` — box of what moved, handle masked out."""
943
+ from .tool_calls.track_piece import changed_bbox
944
+
945
+ try:
946
+ self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
947
+ return changed_bbox(before, after, exclude)
948
+ except Exception as exc:
949
+ _debug(f"track_piece failed: {exc}")
950
+ return None
951
+
952
+ def _execute_slide(
953
+ self,
954
+ page: Any,
955
+ element: Any,
956
+ scope: Any,
957
+ action: Dict[str, Any],
958
+ element_box: Dict[str, float],
959
+ ) -> bool:
960
+ """Drive a puzzle-piece slider until the PIECE reaches the model's slot.
961
+
962
+ The model is asked for one thing here — the centre of the gap — because
963
+ it is the only thing the picture can tell it. What it cannot know is how
964
+ far the handle must travel to put the piece there: the handle is
965
+ elsewhere on the widget, and the ratio between the two is a vendor
966
+ implementation detail that several of them deliberately vary.
967
+
968
+ So this is closed-loop, not a calculation. Press the handle, nudge it
969
+ twice by known amounts, and watch the screen:
970
+
971
+ union(before, after) spans the piece's ORIGINAL left edge to its
972
+ CURRENT right edge, so its width is piece_width + ratio × nudge.
973
+
974
+ Two nudges, two widths, two unknowns — solve for both, then steer the
975
+ remaining distance and re-measure. The mouse is not released until the
976
+ piece is home, because on every one of these puzzles releasing IS the
977
+ submit; there is no Verify button to reconsider at.
978
+
979
+ Returns False if there is nothing here to drag, leaving the caller's
980
+ normal no-op handling to deal with it.
981
+ """
982
+ target_x = (
983
+ (float(action["target_bounding_box"][0]) + float(action["target_bounding_box"][2])) / 2
984
+ ) * element_box["width"]
985
+
986
+ handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
987
+ if handle is None:
988
+ # No track — the sliderless members of the family (Lemin's
989
+ # "cropped") want the piece dragged directly. Same answer from the
990
+ # model, because the two look identical; different gesture. Nothing
991
+ # to close a loop on, since the piece is under the cursor and moves
992
+ # with it one for one.
993
+ piece = self._find_control(scope, DRAGGABLE_PIECE_SELECTORS)
994
+ if piece is None:
995
+ _log("slide action, but the widget has neither a slider nor a draggable piece")
996
+ return False
997
+ box = piece.bounding_box()
998
+ if not box:
999
+ return False
1000
+ # BOTH axes. The rail members travel horizontally and nothing else,
1001
+ # so the handle's own y is the only y there is — but a free drag
1002
+ # carries the piece across the card, and holding the piece's row
1003
+ # here slid it along the TRAY and released it there, 250 px below
1004
+ # the slot, on every attempt.
1005
+ target_y = (
1006
+ (float(action["target_bounding_box"][1])
1007
+ + float(action["target_bounding_box"][3])) / 2
1008
+ ) * element_box["height"]
1009
+ _log("no slider track; dragging the piece to the slot directly")
1010
+ self._smooth_move(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
1011
+ page.mouse.down()
1012
+ _delay(random.random() * 50 + 50)
1013
+ self._smooth_move(page, element_box["x"] + target_x, element_box["y"] + target_y)
1014
+ _delay(random.random() * 50 + 50)
1015
+ page.mouse.up()
1016
+ return True
1017
+
1018
+ hbox = handle.bounding_box()
1019
+ if not hbox:
1020
+ return False
1021
+ start_x = hbox["x"] + hbox["width"] / 2
1022
+ hold_y = hbox["y"] + hbox["height"] / 2
1023
+
1024
+ # Mask the whole horizontal BAND the handle runs in, not just where it
1025
+ # is now: it is about to move across that band, and most vendors fill
1026
+ # the track behind it as it goes. Either would otherwise be the largest
1027
+ # moving thing in frame, and we would track the handle instead of the
1028
+ # piece.
1029
+ pad = max(4.0, hbox["height"] * 0.35)
1030
+ exclude = [
1031
+ 0.0,
1032
+ hbox["y"] - element_box["y"] - pad,
1033
+ element_box["width"],
1034
+ hbox["y"] + hbox["height"] - element_box["y"] + pad,
1035
+ ]
1036
+
1037
+ shots = [_tmp_png("slide") for _ in range(4)]
1038
+ try:
1039
+ self._move_to_element(page, handle, padding_percentage=30.0)
1040
+ page.mouse.down()
1041
+ _delay(random.random() * 60 + 60)
1042
+ self._screenshot(element, shots[0],
1043
+ timeout_ms=self.config.element_screenshot_timeout_ms)
1044
+
1045
+ probes = self.config.slide_probe_offsets_px
1046
+ widths: List[Tuple[float, float]] = []
1047
+ last_box = None
1048
+ for offset, shot in zip(probes, shots[1:]):
1049
+ self._smooth_move(page, start_x + offset, hold_y)
1050
+ _delay(random.random() * 40 + 40)
1051
+ box = self._track_piece(element, shots[0], shot, exclude)
1052
+ if box is not None:
1053
+ widths.append((float(offset), float(box[2] - box[0])))
1054
+ last_box = box
1055
+
1056
+ piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
1057
+ if last_box is None or piece_w is None:
1058
+ # Never saw the piece — a canvas the screenshot cannot separate,
1059
+ # a widget that redraws wholesale, or a press the handle refused.
1060
+ # Fall back on the geometry every one of these puzzles shares:
1061
+ # piece and handle both start flush left, so the handle's travel
1062
+ # is the piece's travel.
1063
+ _log("slider: piece never resolved on screen; steering by handle travel alone")
1064
+ self._smooth_move(page, start_x + (target_x - (start_x - element_box["x"])), hold_y)
1065
+ else:
1066
+ # The offset `last_box` was MEASURED at — not `probes[-1]`, and
1067
+ # not indexed by how many measurements succeeded. If the first
1068
+ # probe failed to resolve and the second worked, those two
1069
+ # disagree, and steering from a base the reading does not belong
1070
+ # to sends the piece somewhere neither the model nor the screen
1071
+ # asked for.
1072
+ offset = float(widths[-1][0])
1073
+ for _ in range(self.config.slide_max_corrections):
1074
+ piece_center = (last_box[2] - piece_w / 2.0)
1075
+ error = target_x - piece_center
1076
+ if abs(error) <= self.config.slide_tolerance_px:
1077
+ break
1078
+ offset += error / ratio
1079
+ self._smooth_move(page, start_x + offset, hold_y)
1080
+ _delay(random.random() * 40 + 40)
1081
+ box = self._track_piece(element, shots[0], shots[3], exclude)
1082
+ if box is None:
1083
+ break # ran out of track; release where we are
1084
+ last_box = box
1085
+ _debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} "
1086
+ f"final_center={last_box[2] - piece_w / 2.0:.1f} target={target_x:.1f}")
1087
+
1088
+ # Settle before letting go. A release in the same tick as the last
1089
+ # move reads as a machine, and some vendors sample the final
1090
+ # milliseconds of the gesture.
1091
+ _delay(random.random() * 120 + 90)
1092
+ finally:
1093
+ try:
1094
+ page.mouse.up()
1095
+ except Exception:
1096
+ pass
1097
+ for shot in shots:
1098
+ _unlink(shot)
1099
+ return True
1100
+
1101
+ @staticmethod
1102
+ def _solve_slide_geometry(
1103
+ widths: Sequence[Tuple[float, float]], widget_width: float
1104
+ ) -> Tuple[Optional[float], float]:
1105
+ """Piece width and handle-to-piece travel ratio, from probe measurements.
1106
+
1107
+ Each measurement is (handle offset, width of what changed), and
1108
+ width = piece_width + ratio × offset. Two of them determine both.
1109
+
1110
+ With only one usable measurement the system is underdetermined, so ratio
1111
+ is ASSUMED to be 1 — true of every vendor observed, and the assumption
1112
+ is stated here rather than buried as a default. A ratio solved from
1113
+ implausible measurements (a redraw, a piece that hit the wall between
1114
+ probes) is rejected the same way: better a 1:1 guess that overshoots and
1115
+ gets corrected than a ratio of 0.02 that sends the handle off the track.
1116
+ """
1117
+ if not widths:
1118
+ return None, 1.0
1119
+ piece_w: Optional[float] = None
1120
+ ratio = 1.0
1121
+ if len(widths) >= 2:
1122
+ (o1, w1), (o2, w2) = widths[0], widths[-1]
1123
+ if o2 != o1:
1124
+ candidate = (w2 - w1) / (o2 - o1)
1125
+ if 0.2 <= candidate <= 3.0:
1126
+ ratio = candidate
1127
+ piece_w = w1 - ratio * o1
1128
+ if piece_w is None:
1129
+ o, w = widths[-1]
1130
+ piece_w = w - ratio * o
1131
+ # A piece narrower than a few pixels, or wider than half the widget, is
1132
+ # a measurement of something else.
1133
+ if not 3.0 <= piece_w <= widget_width * 0.6:
1134
+ return None, ratio
1135
+ return piece_w, ratio
1136
+
475
1137
  # ------------------------------------------------------------------
476
1138
  # Detection
477
1139
  # ------------------------------------------------------------------
@@ -580,6 +1242,14 @@ class PageSolver:
580
1242
  ):
581
1243
  return turnstile_container
582
1244
 
1245
+ # Vendors with one interactive surface (no checkbox/challenge split) —
1246
+ # GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
1247
+ for entry in VENDOR_WIDGET_LOCATORS:
1248
+ for selector in entry["selectors"]:
1249
+ el = page.query_selector(selector)
1250
+ if self._visible(el):
1251
+ return el
1252
+
583
1253
  return None
584
1254
 
585
1255
  def is_captcha_solved(self, page: Any) -> bool:
@@ -591,18 +1261,35 @@ class PageSolver:
591
1261
  a fresh puzzle burns ~18s re-running the pipeline on a closing frame.
592
1262
  """
593
1263
  try:
1264
+ # The token FIRST, and unconditionally. It is a hidden field on the
1265
+ # PAGE, not inside the widget, so it never needed the anchor iframe
1266
+ # to be on screen — and the moment it matters most is precisely when
1267
+ # the anchor is NOT on screen, because hCaptcha keeps its challenge
1268
+ # overlay up for a couple of seconds after the winning submit.
1269
+ # Gating this on `_visible(anchor)` meant the one signal that was
1270
+ # already true went unread, and the loop ground on against a frame
1271
+ # being torn down. See tests/test_solved_detection.py.
1272
+ if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
1273
+ return True
1274
+ if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
1275
+ return True
1276
+ # Turnstile. `detect_captcha` already reads this exact field to
1277
+ # decide a Turnstile widget is UNSOLVED, so the signal was known to
1278
+ # one half of the driver and ignored by the other: a solved
1279
+ # Turnstile reported unsolved here and the loop ground through the
1280
+ # readiness waits and a wasted inference until the widget happened
1281
+ # to disappear. Same defect the hCaptcha token had.
1282
+ if self._has_non_empty_field_value(page, '[name="cf-turnstile-response"]'):
1283
+ return True
1284
+
1285
+ # Anchor state is the fallback, and this one DOES need the iframe:
1286
+ # it is read out of the anchor's own document.
594
1287
  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
1288
+ if self._visible(hc) and self._is_hcaptcha_anchor_checked(hc):
1289
+ return True
600
1290
  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
1291
+ if self._visible(rc) and self._is_recaptcha_anchor_checked(rc):
1292
+ return True
606
1293
  except Exception:
607
1294
  pass
608
1295
  return False
@@ -616,6 +1303,18 @@ class PageSolver:
616
1303
  try:
617
1304
  hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
618
1305
  if self._visible(hc):
1306
+ # "Prompt painted" alone does NOT mean a next round. hCaptcha
1307
+ # leaves the round you just answered on screen while it
1308
+ # verifies, so this fired on the CLOSING frame and broke the
1309
+ # post-submit poll out of its solved-check after ~0ms — which
1310
+ # then committed the solver to ~21s of readiness waits and a
1311
+ # full inference against a frame that was about to be destroyed.
1312
+ # The frame must have actually CHANGED since the submit; we
1313
+ # already snapshot exactly that at submit time.
1314
+ if self._last_submit_frame_hash:
1315
+ current = self._element_frame_hash(hc)
1316
+ if current and current == self._last_submit_frame_hash:
1317
+ return False
619
1318
  frame = hc.content_frame()
620
1319
  prompt = frame.query_selector(".prompt-text") if frame else None
621
1320
  if self._visible(prompt):
@@ -672,13 +1371,20 @@ class PageSolver:
672
1371
  return False
673
1372
 
674
1373
  def _get_verify_button(self, frame: Any) -> Optional[Any]:
1374
+ # `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
1375
+ # markup on the host page rather than a vendor iframe (every
1376
+ # distorted-text captcha), and a document-rooted `//button` does not
1377
+ # resolve against an element handle: the query returned None even with
1378
+ # the button sitting inside that very element, so a typed code was never
1379
+ # submitted. On a Frame the context node is the document, where `.//` and
1380
+ # `//` mean the same thing, so the vendor paths are unaffected.
675
1381
  for text in ("Verify", "Next", "Submit", "Skip"):
676
1382
  lowered = text.lower()
677
1383
  try:
678
1384
  button = frame.query_selector(
679
- f"xpath=//button[contains(translate(., "
1385
+ f"xpath=.//button[contains(translate(., "
680
1386
  f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
681
- f" | //div[@role='button' and contains(translate(., "
1387
+ f" | .//div[@role='button' and contains(translate(., "
682
1388
  f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
683
1389
  )
684
1390
  if self._visible(button):
@@ -692,6 +1398,26 @@ class PageSolver:
692
1398
  hcaptcha_verify = frame.query_selector(".button-submit")
693
1399
  if self._visible(hcaptcha_verify):
694
1400
  return hcaptcha_verify
1401
+ # GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
1402
+ #
1403
+ # Invisible to BOTH shapes above — a bare div carries no
1404
+ # role="button", and "OK" is on none of the four word lists. So
1405
+ # nothing was ever pressed on the puzzles that need pressing, and a
1406
+ # GeeTest board does not grade until you do: the solve loop re-read
1407
+ # the same unchanged panel and re-answered it identically until the
1408
+ # round cap. Ordered icon-click scored 0/31 and 0/13 that way while
1409
+ # the model was answering CORRECTLY (measured 2026-08-19). A driver
1410
+ # that discards a right answer is indistinguishable from a model
1411
+ # that cannot solve the puzzle, which is how this hid.
1412
+ #
1413
+ # Matched by CLASS, not by the word: `geetest_submit_tips` sits
1414
+ # beside it and also reads "OK", and pressing the tooltip does
1415
+ # nothing. `.geetest_submit` is a distinct token, so it cannot
1416
+ # match it. Mirrors getVerifyButton in js/src/solver.ts — CLAUDE.md
1417
+ # 1c, the two ports must behave the same.
1418
+ geetest_submit = frame.query_selector(".geetest_submit")
1419
+ if self._visible(geetest_submit):
1420
+ return geetest_submit
695
1421
  except Exception:
696
1422
  pass
697
1423
  return None
@@ -700,18 +1426,30 @@ class PageSolver:
700
1426
  # Frame readiness
701
1427
  # ------------------------------------------------------------------
702
1428
 
703
- def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None) -> None:
1429
+ def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
1430
+ animations: str = "disabled") -> None:
704
1431
  """
705
- Short timeout + animations disabled, always.
1432
+ Short timeout, and animations disabled unless the caller needs the motion.
706
1433
 
707
1434
  Playwright's default 30s stability wait hangs per-screenshot on a
708
1435
  closing/animating challenge element; that alone made a multi-round solve
709
1436
  take ~115s. Failing fast and skipping a frame is strictly better.
1437
+
1438
+ `animations` is 'disabled' everywhere the goal is a STABLE STILL, which
1439
+ is almost everywhere. It is not a formality: the flag fast-forwards
1440
+ finite animations and FREEZES infinite ones, which is exactly what a
1441
+ model-facing screenshot wants and exactly what a recording must not do.
1442
+ The burst and the animation probe pass 'allow' — see `solver.ts`
1443
+ `recordKeyframeBurst`, which learned this first: GeeTest's svg board
1444
+ cycles in CSS, so a burst taken with animations disabled captured the
1445
+ same picture forty times, the slicer honestly reported `mode=static`,
1446
+ and the solve went back to answering a single still. hCaptcha hid it,
1447
+ because it animates in canvas and the flag does not touch canvas.
710
1448
  """
711
1449
  element.screenshot(
712
1450
  path=path,
713
1451
  timeout=timeout_ms if timeout_ms is not None else 2_500,
714
- animations="disabled",
1452
+ animations=animations,
715
1453
  )
716
1454
 
717
1455
  def _element_frame_hash(self, element: Any) -> Optional[str]:
@@ -737,8 +1475,11 @@ class PageSolver:
737
1475
  cfg = self.config
738
1476
  start = time.monotonic() * 1000.0
739
1477
  previous: Optional[str] = None
740
- still_streak = 0
741
1478
  frames: List[str] = []
1479
+ # This loop only COLLECTS polls; the verdict is `settle_verdict`'s, so
1480
+ # the recorded timelines in tests/test_animated_is_detected.py exercise
1481
+ # the same rule that runs here rather than a copy of it that can drift.
1482
+ samples: List[Tuple[float, bool]] = []
742
1483
  try:
743
1484
  while (time.monotonic() * 1000.0) - start < cfg.settle_timeout_ms:
744
1485
  self._check_deadline("waiting for the challenge to settle")
@@ -752,14 +1493,16 @@ class PageSolver:
752
1493
  frames.append(path)
753
1494
  if previous:
754
1495
  moved = self._has_movement(previous, path, cfg.settle_diff_threshold)
755
- still_streak = 0 if moved else still_streak + 1
756
1496
  if len(frames) > 1:
757
1497
  _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"
1498
+ samples.append(((time.monotonic() * 1000.0) - start, moved))
1499
+ verdict = settle_verdict(
1500
+ samples,
1501
+ settle_frames=cfg.settle_frames,
1502
+ animated_after_ms=cfg.animated_challenge_after_ms,
1503
+ )
1504
+ if verdict != "timeout":
1505
+ return verdict
763
1506
  previous = frames[-1]
764
1507
  _delay(cfg.settle_poll_ms)
765
1508
  return "timeout"
@@ -767,6 +1510,225 @@ class PageSolver:
767
1510
  for path in frames:
768
1511
  _unlink(path)
769
1512
 
1513
+ # ------------------------------------------------------------------
1514
+ # Animated challenges
1515
+ # ------------------------------------------------------------------
1516
+
1517
+ def _settle_or_animated(self, element: Any) -> bool:
1518
+ """Wait for the widget to settle; return whether it is animated instead.
1519
+
1520
+ "Never settles" stopped being a failure. hCaptcha's "select the odd animal"
1521
+ fades its sprites on independent cycles and its "unique motion pattern"
1522
+ puzzle spins identical meshes — those challenges are animated BY DESIGN, and
1523
+ the answer only exists across frames. True here routes the caller to the
1524
+ recording path.
1525
+
1526
+ `video_solve_enabled=False` restores the old behaviour for callers who would
1527
+ rather fail fast than spend the recording time.
1528
+ """
1529
+ # Already established for this widget — don't re-probe every round.
1530
+ if self._known_animated:
1531
+ return True
1532
+
1533
+ with self._phase("settle"):
1534
+ verdict = self._wait_for_element_settled(element)
1535
+ if verdict != "animated":
1536
+ # 'settled' is not proof of static. The settle rule stops at the
1537
+ # first `settle_frames` of stillness, and every animated puzzle we
1538
+ # ship rests between screens for longer than that, so it calls all
1539
+ # of them settled (tests/test_animated_is_detected.py has the
1540
+ # measured timelines).
1541
+ #
1542
+ # What decides it instead is a RECORDING, taken once a round has
1543
+ # already answered the same thing twice — the signature of a
1544
+ # cycling board read as a still. The recording is self-checking: if
1545
+ # the widget really is static it slices to one keyframe and the
1546
+ # caller solves that frame as the still it is. So there is nothing
1547
+ # to be careful about here, and nothing to observe first.
1548
+ if not self._animated_probe_armed or self._animated_probe_done:
1549
+ return False
1550
+ self._animated_probe_done = True
1551
+ self._animated_probe_armed = False
1552
+ _log("[animated] the same answer came back twice — recording the "
1553
+ "challenge to see whether it is cycling")
1554
+
1555
+ # Latch either route. Re-deciding every round would re-record a widget
1556
+ # we have already SEEN animate, and on a puzzle that rests between
1557
+ # screens a later recording can land inside a hold, slice to one frame,
1558
+ # and flip the solve back onto the still path halfway through.
1559
+ self._known_animated = True
1560
+
1561
+ if not self.config.video_solve_enabled:
1562
+ raise AnimatedChallengeError(
1563
+ "the challenge never settles and video_solve_enabled is off"
1564
+ )
1565
+ _log("[animated] challenge is animated — solving it from keyframes")
1566
+ return True
1567
+
1568
+ def _arm_animated_probe(self) -> None:
1569
+ """Round N finished without finishing the CHALLENGE — probe next round.
1570
+
1571
+ Deliberately not armed for round 1. A static puzzle solves there and
1572
+ never pays anything, which is what keeps the common case at the ~530ms
1573
+ settle it has today. Only a widget that survived a round is worth
1574
+ spending a cycle of observation on.
1575
+ """
1576
+ if self.config.animated_probe_enabled and not self._animated_probe_done:
1577
+ self._animated_probe_armed = True
1578
+
1579
+ def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
1580
+ """Record the widget and return `(keyframe_paths, temp_dir)`.
1581
+
1582
+ Screenshots the element at the collector's burst geometry (4 s @ 10 fps),
1583
+ then hands the frames to the SAME slicer the training data was cut with
1584
+ (`keyframes.extract_keyframes`). That shared code path is the whole point:
1585
+ the model answers with a frame NUMBER, and a number only means something if
1586
+ the live set was sliced the way the trained set was.
1587
+
1588
+ Frames are kept in memory and sliced from there — no intermediate mp4. The
1589
+ old pipeline encoded one, and every clip it produced was mp4v, a codec the
1590
+ serving side may or may not decode. Skipping the encode removes that whole
1591
+ class of silent failure along with the disk round-trip.
1592
+
1593
+ The caller owns `temp_dir` and must remove it once the actions are done
1594
+ with: the returned paths are read back by the wait gate on every poll, so
1595
+ cleaning up any earlier would break the click.
1596
+
1597
+ Raises AnimatedChallengeError if nothing could be captured.
1598
+ """
1599
+ from .keyframes import extract_keyframes, write_keyframes
1600
+
1601
+ cfg = self.config
1602
+ fps = max(1, int(cfg.video_burst_fps))
1603
+ total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
1604
+ interval = 1.0 / fps
1605
+
1606
+ frames: List[Any] = []
1607
+
1608
+ remaining = max(0, total - len(frames))
1609
+ shot = _tmp_png("burst")
1610
+ try:
1611
+ for i in range(remaining):
1612
+ self._check_deadline("recording the animated challenge")
1613
+ start = time.monotonic()
1614
+ try:
1615
+ # animations ALLOWED — see `_screenshot`. The JS port has
1616
+ # done this since the GeeTest svg burst came back as 40
1617
+ # copies of one picture; this port never did, so every
1618
+ # CSS-animated vendor was recorded frozen.
1619
+ self._screenshot(element, shot, animations="allow")
1620
+ except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
1621
+ _debug(f"burst frame {i} failed: {exc}")
1622
+ else:
1623
+ import cv2
1624
+
1625
+ img = cv2.imread(shot)
1626
+ if img is not None:
1627
+ frames.append(img)
1628
+ # Drift-corrected: a slow screenshot must not stretch the clip, or
1629
+ # the recording covers more wall-clock than the model trained on and
1630
+ # a cycle's period lands differently across the frames.
1631
+ wait = interval - (time.monotonic() - start)
1632
+ if wait > 0 and i < remaining - 1:
1633
+ time.sleep(wait)
1634
+ finally:
1635
+ _unlink(shot)
1636
+
1637
+ if not frames:
1638
+ raise AnimatedChallengeError(
1639
+ "could not record the animated challenge (no frame screenshotted)"
1640
+ )
1641
+ _log(f"[animated] recorded {len(frames)} frames at {fps}fps")
1642
+
1643
+ kfset = extract_keyframes(frames, fps=float(fps))
1644
+ temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
1645
+ paths = write_keyframes(kfset, temp_dir, stem="challenge")
1646
+ _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1647
+ # The wait gate consults this: `even` means the extractor found no state
1648
+ # that RECURS, so there is nothing for the page to come back to.
1649
+ self._keyframe_mode = kfset.mode
1650
+ return [str(p) for p in paths], temp_dir
1651
+
1652
+ def _wait_for_keyframe(self, element: Any, keyframe_path: str,
1653
+ point_norm: Tuple[float, float]) -> bool:
1654
+ """Hold until the widget looks like `keyframe_path` around `point_norm`.
1655
+
1656
+ This is the reason an animated answer names a frame. The model picked the
1657
+ moment its target was visible; the coordinates are only correct at that
1658
+ moment. Clicking as soon as the answer arrives lands on whatever the sprite
1659
+ happens to be doing — for a cross-fade, usually background.
1660
+
1661
+ Compares only the neighbourhood of the action point, with the same box and
1662
+ the same metric the training label was chosen with
1663
+ (`keyframes.region_box` / `region_diff_ratio`). Local rather than
1664
+ whole-frame because everything ELSE in these puzzles is also moving: a
1665
+ whole-frame match would need every unrelated sprite to align too, and would
1666
+ essentially never open.
1667
+
1668
+ Returns whether the state was reached. On timeout the caller clicks anyway
1669
+ — see `keyframe_wait_timeout_ms`.
1670
+
1671
+ NOT ATTEMPTED on an `even` clip. `keyframes.py` picks that mode precisely
1672
+ when the clip never revisits a picture it has already shown — a slow
1673
+ one-way change, a rotation, a cross-fade — so there is no state for the
1674
+ page to come BACK to and the wait can only ever run out. It is not a
1675
+ cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
1676
+ PER CLICK before giving up and clicking anyway.
1677
+ And `even` is not the rare case. All 116 real clips under
1678
+ `cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
1679
+ fired on real footage, which keyframes.py itself records ("real state
1680
+ separations top out around 0.007, well under this"). So on real traffic
1681
+ this gate was 6s of dead time on every animated click, always followed by
1682
+ the same click it would have made immediately. Measured on
1683
+ `hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
1684
+ 0.0721 against a 0.05 tolerance, then it clicked and solved.
1685
+
1686
+ The gate stays for `cycle`/`static`, where a state genuinely does recur
1687
+ and waiting is the difference between clicking the sprite and clicking
1688
+ the background.
1689
+ """
1690
+ import cv2
1691
+
1692
+ from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1693
+
1694
+ if self._keyframe_mode == "even":
1695
+ _log("[animated] clip is mode=even (no state recurs); "
1696
+ "acting on the model's frame without waiting")
1697
+ return False
1698
+
1699
+ ref = cv2.imread(keyframe_path)
1700
+ if ref is None:
1701
+ _debug(f"keyframe {keyframe_path} unreadable; not waiting")
1702
+ return False
1703
+ box = region_box(ref.shape[1::-1], point_norm)
1704
+
1705
+ cfg = self.config
1706
+ deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
1707
+ probe = _tmp_png("kfwait")
1708
+ best = 1.0
1709
+ try:
1710
+ while (time.monotonic() * 1000.0) < deadline:
1711
+ self._check_deadline("waiting for the challenge keyframe")
1712
+ try:
1713
+ self._screenshot(element, probe)
1714
+ live = cv2.imread(probe)
1715
+ except Exception as exc: # noqa: BLE001
1716
+ _debug(f"keyframe probe failed: {exc}")
1717
+ live = None
1718
+ if live is not None:
1719
+ d = region_diff_ratio(ref, live, box)
1720
+ best = min(best, d)
1721
+ if d <= MATCH_REGION_TOLERANCE:
1722
+ _log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
1723
+ return True
1724
+ _delay(cfg.keyframe_wait_poll_ms)
1725
+ finally:
1726
+ _unlink(probe)
1727
+ _log(f"[animated] widget never matched the chosen keyframe within "
1728
+ f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
1729
+ f"clicking on the model's coordinates anyway")
1730
+ return False
1731
+
770
1732
  def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
771
1733
  """After a submit the frame MUST change (next round, or closing)."""
772
1734
  cfg = self.config
@@ -786,12 +1748,24 @@ class PageSolver:
786
1748
  ships an empty `url("")` placeholder before it arrives. Best-effort: a
787
1749
  timeout falls through to the screenshot, where the existing fail-fast
788
1750
  path still covers a genuinely unsupported puzzle.
1751
+
1752
+ "NOTHING TO WAIT FOR" IS READY. The last clause used to be
1753
+ `return !!(example && ...)`, which is false when there is no example
1754
+ image — so a challenge with no tile grid, no canvas and no example
1755
+ polled until the timeout and then carried on regardless. Measured on
1756
+ `hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
1757
+ full 8s three times over, more than half the budget spent asking a
1758
+ question about elements that were not on the page. A readiness gate can
1759
+ only report on what it can see; with nothing to check it has no opinion,
1760
+ and "no opinion" must not read as "not ready".
789
1761
  """
1762
+ cfg = self.config
790
1763
  try:
791
1764
  frame = challenge_iframe.content_frame()
792
1765
  if not frame:
793
1766
  return
794
- frame.wait_for_selector(".prompt-text", state="visible", timeout=8_000)
1767
+ frame.wait_for_selector(".prompt-text", state="visible",
1768
+ timeout=cfg.hcaptcha_images_timeout_ms)
795
1769
  frame.wait_for_function(
796
1770
  """() => {
797
1771
  const tiles = Array.from(
@@ -806,9 +1780,10 @@ class PageSolver:
806
1780
  if (canvas && canvas.width > 0 && canvas.height > 0) return true;
807
1781
  const example = document.querySelector(
808
1782
  '.challenge-example img, .image-wrapper img');
809
- return !!(example && example.complete && example.naturalWidth > 0);
1783
+ if (example) return example.complete && example.naturalWidth > 0;
1784
+ return true; // nothing here to be waited for
810
1785
  }""",
811
- timeout=8_000,
1786
+ timeout=cfg.hcaptcha_images_timeout_ms,
812
1787
  )
813
1788
  except Exception:
814
1789
  pass # timed out or detached mid-load; screenshot anyway
@@ -981,11 +1956,35 @@ class PageSolver:
981
1956
  ordered.extend(sorted(remaining))
982
1957
  return ordered
983
1958
 
984
- def _current_loading_cells(
1959
+ def _watch_clicked_tiles(
985
1960
  self, page: Any, element: Any, session: _GridSession, priority: Sequence[int] = ()
986
- ) -> List[int]:
1961
+ ) -> Tuple[List[int], bool]:
987
1962
  """
988
- Watch for the ONSET of the refresh over a grace window.
1963
+ Watch the just-clicked tiles until the widget says what it did with them.
1964
+
1965
+ Returns `(loading, chipped)`. Two answers because reCAPTCHA gives a click
1966
+ one of exactly two replies, and they are the two kinds of board:
1967
+
1968
+ * the small blue CHIP in the tile's top-left corner — the photo was
1969
+ KEPT. Nothing is on its way in, the selection is the answer, and the
1970
+ caller should press Verify (`chipped`).
1971
+ * the photo blanking or dissolving under a large centred check — the
1972
+ tile is being SWAPPED, and what lands may match too, so the board has
1973
+ to be read again (`loading`).
1974
+
1975
+ A widget that swaps one clicked cell swaps them all, so the two never
1976
+ share a board and one look at the tiles we just clicked settles it. The
1977
+ chip is what `detect_selected_cells` reports as `selected` — it looks for
1978
+ it in the top-left corner only, behind a circularity and a centroid test
1979
+ a centred check fails — and we already read it every poll.
1980
+
1981
+ `chipped` needs EVERY watched tile, because a partial reading is a
1982
+ misread and the two mistakes do not cost the same: calling a swapping
1983
+ board finished submits half an answer and burns the attempt, while
1984
+ calling a chipped board unfinished costs one inference. It is also only
1985
+ ever OUR click that a chip can be reporting: tiles already wearing one
1986
+ are filtered out of the model's answer before we click, so a chip on a
1987
+ watched tile arrived in response to this round.
989
1988
 
990
1989
  The blank/fade transition LAGS the click by a beat: reCAPTCHA holds a
991
1990
  clicked tile selected (old image visible) for ~1-3s and only then blanks
@@ -1024,17 +2023,23 @@ class PageSolver:
1024
2023
  frames.append(path)
1025
2024
 
1026
2025
  states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
2026
+ # Chip first: a chip landing on a tile ZOOMS its photo out, which
2027
+ # reads as `changing` on the same frame that shows the chip. Test
2028
+ # the swap first and every chipped board looks like a swapping
2029
+ # one for as long as the animation runs.
2030
+ if priority and set((states or {}).get("selected", [])).issuperset(priority):
2031
+ return [], True
1027
2032
  in_scope = lambda c: watch is None or c in watch # noqa: E731
1028
2033
  empty = [c for c in (states or {}).get("empty", []) if in_scope(c)]
1029
2034
  changing = [c for c in (states or {}).get("changing", []) if in_scope(c)]
1030
2035
  loading = sorted(set(empty) | set(changing))
1031
2036
  if loading:
1032
- return self._order_by_priority(loading, priority)
2037
+ return self._order_by_priority(loading, priority), False
1033
2038
  _unlink(frames.pop(0))
1034
- return []
2039
+ return [], False
1035
2040
  except Exception as exc:
1036
2041
  _debug(f"fade-onset error: {exc}")
1037
- return []
2042
+ return [], False
1038
2043
  finally:
1039
2044
  for path in frames:
1040
2045
  _unlink(path)
@@ -1101,8 +2106,14 @@ class PageSolver:
1101
2106
  tiles and waits for at least one to finish reloading before re-solving,
1102
2107
  so we never burn a model call on a grid that is still mid-fade.
1103
2108
 
1104
- Submits ONLY on `done` — never on a round-cap exit, which is left to the
1105
- outer loop to re-detect and decide.
2109
+ Only a board that SWAPS a clicked tile out is worth those extra rounds.
2110
+ One that ticks the tile and keeps the photo has been fully answered by
2111
+ the round that clicked it — same as the 4x4 — so `_watch_clicked_tiles`
2112
+ reports the chip and this submits there and then. Rounds 2..N exist for
2113
+ the fading board and nothing else.
2114
+
2115
+ Submits on `done`, and on a board that ticked our clicks — never on a
2116
+ round-cap exit, which is left to the outer loop to re-detect and decide.
1106
2117
  """
1107
2118
  cfg = self.config
1108
2119
  session = _GridSession(
@@ -1124,7 +2135,8 @@ class PageSolver:
1124
2135
  # Each round can legitimately spend ~10s waiting on fades, so eight
1125
2136
  # of them plus the model calls can outlast the whole solve budget.
1126
2137
  self._check_deadline(f"recaptcha grid round {round_index}")
1127
- self._wait_for_grid_cells_loaded(element)
2138
+ with self._phase("grid-load"):
2139
+ self._wait_for_grid_cells_loaded(element)
1128
2140
  shot = _tmp_png("recap")
1129
2141
  action: Optional[Dict[str, Any]] = None
1130
2142
  try:
@@ -1148,7 +2160,7 @@ class PageSolver:
1148
2160
 
1149
2161
  if action.get("action") == "wait":
1150
2162
  _log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
1151
- loading = self._current_loading_cells(page, element, session, clicked_order)
2163
+ loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
1152
2164
  self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
1153
2165
  continue
1154
2166
 
@@ -1179,11 +2191,18 @@ class PageSolver:
1179
2191
  f"-> cells {clicked_this_round}."
1180
2192
  )
1181
2193
 
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.")
2194
+ loading, chipped = self._watch_clicked_tiles(
2195
+ page, element, session, clicked_this_round
2196
+ )
2197
+ if chipped or not loading:
2198
+ # Either the widget ticked our clicks and kept the photos
2199
+ # (a board that does that is answered), or nothing faded
2200
+ # within the grace window. Submit rather than paying for
2201
+ # another round to be told the same thing.
2202
+ _log(
2203
+ f"[recaptcha-grid] round {round_index}: "
2204
+ f"{'tiles chipped' if chipped else 'nothing loading'}; submitting."
2205
+ )
1187
2206
  should_submit = True
1188
2207
  break
1189
2208
  self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
@@ -1225,55 +2244,145 @@ class PageSolver:
1225
2244
  else:
1226
2245
  puzzle_source = "unknown"
1227
2246
 
2247
+ # Everything the answer might have to be delivered INTO — a text box, a
2248
+ # slider handle — is looked up against this, never against the page.
2249
+ # For the iframed vendors it is the challenge document; for the ones
2250
+ # that render into the host page (GeeTest, Yidun, BotDetect, …) it is
2251
+ # the widget element, whose subtree is the same boundary.
2252
+ scope = element.content_frame() or element
2253
+
2254
+ # Does this puzzle want a STRING rather than a place to click? Only the
2255
+ # DOM can say. The picture cannot: BotDetect's warped code and
2256
+ # hCaptcha's "click the matching character" are the same genre of image
2257
+ # and want opposite answers. Restricted to `unknown` because neither
2258
+ # hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
2259
+ # inside one of their frames would be a false positive by definition.
2260
+ text_mode = puzzle_source == "unknown" and self._find_control(
2261
+ scope, TEXT_INPUT_SELECTORS
2262
+ ) is not None
2263
+ if text_mode:
2264
+ _log("widget has a text box; solving as a distorted-text captcha")
2265
+
1228
2266
  # hCaptcha REUSES the challenge iframe across rounds: after a submit it
1229
2267
  # briefly shows the previous round, then a spinner, then the next one.
1230
2268
  # Screenshotting any of those transitional frames feeds the model a
1231
2269
  # blank/stale grid it correctly calls "unsupported" — which used to abort
1232
2270
  # the whole solve on round 2.
2271
+ is_animated = False
1233
2272
  if puzzle_source == "hcaptcha" and "frame=challenge" in src:
1234
2273
  if self._last_submit_frame_hash:
1235
- self._wait_for_change_since(element, self._last_submit_frame_hash)
2274
+ with self._phase("await-next-round"):
2275
+ self._wait_for_change_since(element, self._last_submit_frame_hash)
1236
2276
  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
- )
2277
+ with self._phase("hcaptcha-images"):
2278
+ self._wait_for_hcaptcha_challenge_images(element)
2279
+ is_animated = self._settle_or_animated(element)
2280
+ # Those three waits total up to ~21s and none of them watches for
2281
+ # success, so the vendor's token routinely lands DURING them. Ask
2282
+ # once more before spending an inference: this is the last free
2283
+ # moment to notice the captcha is already accepted, and the
2284
+ # inference is the single most expensive thing in the loop.
2285
+ if self.is_captcha_solved(page):
2286
+ _log("solved while waiting for the next round; skipping inference.")
2287
+ return False, []
2288
+ elif puzzle_source == "unknown":
2289
+ # Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
2290
+ # probe was never run for these, so an animated one — GeeTest's svg board
2291
+ # cycles its glyph set — was screenshotted mid-cycle and answered from
2292
+ # whatever single moment we happened to catch. reCAPTCHA is excluded on
2293
+ # purpose: it has its own readiness gate below, its grids are never
2294
+ # animated, and a second probe would only add latency to a path that
2295
+ # already works.
2296
+ is_animated = self._settle_or_animated(element)
1242
2297
 
1243
2298
  # Only the image-challenge frame holds a grid. Running grid detection on
1244
2299
  # the anchor checkbox just burns an 8s timeout before the click.
1245
2300
  is_recaptcha_challenge = puzzle_source == "recaptcha" and "recaptcha/api2/bframe" in src
1246
- is_recaptcha_one_shot = False
1247
2301
  if is_recaptcha_challenge:
1248
- self._wait_for_grid_cells_loaded(element)
2302
+ with self._phase("grid-load"):
2303
+ self._wait_for_grid_cells_loaded(element)
1249
2304
  grid = self._get_grid_boxes(element)
1250
2305
  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.
2306
+ # Only a 3x3 ever refreshes its tiles in place, so only a 3x3
2307
+ # can need more than one round — and whether THIS one does is
2308
+ # decided inside the driver, by what the widget does with the
2309
+ # first click. A 4x4 never refreshes: it falls through to the
2310
+ # ordinary click-then-submit path below.
1253
2311
  element_box = element.bounding_box()
1254
2312
  if element_box:
1255
2313
  return self._solve_recaptcha_grid(
1256
2314
  page, element, retry_mode, grid, element_box
1257
2315
  )
1258
- elif grid and grid["size"] == 4:
1259
- is_recaptcha_one_shot = True
1260
2316
 
1261
2317
  shot = _tmp_png("captcha")
1262
2318
  performed_action = False
2319
+ slid = False
2320
+ placed = False
2321
+ clicked = False
2322
+ typed = False
1263
2323
  all_usage: List[Dict[str, Any]] = []
2324
+ keyframe_dir: Optional[str] = None
2325
+ have_shot = False
1264
2326
  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
- )
2327
+ if is_animated:
2328
+ # The freshness guard is deliberately SKIPPED here. It re-solves when
2329
+ # the frame changes during inference, and an animated challenge
2330
+ # changes by definition — every attempt would be judged stale and the
2331
+ # whole re-solve budget would burn without ever acting. The frame
2332
+ # number in the answer is the real guard: it names the state to act
2333
+ # in, and `_execute_click` waits for it.
2334
+ with self._phase("burst"):
2335
+ keyframes, keyframe_dir = self._record_keyframes(element)
2336
+ # THE RECORDING IS THE TEST. A burst of a widget that turns out
2337
+ # not to move slices to a single keyframe (`mode=static`), and
2338
+ # that frame is exactly the still this round would have taken
2339
+ # anyway — so a wrong guess about "is it animated" costs the
2340
+ # burst and nothing else, and cannot produce a wrong answer.
2341
+ #
2342
+ # This replaced a separate probe that watched the widget FIRST
2343
+ # and only recorded if it saw motion. That paid for the
2344
+ # observation twice over: up to `animated_probe_ms` to decide,
2345
+ # then the burst to act, and it still had to be long enough to
2346
+ # outlast the longest hold (8.2s measured on
2347
+ # number_with_highest_value_video) or it answered "static" from
2348
+ # inside one — which it did, live, burning 9s of a 45s budget to
2349
+ # reach the wrong conclusion about a puzzle that was animating.
2350
+ if len(keyframes) < 2:
2351
+ _log("[animated] the recording shows one picture; "
2352
+ "solving it as a still")
2353
+ is_animated = False
2354
+ # Reuse the frame we just recorded rather than taking
2355
+ # another screenshot of the same motionless widget.
2356
+ shutil.copyfile(keyframes[0], shot)
2357
+ have_shot = True
2358
+ shutil.rmtree(keyframe_dir, ignore_errors=True)
2359
+ keyframe_dir = None
2360
+ else:
2361
+ with self._phase("inference"):
2362
+ actions, all_usage = self._get_keyframe_solution(keyframes)
2363
+
2364
+ if not is_animated:
2365
+ if not have_shot:
2366
+ self._screenshot(element, shot,
2367
+ timeout_ms=self.config.element_screenshot_timeout_ms)
2368
+ with self._phase("inference"):
2369
+ actions, all_usage = self._solve_frame_freshness_guarded(
2370
+ element,
2371
+ shot,
2372
+ lambda image_path: self._get_solution(
2373
+ image_path, puzzle_source, retry_mode, text_mode=text_mode
2374
+ ),
2375
+ )
1272
2376
 
1273
2377
  element_box = element.bounding_box()
1274
2378
  if not element_box:
1275
2379
  raise CaptchaSolveError("could not get bounding box of captcha element")
1276
2380
 
2381
+ # Recorded at the moment of EXECUTION, which is what makes a repeat
2382
+ # mean something: this exact answer is about to be performed, so if
2383
+ # it matches the last one, the last one already ran and the page is
2384
+ # still asking the same question.
2385
+ self._note_answer(actions, retry_mode)
1277
2386
  _log(f"executing {len(actions)} action(s)")
1278
2387
  frame = element.content_frame()
1279
2388
  verify_button = None
@@ -1291,43 +2400,123 @@ class PageSolver:
1291
2400
  if not bboxes and not action.get("target_coordinates"):
1292
2401
  _log("click action has no bboxes or coordinates; skipping")
1293
2402
  continue
2403
+ # On an animated challenge, hold each click until the widget is
2404
+ # back in the state the model answered about. Per-click, not once
2405
+ # per action: these puzzles keep cycling, so by the time click 2
2406
+ # comes round the state has moved on again.
2407
+ await_kf = action.get("await_keyframe")
1294
2408
  if bboxes:
1295
2409
  for bbox in bboxes:
2410
+ if await_kf:
2411
+ self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
1296
2412
  self._execute_click(page, {"target_bounding_box": bbox}, element_box)
1297
2413
  _delay(random.random() * 80 + 80)
1298
2414
  else:
1299
2415
  self._execute_click(page, action, element_box)
1300
2416
  performed_action = True
2417
+ clicked = True
2418
+ elif kind == "drag" and not action.get("source_bounding_box"):
2419
+ # No source — a puzzle-piece slider. What you grab is not
2420
+ # what has to arrive, so this cannot go through
2421
+ # _execute_drag: pressing the gap the model named and
2422
+ # dragging from there picks up nothing at all.
2423
+ if self._execute_slide(page, element, scope, action, element_box):
2424
+ performed_action = True
2425
+ slid = True
1301
2426
  elif kind == "drag":
2427
+ # Wait on the SOURCE: the piece has to be there to be picked up.
2428
+ # The destination is not gated — by the time the mouse arrives the
2429
+ # animation has moved on regardless, and a drop is judged by where
2430
+ # it lands, not by what the slot looked like on pickup.
2431
+ await_kf = action.get("await_keyframe")
2432
+ if await_kf and action.get("source_bounding_box"):
2433
+ self._wait_for_keyframe(
2434
+ element, await_kf, _bbox_center(action["source_bounding_box"])
2435
+ )
1302
2436
  self._execute_drag(page, action, element_box)
1303
2437
  performed_action = True
2438
+ placed = True
2439
+ elif kind == "type":
2440
+ if self._execute_type(page, scope, action):
2441
+ performed_action = True
2442
+ typed = True
1304
2443
  elif kind == "wait":
1305
2444
  duration = int(action.get("duration_ms") or 0)
1306
2445
  if duration > 0:
1307
2446
  _delay(duration)
1308
2447
  performed_action = True
1309
2448
 
1310
- if frame:
1311
- verify_button = self._get_verify_button(frame)
2449
+ # `scope` when there is no vendor iframe. Eight vendors render
2450
+ # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2451
+ # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2452
+ # for all of them and the button was never even SEARCHED FOR,
2453
+ # while the text box and the slider handle it sits beside were
2454
+ # both found through `scope` a few lines above. Two containers
2455
+ # for two halves of one interaction.
2456
+ #
2457
+ # This used to be gated on `typed`, for fear of turning up the
2458
+ # submit of the FORM the captcha guards. `scope` is the widget
2459
+ # container and the xpaths are RELATIVE, so that button is out
2460
+ # of reach by construction; what the gate actually did was make
2461
+ # every non-typed inline puzzle unsubmittable. Measured on the
2462
+ # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2463
+ # burning all ten solve loops on a puzzle they had answered on
2464
+ # the first one. The press itself is still bounded by
2465
+ # `should_submit` below, which is where the hazard belongs.
2466
+ lookup = frame or (scope if not slid else None)
2467
+ if lookup is not None:
2468
+ verify_button = self._get_verify_button(lookup)
1312
2469
  if verify_button:
1313
2470
  self._move_to_element(page, verify_button)
1314
2471
 
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:
2472
+ # Submit policy: press the widget's own submit control whenever we
2473
+ # have put an ANSWER into it — a selection, a placed piece, a typed
2474
+ # code — or when we had nothing to do and want the round to advance.
2475
+ #
2476
+ # Two exclusions, and they are the whole rule:
2477
+ #
2478
+ # a completed SLIDE has already submitted. Letting go of the handle
2479
+ # is the gesture these puzzles grade; none of them ships a Verify
2480
+ # button, so anything the generic finder turns up afterwards
2481
+ # belongs to the host page, and pressing it would submit the form
2482
+ # the captcha guards while the verdict is still in flight.
2483
+ # a round that only WAITED has answered nothing. Submitting an
2484
+ # empty board spends the attempt on a puzzle we were about to
2485
+ # solve.
2486
+ #
2487
+ # hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
2488
+ # special cases; they are ordinary click rounds and this covers them.
2489
+ # (reCAPTCHA 3x3 never reaches here — it returned above, to the
2490
+ # driver that owns its fade-and-re-round rounds.)
2491
+ #
2492
+ # A click round DID used to be excluded, on the reasoning that these
2493
+ # boards re-round and a half-made selection spends the attempt. They
2494
+ # do not: the ones that grade themselves mid-selection draw no submit
2495
+ # control at all, so `verify_button` is None and nothing is pressed
2496
+ # either way. What the exclusion actually bought was a whole extra
2497
+ # model call per puzzle, spent asking a board we had already answered
2498
+ # correctly whether it was `done` — 6.2 s of prosopo_grid_3x3's 13.8 s,
2499
+ # on a selection that scored 1.0 on the first call.
2500
+ answered = clicked or placed or typed
2501
+ should_submit = not slid and (answered or not performed_action)
2502
+ if should_submit and verify_button:
1324
2503
  _log(f"clicking Verify to submit ({puzzle_source}).")
1325
2504
  self._move_and_click(page, verify_button)
2505
+ # The press IS an interaction, and saying so is load-bearing:
2506
+ # the caller aborts a round that reports none, so submitting a
2507
+ # `done` answer and then returning False re-arms the very guard
2508
+ # this satisfies — the puzzle is sent and the solve gives up on
2509
+ # it one line later, which is what `prosopo_grid_3x3` did.
2510
+ performed_action = True
1326
2511
  # Snapshot at submit time so the NEXT attempt waits for the real
1327
2512
  # transition before treating whatever is on screen as fresh.
1328
2513
  self._last_submit_frame_hash = self._element_frame_hash(element)
1329
2514
  finally:
1330
2515
  _unlink(shot)
2516
+ # Only now: the wait gate re-reads the keyframe PNG on every poll, so
2517
+ # removing the directory any earlier would break the click it is gating.
2518
+ if keyframe_dir:
2519
+ shutil.rmtree(keyframe_dir, ignore_errors=True)
1331
2520
 
1332
2521
  return performed_action, all_usage
1333
2522
 
@@ -1335,6 +2524,27 @@ class PageSolver:
1335
2524
  # Public entry point
1336
2525
  # ------------------------------------------------------------------
1337
2526
 
2527
+ def watch(self, page: Any, **options: Any) -> "CaptchaWatcher":
2528
+ """
2529
+ A watcher that solves captchas on `page` as they appear.
2530
+
2531
+ Mirrors the TS `solver.watch(page)` in intent, but NOT in whether it
2532
+ blocks: this returns an idle watcher and you choose how to drive it,
2533
+ because a sync Playwright handle cannot be driven from a worker thread.
2534
+
2535
+ solver.watch(page).run() # blocking; hold the page clean
2536
+ w = solver.watch(page)
2537
+ while my_loop():
2538
+ w.poll_once() # cooperative; you own the cadence
2539
+
2540
+ Options are `CaptchaWatcher`'s fields: `interval_ms`, `max_solves`,
2541
+ `error_backoff_ms`, `on_solved`, `on_error`. Nothing is injected into
2542
+ the page; see watcher.py.
2543
+ """
2544
+ from .watcher import CaptchaWatcher
2545
+
2546
+ return CaptchaWatcher(solver=self, page=page, **options)
2547
+
1338
2548
  def solve(self, page: Any) -> SolveResult:
1339
2549
  """
1340
2550
  Solve whatever captcha is on `page`.
@@ -1348,6 +2558,9 @@ class PageSolver:
1348
2558
  self._last_submit_frame_hash = None
1349
2559
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
1350
2560
  self._viewport_cache = None
2561
+ self._cursor_seeded = False
2562
+ self._reset_animated_state()
2563
+ self._budget = PhaseBudget()
1351
2564
 
1352
2565
  # Mint one session id for the WHOLE solve, exactly as the TS driver does
1353
2566
  # per `solve()`. The planner turns it into `X-CK-Session`, which is what
@@ -1364,6 +2577,10 @@ class PageSolver:
1364
2577
  try:
1365
2578
  return self._solve_impl(page, start, cumulative_usage)
1366
2579
  finally:
2580
+ # Printed on the way out of every solve, success or failure — a
2581
+ # solve that FAILED is exactly the one whose time you want itemised.
2582
+ if timings_enabled() and self._budget is not None:
2583
+ print(self._budget.report(), file=sys.stderr)
1367
2584
  self._deadline_ms = None
1368
2585
  if previous_session is None:
1369
2586
  os.environ.pop(_SESSION_ENV, None)
@@ -1384,16 +2601,47 @@ class PageSolver:
1384
2601
  stale_element_retries = 0
1385
2602
  has_interacted = False
1386
2603
  render_waits = 0
1387
- max_render_waits = 6
2604
+ # Strictly FEWER than the solve loops. A render wait consumes an
2605
+ # attempt, so at parity the loop runs out first and the branch below
2606
+ # never fires — "no interactive captcha widget", which is the correct
2607
+ # and benign answer for a reCAPTCHA v3 / invisible page, is reported
2608
+ # instead as "still detected after N solve loops". That is a hard error
2609
+ # for a caller who catches NoCaptchaFoundError to mean "nothing here",
2610
+ # and it appeared the moment max_solve_loops came down from 10 to 6.
2611
+ max_render_waits = min(6, cfg.max_solve_loops - 1)
1388
2612
 
1389
2613
  for attempt in range(1, cfg.max_solve_loops + 1):
2614
+ # Round 1 did not finish the challenge. Before spending another
2615
+ # still inference on it, let this round's settle check probe whether
2616
+ # the widget is animated — the settle rule alone cannot tell, and
2617
+ # answering an animated puzzle from one still is how a solve burns
2618
+ # all ten loops without ever being able to succeed.
2619
+ if attempt >= 2:
2620
+ self._arm_animated_probe()
1390
2621
  if (time.monotonic() * 1000.0) - start > cfg.overall_solve_timeout_ms:
1391
2622
  raise CaptchaSolveError(
1392
2623
  f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms "
1393
2624
  f"(attempt {attempt}/{cfg.max_solve_loops})"
1394
2625
  )
1395
2626
 
1396
- element = self.detect_captcha(page)
2627
+ # Ask the DOM whether we are DONE before asking what to solve next.
2628
+ # Only once we have interacted: before that a populated token is the
2629
+ # "already satisfied" case handled below, and this ordering would
2630
+ # skip the render-wait a fresh widget needs.
2631
+ #
2632
+ # `detect_captcha` is the expensive call — it settles pixels and
2633
+ # screenshots the element — and after the winning submit it returns
2634
+ # the challenge frame hCaptcha is TEARING DOWN. Solving that frame
2635
+ # cannot succeed; it just runs until the handle goes stale, which
2636
+ # measured 19-33s of dead time at the end of every run while the
2637
+ # answer had already been accepted. `is_captcha_solved` is a couple
2638
+ # of cheap DOM reads and is authoritative, so it goes first.
2639
+ if has_interacted and self.is_captcha_solved(page):
2640
+ _log("captcha reports solved; finishing.")
2641
+ return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
2642
+
2643
+ with self._phase("detect"):
2644
+ element = self.detect_captcha(page)
1397
2645
  if not element:
1398
2646
  # Two-stage. A null detection splits into two very different
1399
2647
  # cases and treating them alike is how you either hang on a
@@ -1432,47 +2680,105 @@ class PageSolver:
1432
2680
  did_interact, usage = self._solve_single(page, element, retry_mode_this_loop)
1433
2681
  except AnimatedChallengeError:
1434
2682
  raise
1435
- except UnsupportedCaptchaError:
2683
+ except UnsupportedCaptchaError as unsupported:
1436
2684
  # A settled frame the model cannot solve is normally definitive.
1437
2685
  # BUT mid-solve, a transitional blank frame produces the same
1438
2686
  # verdict — that was the "solves round 1, dies on round 2" bug.
1439
2687
  if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
1440
2688
  unsupported_retries += 1
1441
2689
  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
- )
2690
+ with self._phase("settle"):
2691
+ _settled = self._wait_for_element_settled(current)
2692
+ if current and _settled == "animated":
2693
+ # Used to be terminal. Now it just means the next round is an
2694
+ # animated puzzle: retry the loop and `_solve_single` takes the
2695
+ # recording path. `unsupported_retries` still bounds it, so a
2696
+ # widget that is animated AND unsolvable cannot spin here.
2697
+ if not cfg.video_solve_enabled:
2698
+ raise AnimatedChallengeError(
2699
+ "the challenge never settles and video_solve_enabled is off"
2700
+ )
2701
+ _log('"unsupported" mid-solve and the next round is animated; '
2702
+ "retrying into the recording path.")
2703
+ continue
1446
2704
  _log(
1447
2705
  f'"unsupported" mid-solve; settled and retrying '
1448
2706
  f"({unsupported_retries}/{cfg.max_unsupported_resolves})."
1449
2707
  )
1450
2708
  continue
2709
+ # The solver's OWN message, not a guess about what it saw.
2710
+ # This used to substitute "likely an hCaptcha click/drag
2711
+ # puzzle" for every unsupported verdict, including the ones
2712
+ # that already said exactly what was wrong — "prompt
2713
+ # generation 1 has no distorted-text prompt", say, which names
2714
+ # both the cause and the fix. Reporting a wrong guess in place
2715
+ # of a right answer costs whoever reads the gate an
2716
+ # investigation, every time.
1451
2717
  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
- )
2718
+ f"cannot solve this kind of captcha — {unsupported}"
2719
+ ) from unsupported
1455
2720
  except Exception as exc:
1456
2721
  # A stale/detached handle after a submit is a TRANSITION, not a
1457
2722
  # dead puzzle: hCaptcha swapped in the next round while we held
1458
2723
  # the old iframe. Only after interacting — a first-frame failure
1459
2724
  # is a genuine problem worth surfacing.
1460
2725
  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
2726
+ closed = bool(_CLOSED_TARGET_RE.search(message))
2727
+ if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
2728
+ # ASK THE VENDOR FIRST. The handle most often went stale
2729
+ # BECAUSE the answer was accepted: reCAPTCHA tears the
2730
+ # challenge iframe down the instant it takes an answer, and
2731
+ # hCaptcha swaps the frame out. Re-entering the pipeline
2732
+ # here spends a whole round — detect, screenshot, infer — on
2733
+ # a puzzle that no longer exists, and races the teardown.
2734
+ # Headless usually won that race; headed usually lost it and
2735
+ # surfaced `TargetClosedError` from whatever ran next.
2736
+ #
2737
+ # The top-of-loop check is not enough on its own: it runs
2738
+ # only after the backoff, and it does DOM reads that throw
2739
+ # on a target that is already gone.
2740
+ try:
2741
+ if self.is_captcha_solved(page):
2742
+ _log("captcha reports solved; finishing.")
2743
+ return SolveResult(
2744
+ True, self._last_mouse, _aggregate(cumulative_usage)
2745
+ )
2746
+ except Exception: # noqa: BLE001
2747
+ # Can't consult the vendor — the page is gone. Fall
2748
+ # through; `closed` decides between a named error and a
2749
+ # retry, and neither wants this exception in its place.
2750
+ pass
2751
+
2752
+ if closed:
2753
+ raise PageClosedError(
2754
+ "the page, context or browser closed mid-solve, after the "
2755
+ "answer had been submitted but before the vendor's verdict "
2756
+ "could be read — the solve may in fact have succeeded"
2757
+ ) from exc
2758
+
2759
+ if stale_element_retries < cfg.max_stale_element_retries:
2760
+ stale_element_retries += 1
2761
+ _log(
2762
+ f"stale challenge handle after submit; re-detecting next round "
2763
+ f"({stale_element_retries}/{cfg.max_stale_element_retries})."
2764
+ )
2765
+ _delay(cfg.stale_element_backoff_ms)
2766
+ continue
1473
2767
  raise
1474
2768
 
1475
2769
  has_interacted = has_interacted or did_interact
2770
+
2771
+ # Give up on a solve that is repeating itself, rather than letting
2772
+ # the clock do it. The alternative is not "one more chance" — it is
2773
+ # the same click, again, until `overall_solve_timeout_ms`, which is
2774
+ # how a hopeless captcha came to cost 66s and how half of Tier 3's
2775
+ # wall-clock was being spent on attempts that could not succeed.
2776
+ if self._no_progress_rounds >= cfg.max_no_progress_rounds:
2777
+ raise CaptchaSolveError(
2778
+ f"no progress: the model returned the same answer "
2779
+ f"{self._no_progress_rounds + 1} times running and the challenge "
2780
+ f"is still up (attempt {attempt}/{cfg.max_solve_loops})"
2781
+ )
1476
2782
  render_waits = 0
1477
2783
  cumulative_usage.extend(usage)
1478
2784
 
@@ -1484,17 +2790,52 @@ class PageSolver:
1484
2790
  # definitive signal, so it cannot loop.
1485
2791
  deadline = time.monotonic() * 1000.0 + cfg.post_solve_outcome_timeout_ms
1486
2792
  solved = False
2793
+ widget_gone = 0
2794
+ _verdict_t0 = time.perf_counter()
1487
2795
  while time.monotonic() * 1000.0 < deadline:
1488
2796
  if self.is_captcha_solved(page):
1489
2797
  solved = True
1490
2798
  break
2799
+ # The eight inline vendors have no response token, so
2800
+ # `is_captcha_solved` — which reads only the hCaptcha and
2801
+ # reCAPTCHA anchors — can never fire for them and this loop
2802
+ # ran out its whole 2.5s budget on EVERY round, waiting for
2803
+ # a signal that cannot arrive. Measured on geetest_v4_slide:
2804
+ # 5.2s of a 12.3s solve, spent after the puzzle was already
2805
+ # answered, with the widget sitting there visibly solved.
2806
+ #
2807
+ # "The widget is gone" is the completion signal for those
2808
+ # vendors and is already the authority immediately after
2809
+ # this loop, so this only reaches the same verdict sooner —
2810
+ # confirmed over two polls so a frame caught mid-swap
2811
+ # between rounds cannot read as a solve.
2812
+ if self.detect_captcha(page) is None:
2813
+ widget_gone += 1
2814
+ if widget_gone >= 2:
2815
+ solved = True
2816
+ break
2817
+ else:
2818
+ widget_gone = 0
1491
2819
  if self._is_challenge_freshly_rendered(page):
1492
2820
  break # next round is up; go solve it now
1493
- _delay(200)
2821
+ _delay(cfg.post_solve_outcome_poll_ms)
2822
+ _verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
2823
+ if self._budget is not None:
2824
+ self._budget.add("await-verdict", _verdict_ms)
2825
+ # How long a SUCCESS actually took to show itself. This is the
2826
+ # only number that can size `post_solve_outcome_timeout_ms`: the
2827
+ # window exists to catch a late success, so it needs to cover
2828
+ # the slowest real one and nothing beyond it. A round that ends
2829
+ # any other way spends the whole window by construction — there
2830
+ # is no signal on a wrong answer — so its duration says nothing
2831
+ # about how long the window ought to be.
2832
+ if solved:
2833
+ _log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
1494
2834
  if solved:
1495
2835
  return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
1496
2836
  else:
1497
- _delay(cfg.post_solve_delay_ms + random.random() * 300)
2837
+ with self._phase("post-submit-delay"):
2838
+ _delay(cfg.post_solve_delay_ms + random.random() * 300)
1498
2839
 
1499
2840
  if self._has_recaptcha_underselect_error(page):
1500
2841
  if already_retried_underselect:
@@ -1525,6 +2866,28 @@ class PageSolver:
1525
2866
  # --------------------------------------------------------------------------
1526
2867
 
1527
2868
 
2869
+ def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]:
2870
+ """Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the keyframe
2871
+ wait gate compares around. The solver builds these boxes as a small square
2872
+ around the model's point, so the centre recovers that point exactly."""
2873
+ x1, y1, x2, y2 = (float(v) for v in bbox)
2874
+ return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
2875
+
2876
+
2877
+ def _round_pts(value: Any, places: int = 3) -> Any:
2878
+ """Coordinates rounded, for comparing two answers for sameness.
2879
+
2880
+ Rounded rather than compared exactly because the same tile chosen twice can
2881
+ differ in the last float digit after the normalise/clamp round-trip, and a
2882
+ repeat that reads as "different" is a repeat that costs a round.
2883
+ """
2884
+ if isinstance(value, (int, float)):
2885
+ return round(float(value), places)
2886
+ if isinstance(value, (list, tuple)):
2887
+ return [_round_pts(v, places) for v in value]
2888
+ return value
2889
+
2890
+
1528
2891
  def _as_dict(action: Any) -> Dict[str, Any]:
1529
2892
  """Actions arrive as pydantic models in-process, or dicts from JSON."""
1530
2893
  if isinstance(action, dict):