captchakraken 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,6 +50,7 @@ from contextlib import contextmanager
50
50
  from dataclasses import dataclass, field
51
51
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
52
52
 
53
+ from . import planner
53
54
  from .action_types import CaptchaAction
54
55
  from .solver import CaptchaSolver, UnsupportedCaptchaError
55
56
  from .timing import PhaseBudget, timings_enabled
@@ -156,6 +157,21 @@ _CLOSED_TARGET_RE = re.compile(
156
157
  )
157
158
 
158
159
 
160
+ def answer_needs_element_box(actions: List[Dict[str, Any]]) -> bool:
161
+ """Does this answer need the widget's box to be performed?
162
+
163
+ Only a coordinate-bearing action does. ``done`` means "nothing left to
164
+ click", which is exactly the answer a vendor that closes on success will be
165
+ showing no widget for.
166
+
167
+ An ALLOW-LIST of what needs nothing, not a list of what does: a coordinate
168
+ action added later and forgotten here must default to needing the box,
169
+ because that direction raises instead of clicking at the origin. Twin of
170
+ `answerNeedsElementBox` in the JS port (rule 1c) — if one moves, move both.
171
+ """
172
+ return any((a or {}).get("action") != "done" for a in actions)
173
+
174
+
159
175
  @dataclass
160
176
  class SolveResult:
161
177
  """Mirror of the TS `SolveResult`."""
@@ -210,6 +226,40 @@ def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int) -> st
210
226
  return "timeout"
211
227
 
212
228
 
229
+ def burst_hang_deadline_ms(cfg) -> float:
230
+ """How long a keyframe burst may run before it is called HUNG.
231
+
232
+ A hang detector, not a budget: `overall_solve_timeout_ms` is what bounds
233
+ real time. This only has to notice a screenshot that never returns, so it
234
+ is deliberately slack.
235
+
236
+ Sized off the CEILING, because that is what sizes the loop —
237
+ `_record_keyframes` plans `video_burst_max_ms` worth of frames, not
238
+ `video_burst_duration_ms` worth. Deriving it from the floor gave a
239
+ 120-frame burst 17s, i.e. 141ms per frame against a 100ms interval, and a
240
+ burst that simply ran to its ceiling killed the attempt. Must match
241
+ `burstHangDeadlineMs` in the JS port.
242
+ """
243
+ return 3.0 * float(cfg.video_burst_max_ms) + 5_000.0
244
+
245
+
246
+ #: How unlike the chosen keyframe the widget must look before the gate calls it
247
+ #: a different board, and over how many polls. Measured on GeeTest svg: two
248
+ #: SCREENS of one board differ by 0.0056, a different board by 0.77 — two orders
249
+ #: of magnitude, so this is a plateau rather than a knife edge. Several polls
250
+ #: because a frame caught mid-swap can read high for a moment. Must match
251
+ #: NOT_THIS_BOARD_DIFF / _POLLS in the JS port.
252
+ #: How much the widget must change during inference to count as MOVING. A far
253
+ #: tighter number than `stale_frame_diff_threshold` (0.02) because it answers a
254
+ #: different question: 0.02 asks "did this answer go stale", this asks "is this
255
+ #: the same picture", which is the noise floor. Using the stale threshold for
256
+ #: both made the guard blind to GeeTest svg, whose screens differ by 0.0056.
257
+ #: Must match MOVED_DURING_INFERENCE_DIFF in the JS port.
258
+ _MOVED_DURING_INFERENCE_DIFF = 0.002
259
+ _NOT_THIS_BOARD_DIFF = 0.5
260
+ _NOT_THIS_BOARD_POLLS = 3
261
+
262
+
213
263
  @dataclass
214
264
  class PageSolverConfig:
215
265
  """
@@ -275,7 +325,7 @@ class PageSolverConfig:
275
325
  #: is EXECUTED, so the previous one already ran and moved nothing. Repeating
276
326
  #: it cannot do better; it just spends a round.
277
327
  #:
278
- #: Measured on recaptcha_grid_4x4 (fixture seed 20260730): the model answers
328
+ #: Measured on a reCAPTCHA 4x4 grid (the fixture): the model answers
279
329
  #: `[2,6,7,9,10]` on round 1 and then `[2,6,7,10]` on rounds 2 THROUGH 10 —
280
330
  #: nine identical click sets, each one clicked, each one rejected, ending in
281
331
  #: "still detected after 10 solve loops" at 66.1s. Of that, 39.0s was pure
@@ -285,6 +335,7 @@ class PageSolverConfig:
285
335
  #: the one recovery worth trying (a cycling board reads as a still and
286
336
  #: answers the same way every round). One more round buys that chance.
287
337
  max_no_progress_rounds: int = 2
338
+
288
339
  post_solve_delay_ms: int = 1_200
289
340
  # How long to keep watching for a SUCCESS after submitting.
290
341
  #
@@ -305,7 +356,7 @@ class PageSolverConfig:
305
356
  # 1000ms is therefore the measured worst case (528ms, ~264ms once the poll
306
357
  # below is halved) plus deliberate headroom for a vendor round-trip nobody
307
358
  # has measured yet. Down from 2500ms, which was spent in full on every
308
- # unsuccessful round — 25.5s of one 66.1s recaptcha_grid_4x4 solve.
359
+ # unsuccessful round — 25.5s of one 66.1s a reCAPTCHA 4x4 grid solve.
309
360
  #
310
361
  # To replace the headroom with a number: drive the real vendor pages via
311
362
  # `tests/live-solve/src/demo-targets.ts` and re-read this distribution.
@@ -355,17 +406,55 @@ class PageSolverConfig:
355
406
  # still than spend `video_burst_duration_ms` finding out.
356
407
  animated_probe_enabled: bool = True
357
408
  # Burst geometry. Deliberately identical to the collector's
358
- # (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the finetune repo), so a
409
+ # (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the training repo), so a
359
410
  # challenge recorded here is the same shape of artifact the model trained on —
360
411
  # same clip length, same frame rate, therefore the same keyframe slicing.
361
412
  video_burst_duration_ms: int = 4_000
413
+ #: Hard ceiling on a burst that never repeats a screen. The burst normally
414
+ #: ends when the board's cycle closes; this bounds a continuous animation,
415
+ #: which never closes one. Must match `videoBurstMaxMs` in the JS port.
416
+ video_burst_max_ms: int = 12_000
417
+ #: Record while the still screenshot is being read, so a cycling board is
418
+ #: known before its answer is acted on. Must match `speculativeBurstEnabled`
419
+ #: in the JS port.
420
+ #:
421
+ #: OFF since 2026-09-09, because it cost the most on exactly the boards it
422
+ #: had nothing to say about. `_speculate` calls a board MOVING when two
423
+ #: frames hash differently — an exact SHA-1 of the PNG bytes — so one pixel
424
+ #: of render noise reads as a second screen, `cycle_closed` never fires
425
+ #: (noise does not repeat), and the burst runs to `video_burst_max_ms`.
426
+ #:
427
+ #: Measured over the 6 Sep full Tier 3 (260 attempts), and the numbers are
428
+ #: the wrong way round: the genuinely ANIMATED families closed their cycle
429
+ #: at the 4s floor — tile_flip 3.96s, odd_animal 3.98s, highest_jumper
430
+ #: 3.95s — while STILL boards ran to the 12s ceiling. tower_stack 12.03s,
431
+ #: click_blocked_by_lines 11.93s, missing_piece 11.89s, silhouette_match
432
+ #: 11.76s, tetris_fit 9.96s, connect_path 9.94s. Those are click and drag
433
+ #: archetypes with no animation in them, and three of them scored 0%: they
434
+ #: were not answered wrongly, they ran out of budget being filmed.
435
+ #:
436
+ #: Worse than the wall clock, `len(order) >= 2` also DISCARDS the still
437
+ #: answer and escalates to the keyframe path, so a correct reading of a
438
+ #: static board was being thrown away because the render jittered.
439
+ #:
440
+ #: An animated board is still solved: `animated_probe_enabled` escalates on
441
+ #: the repeated-answer signal and `_record_keyframes` films a bounded 4s.
442
+ #: That costs a genuinely animated board one extra round and saves every
443
+ #: still board 4-12s. Turn this back on only with the digest replaced by a
444
+ #: comparison that has a noise floor.
445
+ speculative_burst_enabled: bool = False
362
446
  video_burst_fps: int = 10
363
447
  # How long to wait for the widget to return to the keyframe the model chose,
364
448
  # before clicking anyway. Bounded because the alternative is worse: these
365
449
  # puzzles cycle, so the state DOES come back — but if the recording caught a
366
450
  # one-off transition it never will, and a click on the model's coordinates is
367
451
  # still a better use of the remaining budget than a timeout.
368
- keyframe_wait_timeout_ms: int = 6_000
452
+ # 9000, not 6000. A GeeTest svg board dwells p50 1.5s / p75 2.0s / max 2.7s
453
+ # per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst case — a
454
+ # 6s budget could not catch the worst case however well aimed. Must match
455
+ # SOLVE_DEFAULTS.keyframeWaitTimeoutMs in the JS port: `video_budget_ms` is
456
+ # derived from it on both sides and the two must not disagree.
457
+ keyframe_wait_timeout_ms: int = 9_000
369
458
  keyframe_wait_poll_ms: int = 120
370
459
  #: Extra wall clock granted ONCE, the first time a solve escalates to a
371
460
  #: recording. NOT a looser `overall_solve_timeout_ms`.
@@ -379,9 +468,9 @@ class PageSolverConfig:
379
468
  #: reported a TIMEOUT — which reads as a slow model rather than as a budget
380
469
  #: with no room for the thing the solver had just decided to do.
381
470
  #:
382
- #: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on
383
- #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
384
- #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
471
+ #: Measured 2026-08-22, a full driver-gate run: EVERY python-port failure on
472
+ #: an hCaptcha odd-one-out animation, an hCaptcha highest-value animation and
473
+ #: an hCaptcha tile-flip animation was "exceeded overall_solve_timeout_ms during
385
474
  #: recording the animated challenge", at 45.7-52.7s elapsed. The same three
386
475
  #: fixtures solve in 11-20s on the rounds where the still path happens to
387
476
  #: answer them, so it is the escalation that does not fit, not the puzzle.
@@ -397,7 +486,7 @@ class PageSolverConfig:
397
486
  DERIVED, so a longer burst or a longer keyframe wait carries its own
398
487
  budget instead of quietly reintroducing the timeout this exists to fix.
399
488
  """
400
- return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms
489
+ return (self.video_burst_max_ms + self.keyframe_wait_timeout_ms
401
490
  + self.video_extra_inference_ms)
402
491
 
403
492
  # Grid load / dynamic-refresh timing.
@@ -676,10 +765,27 @@ class PageSolver:
676
765
  # One-shot: the recording path buys its own budget the first time it is
677
766
  # entered, and never again in the same solve. See video_budget_ms.
678
767
  self._video_budget_granted = False
768
+ # THE ONE recording and THE ONE answer for the animated board on screen.
769
+ # A cycling board needs a single inference: the burst shows the model
770
+ # every screen, the answer names the cell and the screen it is on, and
771
+ # the gate holds the click until that screen is back. None of that
772
+ # changes between rounds, so asking again buys nothing and costs a burst
773
+ # plus a multi-image inference. Mirrors `animatedPlan` in the JS port.
774
+ self._animated_plan: Optional[Tuple[List[str], str, List[CaptchaAction],
775
+ List[Dict[str, Any]]]] = None
679
776
  self._keyframe_mode: Optional[str] = None
777
+ # How many distinct steady screens the current clip sits on; 0 if it is
778
+ # not that kind of clip. THE GATE KEYS ON THIS, NOT ON `_keyframe_mode`
779
+ # — see the JS twin's `keyframeSteadyScreens` and `steady_screens()` in
780
+ # keyframes.py. `even` means the slicer could not PROVE recurrence, not
781
+ # that the board has nothing to come back to.
782
+ self._keyframe_steady_screens: int = 0
680
783
  # Repeat detection; see `max_no_progress_rounds`.
681
784
  self._last_answer_sig: Optional[str] = None
682
785
  self._no_progress_rounds = 0
786
+ #: How many times THIS board has been re-asked. Indexes
787
+ #: `planner.RESAMPLE_TEMPERATURES`; reset by `_fresh_board`.
788
+ self._resample_level = 0
683
789
  # Per-solve phase accounting; see `_phase`.
684
790
  self._budget: Optional[PhaseBudget] = None
685
791
 
@@ -743,7 +849,19 @@ class PageSolver:
743
849
  _log(f"[no-progress] the model returned the same answer again "
744
850
  f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds}) — "
745
851
  f"the previous one already ran and changed nothing")
746
- # A board that reads the same every round is the signature of a
852
+ # THE SAME ANSWER IS NOT NEWS — IT IS ARITHMETIC. `planner` sends
853
+ # `temperature: 0`, so re-reading an unchanged board returns the
854
+ # identical answer by construction, and counting that to a limit
855
+ # measures nothing but the limit. Ask for a different SAMPLE before
856
+ # spending the next round on a reply already known.
857
+ self._resample_level += 1
858
+ self._apply_sampling()
859
+ # …and make sure there is something to re-ask. On the animated path
860
+ # the answer is cached for the life of the board, so without this
861
+ # the new sampling never reaches a request and the identical
862
+ # coordinates go back up the wire.
863
+ self._invalidate_animated_answer()
864
+ # A board that reads the same every round is ALSO the signature of a
747
865
  # CYCLING challenge answered as a still. Give the recording path a
748
866
  # chance before giving up on the solve entirely.
749
867
  self._arm_animated_probe()
@@ -751,14 +869,49 @@ class PageSolver:
751
869
  self._no_progress_rounds = 0
752
870
  self._last_answer_sig = sig
753
871
 
872
+ def _apply_sampling(self) -> None:
873
+ """Point the planner at this board's re-ask level.
874
+
875
+ Level 0 is greedy and is what every first look gets, so this is a no-op
876
+ on the path that already works. Past that it hands the model a
877
+ temperature and a fresh seed, because a board the vendor refused is one
878
+ where the greedy answer has been TESTED and rejected — re-running it is
879
+ the one thing guaranteed not to help.
880
+ """
881
+ target = getattr(self._solver, "planner", None)
882
+ if target is None:
883
+ return
884
+ # `planner.sampling_for_level` is the ONE copy of the schedule, and the
885
+ # JS port reaches the same function through CAPTCHA_RESAMPLE_LEVEL.
886
+ # Deliberately NOT a `PageSolverConfig` field: the contract pins the two
887
+ # ports to the same solver options, and a knob on one side only is the
888
+ # parity gap CLAUDE.md says may only shrink. It is a tuning constant,
889
+ # not something a caller has any way to choose well.
890
+ sampling = planner.sampling_for_level(self._resample_level)
891
+ target.sampling = sampling
892
+ if sampling:
893
+ _log(f"[resample] re-asking this board at temperature "
894
+ f"{sampling['temperature']}, seed {sampling['seed']}")
895
+
896
+ def _fresh_board(self) -> None:
897
+ """A different puzzle is up: go back to the greedy read."""
898
+ if self._resample_level:
899
+ _log("[resample] fresh board — back to greedy")
900
+ self._resample_level = 0
901
+ self._apply_sampling()
902
+
754
903
  def _reset_animated_state(self) -> None:
755
904
  self._known_animated = False
756
905
  self._animated_probe_armed = False
757
906
  self._animated_probe_done = False
758
907
  self._video_budget_granted = False
908
+ self._discard_animated_plan()
759
909
  self._keyframe_mode = None
910
+ self._keyframe_steady_screens = 0
760
911
  self._last_answer_sig: Optional[str] = None
761
912
  self._no_progress_rounds = 0
913
+ self._resample_level = 0
914
+ self._apply_sampling()
762
915
 
763
916
  def _check_deadline(self, where: str) -> None:
764
917
  """
@@ -911,6 +1064,64 @@ class PageSolver:
911
1064
  with self._phase("mouse"):
912
1065
  self._human.click(page, self._last_mouse)
913
1066
 
1067
+ def _click_point_for(
1068
+ self, action: Dict[str, Any], element_box: Dict[str, float]
1069
+ ) -> Optional[Tuple[float, float]]:
1070
+ """Where in the element this click lands, in element-relative pixels.
1071
+
1072
+ Split out of `_execute_click` because on an animated board the point has
1073
+ to be chosen ONCE and used three times — to park the pointer, to ask
1074
+ about the right neighbourhood, and to press. Choosing it inside the
1075
+ click (it is randomised within the target) meant the gate watched the
1076
+ bbox centre while the press landed somewhere else.
1077
+ """
1078
+ bbox = action.get("target_bounding_box")
1079
+ coords = action.get("target_coordinates")
1080
+ if bbox:
1081
+ min_x, min_y, max_x, max_y = (float(v) for v in bbox)
1082
+ px_min_x = min_x * element_box["width"]
1083
+ px_max_x = max_x * element_box["width"]
1084
+ px_min_y = min_y * element_box["height"]
1085
+ px_max_y = max_y * element_box["height"]
1086
+ pad_x = (px_max_x - px_min_x) * 0.1
1087
+ pad_y = (px_max_y - px_min_y) * 0.1
1088
+ return (
1089
+ (px_min_x + pad_x) + random.random() * ((px_max_x - pad_x) - (px_min_x + pad_x)),
1090
+ (px_min_y + pad_y) + random.random() * ((px_max_y - pad_y) - (px_min_y + pad_y)),
1091
+ )
1092
+ if coords:
1093
+ return (float(coords[0]) * element_box["width"],
1094
+ float(coords[1]) * element_box["height"])
1095
+ return None
1096
+
1097
+ def _click_when_frame_matches(
1098
+ self, page: Any, element: Any, action: Dict[str, Any],
1099
+ element_box: Dict[str, float], await_keyframe: str,
1100
+ ) -> None:
1101
+ """Click one target on an ANIMATED board, at the moment its screen is up.
1102
+
1103
+ The ORDER is the point of it: park on the target, open the gate, press
1104
+ in place — so the only thing between "the right screen is showing" and
1105
+ the click is a mouse-down. It used to wait and then click, and the click
1106
+ begins with a humanised move of 274-647ms against a 1500ms median dwell,
1107
+ so the gate could succeed and the press still land on the next screen.
1108
+ Moving to a point the pointer already occupies is a no-op, so parking
1109
+ early costs nothing.
1110
+ """
1111
+ rel = self._click_point_for(action, element_box)
1112
+ if rel is None:
1113
+ _log("click action without coordinates or bounding box; skipping")
1114
+ return
1115
+ at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
1116
+ with self._phase("mouse"):
1117
+ self._human.move(page, at)
1118
+ self._wait_for_keyframe(
1119
+ element, await_keyframe,
1120
+ (rel[0] / element_box["width"], rel[1] / element_box["height"]),
1121
+ )
1122
+ with self._phase("mouse"):
1123
+ self._human.click(page, at)
1124
+
914
1125
  def _execute_click(
915
1126
  self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
916
1127
  ) -> None:
@@ -1441,7 +1652,7 @@ class PageSolver:
1441
1652
  # overlay up for a couple of seconds after the winning submit.
1442
1653
  # Gating this on `_visible(anchor)` meant the one signal that was
1443
1654
  # already true went unread, and the loop ground on against a frame
1444
- # being torn down. See tests/test_solved_detection.py.
1655
+ # being torn down. See the training repo's solved-detection tests.
1445
1656
  if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
1446
1657
  return True
1447
1658
  if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
@@ -1508,28 +1719,55 @@ class PageSolver:
1508
1719
  pass
1509
1720
  return False
1510
1721
 
1511
- def _has_recaptcha_underselect_error(self, page: Any) -> bool:
1512
- """
1513
- reCAPTCHA's "select all matching images" banner, shown after Verify with
1514
- an incomplete selection.
1722
+ #: Which of reCAPTCHA's bframe banners is showing, by selector.
1723
+ #:
1724
+ #: ONE ENTRY PER MEANING. They share a corner and a look and they are three
1725
+ #: different facts; see `_recaptcha_banner_kind`.
1726
+ _RECAPTCHA_BANNERS = (
1727
+ (".rc-imageselect-error-select-more", "select-more"),
1728
+ (".rc-imageselect-error-dynamic-more", "dynamic-more"),
1729
+ (".rc-imageselect-incorrect-response", "rejected"),
1730
+ )
1515
1731
 
1516
- Critically, the tiles do NOT refresh on this error: without special
1517
- handling the model sees the same image, answers `done` (to it, everything
1518
- matching IS selected), we click Verify again, and the solve loops until
1519
- it times out. Detecting it switches the next call into missed-tiles mode.
1732
+ @staticmethod
1733
+ def _banner_is_fatal_after_retry(kind: Optional[str]) -> bool:
1734
+ """May a REPEAT of this banner end the solve?
1735
+
1736
+ Only where repeating means stuck. `dynamic-more` repeating is what a
1737
+ dynamic board's normal progress looks like, and treating it as fatal is
1738
+ the bug this split was made to fix.
1739
+ """
1740
+ return kind in ("select-more", "rejected")
1741
+
1742
+ def _recaptcha_banner_kind(self, page: Any) -> Optional[str]:
1743
+ """WHICH of reCAPTCHA's three bframe banners is showing, if any.
1744
+
1745
+ `rejected` "Please try again." The answer was wrong; a fresh board
1746
+ follows, so the useful response is to solve that one.
1747
+ `select-more` "Please select all matching images." Under-selected, and
1748
+ the tiles do NOT refresh — so a driver that re-submits
1749
+ the same answer loops until the session times out. The
1750
+ missed-tiles retry and the abort exist for this case.
1751
+ `dynamic-more` "Please also check the new images." NOT AN ERROR. It is
1752
+ the dynamic 3x3's normal flow: cleared tiles fade out,
1753
+ replacements fade in, and the widget says so — on
1754
+ essentially every round of that variant.
1755
+
1756
+ This used to be a boolean over all three, which made the third
1757
+ indistinguishable from the two genuine errors and armed the one-retry
1758
+ abort with the sentence that means "you are doing fine". Every dynamic
1759
+ board therefore died at round two. Measured 2026-09-06: three of three
1760
+ `recaptcha_3x3_fade` attempts stopped at exactly boards=2, while
1761
+ `recaptcha_4x4` on the same run passed at 2, 3 and 5 boards.
1520
1762
  """
1521
1763
  try:
1522
1764
  bframe = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
1523
1765
  if not bframe:
1524
- return False
1766
+ return None
1525
1767
  frame = bframe.content_frame()
1526
1768
  if not frame:
1527
- return False
1528
- for selector in (
1529
- ".rc-imageselect-error-select-more",
1530
- ".rc-imageselect-error-dynamic-more",
1531
- ".rc-imageselect-incorrect-response",
1532
- ):
1769
+ return None
1770
+ for selector, kind in self._RECAPTCHA_BANNERS:
1533
1771
  element = frame.query_selector(selector)
1534
1772
  if not element:
1535
1773
  continue
@@ -1538,10 +1776,10 @@ class PageSolver:
1538
1776
  if self._visible(element):
1539
1777
  text = element.text_content() or ""
1540
1778
  if text.strip():
1541
- return True
1779
+ return kind
1542
1780
  except Exception:
1543
1781
  pass
1544
- return False
1782
+ return None
1545
1783
 
1546
1784
  def _get_verify_button(self, frame: Any) -> Optional[Any]:
1547
1785
  # `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
@@ -1749,6 +1987,160 @@ class PageSolver:
1749
1987
  if self.config.animated_probe_enabled and not self._animated_probe_done:
1750
1988
  self._animated_probe_armed = True
1751
1989
 
1990
+ def _should_speculate(self, puzzle_source: str, text_mode: bool) -> bool:
1991
+ """Should this round record while it asks?
1992
+
1993
+ reCAPTCHA is excluded for the same reason it is excluded from the
1994
+ animated path at all: its dynamic 3x3 REPLACES tiles in place and has
1995
+ its own fade gates, so a burst there would film a fade and call it a
1996
+ cycle. A distorted-text round is excluded because the answer is a
1997
+ string, not a place, and no recording helps read one.
1998
+ """
1999
+ cfg = self.config
2000
+ if not cfg.video_solve_enabled:
2001
+ return False
2002
+ if not cfg.speculative_burst_enabled:
2003
+ return False
2004
+ if puzzle_source == "recaptcha":
2005
+ return False
2006
+ return not text_mode
2007
+
2008
+ def _speculate(
2009
+ self, element: Any, shot: str, puzzle_source: str,
2010
+ retry_mode: Optional[str], text_mode: bool,
2011
+ ) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
2012
+ """Read the still and film the widget at once.
2013
+
2014
+ Returns `(actions, usage, keyframe_dir)`. `keyframe_dir` is None when
2015
+ the board turned out to be still — the caller then behaves exactly as
2016
+ it always has.
2017
+ """
2018
+ from concurrent.futures import ThreadPoolExecutor
2019
+
2020
+ import cv2
2021
+
2022
+ from .keyframes import extract_keyframes, write_keyframes
2023
+
2024
+ cfg = self.config
2025
+ fps = max(1, int(cfg.video_burst_fps))
2026
+ floor_frames = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
2027
+ total = max(floor_frames, round(cfg.video_burst_max_ms / (1000.0 / fps)))
2028
+ interval = 1.0 / fps
2029
+
2030
+ frames: List[Any] = []
2031
+ order: List[str] = []
2032
+ last_digest: Optional[str] = None
2033
+ cycle_closed = False
2034
+ probe = _tmp_png("spec")
2035
+
2036
+ pool = ThreadPoolExecutor(max_workers=1)
2037
+ try:
2038
+ # `_get_solution` DIRECTLY, not through the freshness guard.
2039
+ #
2040
+ # Two reasons, and the first is a hard constraint: the guard
2041
+ # screenshots the element to decide whether the answer went stale,
2042
+ # and a Playwright call from this worker would violate the sync
2043
+ # API's thread affinity. Only the planner's HTTP request may cross.
2044
+ #
2045
+ # The second is that the guard is redundant here. It exists to ask
2046
+ # "did the widget change while the model was reading it" — which is
2047
+ # exactly what the burst on the main thread is answering, at the
2048
+ # noise floor, from every frame rather than from one diff at the
2049
+ # end.
2050
+ fut = pool.submit(
2051
+ self._get_solution, shot, puzzle_source, retry_mode, text_mode)
2052
+ with self._phase("burst"):
2053
+ for i in range(total):
2054
+ start = time.monotonic()
2055
+ try:
2056
+ self._screenshot(element, probe, animations="allow")
2057
+ img = cv2.imread(probe)
2058
+ except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
2059
+ _debug(f"speculative frame {i} failed: {exc}")
2060
+ img = None
2061
+ if img is not None:
2062
+ frames.append(img)
2063
+ try:
2064
+ with open(probe, "rb") as fh:
2065
+ d = hashlib.sha1(fh.read()).hexdigest()
2066
+ except Exception: # noqa: BLE001
2067
+ d = None
2068
+ if d is not None and d != last_digest:
2069
+ if d in order and len(order) >= 2:
2070
+ cycle_closed = True
2071
+ elif d not in order:
2072
+ order.append(d)
2073
+ last_digest = d
2074
+ moved = len(order) >= 2
2075
+ # Still, and the answer has landed: nothing more to film.
2076
+ if not moved and fut.done():
2077
+ break
2078
+ # Moving, and the cycle has closed: every screen is in hand.
2079
+ if moved and cycle_closed and len(frames) >= floor_frames:
2080
+ _log(f"[animated] cycle closed after {len(frames) * interval:.1f}s "
2081
+ f"({len(order)} screens); stopping the burst")
2082
+ break
2083
+ wait = interval - (time.monotonic() - start)
2084
+ if wait > 0:
2085
+ time.sleep(wait)
2086
+
2087
+ if len(order) < 2:
2088
+ actions, usage = fut.result()
2089
+ return actions, usage, None
2090
+
2091
+ _log("[animated] the widget moved while the model was reading it — "
2092
+ "dropping the still answer and finishing the recording.")
2093
+ fut.result() # drained, not used: it describes a screen that has gone
2094
+ # THE ESCALATION BUYS ITS OWN BUDGET, once per solve — and only now,
2095
+ # because a speculative burst that turned out to be unnecessary must
2096
+ # not extend the deadline of a still solve.
2097
+ if cfg.video_solve_enabled and not self._video_budget_granted:
2098
+ self._video_budget_granted = True
2099
+ if self._deadline_ms is not None:
2100
+ self._deadline_ms += cfg.video_budget_ms()
2101
+ _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
2102
+ kfset = extract_keyframes(frames, fps=float(fps))
2103
+ self._keyframe_mode = kfset.mode
2104
+ self._keyframe_steady_screens = kfset.steady_screens
2105
+ keyframe_dir = tempfile.mkdtemp(prefix="ckkf_")
2106
+ paths = write_keyframes(kfset, keyframe_dir, stem="challenge")
2107
+ with self._phase("inference"):
2108
+ actions, usage = self._get_keyframe_solution(paths)
2109
+ self._animated_plan = (paths, keyframe_dir, actions, usage)
2110
+ return actions, usage, keyframe_dir
2111
+ finally:
2112
+ pool.shutdown(wait=True)
2113
+ _unlink(probe)
2114
+
2115
+ def _invalidate_animated_answer(self) -> None:
2116
+ """The recording is still good; the ANSWER it produced is not.
2117
+
2118
+ `_animated_plan` caches "one burst, one inference, for as long as this
2119
+ board is up". For a cycling board whose answer merely landed on the
2120
+ wrong screen that is right — the frames do not change, so re-recording
2121
+ buys nothing. It is WRONG the moment the widget has refused the answer:
2122
+ re-submitting the identical coordinates cannot succeed, and the driver
2123
+ did exactly that until it ran out of rounds. Measured 2026-09-09 on
2124
+ an hCaptcha line-pieces board, where every round logged "reusing the recorded
2125
+ answer" and then "the model returned the same answer again" — the model
2126
+ was never asked a second time.
2127
+
2128
+ Drops the ANSWER and keeps the FRAMES, so the retry costs one inference
2129
+ rather than another `video_burst_max_ms` of filming.
2130
+ """
2131
+ plan = self._animated_plan
2132
+ if plan is None or plan[2] is None:
2133
+ return
2134
+ keyframes, keyframe_dir, _actions, _usage = plan
2135
+ self._animated_plan = (keyframes, keyframe_dir, None, None)
2136
+
2137
+ def _discard_animated_plan(self) -> None:
2138
+ """Forget the recorded answer, and delete the frames it was holding."""
2139
+ plan = self._animated_plan
2140
+ self._animated_plan = None
2141
+ if plan and plan[1]:
2142
+ shutil.rmtree(plan[1], ignore_errors=True)
2143
+
1752
2144
  def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
1753
2145
  """Record the widget and return `(keyframe_paths, temp_dir)`.
1754
2146
 
@@ -1773,7 +2165,24 @@ class PageSolver:
1773
2165
 
1774
2166
  cfg = self.config
1775
2167
  fps = max(1, int(cfg.video_burst_fps))
1776
- total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
2168
+ # A BURST MUST OUTLAST ONE FULL CYCLE OR IT OMITS A SCREEN — and may
2169
+ # omit the one the answer is on. CLAUDE.md states that rule; the
2170
+ # COLLECTOR was fixed for it and the driver was not.
2171
+ #
2172
+ # Measured on the live GeeTest svg board, 2026-09-07: three screens
2173
+ # holding 1.5-1.9s each, FULL PERIOD 5.3s, against a 4000ms window. So
2174
+ # the model was shown two screens of three on about half of attempts,
2175
+ # and when the target sat on the third it was being asked a question
2176
+ # with the answer removed.
2177
+ #
2178
+ # Fixed by watching rather than by a bigger constant: the frames of one
2179
+ # screen are byte-identical, so a digest per frame says exactly when the
2180
+ # board returns to a screen already recorded — the cycle is closed and
2181
+ # there is nothing left to capture. A continuous animation never
2182
+ # repeats and stops at the floor, keeping today's behaviour.
2183
+ floor_frames = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
2184
+ total = max(floor_frames,
2185
+ round(cfg.video_burst_max_ms / (1000.0 / fps)))
1777
2186
  interval = 1.0 / fps
1778
2187
 
1779
2188
  # THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
@@ -1814,19 +2223,25 @@ class PageSolver:
1814
2223
  )
1815
2224
 
1816
2225
  frames: List[Any] = []
2226
+ order: List[str] = [] # distinct screens, in first-seen order
2227
+ last_digest: Optional[str] = None
2228
+ cycle_closed = False
2229
+ #: Frame index at which the most recent NEW screen appeared. A board
2230
+ #: that has shown nothing new for a whole floor-length window has
2231
+ #: settled — see the exit below.
2232
+ last_new_at = 0
1817
2233
 
1818
2234
  remaining = max(0, total - len(frames))
1819
2235
  shot = _tmp_png("burst")
1820
2236
  # A burst that runs far past its own length is a hung screenshot, not a
1821
2237
  # tight budget — bounded separately so the two cannot be confused.
1822
- burst_deadline = (time.monotonic() * 1000.0
1823
- + 3 * cfg.video_burst_duration_ms + 5_000)
2238
+ burst_deadline = time.monotonic() * 1000.0 + burst_hang_deadline_ms(cfg)
1824
2239
  try:
1825
2240
  for i in range(remaining):
1826
2241
  if time.monotonic() * 1000.0 > burst_deadline:
1827
2242
  raise CaptchaSolveError(
1828
2243
  f"the animated recording stalled: {i} of {remaining} frames "
1829
- f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
2244
+ f"in {burst_hang_deadline_ms(cfg):.0f}ms. The widget "
1830
2245
  f"is not screenshotting."
1831
2246
  )
1832
2247
  start = time.monotonic()
@@ -1844,6 +2259,63 @@ class PageSolver:
1844
2259
  img = cv2.imread(shot)
1845
2260
  if img is not None:
1846
2261
  frames.append(img)
2262
+ try:
2263
+ with open(shot, "rb") as fh:
2264
+ d = hashlib.sha1(fh.read()).hexdigest()
2265
+ except Exception: # noqa: BLE001 — no digest, no early stop
2266
+ d = None
2267
+ if d is not None and d != last_digest:
2268
+ # A screen already recorded, coming back after
2269
+ # another one: the loop has closed and every screen
2270
+ # is now in the clip.
2271
+ if d in order and len(order) >= 2:
2272
+ cycle_closed = True
2273
+ elif d not in order:
2274
+ order.append(d)
2275
+ last_new_at = len(frames)
2276
+ last_digest = d
2277
+ # Past the floor with the cycle closed — more frames are the
2278
+ # same screens again, paid for in budget the solve needs.
2279
+ if cycle_closed and len(frames) >= floor_frames:
2280
+ _log(f"[animated] cycle closed after {len(frames) * interval:.1f}s "
2281
+ f"({len(order)} screens); stopping the burst")
2282
+ break
2283
+ # NOTHING NEW FOR A WHOLE FLOOR-LENGTH WINDOW: the board has
2284
+ # SETTLED, and a settled board can never close a cycle —
2285
+ # `cycle_closed` needs a digest to come back AFTER a different
2286
+ # one. Without this exit the only way out is `remaining`, so
2287
+ # every escalation onto a widget that is not cycling filmed the
2288
+ # full `video_burst_max_ms`.
2289
+ #
2290
+ # `_speculate` has always had this half (`not moved and
2291
+ # fut.done()`); it could, because it has an inference to wait
2292
+ # on. This path had nothing equivalent and so could not stop
2293
+ # early at all.
2294
+ #
2295
+ # "Settled", not "one screen", because there are THREE shapes
2296
+ # and only one of them is a cycle. Measured on the real fixtures
2297
+ # through camoufox, one element screenshotted 25x at 10fps:
2298
+ #
2299
+ # click_blocked_by_lines 1 distinct sha1 MAD 0.00 never moves
2300
+ # connect_path 1 distinct sha1 MAD 0.00 never moves
2301
+ # tower_stack 2 distinct sha1 MAD 32.51 moves ONCE
2302
+ # geetest_v4_svg 3 distinct sha1 MAD 8.29 really cycles
2303
+ #
2304
+ # `tower_stack` is why a one-screen test is not enough: it
2305
+ # transitions once and then holds, so it has two screens, never
2306
+ # repeats either, and ran the full 12s under a one-screen rule.
2307
+ #
2308
+ # The window is the burst FLOOR (4s), which is safely longer
2309
+ # than the worst dwell a cycling board holds a screen for —
2310
+ # measured p50 1.5s / p75 2.0s / max 2.7s on the geetest svg
2311
+ # board — so a real cycle always produces a new screen inside
2312
+ # the window and is never cut short here.
2313
+ if (len(frames) >= floor_frames
2314
+ and len(frames) - last_new_at >= floor_frames):
2315
+ _log(f"[animated] no new screen for {floor_frames * interval:.1f}s "
2316
+ f"({len(order)} seen) — the board has settled; "
2317
+ f"stopping the burst")
2318
+ break
1847
2319
  # Drift-corrected: a slow screenshot must not stretch the clip, or
1848
2320
  # the recording covers more wall-clock than the model trained on and
1849
2321
  # a cycle's period lands differently across the frames.
@@ -1866,6 +2338,7 @@ class PageSolver:
1866
2338
  # The wait gate consults this: `even` means the extractor found no state
1867
2339
  # that RECURS, so there is nothing for the page to come back to.
1868
2340
  self._keyframe_mode = kfset.mode
2341
+ self._keyframe_steady_screens = kfset.steady_screens
1869
2342
  return [str(p) for p in paths], temp_dir
1870
2343
 
1871
2344
  def _wait_for_keyframe(self, element: Any, keyframe_path: str,
@@ -1894,12 +2367,12 @@ class PageSolver:
1894
2367
  cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
1895
2368
  PER CLICK before giving up and clicking anyway.
1896
2369
  And `even` is not the rare case. All 116 real clips under
1897
- `cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
2370
+ `the sample corpus/**/keyframes/` are `even`; `cycle` has never once
1898
2371
  fired on real footage, which keyframes.py itself records ("real state
1899
2372
  separations top out around 0.007, well under this"). So on real traffic
1900
2373
  this gate was 6s of dead time on every animated click, always followed by
1901
2374
  the same click it would have made immediately. Measured on
1902
- `hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
2375
+ an hCaptcha rotating-object animation: 6.0s of a 28.8s solve, closest region diff
1903
2376
  0.0721 against a 0.05 tolerance, then it clicked and solved.
1904
2377
 
1905
2378
  The gate stays for `cycle`/`static`, where a state genuinely does recur
@@ -1910,9 +2383,17 @@ class PageSolver:
1910
2383
 
1911
2384
  from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1912
2385
 
1913
- if self._keyframe_mode == "even":
1914
- _log("[animated] clip is mode=even (no state recurs); "
1915
- "acting on the model's frame without waiting")
2386
+ # NOT `mode == "even"`. A board that holds a few screens is worth
2387
+ # waiting for whether or not one 4s burst was long enough to catch it
2388
+ # repeating — and it never is, which is why every real animated capture
2389
+ # slices `even` and this gate was off on all of them. What is genuinely
2390
+ # not worth waiting for is a clip with NO steady screens: a rotation, a
2391
+ # one-way fade, a sprite crossing. Measured: 0 steady screens for all
2392
+ # five continuous hCaptcha video types, 2-3 for every real GeeTest svg.
2393
+ if self._keyframe_steady_screens < 2:
2394
+ _log(f"[animated] clip sits on {self._keyframe_steady_screens} steady "
2395
+ f"screen(s); nothing to come back to, acting on the model's "
2396
+ f"frame without waiting")
1916
2397
  return False
1917
2398
 
1918
2399
  ref = cv2.imread(keyframe_path)
@@ -1925,11 +2406,17 @@ class PageSolver:
1925
2406
  deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
1926
2407
  probe = _tmp_png("kfwait")
1927
2408
  best = 1.0
2409
+ polls = 0
1928
2410
  try:
1929
2411
  while (time.monotonic() * 1000.0) < deadline:
1930
2412
  self._check_deadline("waiting for the challenge keyframe")
1931
2413
  try:
1932
- self._screenshot(element, probe)
2414
+ # animations="allow": the motion is the thing being watched.
2415
+ # "disabled" FREEZES an infinite CSS animation, so the gate
2416
+ # would photograph one frozen screen on every poll and could
2417
+ # never see the board come round. The JS twin carries the
2418
+ # full note.
2419
+ self._screenshot(element, probe, animations="allow")
1933
2420
  live = cv2.imread(probe)
1934
2421
  except Exception as exc: # noqa: BLE001
1935
2422
  _debug(f"keyframe probe failed: {exc}")
@@ -1940,12 +2427,31 @@ class PageSolver:
1940
2427
  if d <= MATCH_REGION_TOLERANCE:
1941
2428
  _log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
1942
2429
  return True
2430
+ # NOT THIS BOARD AT ALL — stop waiting for a screen of it.
2431
+ # Two SCREENS of one board differ by 0.0056 (measured on
2432
+ # GeeTest svg); a different board reads 0.77. So a diff this
2433
+ # large means the puzzle we recorded is gone — solved,
2434
+ # refreshed or replaced — and the rest of the budget buys
2435
+ # nothing. See the JS twin for the full note.
2436
+ polls += 1
2437
+ if polls >= _NOT_THIS_BOARD_POLLS and best > _NOT_THIS_BOARD_DIFF:
2438
+ _log(f"[animated] the widget no longer resembles the recorded "
2439
+ f"board (best diff={best:.4f} over {polls} polls); not "
2440
+ f"waiting out the budget")
2441
+ self._discard_animated_plan()
2442
+ return False
1943
2443
  _delay(cfg.keyframe_wait_poll_ms)
1944
2444
  finally:
1945
2445
  _unlink(probe)
2446
+ # NEVER SAW IT. Either the board moved on to a different challenge or
2447
+ # the answer was for a screen this widget does not show. Both mean the
2448
+ # plan is spent, so the next round records afresh rather than
2449
+ # re-clicking a cell chosen from pictures that are gone.
2450
+ self._discard_animated_plan()
1946
2451
  _log(f"[animated] widget never matched the chosen keyframe within "
1947
2452
  f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
1948
- f"clicking on the model's coordinates anyway")
2453
+ f"clicking on the model's coordinates anyway, and recording "
2454
+ f"afresh next round")
1949
2455
  return False
1950
2456
 
1951
2457
  def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
@@ -1972,7 +2478,7 @@ class PageSolver:
1972
2478
  `return !!(example && ...)`, which is false when there is no example
1973
2479
  image — so a challenge with no tile grid, no canvas and no example
1974
2480
  polled until the timeout and then carried on regardless. Measured on
1975
- `hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
2481
+ an hCaptcha highest-value animation: 24.0s of a 45.2s solve, the
1976
2482
  full 8s three times over, more than half the budget spent asking a
1977
2483
  question about elements that were not on the page. A readiness gate can
1978
2484
  only report on what it can see; with nothing to check it has no opinion,
@@ -2109,9 +2615,42 @@ class PageSolver:
2109
2615
  if not cfg.stale_frame_resolve_enabled:
2110
2616
  return actions, merged_usage
2111
2617
 
2618
+ # DID THE PICTURE MOVE WHILE THE MODEL READ IT? Asked once per
2619
+ # round, at the noise floor rather than the staleness threshold. A
2620
+ # still puzzle answers no for the price of one frame diff; a
2621
+ # cycling one answers yes and stops being treated as a still a
2622
+ # whole round earlier than "the same answer came back twice"
2623
+ # allows. Acting on one observation is safe because the recording
2624
+ # is self-checking — see `_settle_or_animated`.
2625
+ if self._frame_changed_since(element, current,
2626
+ _MOVED_DURING_INFERENCE_DIFF):
2627
+ self._arm_animated_probe()
2628
+ _log("[freshness] the widget moved while the model was reading "
2629
+ "it, with nothing clicked — recording it rather than "
2630
+ "answering another still.")
2631
+
2632
+ changed_during_inference = 0
2112
2633
  for attempt in range(cfg.max_stale_frame_resolves):
2113
2634
  if not self._frame_changed_since(element, current, cfg.stale_frame_diff_threshold):
2114
2635
  break # frame held still through inference — answer is valid
2636
+ # CHANGED AGAIN, having already re-solved once and touched
2637
+ # nothing. One change is a board still developing — tiles
2638
+ # fading in — which is what the re-solve below is for. A SECOND
2639
+ # change in the same round is a board that does not stop, and
2640
+ # re-solving it is futile: each answer is for a screen that will
2641
+ # be gone before the click.
2642
+ #
2643
+ # This is the earliest the driver can know. Both ports otherwise
2644
+ # wait for the same answer to come back twice, which costs a
2645
+ # whole extra round and its inference — ~10s of a 40s solve
2646
+ # spent rediscovering what the guard has just watched happen.
2647
+ changed_during_inference += 1
2648
+ if changed_during_inference >= 2:
2649
+ self._arm_animated_probe()
2650
+ _log("[freshness] the frame changed twice during inference with "
2651
+ "nothing clicked — this board cycles; recording it rather "
2652
+ "than re-solving a screen that has gone.")
2653
+ return actions, merged_usage
2115
2654
  fresh = _tmp_png("freshsolve")
2116
2655
  try:
2117
2656
  self._screenshot(element, fresh)
@@ -2458,7 +2997,7 @@ class PageSolver:
2458
2997
  self._move_and_click(page, verify)
2459
2998
  # The press IS an interaction, and saying so is load-bearing
2460
2999
  # — the same rule `_solve_single` learned on
2461
- # prosopo_grid_3x3, in the other driver, on another day. A
3000
+ # a Prosopo 3x3 grid, in the other driver, on another day. A
2462
3001
  # `done` round clicks no tile, so without this the one
2463
3002
  # answer shape that submits and does nothing else reports
2464
3003
  # having done nothing: the caller then takes the
@@ -2582,8 +3121,22 @@ class PageSolver:
2582
3121
  # whole re-solve budget would burn without ever acting. The frame
2583
3122
  # number in the answer is the real guard: it names the state to act
2584
3123
  # in, and `_execute_click` waits for it.
2585
- with self._phase("burst"):
2586
- keyframes, keyframe_dir = self._record_keyframes(element)
3124
+ if self._animated_plan is not None:
3125
+ # ONE burst for as long as this board is up — and, until
3126
+ # 2026-09-09, one INFERENCE too, which is what made a
3127
+ # refused answer immortal. `actions` is None once the
3128
+ # widget has rejected them (`_invalidate_animated_answer`),
3129
+ # so the frames are reused and the QUESTION is asked again.
3130
+ keyframes, keyframe_dir, actions, all_usage = self._animated_plan
3131
+ reused_plan = actions is not None
3132
+ _log("[animated] reusing the recorded answer — "
3133
+ "same board, same screens" if reused_plan else
3134
+ "[animated] same screens, but the last answer was "
3135
+ "refused — re-asking on the frames already recorded")
3136
+ else:
3137
+ reused_plan = False
3138
+ with self._phase("burst"):
3139
+ keyframes, keyframe_dir = self._record_keyframes(element)
2587
3140
  # THE RECORDING IS THE TEST. A burst of a widget that turns out
2588
3141
  # not to move slices to a single keyframe (`mode=static`), and
2589
3142
  # that frame is exactly the still this round would have taken
@@ -2606,13 +3159,45 @@ class PageSolver:
2606
3159
  # another screenshot of the same motionless widget.
2607
3160
  shutil.copyfile(keyframes[0], shot)
2608
3161
  have_shot = True
3162
+ # Not a cycling board after all, so there is no plan to keep.
3163
+ self._discard_animated_plan()
2609
3164
  shutil.rmtree(keyframe_dir, ignore_errors=True)
2610
3165
  keyframe_dir = None
2611
- else:
3166
+ elif not reused_plan:
2612
3167
  with self._phase("inference"):
2613
3168
  actions, all_usage = self._get_keyframe_solution(keyframes)
3169
+ self._animated_plan = (keyframes, keyframe_dir,
3170
+ actions, all_usage)
3171
+
3172
+ if not is_animated and self._should_speculate(puzzle_source, text_mode):
3173
+ # ASK AND WATCH AT THE SAME TIME. The JS twin carries the full
3174
+ # note; in short, the burst is the only thing that can tell a
3175
+ # cycling board from a still one AND is also the recording that
3176
+ # answers it, so it runs while the screenshot is being read.
3177
+ #
3178
+ # A still board sees one picture, keeps its answer and drops the
3179
+ # frames — identical behaviour and identical cost, because the
3180
+ # recording happened inside a wait we were already making. A
3181
+ # moving board's still answer is discarded UNREAD, because it
3182
+ # describes a screen that has gone.
3183
+ #
3184
+ # THE MODEL CALL GOES TO A WORKER THREAD AND THE SCREENSHOTS DO
3185
+ # NOT. Playwright's sync API is thread-affine, so every browser
3186
+ # call stays on this thread; the planner's HTTP request touches
3187
+ # no browser and is the only thing handed off. Exactly one model
3188
+ # call is in flight at a time, so the shared planner sees no
3189
+ # concurrency either.
3190
+ if not have_shot:
3191
+ with self._phase("screenshot"):
3192
+ self._screenshot(
3193
+ element, shot,
3194
+ timeout_ms=self.config.element_screenshot_timeout_ms)
3195
+ actions, all_usage, keyframe_dir = self._speculate(
3196
+ element, shot, puzzle_source, retry_mode, text_mode)
3197
+ if keyframe_dir is not None:
3198
+ is_animated = True
2614
3199
 
2615
- if not is_animated:
3200
+ elif not is_animated:
2616
3201
  if not have_shot:
2617
3202
  with self._phase("screenshot"):
2618
3203
  self._screenshot(
@@ -2627,8 +3212,17 @@ class PageSolver:
2627
3212
  ),
2628
3213
  )
2629
3214
 
3215
+ # The box turns NORMALISED coordinates into page ones, so only an
3216
+ # action that carries coordinates needs it. A "done" answer carries
3217
+ # none — it means "nothing left to click" — and several vendors
3218
+ # CLOSE the challenge the moment they accept it, so the widget is
3219
+ # legitimately gone by the time that answer arrives. Demanding a box
3220
+ # for it failed the solve at the moment it succeeded: measured
3221
+ # 2026-09-07 on gt4.geetest.com, 22 of 22 live GeeTest attempts, each
3222
+ # banked as the model being wrong. The JS port carries the identical
3223
+ # rule (rule 1c) and `answer_needs_element_box` is its twin.
2630
3224
  element_box = element.bounding_box()
2631
- if not element_box:
3225
+ if not element_box and answer_needs_element_box(actions):
2632
3226
  raise CaptchaSolveError("could not get bounding box of captcha element")
2633
3227
 
2634
3228
  # Recorded at the moment of EXECUTION, which is what makes a repeat
@@ -2660,9 +3254,12 @@ class PageSolver:
2660
3254
  await_kf = action.get("await_keyframe")
2661
3255
  if bboxes:
2662
3256
  for bbox in bboxes:
3257
+ one = {"target_bounding_box": bbox}
2663
3258
  if await_kf:
2664
- self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
2665
- self._execute_click(page, {"target_bounding_box": bbox}, element_box)
3259
+ self._click_when_frame_matches(
3260
+ page, element, one, element_box, await_kf)
3261
+ else:
3262
+ self._execute_click(page, one, element_box)
2666
3263
  self._human.pause("between")
2667
3264
  else:
2668
3265
  self._execute_click(page, action, element_box)
@@ -2762,7 +3359,7 @@ class PageSolver:
2762
3359
  # control at all, so `verify_button` is None and nothing is pressed
2763
3360
  # either way. What the exclusion actually bought was a whole extra
2764
3361
  # model call per puzzle, spent asking a board we had already answered
2765
- # correctly whether it was `done` — 6.2 s of prosopo_grid_3x3's 13.8 s,
3362
+ # correctly whether it was `done` — 6.2 s of a Prosopo 3x3 grid's 13.8 s,
2766
3363
  # on a selection that scored 1.0 on the first call.
2767
3364
  answered = clicked or placed or typed
2768
3365
  should_submit = not slid and (answered or not performed_action)
@@ -2773,7 +3370,7 @@ class PageSolver:
2773
3370
  # the caller aborts a round that reports none, so submitting a
2774
3371
  # `done` answer and then returning False re-arms the very guard
2775
3372
  # this satisfies — the puzzle is sent and the solve gives up on
2776
- # it one line later, which is what `prosopo_grid_3x3` did.
3373
+ # it one line later, which is what a Prosopo 3x3 grid did.
2777
3374
  performed_action = True
2778
3375
  # Snapshot at submit time so the NEXT attempt waits for the real
2779
3376
  # transition before treating whatever is on screen as fresh.
@@ -2782,7 +3379,11 @@ class PageSolver:
2782
3379
  _unlink(shot)
2783
3380
  # Only now: the wait gate re-reads the keyframe PNG on every poll, so
2784
3381
  # removing the directory any earlier would break the click it is gating.
2785
- if keyframe_dir:
3382
+ # ...and not while the PLAN still holds them: the next round
3383
+ # re-executes this same answer, and the gate reads these PNGs on
3384
+ # every poll.
3385
+ held = self._animated_plan[1] if self._animated_plan else None
3386
+ if keyframe_dir and keyframe_dir != held:
2786
3387
  shutil.rmtree(keyframe_dir, ignore_errors=True)
2787
3388
 
2788
3389
  return performed_action, all_usage
@@ -3099,7 +3700,7 @@ class PageSolver:
3099
3700
  # `is_captcha_solved` — which reads only the hCaptcha and
3100
3701
  # reCAPTCHA anchors — can never fire for them and this loop
3101
3702
  # ran out its whole 2.5s budget on EVERY round, waiting for
3102
- # a signal that cannot arrive. Measured on geetest_v4_slide:
3703
+ # a signal that cannot arrive. Measured on a GeeTest v4 slider:
3103
3704
  # 5.2s of a 12.3s solve, spent after the puzzle was already
3104
3705
  # answered, with the widget sitting there visibly solved.
3105
3706
  #
@@ -3116,6 +3717,9 @@ class PageSolver:
3116
3717
  else:
3117
3718
  widget_gone = 0
3118
3719
  if self._is_challenge_freshly_rendered(page):
3720
+ # A DIFFERENT puzzle, so the greedy answer has not been
3721
+ # tested against it and the escalation must not carry over.
3722
+ self._fresh_board()
3119
3723
  break # next round is up; go solve it now
3120
3724
  _delay(cfg.post_solve_outcome_poll_ms)
3121
3725
  _verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
@@ -3134,7 +3738,8 @@ class PageSolver:
3134
3738
  _log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
3135
3739
  return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
3136
3740
 
3137
- if self._has_recaptcha_underselect_error(page):
3741
+ _banner = self._recaptcha_banner_kind(page)
3742
+ if self._banner_is_fatal_after_retry(_banner):
3138
3743
  if already_retried_underselect:
3139
3744
  raise CaptchaSolveError(
3140
3745
  "reCAPTCHA still showing the under-selection error after retry; "