captchakraken 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +96 -11
  15. package/dist/selectors.js +226 -112
  16. package/dist/solver.d.ts +93 -13
  17. package/dist/solver.js +579 -208
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +621 -259
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +224 -131
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import io
7
- import itertools
8
7
  import json
9
8
  import math
10
9
  import os
@@ -17,18 +16,16 @@ import time
17
16
  import uuid
18
17
  from concurrent.futures import ThreadPoolExecutor
19
18
  from contextlib import contextmanager
20
- from dataclasses import dataclass, field
21
- from typing import AbstractSet, Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple
19
+ from dataclasses import InitVar, dataclass, field
20
+ from typing import AbstractSet, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Set, Tuple, Union
22
21
 
23
22
  from . import planner
24
23
  from .action_types import CaptchaAction
25
- from .humanize import Humanizer, resolve as resolve_humanizer
24
+ from .humanize import Humanizer, hold, resolve as resolve_humanizer
26
25
  from .kinds import (ActionKind, FrameRole, HumanizationMode, KeyframeMode, PauseKind, Phase, PromptFamily,
27
26
  RecaptchaBanner, RetryMode, SettleVerdict, Vendor, Verdict)
28
- from .selectors import (ACCEPTED_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
29
- SUBMIT_SELECTORS, TEXT_INPUT_SELECTORS, TEXT_INPUT_VENDOR_SELECTORS, VENDORS, WIDGET_PROBES,
30
- VendorSelectors, WidgetProbe)
31
- from .solver import CaptchaSolver, UnsupportedCaptchaError
27
+ from .selectors import DEFAULT_CONFIG_PATH, DEFAULT_TABLE, SelectorTable, VendorSelectors, WidgetProbe
28
+ from .solver import AnimatedRecording, CaptchaSolver, UnsupportedCaptchaError, cut_recording
32
29
  from .timing import PhaseBudget, timings_enabled
33
30
  from .verdicts import RoundVerdict, VerdictLog
34
31
 
@@ -197,6 +194,40 @@ class SolveResult:
197
194
  verdicts: List[RoundVerdict] = field(default_factory=list)
198
195
 
199
196
 
197
+ #: A typed captcha's text box widens the shot only when it sits by the image: the union may be at most this many
198
+ #: times the image's area. A vendor-named box found in the enclosing form can be a whole login form away.
199
+ ANSWER_BOX_MAX_GROWTH = 4.0
200
+
201
+
202
+ class _WithAnswerBox:
203
+ """A typed captcha's image and its text box, photographed and pointed into as one element.
204
+
205
+ BotDetect's widget element is a 280x50 strip holding only the image; its text box is a sibling. Every
206
+ training board and every real capture of a typed captcha shows the box under the image, and on the bare
207
+ strip the pointer read the image as a click puzzle (p(type) 0.46) or doubled a glyph, where the same frame
208
+ with its box read R9VK at p >= 0.89 per character. The answer's points are taken in this union's frame, so
209
+ `bounding_box` is the union everywhere the round reads it; everything else is the element's own.
210
+ """
211
+
212
+ def __init__(self, element: Any, box: Any):
213
+ self._element, self._box = element, box
214
+
215
+ def bounding_box(self) -> Optional[Dict[str, float]]:
216
+ a = self._element.bounding_box()
217
+ b = self._box.bounding_box() if a else None
218
+ if not a or not b:
219
+ return a
220
+ x0, y0 = min(a["x"], b["x"]), min(a["y"], b["y"])
221
+ x1 = max(a["x"] + a["width"], b["x"] + b["width"])
222
+ y1 = max(a["y"] + a["height"], b["y"] + b["height"])
223
+ if (x1 - x0) * (y1 - y0) > ANSWER_BOX_MAX_GROWTH * max(a["width"] * a["height"], 1.0):
224
+ return a
225
+ return {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
226
+
227
+ def __getattr__(self, name: str) -> Any:
228
+ return getattr(self._element, name)
229
+
230
+
200
231
  @dataclass
201
232
  class _GridSession:
202
233
  grid_boxes: List[Sequence[int]]
@@ -250,12 +281,29 @@ _NOT_THIS_BOARD_POLLS = 3
250
281
  # How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read
251
282
  # inside this window is wearing our own feedback, which is not a screen it ever showed by itself.
252
283
  INPUT_SETTLE_MS = 400
284
+ # While a reCAPTCHA tile reloads the hand rests on it, drifting a few pixels every second or two. Reaching for a
285
+ # different reloading tile on every 250 ms poll read as a cursor darting about the board.
286
+ IDLE_DRIFT_EVERY_MS = (1200.0, 2800.0)
287
+ IDLE_DRIFT_PX = 3.0
288
+ # How much longer than `grid_load_timeout_ms` a reCAPTCHA tile that is still painting is waited on.
289
+ GRID_LOAD_CAP_FACTOR = 3
290
+ # A press-and-hold is released once the widget's picture has stopped changing for this long after its fill
291
+ # moved, and at HOLD_MAX_MS whatever it shows. Press & Hold widgets ask for seconds, not tens of seconds.
292
+ HOLD_SETTLE_MS = 900
293
+ HOLD_MAX_MS = 12000
253
294
 
254
295
 
255
296
  @dataclass
256
297
  class PageSolverConfig:
257
298
  humanization: Optional[HumanizationMode] = None
258
299
  humanizer: Optional[Humanizer] = None
300
+ # Every URL pattern and selector the solver detects and drives with: a path to a config file of the shape of the
301
+ # repository's `captchakraken.config.json`, or the parsed object. What it gives is laid over the shipped default,
302
+ # so a file holding only a `custom` entry adds that entry and keeps every built-in vendor.
303
+ selectors: Union[str, "os.PathLike[str]", Mapping[str, Any]] = DEFAULT_CONFIG_PATH
304
+ # Deprecated, kept working: your own vendor entry in code. Prefer `custom` in the `selectors` file;
305
+ # when both are given this one wins.
306
+ custom_selectors: Optional[VendorSelectors] = None
259
307
  touch_driver: Optional[Any] = None
260
308
  touch_transform: Optional[Dict[str, Any]] = None
261
309
  starting_mouse_position: Optional[Tuple[float, float]] = None
@@ -265,16 +313,11 @@ class PageSolverConfig:
265
313
  # A page just navigated has often not drawn its widget; this long is spent looking before "no captcha" is
266
314
  # believed. It is not charged to overall_solve_timeout_ms, which starts when there is something to solve.
267
315
  detection_timeout_ms: int = 15_000
268
- # Repeats of one answer on one board before the solve ends; the first repeats are re-asked at a fresh sample.
316
+ # Repeats of one answer on one board before the solve ends; the first repeats are re-asked naming the refused answer.
269
317
  max_no_progress_rounds: int = 2
270
- # Deprecated and ignored since 3.2.0: a round that performed nothing goes straight on to the next.
271
- post_solve_delay_ms: int = 1_200
272
318
  post_solve_outcome_timeout_ms: int = 1_000
273
319
  post_solve_outcome_poll_ms: int = 75
274
320
  element_screenshot_timeout_ms: int = 8_000
275
- # Deprecated and ignored since 3.2.0: each of these failures now counts against max_solve_loops.
276
- max_unsupported_resolves: int = 3
277
- max_stale_element_retries: int = 3
278
321
  # The pause before a failed round is tried again.
279
322
  stale_element_backoff_ms: int = 900
280
323
  stale_frame_resolve_enabled: bool = True
@@ -300,7 +343,7 @@ class PageSolverConfig:
300
343
  keyframe_wait_timeout_ms: int = 9_000
301
344
  keyframe_wait_poll_ms: int = 120
302
345
  video_extra_inference_ms: int = 8_000
303
- hcaptcha_images_timeout_ms: int = 3_000
346
+ board_images_timeout_ms: int = 3_000
304
347
  grid_load_poll_interval_ms: int = 250
305
348
  grid_load_timeout_ms: int = 8_000
306
349
  board_paint_poll_ms: int = 180
@@ -313,12 +356,29 @@ class PageSolverConfig:
313
356
  recaptcha_tile_hover_enabled: bool = True
314
357
  slide_tolerance_px: float = 2.0
315
358
  slide_max_corrections: int = 3
359
+ # Older names, still accepted. The first three have done nothing since 3.2.0 (each failure counts against
360
+ # max_solve_loops); the last is board_images_timeout_ms, which waits for any vendor's `images`, not only hCaptcha's.
361
+ post_solve_delay_ms: InitVar[Optional[int]] = None
362
+ max_unsupported_resolves: InitVar[Optional[int]] = None
363
+ max_stale_element_retries: InitVar[Optional[int]] = None
364
+ hcaptcha_images_timeout_ms: InitVar[Optional[int]] = None
365
+
366
+ def __post_init__(self, post_solve_delay_ms, max_unsupported_resolves, max_stale_element_retries,
367
+ hcaptcha_images_timeout_ms) -> None:
368
+ if hcaptcha_images_timeout_ms is not None:
369
+ self.board_images_timeout_ms = hcaptcha_images_timeout_ms
316
370
 
317
371
  def video_budget_ms(self) -> int:
318
372
  return (self.video_burst_max_ms + self.keyframe_wait_timeout_ms
319
373
  + self.video_extra_inference_ms)
320
374
 
321
375
 
376
+ # The old name reads and writes the new field; as an InitVar alone it was accepted by __init__ but read back as None.
377
+ PageSolverConfig.hcaptcha_images_timeout_ms = property( # type: ignore[assignment]
378
+ lambda self: self.board_images_timeout_ms,
379
+ lambda self, v: setattr(self, "board_images_timeout_ms", v))
380
+
381
+
322
382
  class Widget(NamedTuple):
323
383
  """What detection found: the handle to photograph, the locator to search inside, and which table entry named it."""
324
384
 
@@ -366,6 +426,16 @@ def _frame_of(at: Any) -> Optional[Any]:
366
426
  return None
367
427
 
368
428
 
429
+ def _host_scopes(page: Any, enclaves: Sequence[str]) -> List[Any]:
430
+ """Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave
431
+ is skipped because Playwright judges an element visible within its own document, whatever its frame's visibility."""
432
+ return [page, *(f for f in map(_frame_of, _visible(page, enclaves)) if f is not None)]
433
+
434
+
435
+ def _visible_in(scopes: Sequence[Any], selectors: Sequence[str]) -> List[Any]:
436
+ return [at for scope in scopes for at in _visible(scope, selectors)]
437
+
438
+
369
439
  def _input_value(handle: Any) -> str:
370
440
  try:
371
441
  return str(handle.input_value() or "")
@@ -380,8 +450,9 @@ def _has_text(handle: Any) -> bool:
380
450
  return False
381
451
 
382
452
 
383
- SLIDE_PIECE_MIN_PX = 3.0
384
- SLIDE_PIECE_MAX_FRACTION = 0.6
453
+ # The nudge that shows the tracker what the handle moves: enough to separate the part from still ground, little enough
454
+ # that a piece resting against the board's edge stays on it.
455
+ SLIDE_PROBE_PX = 8.0
385
456
 
386
457
  # No prompt and no pictures both read as ready: waiting for `.prompt-text` to exist once paid the whole timeout
387
458
  # before every board of a challenge that draws none (tower_stack: 18s a round, 2/2 -> 0/2).
@@ -409,6 +480,8 @@ _RESOURCES_JS = """() => {
409
480
  class PageSolver:
410
481
  """Drives one Playwright-compatible `page` through a captcha. Reusable across solves."""
411
482
 
483
+ _sel: SelectorTable = DEFAULT_TABLE
484
+
412
485
  def __init__(self, config: Optional[PageSolverConfig] = None,
413
486
  solver: Optional[CaptchaSolver] = None, **solver_kwargs: Any) -> None:
414
487
  self.config = config or PageSolverConfig()
@@ -416,10 +489,14 @@ class PageSolver:
416
489
  solver_kwargs.setdefault("expert", self.config.expert)
417
490
  self._solver = solver or CaptchaSolver(**solver_kwargs)
418
491
  self._human: Humanizer = resolve_humanizer(self.config)
492
+ self._sel = SelectorTable(self.config.custom_selectors, config=self.config.selectors)
493
+ # The config's `detector`: which element detector finds this solver's grids and tick boxes.
494
+ from .pointer import detector as _detector
495
+ _detector.configure(self._sel.config.detector)
419
496
  self._last_submit_frame_hash: Optional[str] = None
420
497
  self._deadline_ms: Optional[float] = None
421
498
  self._budget: Optional[PhaseBudget] = None
422
- self._animated_plan: Optional[Tuple[List[str], str, Any, Any]] = None
499
+ self._animated_plan: Optional[Tuple[AnimatedRecording, str, Any, Any]] = None
423
500
  self._film_frames: List[Any] = []
424
501
  self._film_ms: float = 0.0
425
502
  # The digests of every screen the film holds: what a later burst is checked against to answer
@@ -464,6 +541,7 @@ class PageSolver:
464
541
 
465
542
  def _reset_animated_state(self) -> None:
466
543
  self._acted_on_board = False
544
+ self._submitted_on_board = False
467
545
  self._known_animated = False
468
546
  self._animated_probe_armed = False
469
547
  self._animated_probe_done = False
@@ -472,37 +550,75 @@ class PageSolver:
472
550
  self._keyframe_mode: Optional[KeyframeMode] = None
473
551
  self._keyframe_steady_screens = 0
474
552
  self._last_answer_sig: Optional[str] = None
553
+ self._tried_answers: Set[str] = set()
554
+ self._refreshed_boards: Set[str] = set()
555
+ self._round_board: Optional[str] = None
475
556
  self._no_progress_rounds = 0
476
- self._resample_level = 0
477
- self._apply_sampling()
557
+ self._refused_rounds = 0
558
+ self._fallback_board: Optional[str] = None
559
+ self._exchanges_at_answer = 0
560
+ self._drop_pointer_history()
561
+ self._apply_refusals()
478
562
 
479
- @staticmethod
480
- def _answer_signature(actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> Optional[str]:
481
- """Keyed on retry mode: the missed-tiles answer legitimately overlaps the previous one."""
563
+ def _answer_signature(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> Optional[str]:
564
+ """Keyed on the BOARD and the retry mode, not the answer alone.
565
+
566
+ A paged puzzle's next arrow is at the same point on every page, so a signature over the actions
567
+ alone would call a correct page turn a repeated answer and stop pressing it.
568
+ """
482
569
  try:
483
570
  parts = [(a.get("action"), _round_pts(a.get("target_bounding_boxes")),
484
571
  _round_pts(a.get("target_bounding_box")), _round_pts(a.get("target_coordinates")),
485
572
  _round_pts(a.get("source_bounding_box")), a.get("text"))
486
573
  for a in map(_as_dict, actions)]
487
- return repr((retry_mode, parts))
574
+ return repr((self._fallback_board, retry_mode, parts))
488
575
  except Exception:
489
576
  return None
490
577
 
491
578
  def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> bool:
492
- """True when this answer already ran and changed nothing: resample, re-ask, and arm the probe."""
579
+ """True when this answer already ran and changed nothing: re-ask avoiding it, and arm the probe."""
493
580
  sig = self._answer_signature(actions, retry_mode)
494
- if sig is not None and sig == self._last_answer_sig:
581
+ # A refresh asks for a different board, so the same refresh twice is two boards, not a refused answer. An
582
+ # answer that could not be signed could not be read either, and is not looked into.
583
+ if sig is not None and any(_as_dict(a).get("action") == ActionKind.REFRESH for a in actions):
584
+ self._no_progress_rounds = 0
585
+ self._last_answer_sig = None
586
+ if self._round_board is not None:
587
+ self._refreshed_boards.add(self._round_board)
588
+ return False
589
+ # A vendor can deal the same picture again, which clears the plans but not what was pressed on it. A paged
590
+ # puzzle is exempt: coming back to a page after the others is a new question, because the history differs.
591
+ tried = (self._fallback_board is not None and not self._pointer_history and sig in self._tried_answers)
592
+ if sig is not None and (sig == self._last_answer_sig or tried):
495
593
  self._no_progress_rounds += 1
496
594
  _log(f"[no-progress] the model returned the same answer again ({self._no_progress_rounds} in a row)")
497
- self._resample_level += 1
498
- self._apply_sampling()
595
+ # A board that said nothing to its server has refused nothing, and another plan is one more press.
596
+ if not self._board_stayed_silent():
597
+ self._refused_rounds += 1
598
+ self._apply_refusals()
499
599
  self._invalidate_animated_answer()
500
600
  self._arm_animated_probe()
501
601
  return True
502
602
  self._no_progress_rounds = 0
503
603
  self._last_answer_sig = sig
604
+ if sig is not None:
605
+ self._tried_answers.add(sig)
606
+ self._exchanges_at_answer = self._verdicts.exchanges if self._verdicts else 0
504
607
  return False
505
608
 
609
+ def _another_move_left(self, retry_mode: Optional[RetryMode]) -> bool:
610
+ """Whether the round after a repeat can differ. At temperature 0 the same question gets the same answer,
611
+ so it takes a different question (a retry mode, a film) or one of the model's other plans."""
612
+ target = getattr(self._solver, "planner", None)
613
+ pointer = bool(getattr(getattr(target, "prompts", None), "pointer", False))
614
+ return bool(retry_mode or self._known_animated
615
+ or (self.config.video_solve_enabled and self._animated_probe_armed)
616
+ or (pointer and target.avoid_refused))
617
+
618
+ def _board_stayed_silent(self) -> bool:
619
+ log = self._verdicts
620
+ return log is not None and log.listening and log.exchanges == self._exchanges_at_answer
621
+
506
622
  def _bound_ask_to_the_budget(self) -> None:
507
623
  """Give the planner what is LEFT of the solve, so one ask cannot outlive the whole thing.
508
624
 
@@ -517,13 +633,101 @@ class PageSolver:
517
633
  target.request_timeout_s = max(planner.MIN_REQUEST_TIMEOUT_S,
518
634
  min(planner.DEFAULT_REQUEST_TIMEOUT_S, left_s))
519
635
 
520
- def _apply_sampling(self) -> None:
636
+ def _apply_refusals(self) -> None:
521
637
  target = getattr(self._solver, "planner", None)
522
638
  if target is None:
523
639
  return
524
- target.sampling = sampling = planner.sampling_for_level(self._resample_level)
525
- if sampling:
526
- _log(f"[resample] temperature {sampling['temperature']}, seed {sampling['seed']}")
640
+ # A pointer model is an argmax and samples nothing. Once an answer has come back unchanged and refused,
641
+ # every ask on this board names the answers already tried, and the pointer returns its likeliest other one.
642
+ target.avoid_refused = self._refused_rounds > 0
643
+ if not self._refused_rounds and hasattr(target, "pointer_answers"):
644
+ target.pointer_answers.clear()
645
+ if hasattr(target, "pointer_fallbacks"):
646
+ target.pointer_fallbacks.clear()
647
+ if hasattr(target, "pointer_has_actions"):
648
+ target.pointer_has_actions = False
649
+ self._fallback_board = None
650
+
651
+ def _drop_pointer_history(self) -> None:
652
+ """A paged puzzle's earlier screens do not belong to the next puzzle."""
653
+ directory = getattr(self, "_pointer_history_dir", None)
654
+ if directory:
655
+ shutil.rmtree(directory, ignore_errors=True)
656
+ self._pointer_history: List[str] = []
657
+ self._pointer_history_dir: Optional[str] = None
658
+ planner = getattr(self._solver, "planner", None)
659
+ if planner is not None and hasattr(planner, "pointer_history"):
660
+ planner.pointer_history = self._pointer_history
661
+
662
+ def _keep_pointer_screen(self, shot: str, puzzle_source: Vendor, actions: Sequence[Any]) -> None:
663
+ """A FunCaptcha page turn's answer depends on the screens already seen; keep each one for the next ask.
664
+
665
+ The model reads the earlier screens of the same puzzle as history. Sent only the current page, it
666
+ has nothing to compare against and keeps answering "next arrow", including on the match.
667
+ """
668
+ if puzzle_source != Vendor.FUNCAPTCHA:
669
+ return
670
+ kinds = {_as_dict(a).get("action") for a in actions}
671
+ if kinds != {ActionKind.CLICK}:
672
+ return
673
+ if self._pointer_history_dir is None:
674
+ self._pointer_history_dir = tempfile.mkdtemp(prefix="ck_pghist_")
675
+ path = os.path.join(self._pointer_history_dir, f"{len(self._pointer_history):02d}.png")
676
+ try:
677
+ shutil.copyfile(shot, path)
678
+ except OSError:
679
+ return
680
+ self._pointer_history.append(path)
681
+ while len(self._pointer_history) > 8:
682
+ old = self._pointer_history.pop(0)
683
+ _unlink(old)
684
+ planner = getattr(self._solver, "planner", None)
685
+ if planner is not None and hasattr(planner, "pointer_history"):
686
+ planner.pointer_history = self._pointer_history
687
+
688
+ def _board_hash(self, element: Any, shot: str, filmed: bool) -> str:
689
+ """The board's identity, always from a plain photograph: a filmed frame of an unchanged board differs in bytes."""
690
+ if not filmed:
691
+ return _sha1(shot)
692
+ plain = _tmp_png("board")
693
+ try:
694
+ self._screenshot(element, plain, timeout_ms=self.config.element_screenshot_timeout_ms)
695
+ return _sha1(plain)
696
+ finally:
697
+ _unlink(plain)
698
+
699
+ def _guard_fallbacks_to_board(self, board: str) -> None:
700
+ """A fallback plan answers the board it was read off. Reuse it only while that board is still up.
701
+
702
+ The next round's screenshot is the test. Without it, a vendor that deals a new board without
703
+ saying so has the previous board's second plan pressed onto the new one.
704
+
705
+ A refresh that brought the same picture back has replaced nothing, and every plan has been spent on it.
706
+ """
707
+ if board in self._refreshed_boards:
708
+ raise CaptchaSolveError("no progress: the board came back unchanged after a refresh")
709
+ self._round_board = board
710
+ target = getattr(self._solver, "planner", None)
711
+ if target is None or not getattr(getattr(target, "prompts", None), "pointer", False):
712
+ return
713
+ if self._fallback_board is not None and board != self._fallback_board:
714
+ if self._refused_rounds or getattr(target, "pointer_fallbacks", None):
715
+ _log("[pointer] the board changed; dropping the previous answer's fallbacks")
716
+ self._refused_rounds = 0
717
+ self._apply_refusals()
718
+ self._fallback_board = board
719
+
720
+ def _keep_plans_of_this_many_clicks(self, actions: List[Any]) -> None:
721
+ """A click board with no Verify submits itself at a count, so a plan of any other count leaves its tiles
722
+ ticked and never submits, and the next plan's clicks untick them."""
723
+ plan = [_as_dict(a) for a in actions]
724
+ if not plan or any(a.get("action") != ActionKind.CLICK for a in plan):
725
+ return
726
+ clicks = sum(len(a.get("target_bounding_boxes") or [1]) for a in plan)
727
+ target = getattr(self._solver, "planner", None)
728
+ if getattr(target, "pointer_fallbacks", None):
729
+ target.pointer_fallbacks = [f for f in target.pointer_fallbacks if f.get("action") != ActionKind.CLICK
730
+ or len(f.get("points") or []) == clicks]
527
731
 
528
732
  def _fresh_board(self) -> None:
529
733
  # ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer describes the
@@ -535,9 +739,11 @@ class PageSolver:
535
739
  # Its own second look, but only once IT fails: an arm set on the previous board is evidence about that one.
536
740
  self._animated_probe_armed = False
537
741
  self._animated_probe_done = False
538
- self._resample_level = 0
742
+ self._refused_rounds = 0
539
743
  self._acted_on_board = False
540
- self._apply_sampling()
744
+ self._submitted_on_board = False
745
+ self._drop_pointer_history()
746
+ self._apply_refusals()
541
747
 
542
748
  def _check_deadline(self, where: str) -> None:
543
749
  # Called inside loops too: a camoufox session once ran ten minutes past a 120s timeout because only
@@ -604,11 +810,20 @@ class PageSolver:
604
810
 
605
811
  def _get_solution(self, image_path: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
606
812
  text_mode: bool = False):
607
- return self._ask(lambda: self._solver.solve(
608
- image_path, puzzle_source=puzzle_source, retry_mode=retry_mode, text_mode=text_mode))
813
+ def solve():
814
+ try:
815
+ return self._solver.solve(image_path, puzzle_source=puzzle_source, retry_mode=retry_mode,
816
+ text_mode=text_mode)
817
+ except UnsupportedCaptchaError:
818
+ # No answer is an empty plan, which presses Skip for a new board. An empty text box is a wrong answer.
819
+ if text_mode:
820
+ raise
821
+ return []
822
+ return self._ask(solve)
609
823
 
610
- def _get_keyframe_solution(self, keyframe_paths: Sequence[str]):
611
- return self._ask(lambda: self._solver.solve_keyframes(keyframe_paths))
824
+ def _get_keyframe_solution(self, recording: AnimatedRecording):
825
+ """The animated ask: keyframes or video, whichever the model's prompt generation was trained on."""
826
+ return self._ask(lambda: self._solver.solve_animated(recording))
612
827
 
613
828
  # ── gestures ─────────────────────────────────────────────────────────
614
829
 
@@ -657,6 +872,17 @@ class PageSolver:
657
872
  return float(coords[0]) * element_box["width"], float(coords[1]) * element_box["height"]
658
873
  return None
659
874
 
875
+ @staticmethod
876
+ def _hold_point_for(action: Dict[str, Any], element_box: Dict[str, float]) -> Optional[Tuple[float, float]]:
877
+ """Element-relative press point of a hold: the model's own point, the middle of its box. A hold can be on a
878
+ bar narrower than that box, and a random spot in it lands off the bar, or close enough to its edge that the
879
+ hand's drift lets go: the widget then never fills, and the same answer fails the same way."""
880
+ bbox = action.get("target_bounding_box")
881
+ if not bbox:
882
+ return None
883
+ x1, y1, x2, y2 = (float(v) for v in bbox)
884
+ return (x1 + x2) / 2 * element_box["width"], (y1 + y2) / 2 * element_box["height"]
885
+
660
886
  def _execute_click(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float],
661
887
  element: Any = None, await_keyframe: Optional[str] = None) -> None:
662
888
  # Every gesture marks the board acted on so the speculative burst is never re-run over a touched board.
@@ -679,6 +905,121 @@ class PageSolver:
679
905
  element_box: Dict[str, float], await_keyframe: str) -> None:
680
906
  self._execute_click(page, action, element_box, element, await_keyframe)
681
907
 
908
+ def _execute_hold(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float], element: Any,
909
+ completes_itself: bool = False) -> bool:
910
+ """Press-and-hold until the widget completes: True when it did, False when the cap ran out.
911
+
912
+ Complete is the vendor's done signal, the widget closing, or its picture having changed (the fill)
913
+ and then sat still for HOLD_SETTLE_MS. A widget that draws no progress at all is held to HOLD_MAX_MS.
914
+ When the answer names the fill's line, the fill reaching its end is complete too: see `_fill_reached`.
915
+ Except on a widget that `completes_itself` (HUMAN's button): it verifies only once it has shown its
916
+ done state, and letting go AT full resets it, so only its own signals end the hold.
917
+ """
918
+ self._acted_on_board = True
919
+ rel = self._hold_point_for(action, element_box)
920
+ if rel is None:
921
+ _log("hold action without a bounding box; skipping")
922
+ return False
923
+ at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
924
+ first = self._element_frame_hash(element)
925
+ seen = {"hash": first, "since": time.monotonic(), "changed": False}
926
+ line = action.get("fill_from_bounding_box") and action.get("fill_to_bounding_box")
927
+ shots = (_tmp_png("hold"), _tmp_png("hold")) if line and not completes_itself else None
928
+ looks: List[Dict[str, Any]] = []
929
+
930
+ def done() -> bool:
931
+ if self.is_captcha_solved(page):
932
+ _debug("hold: the vendor reports solved")
933
+ return True
934
+ try:
935
+ if not element.bounding_box():
936
+ _debug("hold: the widget closed")
937
+ return True
938
+ except Exception:
939
+ _debug("hold: the widget closed")
940
+ return True
941
+ n = len(looks)
942
+ if shots and self._fill_reached(element, action, element_box, shots, looks):
943
+ return True
944
+ # A look that just shot the widget is its frame too: a second screenshot per look slowed the looks
945
+ # enough for a fast fill to pass the mark unseen between two.
946
+ fresh = shots[0 if n == 0 else 1] if shots and len(looks) > n else None
947
+ h, now = (_sha1(fresh) if fresh else self._element_frame_hash(element)), time.monotonic()
948
+ if h != seen["hash"]:
949
+ seen.update(hash=h, since=now, changed=seen["changed"] or h != first)
950
+ return False
951
+ settled = seen["changed"] and (now - seen["since"]) * 1000.0 >= HOLD_SETTLE_MS
952
+ if settled:
953
+ _debug(f"hold: the picture sat still for {HOLD_SETTLE_MS}ms")
954
+ return settled
955
+
956
+ try:
957
+ with self._gesture():
958
+ completed = hold(self._human, page, at, done, HOLD_MAX_MS)
959
+ finally:
960
+ for shot in shots or ():
961
+ _unlink(shot)
962
+ _log("held until the widget completed." if completed else f"released the hold at the {HOLD_MAX_MS}ms cap.")
963
+ return completed
964
+
965
+ def _fill_reached(self, element: Any, action: Dict[str, Any], element_box: Dict[str, float],
966
+ shots: Tuple[str, str], looks: List[Dict[str, Any]]) -> bool:
967
+ """One look at a held widget's fill (`tool_calls.fill_tracker`): True once it reaches the answer's `to`, after
968
+ waiting out the tracker's `release_in` when the fill arrives between looks.
969
+
970
+ The first look only shoots the reference, after the press, so the button's own pressed look is not read as
971
+ fill; `looks` carries what each later look saw. A look that cannot be shot or read is not a release: the
972
+ widget's own signals still end the hold."""
973
+ from .tool_calls.fill_tracker import fill_step
974
+
975
+ first, current = shots
976
+ w, h = element_box["width"], element_box["height"]
977
+ timeout = self.config.element_screenshot_timeout_ms
978
+ try:
979
+ if not looks:
980
+ shot_at = time.monotonic()
981
+ self._screenshot(element, first, timeout_ms=timeout)
982
+ looks.append({"k": self._shot_scale(first, w), "t": (shot_at + time.monotonic()) / 2})
983
+ return False
984
+ shot_at = time.monotonic()
985
+ self._screenshot(element, current, timeout_ms=timeout)
986
+ # The shot was taken somewhere inside the call; its middle is the best guess, and the release is timed
987
+ # from it.
988
+ shot_at = (shot_at + time.monotonic()) / 2
989
+ k, history = looks[0]["k"], looks[1:]
990
+
991
+ def at(bbox: Sequence[float]) -> Tuple[float, float]:
992
+ return (float(bbox[0]) + float(bbox[2])) / 2 * w * k, (float(bbox[1]) + float(bbox[3])) / 2 * h * k
993
+
994
+ gaps = [b["t"] - a["t"] for a, b in zip(history, history[1:])]
995
+ reply = fill_step(first, current, start=at(action["fill_from_bounding_box"]),
996
+ end=at(action["fill_to_bounding_box"]), t=shot_at, history=history, first_t=looks[0]["t"],
997
+ poll_s=sum(gaps) / len(gaps) if gaps else 0.25, tol=self.config.slide_tolerance_px * k)
998
+ except Exception as exc:
999
+ _debug(f"hold: the fill was not read ({exc})")
1000
+ return False
1001
+ looks.append(reply["observation"])
1002
+ if reply["release"]:
1003
+ _log(f"hold: the fill reaches its mark {reply['release_in'] or 0:.2f}s after a look "
1004
+ f"{(reply['length'] - reply['edge']) / k:+.1f}px short of it")
1005
+ _delay((shot_at + (reply["release_in"] or 0.0) - time.monotonic()) * 1000.0)
1006
+ return bool(reply["release"])
1007
+
1008
+ def _execute_refresh(self, page: Any, scope: Any, action: Dict[str, Any],
1009
+ element_box: Optional[Dict[str, float]]) -> bool:
1010
+ """Deal a new puzzle: the vendor's own control, else the model's point on one. False when there is neither."""
1011
+ control = self._find_control(scope, self._sel.refresh)
1012
+ if control is not None:
1013
+ self._acted_on_board = True
1014
+ self._move_and_click(page, control)
1015
+ elif action.get("target_bounding_box") and element_box:
1016
+ self._execute_click(page, action, element_box)
1017
+ else:
1018
+ _log("the model asked for a new puzzle, and there is no control to deal one; ending the round.")
1019
+ return False
1020
+ _log("asked for a new puzzle instead of solving this one.")
1021
+ return True
1022
+
682
1023
  def _execute_drag(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]) -> None:
683
1024
  self._acted_on_board = True
684
1025
 
@@ -693,31 +1034,16 @@ class PageSolver:
693
1034
  first = next(iter(_visible(scope, selectors)), None)
694
1035
  return _handle_of(first) if first is not None else None
695
1036
 
696
- def _measure_piece_box(self, scope: Any, widget_width: float) -> Optional[Dict[str, float]]:
697
- """The slider piece's box: the first visible match small enough to be a piece.
698
-
699
- Runs during detection, so it must not mark the board acted on: doing so once disabled the
700
- speculative burst on every slide solve.
701
- """
702
- for candidate in _handles(_visible(scope, PIECE_SELECTORS)):
703
- try:
704
- b = candidate.bounding_box()
705
- except Exception:
706
- continue
707
- if b and SLIDE_PIECE_MIN_PX <= b["width"] <= widget_width * SLIDE_PIECE_MAX_FRACTION:
708
- return b
709
- return None
710
-
711
1037
  def _answer_box(self, scope: Any, at: Any = None) -> Optional[Any]:
712
1038
  """The text box inside the widget, else a vendor-named one in its enclosing fieldset/form.
713
1039
 
714
1040
  Only vendor-named selectors widen: BotDetect's `.BDC_CaptchaDiv` is 280x50 and holds only the image,
715
1041
  with `#captchaCode` in a sibling div. The generic tail there would take a login form's own box.
716
1042
  """
717
- inside = self._find_control(scope, TEXT_INPUT_SELECTORS)
1043
+ inside = self._find_control(scope, self._sel.text_input)
718
1044
  if inside is not None or at is None:
719
1045
  return inside
720
- around = (self._find_control(at.locator(f"xpath={axis}"), TEXT_INPUT_VENDOR_SELECTORS)
1046
+ around = (self._find_control(at.locator(f"xpath={axis}"), self._sel.text_input_vendor)
721
1047
  for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"))
722
1048
  return next((found for found in around if found is not None), None)
723
1049
 
@@ -736,20 +1062,6 @@ class PageSolver:
736
1062
  _log(f"typed {len(text)} character(s) into the captcha field")
737
1063
  return True
738
1064
 
739
- def _track_piece(self, element: Any, before: str, after: str, exclude: Sequence[float],
740
- travel: float = 0.0) -> Optional[Dict[str, Any]]:
741
- from .tool_calls.track_piece import changed_bbox, locate_piece
742
-
743
- try:
744
- self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
745
- bbox = changed_bbox(before, after, exclude)
746
- if bbox is None:
747
- return None
748
- return {"bbox": bbox, "piece": locate_piece(before, after, travel, exclude)}
749
- except Exception as exc:
750
- _debug(f"track_piece failed: {exc}")
751
- return None
752
-
753
1065
  @staticmethod
754
1066
  def _shot_scale(shot: str, css_width: float) -> float:
755
1067
  dims = _read_png_dimensions(shot)
@@ -757,77 +1069,71 @@ class PageSolver:
757
1069
 
758
1070
  def _execute_slide(self, page: Any, element: Any, scope: Any, action: Dict[str, Any],
759
1071
  element_box: Dict[str, float]) -> bool:
760
- """Aim the handle at the slot once, then look and correct until the piece is home."""
1072
+ """Press the handle, nudge it to see what it moves, then steer that part's point onto its target.
1073
+
1074
+ A generation-3 answer names all three points, so nothing is read from the page but pixels. A generation-2
1075
+ answer names only the target: its handle comes from the selector table and the part it moves is whatever
1076
+ moved most. That path goes when the last generation-2 model does.
1077
+ """
1078
+ from .tool_calls.slide_tracker import TrackingError, slide_step
1079
+
761
1080
  self._acted_on_board = True
762
- tb = action["target_bounding_box"]
763
- target_x = (float(tb[0]) + float(tb[2])) / 2 * element_box["width"]
1081
+ w, h = element_box["width"], element_box["height"]
764
1082
 
765
- handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
766
- if handle is None:
767
- piece = self._find_control(scope, PIECE_SELECTORS)
768
- box = piece and piece.bounding_box()
1083
+ def at(bbox: Sequence[float]) -> Tuple[float, float]:
1084
+ return (float(bbox[0]) + float(bbox[2])) / 2 * w, (float(bbox[1]) + float(bbox[3])) / 2 * h
1085
+
1086
+ goal = at(action["target_bounding_box"])
1087
+ if action.get("handle_bounding_box"):
1088
+ handle, seed = at(action["handle_bounding_box"]), at(action["source_bounding_box"])
1089
+ else:
1090
+ slider = self._find_control(scope, self._sel.slider_handle)
1091
+ piece = None if slider is not None else self._find_control(scope, self._sel.piece)
1092
+ box = (slider or piece).bounding_box() if (slider or piece) is not None else None
769
1093
  if not box:
770
1094
  _log("slide action, but the widget has neither a slider nor a draggable piece")
771
1095
  return False
772
- target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
773
- with self._gesture():
774
- self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
775
- (element_box["x"] + target_x, element_box["y"] + target_y))
776
- return True
777
-
778
- hbox = handle.bounding_box()
779
- if not hbox:
780
- return False
781
- start_x, hold_y = hbox["x"] + hbox["width"] / 2, hbox["y"] + hbox["height"] / 2
782
- # Mask from the handle's band to the widget's bottom: the filled track moves too.
783
- pad = max(4.0, hbox["height"] * 0.35)
784
- band = [0.0, hbox["y"] - element_box["y"] - pad, element_box["width"], element_box["height"]]
785
- shots = [_tmp_png("slide") for _ in range(2)]
1096
+ handle = (box["x"] + box["width"] / 2 - element_box["x"], box["y"] + box["height"] / 2 - element_box["y"])
1097
+ # A piece with no track is the handle itself, and what it moves is it.
1098
+ seed = handle if piece is not None else None
1099
+
1100
+ press = (element_box["x"] + handle[0], element_box["y"] + handle[1])
1101
+ probe = SLIDE_PROBE_PX if goal[0] >= (seed or handle)[0] else -SLIDE_PROBE_PX
1102
+ timeout = self.config.element_screenshot_timeout_ms
1103
+ first, probed, current = (_tmp_png("slide") for _ in range(3))
786
1104
  try:
787
- self._move_to_element(page, handle, padding_percentage=30.0)
1105
+ self._smooth_move(page, *press)
788
1106
  self._human.press(page)
789
1107
  self._human.pause(PauseKind.GRAB)
790
- self._screenshot(element, shots[0], timeout_ms=self.config.element_screenshot_timeout_ms)
791
- scale = self._shot_scale(shots[0], element_box["width"])
792
- exclude = [v * scale for v in band]
793
-
794
- def live_center():
795
- b = self._measure_piece_box(scope, element_box["width"])
796
- return None if b is None else b["x"] + b["width"] / 2.0 - element_box["x"]
797
-
798
- rest = live_center()
799
- offset = target_x - (start_x - element_box["x"] if rest is None else rest)
800
- self._smooth_move(page, start_x + offset, hold_y)
801
-
802
- widths: List[Tuple[float, float]] = []
803
- last_box = last_piece = None
804
- ratio, steered = 1.0, False
805
- for _ in range(self.config.slide_max_corrections):
806
- self._human.pause(PauseKind.PROBE)
807
- seen = self._track_piece(element, shots[0], shots[1], exclude, travel=offset * ratio * scale)
808
- if seen is not None:
809
- box = seen["bbox"]
810
- widths.append((offset, float(box[2] - box[0]) / scale))
811
- last_box, last_piece = box, seen["piece"]
812
- piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
813
- live = live_center()
814
- if live is not None:
815
- center = live
816
- elif last_piece is not None:
817
- center = last_piece["centre"] / scale
818
- elif last_box is not None and piece_w is not None:
819
- center = last_box[2] / scale - piece_w / 2.0
820
- else:
821
- continue
822
- steered = True
823
- error = target_x - center
824
- _debug(f"slider: center={center:.1f} target={target_x:.1f} error={error:+.1f} ratio={ratio:.3f}")
825
- if abs(error) <= self.config.slide_tolerance_px:
1108
+ self._screenshot(element, first, timeout_ms=timeout)
1109
+ k = self._shot_scale(first, w)
1110
+ self._smooth_move(page, press[0] + probe, press[1])
1111
+ self._human.pause(PauseKind.PROBE)
1112
+ self._screenshot(element, probed, timeout_ms=timeout)
1113
+ ask = dict(probe_s=probe * k, goal=(goal[0] * k, goal[1] * k), seed=seed and (seed[0] * k, seed[1] * k),
1114
+ handle=(handle[0] * k, handle[1] * k), bounds=(-handle[0] * k, (w - handle[0]) * k),
1115
+ tol=self.config.slide_tolerance_px * k)
1116
+ look, s, history, moves = probed, probe * k, [], 0
1117
+ while True:
1118
+ try:
1119
+ reply = slide_step(first, probed, look, s=s, history=history, **ask)
1120
+ except TrackingError as exc:
1121
+ _log(f"slider: {exc}; released where the handle is")
1122
+ break
1123
+ if look is not probed:
1124
+ history.append(reply["observation"])
1125
+ _debug(f"slider: point at ({reply['pos'][0] / k:.1f}, {reply['pos'][1] / k:.1f}), "
1126
+ f"{reply['error'] / k:.1f}px from its target, {reply['kind']}")
1127
+ if reply["done"] or moves >= self.config.slide_max_corrections:
1128
+ if reply["error"] > ask["tol"]:
1129
+ _log(f"slider: releasing {reply['error'] / k:.1f}px off target")
826
1130
  break
827
- offset += error / ratio
828
- self._smooth_move(page, start_x + offset, hold_y)
829
- if not steered:
830
- _log("slider: the piece never resolved on screen; released where the opening sweep put it")
1131
+ s = reply["next"]
1132
+ dx, dy = (s[0], s[1]) if reply["grabbed"] else (s, 0.0)
1133
+ self._smooth_move(page, press[0] + dx / k, press[1] + dy / k)
1134
+ self._human.pause(PauseKind.PROBE)
1135
+ self._screenshot(element, current, timeout_ms=timeout)
1136
+ look, moves = current, moves + 1
831
1137
  self._human.pause(PauseKind.SETTLE)
832
1138
  finally:
833
1139
  try:
@@ -835,42 +1141,21 @@ class PageSolver:
835
1141
  except Exception:
836
1142
  pass
837
1143
  self._last_input_ms = _now()
838
- for shot in shots:
1144
+ for shot in (first, probed, current):
839
1145
  _unlink(shot)
840
1146
  return True
841
1147
 
842
- @staticmethod
843
- def _solve_slide_geometry(widths: Sequence[Tuple[float, float]],
844
- widget_width: float) -> Tuple[Optional[float], float]:
845
- """Piece width and handle-to-piece ratio from `(offset, changed width)` readings.
846
-
847
- The widest-apart pair over a 16px spread: changed widths are ±1px, so the ratio error is ±0.13 at the
848
- floor and ±0.5 at 4px. An implausible ratio falls back to 1:1 (true of every vendor observed) rather
849
- than a 0.02 that sends the handle off the track and camoufox into a move that never returns.
850
- """
851
- if not widths:
852
- return None, 1.0
853
- piece_w, ratio = None, 1.0
854
- (o1, w1), (o2, w2) = min(widths, key=lambda m: m[0]), max(widths, key=lambda m: m[0])
855
- if o2 - o1 >= 16.0 and 0.2 <= (w2 - w1) / (o2 - o1) <= 3.0:
856
- ratio = (w2 - w1) / (o2 - o1)
857
- piece_w = w1 - ratio * o1
858
- if piece_w is None:
859
- o, w = widths[-1]
860
- piece_w = w - ratio * o
861
- if not (SLIDE_PIECE_MIN_PX <= piece_w <= widget_width * SLIDE_PIECE_MAX_FRACTION):
862
- return None, ratio
863
- return piece_w, ratio
864
-
865
1148
  # ── detection ────────────────────────────────────────────────────────
866
1149
 
867
- def _has_value(self, page: Any, selector: str) -> bool:
1150
+ def _has_value(self, scopes: Sequence[Any], selector: str) -> bool:
868
1151
  """Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter."""
869
- try:
870
- fields = _handles(page.locator(selector).all())
871
- except Exception:
872
- return False
873
- return any(_input_value(f).strip() for f in fields)
1152
+ def fields(scope: Any) -> List[Any]:
1153
+ try:
1154
+ return _handles(scope.locator(selector).all())
1155
+ except Exception:
1156
+ return []
1157
+
1158
+ return any(_input_value(f).strip() for scope in scopes for f in fields(scope))
874
1159
 
875
1160
  def _visible_with_text(self, scope: Any, selector: str) -> bool:
876
1161
  """A visible match carrying text; an empty banner or prompt is a placeholder, not a signal."""
@@ -891,24 +1176,25 @@ class PageSolver:
891
1176
  except Exception:
892
1177
  return 0
893
1178
 
894
- return any(count(p.selector) > 0 for p in WIDGET_PROBES)
1179
+ return any(count(p.selector) > 0 for p in self._sel.widget_probes)
895
1180
 
896
1181
  def detect_captcha(self, page: Any) -> Optional[Widget]:
897
1182
  """Open challenges first, then unsolved checkboxes, then the inline vendors."""
898
- for probe in WIDGET_PROBES:
899
- at = next(iter(_visible(page, (probe.selector,))), None)
900
- if at is None or self._already_accepted(page, at, probe):
1183
+ scopes = _host_scopes(page, self._sel.enclave)
1184
+ for probe in self._sel.widget_probes:
1185
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
1186
+ if at is None or self._already_accepted(scopes, at, probe):
901
1187
  continue
902
1188
  element = _handle_of(at)
903
1189
  return Widget(element, at, probe.vendor, probe.role) if element is not None else None
904
1190
  return None
905
1191
 
906
- def _already_accepted(self, page: Any, at: Any, probe: WidgetProbe) -> bool:
1192
+ def _already_accepted(self, scopes: Sequence[Any], at: Any, probe: WidgetProbe) -> bool:
907
1193
  """A checkbox the vendor has already accepted is not a captcha to solve."""
908
- s = SELECTORS[probe.vendor]
1194
+ s = self._sel.selectors[probe.vendor]
909
1195
  if probe.role != FrameRole.CHECKBOX:
910
1196
  return False
911
- return bool(s.response and self._has_value(page, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
1197
+ return bool(s.response and self._has_value(scopes, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
912
1198
 
913
1199
  def vendors_on_the_wire(self, page: Any) -> List[Vendor]:
914
1200
  """Which vendors' code the page loaded. Resource timing, because a request listener would have to have been attached before navigation."""
@@ -917,13 +1203,14 @@ class PageSolver:
917
1203
  except Exception:
918
1204
  return []
919
1205
  blob = " ".join(str(n) for n in (names or []))
920
- return [vendor for vendor, s in VENDORS if any(h in blob for h in s.hosts)]
1206
+ return [vendor for vendor, s in self._sel.vendors if any(h in blob for h in s.hosts)]
921
1207
 
922
1208
  def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
923
1209
  """Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame
924
- (an invisible badge) is not a widget, so what is left is markup the table no longer matches."""
925
- return [vendor for vendor, s in VENDORS if s.hosts and len(_visible(page, [f'iframe[src*="{h}"]' for h in s.hosts]))
926
- > len(_visible(page, s.passive))]
1210
+ (an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches."""
1211
+ scopes = _host_scopes(page, self._sel.enclave)
1212
+ return [vendor for vendor, s in self._sel.vendors if s.hosts and len(_visible_in(scopes, [f'iframe[src*="{h}"]' for h in s.hosts]))
1213
+ > len(_visible_in(scopes, s.passive))]
927
1214
 
928
1215
  def _no_widget_message(self, page: Any) -> str:
929
1216
  base = f"no interactive captcha widget detected within {self.config.detection_timeout_ms}ms"
@@ -954,11 +1241,12 @@ class PageSolver:
954
1241
 
955
1242
  def _is_blocked(self, page: Any) -> bool:
956
1243
  """The vendor showing its refusal screen: rate-limited or flagged, with no board to answer."""
957
- for probe in WIDGET_PROBES:
958
- blocked = SELECTORS[probe.vendor].blocked
1244
+ scopes = _host_scopes(page, self._sel.enclave)
1245
+ for probe in self._sel.widget_probes:
1246
+ blocked = self._sel.selectors[probe.vendor].blocked
959
1247
  if probe.role != FrameRole.CHALLENGE or not blocked:
960
1248
  continue
961
- at = next(iter(_visible(page, (probe.selector,))), None)
1249
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
962
1250
  frame = _frame_of(at) if at is not None else None
963
1251
  if frame and any(self._visible_with_text(frame, selector) for selector in blocked):
964
1252
  return True
@@ -972,13 +1260,14 @@ class PageSolver:
972
1260
  def is_captcha_solved(self, page: Any) -> bool:
973
1261
  """The vendor's own done signal: a response token, a painted success state, or a checked box."""
974
1262
  try:
975
- if any(self._has_value(page, s) for s in RESPONSE_SELECTORS) or _visible(page, ACCEPTED_SELECTORS):
1263
+ scopes = _host_scopes(page, self._sel.enclave)
1264
+ if any(self._has_value(scopes, s) for s in self._sel.response) or _visible_in(scopes, self._sel.accepted):
976
1265
  return True
977
- for probe in WIDGET_PROBES:
978
- checked = SELECTORS[probe.vendor].checked
1266
+ for probe in self._sel.widget_probes:
1267
+ checked = self._sel.selectors[probe.vendor].checked
979
1268
  if probe.role != FrameRole.CHECKBOX or not checked:
980
1269
  continue
981
- at = next(iter(_visible(page, (probe.selector,))), None)
1270
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
982
1271
  if at is not None and self._checked_in(at, checked):
983
1272
  return True
984
1273
  except Exception:
@@ -988,11 +1277,12 @@ class PageSolver:
988
1277
  def _is_challenge_freshly_rendered(self, page: Any) -> bool:
989
1278
  """A next round has painted, as opposed to the answered frame animating closed."""
990
1279
  try:
991
- for probe in WIDGET_PROBES:
992
- fresh = SELECTORS[probe.vendor].fresh
1280
+ scopes = _host_scopes(page, self._sel.enclave)
1281
+ for probe in self._sel.widget_probes:
1282
+ fresh = self._sel.selectors[probe.vendor].fresh
993
1283
  if probe.role != FrameRole.CHALLENGE or not fresh:
994
1284
  continue
995
- at = next(iter(_visible(page, (probe.selector,))), None)
1285
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
996
1286
  element = _handle_of(at) if at is not None else None
997
1287
  if element is None:
998
1288
  continue
@@ -1005,18 +1295,15 @@ class PageSolver:
1005
1295
  pass
1006
1296
  return False
1007
1297
 
1008
- @staticmethod
1009
- def _banner_is_fatal_after_retry(kind: Optional[RecaptchaBanner]) -> bool:
1010
- return kind in (RecaptchaBanner.SELECT_MORE, RecaptchaBanner.REJECTED)
1011
-
1012
1298
  def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
1013
1299
  """Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
1014
1300
  try:
1015
- for probe in WIDGET_PROBES:
1016
- banners = SELECTORS[probe.vendor].banners
1301
+ scopes = _host_scopes(page, self._sel.enclave)
1302
+ for probe in self._sel.widget_probes:
1303
+ banners = self._sel.selectors[probe.vendor].banners
1017
1304
  if probe.role != FrameRole.CHALLENGE or not banners:
1018
1305
  continue
1019
- at = next(iter(_visible(page, (probe.selector,))), None)
1306
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
1020
1307
  frame = _frame_of(at) if at is not None else None
1021
1308
  if not frame:
1022
1309
  continue
@@ -1033,11 +1320,15 @@ class PageSolver:
1033
1320
  Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering
1034
1321
  the checkbox picture with a drag.
1035
1322
  """
1036
- box = self._checkbox_box(frame, widget.vendor)
1323
+ box = self._checkbox_box(frame, widget.vendor) if frame is not None else (
1324
+ widget.element if self._sel.selectors[widget.vendor].box else None)
1037
1325
  if box is not None:
1038
1326
  self._move_to_element(page, box)
1039
1327
  else:
1040
- self._smooth_move(page, *self._find_checkbox_on_screen(widget.element))
1328
+ at = self._find_checkbox_on_screen(page, widget.element)
1329
+ if at is None:
1330
+ return
1331
+ self._smooth_move(page, *at)
1041
1332
  self._human.pause(PauseKind.BETWEEN)
1042
1333
  with self._gesture():
1043
1334
  self._human.click(page, self._last_mouse)
@@ -1048,7 +1339,7 @@ class PageSolver:
1048
1339
 
1049
1340
  Turnstile's box is in a closed shadow root, and a frame can refuse access; both are found on screen instead.
1050
1341
  """
1051
- box = SELECTORS[vendor].box
1342
+ box = self._sel.selectors[vendor].box
1052
1343
  if frame is None or not box:
1053
1344
  return None
1054
1345
  try:
@@ -1057,8 +1348,25 @@ class PageSolver:
1057
1348
  _log(f"[checkbox] the box is out of the DOM's reach ({str(exc).splitlines()[0]}); finding it on screen")
1058
1349
  return None
1059
1350
 
1060
- def _find_checkbox_on_screen(self, element: Any) -> Tuple[float, float]:
1061
- """One capture and OpenCV: a point inside the tick box, in page coordinates. No model is asked."""
1351
+ def _find_checkbox_on_screen(self, page: Any, element: Any) -> Optional[Tuple[float, float]]:
1352
+ """A point inside the tick box, in page coordinates, or None when the widget passed without drawing one.
1353
+
1354
+ Turnstile shows "Verifying…" before its box, so an empty capture is looked at again until
1355
+ `detection_timeout_ms`.
1356
+ """
1357
+ deadline = _now() + self.config.detection_timeout_ms
1358
+ while True:
1359
+ at = self._checkbox_on_screen(element)
1360
+ if at is not None:
1361
+ return at
1362
+ if self.is_captcha_solved(page):
1363
+ return None
1364
+ if _now() >= deadline:
1365
+ raise UnsupportedCaptchaError("the checkbox widget shows no tick box to click")
1366
+ _delay(_DETECTION_POLL_MS)
1367
+
1368
+ def _checkbox_on_screen(self, element: Any) -> Optional[Tuple[float, float]]:
1369
+ """One capture and the element detector, no model: a point inside the tick box, or None when none is drawn."""
1062
1370
  from .tool_calls.find_checkbox import find_checkbox
1063
1371
 
1064
1372
  box = element.bounding_box()
@@ -1073,7 +1381,7 @@ class PageSolver:
1073
1381
  finally:
1074
1382
  _unlink(shot)
1075
1383
  if found is None:
1076
- raise UnsupportedCaptchaError("the checkbox widget shows no tick box to click")
1384
+ return None
1077
1385
  x, y, w, h = (v / scale for v in found)
1078
1386
  return box["x"] + x + w * (0.3 + random.random() * 0.4), box["y"] + y + h * (0.3 + random.random() * 0.4)
1079
1387
 
@@ -1091,7 +1399,7 @@ class PageSolver:
1091
1399
  _delay(self.config.post_solve_outcome_poll_ms)
1092
1400
 
1093
1401
  def _get_verify_button(self, scope: Any) -> Optional[Any]:
1094
- return self._find_control(scope, SUBMIT_SELECTORS)
1402
+ return self._find_control(scope, self._sel.submit)
1095
1403
 
1096
1404
  # ── frames and polling ───────────────────────────────────────────────
1097
1405
 
@@ -1211,17 +1519,32 @@ class PageSolver:
1211
1519
  "waiting for the challenge to settle") or SettleVerdict.TIMEOUT
1212
1520
 
1213
1521
  def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
1522
+ """Hold the next ask until every tile has painted: a model asked about a half-loaded board answers it.
1523
+
1524
+ Past `grid_load_timeout_ms` a blank tile that has stopped changing is taken as painted (a pale photo,
1525
+ or a tile whose image never came), and a board with no grid to read is let through. A tile still
1526
+ painting is waited on until GRID_LOAD_CAP_FACTOR times that, or the solve's deadline.
1527
+ """
1214
1528
  cfg = self.config
1215
1529
 
1216
- def judge(frames, _elapsed):
1530
+ def judge(frames, elapsed):
1217
1531
  if len(frames) < 2:
1218
1532
  return None
1219
1533
  boxes = self._find_grid(frames[1])
1220
- states = boxes and self._grid_cell_states(frames[0], frames[1], boxes)
1221
- return True if states and not states["empty"] and not states["changing"] and states["loaded"] else None
1534
+ if not boxes:
1535
+ return False if elapsed >= cfg.grid_load_timeout_ms else None
1536
+ states = self._grid_cell_states(frames[0], frames[1], boxes)
1537
+ if not states or states["changing"] or not states["loaded"]:
1538
+ return None
1539
+ if not states["empty"] or elapsed >= cfg.grid_load_timeout_ms:
1540
+ return True
1541
+ return None
1222
1542
 
1223
- return bool(self._poll(element, cfg.grid_load_timeout_ms, cfg.grid_load_poll_interval_ms,
1224
- judge, "waiting for grid cells to load"))
1543
+ verdict = self._poll(element, cfg.grid_load_timeout_ms * GRID_LOAD_CAP_FACTOR, cfg.grid_load_poll_interval_ms,
1544
+ judge, "waiting for grid cells to load")
1545
+ if verdict is not True:
1546
+ _log("[recaptcha-grid] the board never finished loading; asking about it as it stands")
1547
+ return verdict is True
1225
1548
 
1226
1549
  def _wait_for_board_painted(self, element: Any) -> int:
1227
1550
  """A still board is not a loaded board: hold until the panel's centre carries structure."""
@@ -1250,7 +1573,7 @@ class PageSolver:
1250
1573
  """Best-effort: hold until the board's pictures have painted."""
1251
1574
  try:
1252
1575
  frame.wait_for_function(_BOARD_IMAGES_READY_JS, arg={"prompt": selectors.fresh or "", "images": ",".join(selectors.images)},
1253
- timeout=self.config.hcaptcha_images_timeout_ms)
1576
+ timeout=self.config.board_images_timeout_ms)
1254
1577
  except Exception:
1255
1578
  pass
1256
1579
 
@@ -1368,7 +1691,7 @@ class PageSolver:
1368
1691
  Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
1369
1692
  number_with_highest_value_video and failed every seed either way.
1370
1693
  """
1371
- import cv2
1694
+ from .keyframes import read_frame
1372
1695
 
1373
1696
  cfg = self.config
1374
1697
  floor_ms = float(cfg.video_burst_duration_ms)
@@ -1394,7 +1717,7 @@ class PageSolver:
1394
1717
  if _now() - self._last_input_ms >= INPUT_SETTLE_MS:
1395
1718
  try:
1396
1719
  self._screenshot(element, shot, animations="allow")
1397
- img = cv2.imread(shot)
1720
+ img = read_frame(shot)
1398
1721
  except Exception as exc:
1399
1722
  _debug(f"burst frame failed: {exc}")
1400
1723
  if img is not None:
@@ -1446,20 +1769,17 @@ class PageSolver:
1446
1769
  f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
1447
1770
  return frames, order, cycle_closed or animating, elapsed_ms, cycle_closed
1448
1771
 
1449
- def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[List[str], str]:
1450
- """Cut a burst with the training-side slicer; the model answers with a frame number into it."""
1451
- from .keyframes import extract_keyframes, write_keyframes
1452
-
1453
- kfset = extract_keyframes(frames, fps=measured_fps(len(frames), burst_ms, self.config.video_burst_fps))
1454
- self._keyframe_mode = kfset.mode
1455
- self._keyframe_steady_screens = kfset.steady_screens
1772
+ def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[AnimatedRecording, str]:
1773
+ """Cut a burst with the training-side slicer; its keyframes steer the wait gate whatever the model sees."""
1456
1774
  temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
1457
- paths = [str(p) for p in write_keyframes(kfset, temp_dir, stem="challenge")]
1458
- _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1459
- return paths, temp_dir
1775
+ recording = cut_recording(frames, measured_fps(len(frames), burst_ms, self.config.video_burst_fps), temp_dir)
1776
+ self._keyframe_mode = recording.keyframes.mode
1777
+ self._keyframe_steady_screens = recording.keyframes.steady_screens
1778
+ _log(f"[animated] sliced to {len(recording.keyframe_paths)} keyframe(s) (mode={recording.keyframes.mode})")
1779
+ return recording, temp_dir
1460
1780
 
1461
- def _record_keyframes(self, element: Any) -> Tuple[List[str], str, bool]:
1462
- """Record the widget and return `(keyframe_paths, temp_dir, moved)`; the caller removes the dir.
1781
+ def _record_keyframes(self, element: Any) -> Tuple[AnimatedRecording, str, bool]:
1782
+ """Record the widget and return `(recording, temp_dir, moved)`; the caller removes the dir.
1463
1783
 
1464
1784
  Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
1465
1785
 
@@ -1526,8 +1846,8 @@ class PageSolver:
1526
1846
  if len(self._film_frames) > len(frames):
1527
1847
  _log(f"[animated] slicing {len(self._film_frames)} frames filmed over "
1528
1848
  f"{self._film_ms / 1000:.1f}s — every round of this board, not just this one")
1529
- paths, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
1530
- return paths, temp_dir, moved
1849
+ recording, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
1850
+ return recording, temp_dir, moved
1531
1851
 
1532
1852
  def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
1533
1853
  text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
@@ -1556,10 +1876,10 @@ class PageSolver:
1556
1876
  self._known_animated = True
1557
1877
  fut.result()
1558
1878
  self._grant_video_budget()
1559
- paths, keyframe_dir = self._slice(frames, burst_ms)
1879
+ recording, keyframe_dir = self._slice(frames, burst_ms)
1560
1880
  with self._phase(Phase.INFERENCE):
1561
- actions, usage = self._get_keyframe_solution(paths)
1562
- self._animated_plan = (paths, keyframe_dir, actions, usage)
1881
+ actions, usage = self._get_keyframe_solution(recording)
1882
+ self._animated_plan = (recording, keyframe_dir, actions, usage)
1563
1883
  return actions, usage, keyframe_dir
1564
1884
  finally:
1565
1885
  pool.shutdown(wait=True)
@@ -1597,9 +1917,7 @@ class PageSolver:
1597
1917
 
1598
1918
  def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
1599
1919
  """Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
1600
- import cv2
1601
-
1602
- from .keyframes import MATCH_REGION_TOLERANCE, region_diff_ratio
1920
+ from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_diff_ratio
1603
1921
 
1604
1922
  folder, base = os.path.dirname(keyframe_path), os.path.basename(keyframe_path)
1605
1923
  prefix = re.sub(r"_\d+\.png$", "_", base, flags=re.IGNORECASE)
@@ -1611,18 +1929,16 @@ class PageSolver:
1611
1929
  except OSError:
1612
1930
  return False
1613
1931
  for other in siblings:
1614
- img = cv2.imread(other)
1932
+ img = read_frame(other)
1615
1933
  if img is not None and img.shape == ref.shape and region_diff_ratio(ref, img, box) <= MATCH_REGION_TOLERANCE:
1616
1934
  return True
1617
1935
  return False
1618
1936
 
1619
1937
  def _wait_for_keyframe(self, element: Any, keyframe_path: str, point_norm: Tuple[float, float]) -> bool:
1620
1938
  """Hold until the widget looks like the keyframe around the action point, or the wait is spent."""
1621
- import cv2
1939
+ from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_box, region_diff_ratio
1622
1940
 
1623
- from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1624
-
1625
- ref = cv2.imread(keyframe_path)
1941
+ ref = read_frame(keyframe_path)
1626
1942
  if ref is None:
1627
1943
  return False
1628
1944
  box = region_box(ref.shape[1::-1], point_norm)
@@ -1640,7 +1956,7 @@ class PageSolver:
1640
1956
  self._check_deadline("waiting for the challenge keyframe")
1641
1957
  try:
1642
1958
  self._screenshot(element, probe, animations="allow")
1643
- live = cv2.imread(probe)
1959
+ live = read_frame(probe)
1644
1960
  except Exception:
1645
1961
  live = None
1646
1962
  if live is not None:
@@ -1669,13 +1985,20 @@ class PageSolver:
1669
1985
  session.element_box["y"] + (y1 + y2) / 2 * session.scale_y)
1670
1986
 
1671
1987
  def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
1988
+ """Rest the hand on a tile: reach it once, then drift a few pixels inside it."""
1672
1989
  if not self._human.hovers:
1673
1990
  return
1674
- first = session.grid_boxes[0]
1675
- cx, cy = self._cell_center_page(cell, session)
1676
- self._smooth_move(page,
1677
- cx + (random.random() - 0.5) * (first[2] - first[0]) * session.scale_x * 0.4,
1678
- cy + (random.random() - 0.5) * (first[3] - first[1]) * session.scale_y * 0.4)
1991
+ x1, y1, x2, y2 = session.grid_boxes[cell - 1]
1992
+ left, top = session.element_box["x"] + x1 * session.scale_x, session.element_box["y"] + y1 * session.scale_y
1993
+ right, bottom = session.element_box["x"] + x2 * session.scale_x, session.element_box["y"] + y2 * session.scale_y
1994
+ x, y = self._last_mouse
1995
+ if left <= x <= right and top <= y <= bottom:
1996
+ x, y = x + random.gauss(0.0, IDLE_DRIFT_PX), y + random.gauss(0.0, IDLE_DRIFT_PX)
1997
+ else:
1998
+ cx, cy = self._cell_center_page(cell, session)
1999
+ x, y = cx + (random.random() - 0.5) * (right - left) * 0.4, cy + (random.random() - 0.5) * (bottom - top) * 0.4
2000
+ inset = min(4.0, (right - left) / 4, (bottom - top) / 4)
2001
+ self._smooth_move(page, min(max(x, left + inset), right - inset), min(max(y, top + inset), bottom - inset))
1679
2002
 
1680
2003
  @staticmethod
1681
2004
  def _bbox_to_cell(bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]],
@@ -1696,11 +2019,14 @@ class PageSolver:
1696
2019
  def _hover_loop(self, page: Any, session: _GridSession, cells: Sequence[int]) -> Optional[Callable[[], None]]:
1697
2020
  if not cells:
1698
2021
  return None
1699
- cycle = itertools.cycle(cells)
2022
+ due = [0.0]
1700
2023
 
1701
2024
  def hover():
2025
+ if _now() < due[0]:
2026
+ return
2027
+ due[0] = _now() + random.uniform(*IDLE_DRIFT_EVERY_MS)
1702
2028
  try:
1703
- self._hover_cell(page, session, next(cycle))
2029
+ self._hover_cell(page, session, cells[0])
1704
2030
  except Exception:
1705
2031
  pass
1706
2032
 
@@ -1789,6 +2115,10 @@ class PageSolver:
1789
2115
  loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
1790
2116
  self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
1791
2117
  continue
2118
+ if kind == ActionKind.REFRESH:
2119
+ # A new board replaces this one: nothing clicked on it is submitted; the next loop reads the new one.
2120
+ refreshed = self._execute_refresh(page, element.content_frame(), action, element_box)
2121
+ return refreshed or performed, all_usage
1792
2122
  if kind != ActionKind.CLICK:
1793
2123
  _log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
1794
2124
  continue
@@ -1800,6 +2130,10 @@ class PageSolver:
1800
2130
  clicked_this_round: List[int] = []
1801
2131
  for bbox in bboxes:
1802
2132
  cell = self._bbox_to_cell(bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h)
2133
+ if cell is not None and cell in clicked_this_round:
2134
+ # Two points in one tile: a second press deselects what the first selected.
2135
+ _log(f"[recaptcha-grid] round {round_index}: cell {cell} is already clicked; not pressing it again.")
2136
+ continue
1803
2137
  self._execute_click(page, {"target_bounding_box": bbox}, element_box)
1804
2138
  if cell is not None:
1805
2139
  clicked_order.append(cell)
@@ -1843,13 +2177,13 @@ class PageSolver:
1843
2177
  self._click_checkbox(page, widget, frame)
1844
2178
  return True, []
1845
2179
 
1846
- if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
2180
+ if frame and role == FrameRole.CHALLENGE and self._sel.selectors[puzzle_source].images:
1847
2181
  if self._last_submit_frame_hash:
1848
2182
  with self._phase(Phase.AWAIT_NEXT_ROUND):
1849
2183
  self._wait_for_change_since(element, self._last_submit_frame_hash)
1850
2184
  self._last_submit_frame_hash = None
1851
- with self._phase(Phase.HCAPTCHA_IMAGES):
1852
- self._wait_for_board_images(frame, SELECTORS[puzzle_source])
2185
+ with self._phase(Phase.BOARD_IMAGES):
2186
+ self._wait_for_board_images(frame, self._sel.selectors[puzzle_source])
1853
2187
 
1854
2188
  # THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of
1855
2189
  # the DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet
@@ -1863,9 +2197,11 @@ class PageSolver:
1863
2197
  self._wait_for_board_painted(element)
1864
2198
 
1865
2199
  # Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
1866
- text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
2200
+ answer_box = None if puzzle_source in VENDORS_WITH_BESPOKE_HANDLING else self._answer_box(scope, widget.at)
2201
+ text_mode = answer_box is not None
1867
2202
  if text_mode:
1868
2203
  _log("widget has a text box; solving as a distorted-text captcha")
2204
+ element = _WithAnswerBox(element, answer_box)
1869
2205
 
1870
2206
  # A reCAPTCHA board is read by its grid below, not filmed.
1871
2207
  filmable = puzzle_source != Vendor.RECAPTCHA and not text_mode
@@ -1892,20 +2228,21 @@ class PageSolver:
1892
2228
  self._wait_for_board_painted(element)
1893
2229
 
1894
2230
  shot = _tmp_png("captcha")
2231
+ self._round_board = None
1895
2232
  performed = slid = answered = have_shot = False
1896
2233
  all_usage: List[Dict[str, Any]] = []
1897
2234
  keyframe_dir: Optional[str] = None
1898
2235
  try:
1899
2236
  if is_animated:
1900
2237
  if self._animated_plan is not None:
1901
- keyframes, keyframe_dir, actions, all_usage = self._animated_plan
2238
+ recording, keyframe_dir, actions, all_usage = self._animated_plan
1902
2239
  reused = actions is not None
1903
2240
  if not reused:
1904
2241
  # Drop the ANSWER, keep the FRAMES — and the frames grow. Filming another chunk and
1905
2242
  # re-slicing the accumulation puts a better question up; re-asking the same frames
1906
2243
  # puts the same question up and gets the same answer back.
1907
2244
  with self._phase(Phase.BURST):
1908
- keyframes, new_dir, moved = self._record_keyframes(element)
2245
+ recording, new_dir, moved = self._record_keyframes(element)
1909
2246
  shutil.rmtree(keyframe_dir, ignore_errors=True)
1910
2247
  keyframe_dir = new_dir
1911
2248
  self._known_animated = self._known_animated or moved
@@ -1914,27 +2251,28 @@ class PageSolver:
1914
2251
  else:
1915
2252
  reused = False
1916
2253
  with self._phase(Phase.BURST):
1917
- keyframes, keyframe_dir, moved = self._record_keyframes(element)
2254
+ recording, keyframe_dir, moved = self._record_keyframes(element)
1918
2255
  self._known_animated = self._known_animated or moved
1919
2256
  # The clip answers the question the probe asked: a board that never moved is a still, and the
1920
2257
  # video expert can only answer a still with a frame number the widget will not take.
1921
- if len(keyframes) < 2 or not self._known_animated:
2258
+ if len(recording.keyframe_paths) < 2 or not self._known_animated:
1922
2259
  _log("[animated] the recording shows a still board; solving it as a still")
1923
2260
  is_animated = False
1924
- shutil.copyfile(keyframes[-1], shot)
2261
+ shutil.copyfile(recording.keyframe_paths[-1], shot)
1925
2262
  have_shot = True
1926
2263
  self._stop_animated_film()
1927
2264
  shutil.rmtree(keyframe_dir, ignore_errors=True)
1928
2265
  keyframe_dir = None
1929
2266
  elif not reused:
1930
2267
  with self._phase(Phase.INFERENCE):
1931
- actions, all_usage = self._get_keyframe_solution(keyframes)
1932
- self._animated_plan = (keyframes, keyframe_dir, actions, all_usage)
2268
+ actions, all_usage = self._get_keyframe_solution(recording)
2269
+ self._animated_plan = (recording, keyframe_dir, actions, all_usage)
1933
2270
 
1934
2271
  if not is_animated:
1935
2272
  if not have_shot:
1936
2273
  with self._phase(Phase.SCREENSHOT):
1937
2274
  self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
2275
+ self._guard_fallbacks_to_board(self._board_hash(element, shot, filmed=have_shot))
1938
2276
  if self._should_speculate(puzzle_source, text_mode):
1939
2277
  actions, all_usage, keyframe_dir = self._speculate(element, shot, puzzle_source, retry_mode, text_mode)
1940
2278
  is_animated = keyframe_dir is not None
@@ -1957,10 +2295,12 @@ class PageSolver:
1957
2295
 
1958
2296
  _log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
1959
2297
  # A repeated answer is not re-performed: the widget already refused it, and every extra press is
1960
- # behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
2298
+ # behaviour a vendor scores. Re-asking, or asking a recording, is the round's only move.
1961
2299
  # `_note_answer` drops the refused animated answer on its way through, so the next round re-asks.
1962
2300
  if self._note_answer(actions, retry_mode):
1963
2301
  return False, all_usage
2302
+ if not is_animated:
2303
+ self._keep_pointer_screen(shot, puzzle_source, actions)
1964
2304
  _log(f"executing {len(actions)} action(s)")
1965
2305
 
1966
2306
  for raw_action in actions:
@@ -1980,7 +2320,7 @@ class PageSolver:
1980
2320
  if bbox:
1981
2321
  self._human.pause(PauseKind.BETWEEN)
1982
2322
  performed = answered = True
1983
- elif kind == ActionKind.DRAG and not action.get("source_bounding_box"):
2323
+ elif kind == ActionKind.SLIDE or (kind == ActionKind.DRAG and not action.get("source_bounding_box")):
1984
2324
  if self._execute_slide(page, element, scope, action, element_box):
1985
2325
  performed = slid = True
1986
2326
  elif kind == ActionKind.DRAG:
@@ -1991,6 +2331,13 @@ class PageSolver:
1991
2331
  elif kind == ActionKind.TYPE:
1992
2332
  if self._execute_type(page, scope, action, widget.at):
1993
2333
  performed = answered = True
2334
+ elif kind == ActionKind.HOLD:
2335
+ # The widget submits itself when the hold completes, like a slide on release.
2336
+ self._execute_hold(page, action, element_box, element, self._sel.selectors[widget.vendor].hold_completes)
2337
+ performed = slid = True
2338
+ elif kind == ActionKind.REFRESH:
2339
+ # Nothing after a refresh, Verify included, belongs to the board it replaces.
2340
+ return self._execute_refresh(page, scope, action, element_box), all_usage
1994
2341
  elif kind == ActionKind.WAIT:
1995
2342
  duration = int(action.get("duration_ms") or 0)
1996
2343
  if duration > 0:
@@ -1998,13 +2345,19 @@ class PageSolver:
1998
2345
  performed = True
1999
2346
 
2000
2347
  # A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
2001
- # submit the guarded form while the verdict is in flight. An empty or `done` plan still presses Verify/Skip.
2002
- verify_button = self._get_verify_button(scope) if frame or not slid else None
2003
- if not slid and (answered or not performed) and verify_button:
2348
+ # submit the guarded form while the verdict is in flight, unless this board has already taken a Verify
2349
+ # from us: then that button is the widget's, and a slide on it submits nothing by itself. An empty or
2350
+ # `done` plan still presses Verify/Skip. FunCaptcha's answer is the click itself: an arrow pages and
2351
+ # Submit submits. The generic Verify fallback would submit the page an arrow just turned to.
2352
+ submits = self._submitted_on_board if slid else answered or not performed
2353
+ verify_button = self._get_verify_button(scope) if submits and puzzle_source != Vendor.FUNCAPTCHA else None
2354
+ if verify_button:
2004
2355
  _log(f"clicking Verify to submit ({puzzle_source}).")
2005
2356
  self._move_and_click(page, verify_button)
2006
- performed = True
2357
+ performed = self._submitted_on_board = True
2007
2358
  self._last_submit_frame_hash = self._element_frame_hash(element)
2359
+ elif answered and puzzle_source != Vendor.FUNCAPTCHA:
2360
+ self._keep_plans_of_this_many_clicks(actions)
2008
2361
  finally:
2009
2362
  _unlink(shot)
2010
2363
  held = self._animated_plan[1] if self._animated_plan else None
@@ -2221,8 +2574,15 @@ class PageSolver:
2221
2574
  raise blocked("it is showing its try-again-later screen")
2222
2575
  if said == Verdict.REJECTED:
2223
2576
  _log(f"[verdict] the vendor rejected the answer after {verdict_ms:.0f}ms")
2224
-
2225
- if self._banner_is_fatal_after_retry(self._banner_kind(page)):
2577
+ # Only a refusal earns the server's next plan. A round that ended with no verdict, or on a
2578
+ # board that merely repainted, has not refused anything, and every extra press is behaviour
2579
+ # a vendor scores.
2580
+ self._refused_rounds += 1
2581
+ self._apply_refusals()
2582
+
2583
+ # Only "select all matching images" means tiles were missed; "please try again" is a wrong answer and
2584
+ # deals a new board, which is asked fresh.
2585
+ if self._banner_kind(page) == RecaptchaBanner.SELECT_MORE:
2226
2586
  _log("reCAPTCHA under-selection error; the next round asks for the missed tiles.")
2227
2587
  pending_retry_mode = RetryMode.MISSED_TILES
2228
2588
 
@@ -2231,9 +2591,9 @@ class PageSolver:
2231
2591
  if self._no_progress_rounds:
2232
2592
  stuck = CaptchaSolveError(f"no progress: the model returned the same answer "
2233
2593
  f"{self._no_progress_rounds + 1} times running")
2234
- # A board answered the same way after every resample is one the model cannot read: measured, no
2235
- # solve ever followed a third identical answer, and each further round cost the board 3-7s.
2236
- if self._no_progress_rounds >= cfg.max_no_progress_rounds:
2594
+ # A repeat pressed nothing, so a further round is only worth its 3-7s if it can do something else.
2595
+ if (self._no_progress_rounds >= cfg.max_no_progress_rounds
2596
+ or not self._another_move_left(pending_retry_mode)):
2237
2597
  raise stuck
2238
2598
  again(stuck)
2239
2599
  elif not did_interact:
@@ -2242,6 +2602,8 @@ class PageSolver:
2242
2602
  if cfg.video_solve_enabled:
2243
2603
  self._arm_animated_probe()
2244
2604
  again(CaptchaSolveError("the solver performed no interactions this round"))
2605
+ else:
2606
+ last_failure = None
2245
2607
 
2246
2608
  if isinstance(last_failure, UnsupportedChallengeError):
2247
2609
  raise UnsupportedChallengeError(f"{last_failure} (after {cfg.max_solve_loops} solve loops)")