captchakraken 3.2.1 → 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 +93 -11
  15. package/dist/selectors.js +226 -116
  16. package/dist/solver.d.ts +92 -12
  17. package/dist/solver.js +562 -203
  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 +595 -251
  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 +221 -135
  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, ENCLAVE_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,10 +426,10 @@ def _frame_of(at: Any) -> Optional[Any]:
366
426
  return None
367
427
 
368
428
 
369
- def _host_scopes(page: Any) -> List[Any]:
429
+ def _host_scopes(page: Any, enclaves: Sequence[str]) -> List[Any]:
370
430
  """Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave
371
431
  is skipped because Playwright judges an element visible within its own document, whatever its frame's visibility."""
372
- return [page, *(f for f in map(_frame_of, _visible(page, ENCLAVE_SELECTORS)) if f is not None)]
432
+ return [page, *(f for f in map(_frame_of, _visible(page, enclaves)) if f is not None)]
373
433
 
374
434
 
375
435
  def _visible_in(scopes: Sequence[Any], selectors: Sequence[str]) -> List[Any]:
@@ -390,8 +450,9 @@ def _has_text(handle: Any) -> bool:
390
450
  return False
391
451
 
392
452
 
393
- SLIDE_PIECE_MIN_PX = 3.0
394
- 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
395
456
 
396
457
  # No prompt and no pictures both read as ready: waiting for `.prompt-text` to exist once paid the whole timeout
397
458
  # before every board of a challenge that draws none (tower_stack: 18s a round, 2/2 -> 0/2).
@@ -419,6 +480,8 @@ _RESOURCES_JS = """() => {
419
480
  class PageSolver:
420
481
  """Drives one Playwright-compatible `page` through a captcha. Reusable across solves."""
421
482
 
483
+ _sel: SelectorTable = DEFAULT_TABLE
484
+
422
485
  def __init__(self, config: Optional[PageSolverConfig] = None,
423
486
  solver: Optional[CaptchaSolver] = None, **solver_kwargs: Any) -> None:
424
487
  self.config = config or PageSolverConfig()
@@ -426,10 +489,14 @@ class PageSolver:
426
489
  solver_kwargs.setdefault("expert", self.config.expert)
427
490
  self._solver = solver or CaptchaSolver(**solver_kwargs)
428
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)
429
496
  self._last_submit_frame_hash: Optional[str] = None
430
497
  self._deadline_ms: Optional[float] = None
431
498
  self._budget: Optional[PhaseBudget] = None
432
- self._animated_plan: Optional[Tuple[List[str], str, Any, Any]] = None
499
+ self._animated_plan: Optional[Tuple[AnimatedRecording, str, Any, Any]] = None
433
500
  self._film_frames: List[Any] = []
434
501
  self._film_ms: float = 0.0
435
502
  # The digests of every screen the film holds: what a later burst is checked against to answer
@@ -474,6 +541,7 @@ class PageSolver:
474
541
 
475
542
  def _reset_animated_state(self) -> None:
476
543
  self._acted_on_board = False
544
+ self._submitted_on_board = False
477
545
  self._known_animated = False
478
546
  self._animated_probe_armed = False
479
547
  self._animated_probe_done = False
@@ -482,37 +550,75 @@ class PageSolver:
482
550
  self._keyframe_mode: Optional[KeyframeMode] = None
483
551
  self._keyframe_steady_screens = 0
484
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
485
556
  self._no_progress_rounds = 0
486
- self._resample_level = 0
487
- 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()
488
562
 
489
- @staticmethod
490
- def _answer_signature(actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> Optional[str]:
491
- """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
+ """
492
569
  try:
493
570
  parts = [(a.get("action"), _round_pts(a.get("target_bounding_boxes")),
494
571
  _round_pts(a.get("target_bounding_box")), _round_pts(a.get("target_coordinates")),
495
572
  _round_pts(a.get("source_bounding_box")), a.get("text"))
496
573
  for a in map(_as_dict, actions)]
497
- return repr((retry_mode, parts))
574
+ return repr((self._fallback_board, retry_mode, parts))
498
575
  except Exception:
499
576
  return None
500
577
 
501
578
  def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> bool:
502
- """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."""
503
580
  sig = self._answer_signature(actions, retry_mode)
504
- 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):
505
593
  self._no_progress_rounds += 1
506
594
  _log(f"[no-progress] the model returned the same answer again ({self._no_progress_rounds} in a row)")
507
- self._resample_level += 1
508
- 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()
509
599
  self._invalidate_animated_answer()
510
600
  self._arm_animated_probe()
511
601
  return True
512
602
  self._no_progress_rounds = 0
513
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
514
607
  return False
515
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
+
516
622
  def _bound_ask_to_the_budget(self) -> None:
517
623
  """Give the planner what is LEFT of the solve, so one ask cannot outlive the whole thing.
518
624
 
@@ -527,13 +633,101 @@ class PageSolver:
527
633
  target.request_timeout_s = max(planner.MIN_REQUEST_TIMEOUT_S,
528
634
  min(planner.DEFAULT_REQUEST_TIMEOUT_S, left_s))
529
635
 
530
- def _apply_sampling(self) -> None:
636
+ def _apply_refusals(self) -> None:
531
637
  target = getattr(self._solver, "planner", None)
532
638
  if target is None:
533
639
  return
534
- target.sampling = sampling = planner.sampling_for_level(self._resample_level)
535
- if sampling:
536
- _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]
537
731
 
538
732
  def _fresh_board(self) -> None:
539
733
  # ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer describes the
@@ -545,9 +739,11 @@ class PageSolver:
545
739
  # Its own second look, but only once IT fails: an arm set on the previous board is evidence about that one.
546
740
  self._animated_probe_armed = False
547
741
  self._animated_probe_done = False
548
- self._resample_level = 0
742
+ self._refused_rounds = 0
549
743
  self._acted_on_board = False
550
- self._apply_sampling()
744
+ self._submitted_on_board = False
745
+ self._drop_pointer_history()
746
+ self._apply_refusals()
551
747
 
552
748
  def _check_deadline(self, where: str) -> None:
553
749
  # Called inside loops too: a camoufox session once ran ten minutes past a 120s timeout because only
@@ -614,11 +810,20 @@ class PageSolver:
614
810
 
615
811
  def _get_solution(self, image_path: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
616
812
  text_mode: bool = False):
617
- return self._ask(lambda: self._solver.solve(
618
- 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)
619
823
 
620
- def _get_keyframe_solution(self, keyframe_paths: Sequence[str]):
621
- 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))
622
827
 
623
828
  # ── gestures ─────────────────────────────────────────────────────────
624
829
 
@@ -667,6 +872,17 @@ class PageSolver:
667
872
  return float(coords[0]) * element_box["width"], float(coords[1]) * element_box["height"]
668
873
  return None
669
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
+
670
886
  def _execute_click(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float],
671
887
  element: Any = None, await_keyframe: Optional[str] = None) -> None:
672
888
  # Every gesture marks the board acted on so the speculative burst is never re-run over a touched board.
@@ -689,6 +905,121 @@ class PageSolver:
689
905
  element_box: Dict[str, float], await_keyframe: str) -> None:
690
906
  self._execute_click(page, action, element_box, element, await_keyframe)
691
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
+
692
1023
  def _execute_drag(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]) -> None:
693
1024
  self._acted_on_board = True
694
1025
 
@@ -703,31 +1034,16 @@ class PageSolver:
703
1034
  first = next(iter(_visible(scope, selectors)), None)
704
1035
  return _handle_of(first) if first is not None else None
705
1036
 
706
- def _measure_piece_box(self, scope: Any, widget_width: float) -> Optional[Dict[str, float]]:
707
- """The slider piece's box: the first visible match small enough to be a piece.
708
-
709
- Runs during detection, so it must not mark the board acted on: doing so once disabled the
710
- speculative burst on every slide solve.
711
- """
712
- for candidate in _handles(_visible(scope, PIECE_SELECTORS)):
713
- try:
714
- b = candidate.bounding_box()
715
- except Exception:
716
- continue
717
- if b and SLIDE_PIECE_MIN_PX <= b["width"] <= widget_width * SLIDE_PIECE_MAX_FRACTION:
718
- return b
719
- return None
720
-
721
1037
  def _answer_box(self, scope: Any, at: Any = None) -> Optional[Any]:
722
1038
  """The text box inside the widget, else a vendor-named one in its enclosing fieldset/form.
723
1039
 
724
1040
  Only vendor-named selectors widen: BotDetect's `.BDC_CaptchaDiv` is 280x50 and holds only the image,
725
1041
  with `#captchaCode` in a sibling div. The generic tail there would take a login form's own box.
726
1042
  """
727
- inside = self._find_control(scope, TEXT_INPUT_SELECTORS)
1043
+ inside = self._find_control(scope, self._sel.text_input)
728
1044
  if inside is not None or at is None:
729
1045
  return inside
730
- 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)
731
1047
  for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"))
732
1048
  return next((found for found in around if found is not None), None)
733
1049
 
@@ -746,20 +1062,6 @@ class PageSolver:
746
1062
  _log(f"typed {len(text)} character(s) into the captcha field")
747
1063
  return True
748
1064
 
749
- def _track_piece(self, element: Any, before: str, after: str, exclude: Sequence[float],
750
- travel: float = 0.0) -> Optional[Dict[str, Any]]:
751
- from .tool_calls.track_piece import changed_bbox, locate_piece
752
-
753
- try:
754
- self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
755
- bbox = changed_bbox(before, after, exclude)
756
- if bbox is None:
757
- return None
758
- return {"bbox": bbox, "piece": locate_piece(before, after, travel, exclude)}
759
- except Exception as exc:
760
- _debug(f"track_piece failed: {exc}")
761
- return None
762
-
763
1065
  @staticmethod
764
1066
  def _shot_scale(shot: str, css_width: float) -> float:
765
1067
  dims = _read_png_dimensions(shot)
@@ -767,77 +1069,71 @@ class PageSolver:
767
1069
 
768
1070
  def _execute_slide(self, page: Any, element: Any, scope: Any, action: Dict[str, Any],
769
1071
  element_box: Dict[str, float]) -> bool:
770
- """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
+
771
1080
  self._acted_on_board = True
772
- tb = action["target_bounding_box"]
773
- target_x = (float(tb[0]) + float(tb[2])) / 2 * element_box["width"]
1081
+ w, h = element_box["width"], element_box["height"]
1082
+
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
774
1085
 
775
- handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
776
- if handle is None:
777
- piece = self._find_control(scope, PIECE_SELECTORS)
778
- box = piece and piece.bounding_box()
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
779
1093
  if not box:
780
1094
  _log("slide action, but the widget has neither a slider nor a draggable piece")
781
1095
  return False
782
- target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
783
- with self._gesture():
784
- self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
785
- (element_box["x"] + target_x, element_box["y"] + target_y))
786
- return True
787
-
788
- hbox = handle.bounding_box()
789
- if not hbox:
790
- return False
791
- start_x, hold_y = hbox["x"] + hbox["width"] / 2, hbox["y"] + hbox["height"] / 2
792
- # Mask from the handle's band to the widget's bottom: the filled track moves too.
793
- pad = max(4.0, hbox["height"] * 0.35)
794
- band = [0.0, hbox["y"] - element_box["y"] - pad, element_box["width"], element_box["height"]]
795
- 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))
796
1104
  try:
797
- self._move_to_element(page, handle, padding_percentage=30.0)
1105
+ self._smooth_move(page, *press)
798
1106
  self._human.press(page)
799
1107
  self._human.pause(PauseKind.GRAB)
800
- self._screenshot(element, shots[0], timeout_ms=self.config.element_screenshot_timeout_ms)
801
- scale = self._shot_scale(shots[0], element_box["width"])
802
- exclude = [v * scale for v in band]
803
-
804
- def live_center():
805
- b = self._measure_piece_box(scope, element_box["width"])
806
- return None if b is None else b["x"] + b["width"] / 2.0 - element_box["x"]
807
-
808
- rest = live_center()
809
- offset = target_x - (start_x - element_box["x"] if rest is None else rest)
810
- self._smooth_move(page, start_x + offset, hold_y)
811
-
812
- widths: List[Tuple[float, float]] = []
813
- last_box = last_piece = None
814
- ratio, steered = 1.0, False
815
- for _ in range(self.config.slide_max_corrections):
816
- self._human.pause(PauseKind.PROBE)
817
- seen = self._track_piece(element, shots[0], shots[1], exclude, travel=offset * ratio * scale)
818
- if seen is not None:
819
- box = seen["bbox"]
820
- widths.append((offset, float(box[2] - box[0]) / scale))
821
- last_box, last_piece = box, seen["piece"]
822
- piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
823
- live = live_center()
824
- if live is not None:
825
- center = live
826
- elif last_piece is not None:
827
- center = last_piece["centre"] / scale
828
- elif last_box is not None and piece_w is not None:
829
- center = last_box[2] / scale - piece_w / 2.0
830
- else:
831
- continue
832
- steered = True
833
- error = target_x - center
834
- _debug(f"slider: center={center:.1f} target={target_x:.1f} error={error:+.1f} ratio={ratio:.3f}")
835
- 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")
836
1130
  break
837
- offset += error / ratio
838
- self._smooth_move(page, start_x + offset, hold_y)
839
- if not steered:
840
- _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
841
1137
  self._human.pause(PauseKind.SETTLE)
842
1138
  finally:
843
1139
  try:
@@ -845,33 +1141,10 @@ class PageSolver:
845
1141
  except Exception:
846
1142
  pass
847
1143
  self._last_input_ms = _now()
848
- for shot in shots:
1144
+ for shot in (first, probed, current):
849
1145
  _unlink(shot)
850
1146
  return True
851
1147
 
852
- @staticmethod
853
- def _solve_slide_geometry(widths: Sequence[Tuple[float, float]],
854
- widget_width: float) -> Tuple[Optional[float], float]:
855
- """Piece width and handle-to-piece ratio from `(offset, changed width)` readings.
856
-
857
- The widest-apart pair over a 16px spread: changed widths are ±1px, so the ratio error is ±0.13 at the
858
- floor and ±0.5 at 4px. An implausible ratio falls back to 1:1 (true of every vendor observed) rather
859
- than a 0.02 that sends the handle off the track and camoufox into a move that never returns.
860
- """
861
- if not widths:
862
- return None, 1.0
863
- piece_w, ratio = None, 1.0
864
- (o1, w1), (o2, w2) = min(widths, key=lambda m: m[0]), max(widths, key=lambda m: m[0])
865
- if o2 - o1 >= 16.0 and 0.2 <= (w2 - w1) / (o2 - o1) <= 3.0:
866
- ratio = (w2 - w1) / (o2 - o1)
867
- piece_w = w1 - ratio * o1
868
- if piece_w is None:
869
- o, w = widths[-1]
870
- piece_w = w - ratio * o
871
- if not (SLIDE_PIECE_MIN_PX <= piece_w <= widget_width * SLIDE_PIECE_MAX_FRACTION):
872
- return None, ratio
873
- return piece_w, ratio
874
-
875
1148
  # ── detection ────────────────────────────────────────────────────────
876
1149
 
877
1150
  def _has_value(self, scopes: Sequence[Any], selector: str) -> bool:
@@ -903,12 +1176,12 @@ class PageSolver:
903
1176
  except Exception:
904
1177
  return 0
905
1178
 
906
- 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)
907
1180
 
908
1181
  def detect_captcha(self, page: Any) -> Optional[Widget]:
909
1182
  """Open challenges first, then unsolved checkboxes, then the inline vendors."""
910
- scopes = _host_scopes(page)
911
- for probe in WIDGET_PROBES:
1183
+ scopes = _host_scopes(page, self._sel.enclave)
1184
+ for probe in self._sel.widget_probes:
912
1185
  at = next(iter(_visible_in(scopes, (probe.selector,))), None)
913
1186
  if at is None or self._already_accepted(scopes, at, probe):
914
1187
  continue
@@ -918,7 +1191,7 @@ class PageSolver:
918
1191
 
919
1192
  def _already_accepted(self, scopes: Sequence[Any], at: Any, probe: WidgetProbe) -> bool:
920
1193
  """A checkbox the vendor has already accepted is not a captcha to solve."""
921
- s = SELECTORS[probe.vendor]
1194
+ s = self._sel.selectors[probe.vendor]
922
1195
  if probe.role != FrameRole.CHECKBOX:
923
1196
  return False
924
1197
  return bool(s.response and self._has_value(scopes, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
@@ -930,13 +1203,13 @@ class PageSolver:
930
1203
  except Exception:
931
1204
  return []
932
1205
  blob = " ".join(str(n) for n in (names or []))
933
- 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)]
934
1207
 
935
1208
  def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
936
1209
  """Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame
937
1210
  (an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches."""
938
- scopes = _host_scopes(page)
939
- return [vendor for vendor, s in VENDORS if s.hosts and len(_visible_in(scopes, [f'iframe[src*="{h}"]' for h in s.hosts]))
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]))
940
1213
  > len(_visible_in(scopes, s.passive))]
941
1214
 
942
1215
  def _no_widget_message(self, page: Any) -> str:
@@ -968,9 +1241,9 @@ class PageSolver:
968
1241
 
969
1242
  def _is_blocked(self, page: Any) -> bool:
970
1243
  """The vendor showing its refusal screen: rate-limited or flagged, with no board to answer."""
971
- scopes = _host_scopes(page)
972
- for probe in WIDGET_PROBES:
973
- 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
974
1247
  if probe.role != FrameRole.CHALLENGE or not blocked:
975
1248
  continue
976
1249
  at = next(iter(_visible_in(scopes, (probe.selector,))), None)
@@ -987,11 +1260,11 @@ class PageSolver:
987
1260
  def is_captcha_solved(self, page: Any) -> bool:
988
1261
  """The vendor's own done signal: a response token, a painted success state, or a checked box."""
989
1262
  try:
990
- scopes = _host_scopes(page)
991
- if any(self._has_value(scopes, s) for s in RESPONSE_SELECTORS) or _visible_in(scopes, 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):
992
1265
  return True
993
- for probe in WIDGET_PROBES:
994
- checked = SELECTORS[probe.vendor].checked
1266
+ for probe in self._sel.widget_probes:
1267
+ checked = self._sel.selectors[probe.vendor].checked
995
1268
  if probe.role != FrameRole.CHECKBOX or not checked:
996
1269
  continue
997
1270
  at = next(iter(_visible_in(scopes, (probe.selector,))), None)
@@ -1004,9 +1277,9 @@ class PageSolver:
1004
1277
  def _is_challenge_freshly_rendered(self, page: Any) -> bool:
1005
1278
  """A next round has painted, as opposed to the answered frame animating closed."""
1006
1279
  try:
1007
- scopes = _host_scopes(page)
1008
- for probe in WIDGET_PROBES:
1009
- 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
1010
1283
  if probe.role != FrameRole.CHALLENGE or not fresh:
1011
1284
  continue
1012
1285
  at = next(iter(_visible_in(scopes, (probe.selector,))), None)
@@ -1022,16 +1295,12 @@ class PageSolver:
1022
1295
  pass
1023
1296
  return False
1024
1297
 
1025
- @staticmethod
1026
- def _banner_is_fatal_after_retry(kind: Optional[RecaptchaBanner]) -> bool:
1027
- return kind in (RecaptchaBanner.SELECT_MORE, RecaptchaBanner.REJECTED)
1028
-
1029
1298
  def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
1030
1299
  """Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
1031
1300
  try:
1032
- scopes = _host_scopes(page)
1033
- for probe in WIDGET_PROBES:
1034
- 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
1035
1304
  if probe.role != FrameRole.CHALLENGE or not banners:
1036
1305
  continue
1037
1306
  at = next(iter(_visible_in(scopes, (probe.selector,))), None)
@@ -1051,11 +1320,15 @@ class PageSolver:
1051
1320
  Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering
1052
1321
  the checkbox picture with a drag.
1053
1322
  """
1054
- 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)
1055
1325
  if box is not None:
1056
1326
  self._move_to_element(page, box)
1057
1327
  else:
1058
- 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)
1059
1332
  self._human.pause(PauseKind.BETWEEN)
1060
1333
  with self._gesture():
1061
1334
  self._human.click(page, self._last_mouse)
@@ -1066,7 +1339,7 @@ class PageSolver:
1066
1339
 
1067
1340
  Turnstile's box is in a closed shadow root, and a frame can refuse access; both are found on screen instead.
1068
1341
  """
1069
- box = SELECTORS[vendor].box
1342
+ box = self._sel.selectors[vendor].box
1070
1343
  if frame is None or not box:
1071
1344
  return None
1072
1345
  try:
@@ -1075,8 +1348,25 @@ class PageSolver:
1075
1348
  _log(f"[checkbox] the box is out of the DOM's reach ({str(exc).splitlines()[0]}); finding it on screen")
1076
1349
  return None
1077
1350
 
1078
- def _find_checkbox_on_screen(self, element: Any) -> Tuple[float, float]:
1079
- """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."""
1080
1370
  from .tool_calls.find_checkbox import find_checkbox
1081
1371
 
1082
1372
  box = element.bounding_box()
@@ -1091,7 +1381,7 @@ class PageSolver:
1091
1381
  finally:
1092
1382
  _unlink(shot)
1093
1383
  if found is None:
1094
- raise UnsupportedCaptchaError("the checkbox widget shows no tick box to click")
1384
+ return None
1095
1385
  x, y, w, h = (v / scale for v in found)
1096
1386
  return box["x"] + x + w * (0.3 + random.random() * 0.4), box["y"] + y + h * (0.3 + random.random() * 0.4)
1097
1387
 
@@ -1109,7 +1399,7 @@ class PageSolver:
1109
1399
  _delay(self.config.post_solve_outcome_poll_ms)
1110
1400
 
1111
1401
  def _get_verify_button(self, scope: Any) -> Optional[Any]:
1112
- return self._find_control(scope, SUBMIT_SELECTORS)
1402
+ return self._find_control(scope, self._sel.submit)
1113
1403
 
1114
1404
  # ── frames and polling ───────────────────────────────────────────────
1115
1405
 
@@ -1229,17 +1519,32 @@ class PageSolver:
1229
1519
  "waiting for the challenge to settle") or SettleVerdict.TIMEOUT
1230
1520
 
1231
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
+ """
1232
1528
  cfg = self.config
1233
1529
 
1234
- def judge(frames, _elapsed):
1530
+ def judge(frames, elapsed):
1235
1531
  if len(frames) < 2:
1236
1532
  return None
1237
1533
  boxes = self._find_grid(frames[1])
1238
- states = boxes and self._grid_cell_states(frames[0], frames[1], boxes)
1239
- 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
1240
1542
 
1241
- return bool(self._poll(element, cfg.grid_load_timeout_ms, cfg.grid_load_poll_interval_ms,
1242
- 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
1243
1548
 
1244
1549
  def _wait_for_board_painted(self, element: Any) -> int:
1245
1550
  """A still board is not a loaded board: hold until the panel's centre carries structure."""
@@ -1268,7 +1573,7 @@ class PageSolver:
1268
1573
  """Best-effort: hold until the board's pictures have painted."""
1269
1574
  try:
1270
1575
  frame.wait_for_function(_BOARD_IMAGES_READY_JS, arg={"prompt": selectors.fresh or "", "images": ",".join(selectors.images)},
1271
- timeout=self.config.hcaptcha_images_timeout_ms)
1576
+ timeout=self.config.board_images_timeout_ms)
1272
1577
  except Exception:
1273
1578
  pass
1274
1579
 
@@ -1386,7 +1691,7 @@ class PageSolver:
1386
1691
  Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
1387
1692
  number_with_highest_value_video and failed every seed either way.
1388
1693
  """
1389
- import cv2
1694
+ from .keyframes import read_frame
1390
1695
 
1391
1696
  cfg = self.config
1392
1697
  floor_ms = float(cfg.video_burst_duration_ms)
@@ -1412,7 +1717,7 @@ class PageSolver:
1412
1717
  if _now() - self._last_input_ms >= INPUT_SETTLE_MS:
1413
1718
  try:
1414
1719
  self._screenshot(element, shot, animations="allow")
1415
- img = cv2.imread(shot)
1720
+ img = read_frame(shot)
1416
1721
  except Exception as exc:
1417
1722
  _debug(f"burst frame failed: {exc}")
1418
1723
  if img is not None:
@@ -1464,20 +1769,17 @@ class PageSolver:
1464
1769
  f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
1465
1770
  return frames, order, cycle_closed or animating, elapsed_ms, cycle_closed
1466
1771
 
1467
- def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[List[str], str]:
1468
- """Cut a burst with the training-side slicer; the model answers with a frame number into it."""
1469
- from .keyframes import extract_keyframes, write_keyframes
1470
-
1471
- kfset = extract_keyframes(frames, fps=measured_fps(len(frames), burst_ms, self.config.video_burst_fps))
1472
- self._keyframe_mode = kfset.mode
1473
- 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."""
1474
1774
  temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
1475
- paths = [str(p) for p in write_keyframes(kfset, temp_dir, stem="challenge")]
1476
- _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1477
- 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
1478
1780
 
1479
- def _record_keyframes(self, element: Any) -> Tuple[List[str], str, bool]:
1480
- """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.
1481
1783
 
1482
1784
  Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
1483
1785
 
@@ -1544,8 +1846,8 @@ class PageSolver:
1544
1846
  if len(self._film_frames) > len(frames):
1545
1847
  _log(f"[animated] slicing {len(self._film_frames)} frames filmed over "
1546
1848
  f"{self._film_ms / 1000:.1f}s — every round of this board, not just this one")
1547
- paths, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
1548
- 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
1549
1851
 
1550
1852
  def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
1551
1853
  text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
@@ -1574,10 +1876,10 @@ class PageSolver:
1574
1876
  self._known_animated = True
1575
1877
  fut.result()
1576
1878
  self._grant_video_budget()
1577
- paths, keyframe_dir = self._slice(frames, burst_ms)
1879
+ recording, keyframe_dir = self._slice(frames, burst_ms)
1578
1880
  with self._phase(Phase.INFERENCE):
1579
- actions, usage = self._get_keyframe_solution(paths)
1580
- 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)
1581
1883
  return actions, usage, keyframe_dir
1582
1884
  finally:
1583
1885
  pool.shutdown(wait=True)
@@ -1615,9 +1917,7 @@ class PageSolver:
1615
1917
 
1616
1918
  def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
1617
1919
  """Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
1618
- import cv2
1619
-
1620
- from .keyframes import MATCH_REGION_TOLERANCE, region_diff_ratio
1920
+ from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_diff_ratio
1621
1921
 
1622
1922
  folder, base = os.path.dirname(keyframe_path), os.path.basename(keyframe_path)
1623
1923
  prefix = re.sub(r"_\d+\.png$", "_", base, flags=re.IGNORECASE)
@@ -1629,18 +1929,16 @@ class PageSolver:
1629
1929
  except OSError:
1630
1930
  return False
1631
1931
  for other in siblings:
1632
- img = cv2.imread(other)
1932
+ img = read_frame(other)
1633
1933
  if img is not None and img.shape == ref.shape and region_diff_ratio(ref, img, box) <= MATCH_REGION_TOLERANCE:
1634
1934
  return True
1635
1935
  return False
1636
1936
 
1637
1937
  def _wait_for_keyframe(self, element: Any, keyframe_path: str, point_norm: Tuple[float, float]) -> bool:
1638
1938
  """Hold until the widget looks like the keyframe around the action point, or the wait is spent."""
1639
- import cv2
1640
-
1641
- from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
1939
+ from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_box, region_diff_ratio
1642
1940
 
1643
- ref = cv2.imread(keyframe_path)
1941
+ ref = read_frame(keyframe_path)
1644
1942
  if ref is None:
1645
1943
  return False
1646
1944
  box = region_box(ref.shape[1::-1], point_norm)
@@ -1658,7 +1956,7 @@ class PageSolver:
1658
1956
  self._check_deadline("waiting for the challenge keyframe")
1659
1957
  try:
1660
1958
  self._screenshot(element, probe, animations="allow")
1661
- live = cv2.imread(probe)
1959
+ live = read_frame(probe)
1662
1960
  except Exception:
1663
1961
  live = None
1664
1962
  if live is not None:
@@ -1687,13 +1985,20 @@ class PageSolver:
1687
1985
  session.element_box["y"] + (y1 + y2) / 2 * session.scale_y)
1688
1986
 
1689
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."""
1690
1989
  if not self._human.hovers:
1691
1990
  return
1692
- first = session.grid_boxes[0]
1693
- cx, cy = self._cell_center_page(cell, session)
1694
- self._smooth_move(page,
1695
- cx + (random.random() - 0.5) * (first[2] - first[0]) * session.scale_x * 0.4,
1696
- 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))
1697
2002
 
1698
2003
  @staticmethod
1699
2004
  def _bbox_to_cell(bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]],
@@ -1714,11 +2019,14 @@ class PageSolver:
1714
2019
  def _hover_loop(self, page: Any, session: _GridSession, cells: Sequence[int]) -> Optional[Callable[[], None]]:
1715
2020
  if not cells:
1716
2021
  return None
1717
- cycle = itertools.cycle(cells)
2022
+ due = [0.0]
1718
2023
 
1719
2024
  def hover():
2025
+ if _now() < due[0]:
2026
+ return
2027
+ due[0] = _now() + random.uniform(*IDLE_DRIFT_EVERY_MS)
1720
2028
  try:
1721
- self._hover_cell(page, session, next(cycle))
2029
+ self._hover_cell(page, session, cells[0])
1722
2030
  except Exception:
1723
2031
  pass
1724
2032
 
@@ -1807,6 +2115,10 @@ class PageSolver:
1807
2115
  loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
1808
2116
  self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
1809
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
1810
2122
  if kind != ActionKind.CLICK:
1811
2123
  _log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
1812
2124
  continue
@@ -1818,6 +2130,10 @@ class PageSolver:
1818
2130
  clicked_this_round: List[int] = []
1819
2131
  for bbox in bboxes:
1820
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
1821
2137
  self._execute_click(page, {"target_bounding_box": bbox}, element_box)
1822
2138
  if cell is not None:
1823
2139
  clicked_order.append(cell)
@@ -1861,13 +2177,13 @@ class PageSolver:
1861
2177
  self._click_checkbox(page, widget, frame)
1862
2178
  return True, []
1863
2179
 
1864
- 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:
1865
2181
  if self._last_submit_frame_hash:
1866
2182
  with self._phase(Phase.AWAIT_NEXT_ROUND):
1867
2183
  self._wait_for_change_since(element, self._last_submit_frame_hash)
1868
2184
  self._last_submit_frame_hash = None
1869
- with self._phase(Phase.HCAPTCHA_IMAGES):
1870
- 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])
1871
2187
 
1872
2188
  # THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of
1873
2189
  # the DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet
@@ -1881,9 +2197,11 @@ class PageSolver:
1881
2197
  self._wait_for_board_painted(element)
1882
2198
 
1883
2199
  # Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
1884
- 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
1885
2202
  if text_mode:
1886
2203
  _log("widget has a text box; solving as a distorted-text captcha")
2204
+ element = _WithAnswerBox(element, answer_box)
1887
2205
 
1888
2206
  # A reCAPTCHA board is read by its grid below, not filmed.
1889
2207
  filmable = puzzle_source != Vendor.RECAPTCHA and not text_mode
@@ -1910,20 +2228,21 @@ class PageSolver:
1910
2228
  self._wait_for_board_painted(element)
1911
2229
 
1912
2230
  shot = _tmp_png("captcha")
2231
+ self._round_board = None
1913
2232
  performed = slid = answered = have_shot = False
1914
2233
  all_usage: List[Dict[str, Any]] = []
1915
2234
  keyframe_dir: Optional[str] = None
1916
2235
  try:
1917
2236
  if is_animated:
1918
2237
  if self._animated_plan is not None:
1919
- keyframes, keyframe_dir, actions, all_usage = self._animated_plan
2238
+ recording, keyframe_dir, actions, all_usage = self._animated_plan
1920
2239
  reused = actions is not None
1921
2240
  if not reused:
1922
2241
  # Drop the ANSWER, keep the FRAMES — and the frames grow. Filming another chunk and
1923
2242
  # re-slicing the accumulation puts a better question up; re-asking the same frames
1924
2243
  # puts the same question up and gets the same answer back.
1925
2244
  with self._phase(Phase.BURST):
1926
- keyframes, new_dir, moved = self._record_keyframes(element)
2245
+ recording, new_dir, moved = self._record_keyframes(element)
1927
2246
  shutil.rmtree(keyframe_dir, ignore_errors=True)
1928
2247
  keyframe_dir = new_dir
1929
2248
  self._known_animated = self._known_animated or moved
@@ -1932,27 +2251,28 @@ class PageSolver:
1932
2251
  else:
1933
2252
  reused = False
1934
2253
  with self._phase(Phase.BURST):
1935
- keyframes, keyframe_dir, moved = self._record_keyframes(element)
2254
+ recording, keyframe_dir, moved = self._record_keyframes(element)
1936
2255
  self._known_animated = self._known_animated or moved
1937
2256
  # The clip answers the question the probe asked: a board that never moved is a still, and the
1938
2257
  # video expert can only answer a still with a frame number the widget will not take.
1939
- if len(keyframes) < 2 or not self._known_animated:
2258
+ if len(recording.keyframe_paths) < 2 or not self._known_animated:
1940
2259
  _log("[animated] the recording shows a still board; solving it as a still")
1941
2260
  is_animated = False
1942
- shutil.copyfile(keyframes[-1], shot)
2261
+ shutil.copyfile(recording.keyframe_paths[-1], shot)
1943
2262
  have_shot = True
1944
2263
  self._stop_animated_film()
1945
2264
  shutil.rmtree(keyframe_dir, ignore_errors=True)
1946
2265
  keyframe_dir = None
1947
2266
  elif not reused:
1948
2267
  with self._phase(Phase.INFERENCE):
1949
- actions, all_usage = self._get_keyframe_solution(keyframes)
1950
- 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)
1951
2270
 
1952
2271
  if not is_animated:
1953
2272
  if not have_shot:
1954
2273
  with self._phase(Phase.SCREENSHOT):
1955
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))
1956
2276
  if self._should_speculate(puzzle_source, text_mode):
1957
2277
  actions, all_usage, keyframe_dir = self._speculate(element, shot, puzzle_source, retry_mode, text_mode)
1958
2278
  is_animated = keyframe_dir is not None
@@ -1975,10 +2295,12 @@ class PageSolver:
1975
2295
 
1976
2296
  _log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
1977
2297
  # A repeated answer is not re-performed: the widget already refused it, and every extra press is
1978
- # 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.
1979
2299
  # `_note_answer` drops the refused animated answer on its way through, so the next round re-asks.
1980
2300
  if self._note_answer(actions, retry_mode):
1981
2301
  return False, all_usage
2302
+ if not is_animated:
2303
+ self._keep_pointer_screen(shot, puzzle_source, actions)
1982
2304
  _log(f"executing {len(actions)} action(s)")
1983
2305
 
1984
2306
  for raw_action in actions:
@@ -1998,7 +2320,7 @@ class PageSolver:
1998
2320
  if bbox:
1999
2321
  self._human.pause(PauseKind.BETWEEN)
2000
2322
  performed = answered = True
2001
- 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")):
2002
2324
  if self._execute_slide(page, element, scope, action, element_box):
2003
2325
  performed = slid = True
2004
2326
  elif kind == ActionKind.DRAG:
@@ -2009,6 +2331,13 @@ class PageSolver:
2009
2331
  elif kind == ActionKind.TYPE:
2010
2332
  if self._execute_type(page, scope, action, widget.at):
2011
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
2012
2341
  elif kind == ActionKind.WAIT:
2013
2342
  duration = int(action.get("duration_ms") or 0)
2014
2343
  if duration > 0:
@@ -2016,13 +2345,19 @@ class PageSolver:
2016
2345
  performed = True
2017
2346
 
2018
2347
  # A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
2019
- # submit the guarded form while the verdict is in flight. An empty or `done` plan still presses Verify/Skip.
2020
- verify_button = self._get_verify_button(scope) if frame or not slid else None
2021
- 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:
2022
2355
  _log(f"clicking Verify to submit ({puzzle_source}).")
2023
2356
  self._move_and_click(page, verify_button)
2024
- performed = True
2357
+ performed = self._submitted_on_board = True
2025
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)
2026
2361
  finally:
2027
2362
  _unlink(shot)
2028
2363
  held = self._animated_plan[1] if self._animated_plan else None
@@ -2239,8 +2574,15 @@ class PageSolver:
2239
2574
  raise blocked("it is showing its try-again-later screen")
2240
2575
  if said == Verdict.REJECTED:
2241
2576
  _log(f"[verdict] the vendor rejected the answer after {verdict_ms:.0f}ms")
2242
-
2243
- 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:
2244
2586
  _log("reCAPTCHA under-selection error; the next round asks for the missed tiles.")
2245
2587
  pending_retry_mode = RetryMode.MISSED_TILES
2246
2588
 
@@ -2249,9 +2591,9 @@ class PageSolver:
2249
2591
  if self._no_progress_rounds:
2250
2592
  stuck = CaptchaSolveError(f"no progress: the model returned the same answer "
2251
2593
  f"{self._no_progress_rounds + 1} times running")
2252
- # A board answered the same way after every resample is one the model cannot read: measured, no
2253
- # solve ever followed a third identical answer, and each further round cost the board 3-7s.
2254
- 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)):
2255
2597
  raise stuck
2256
2598
  again(stuck)
2257
2599
  elif not did_interact:
@@ -2260,6 +2602,8 @@ class PageSolver:
2260
2602
  if cfg.video_solve_enabled:
2261
2603
  self._arm_animated_probe()
2262
2604
  again(CaptchaSolveError("the solver performed no interactions this round"))
2605
+ else:
2606
+ last_failure = None
2263
2607
 
2264
2608
  if isinstance(last_failure, UnsupportedChallengeError):
2265
2609
  raise UnsupportedChallengeError(f"{last_failure} (after {cfg.max_solve_loops} solve loops)")