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.
- package/dist/cli-invocation.d.ts +14 -16
- package/dist/cli-invocation.js +28 -20
- package/dist/humanize.d.ts +3 -3
- package/dist/humanize.js +3 -3
- package/dist/model-name.d.ts +1 -1
- package/dist/model-name.js +1 -1
- package/dist/solver.d.ts +202 -11
- package/dist/solver.js +690 -113
- package/dist/trajectory.d.ts +1 -1
- package/dist/trajectory.js +1 -1
- package/dist/types.d.ts +14 -2
- package/package.json +2 -1
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +1 -1
- package/python/src/captchakraken/cli.py +4 -0
- package/python/src/captchakraken/humanize.py +2 -2
- package/python/src/captchakraken/keyframes.py +86 -13
- package/python/src/captchakraken/models.json +45 -19
- package/python/src/captchakraken/page_solver.py +657 -52
- package/python/src/captchakraken/planner.py +86 -7
- package/python/src/captchakraken/prompts.py +5 -5
- package/python/src/captchakraken/solver.py +2 -2
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
383
|
-
#:
|
|
384
|
-
#:
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
|
1766
|
+
return None
|
|
1525
1767
|
frame = bframe.content_frame()
|
|
1526
1768
|
if not frame:
|
|
1527
|
-
return
|
|
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
|
|
1779
|
+
return kind
|
|
1542
1780
|
except Exception:
|
|
1543
1781
|
pass
|
|
1544
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
2586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2665
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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; "
|