captchakraken 3.1.0 → 3.2.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 (44) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +1 -1
  3. package/README.md +10 -13
  4. package/dist/index.d.ts +1 -0
  5. package/dist/kinds.d.ts +8 -0
  6. package/dist/kinds.js +3 -1
  7. package/dist/model-name.d.ts +6 -0
  8. package/dist/model-name.js +28 -0
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/png.d.ts +16 -0
  11. package/dist/png.js +119 -0
  12. package/dist/puppeteer-adapter.d.ts +7 -1
  13. package/dist/puppeteer-adapter.js +6 -0
  14. package/dist/selectors.d.ts +6 -0
  15. package/dist/selectors.js +4 -0
  16. package/dist/solver.d.ts +76 -4
  17. package/dist/solver.js +486 -165
  18. package/dist/types.d.ts +10 -5
  19. package/dist/verdicts.d.ts +45 -0
  20. package/dist/verdicts.js +125 -0
  21. package/package.json +7 -7
  22. package/python/AGENTS.md +13 -8
  23. package/python/LICENSE +1 -1
  24. package/python/README.md +10 -13
  25. package/python/pyproject.toml +6 -6
  26. package/python/src/captchakraken/__init__.py +5 -1
  27. package/python/src/captchakraken/cli.py +7 -1
  28. package/python/src/captchakraken/kinds.py +9 -0
  29. package/python/src/captchakraken/page_solver.py +439 -152
  30. package/python/src/captchakraken/planner.py +52 -11
  31. package/python/src/captchakraken/prompts.py +24 -0
  32. package/python/src/captchakraken/selectors.py +10 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
  35. package/python/src/captchakraken/verdicts.py +154 -0
  36. package/python/Dockerfile +0 -41
  37. package/python/examples/README.md +0 -114
  38. package/python/examples/_harness.py +0 -177
  39. package/python/examples/demoHcaptcha.py +0 -8
  40. package/python/examples/demoRecaptcha.py +0 -8
  41. package/python/examples/watch_playwright.py +0 -31
  42. package/python/examples/with_local_gguf.py +0 -28
  43. package/python/examples/with_playwright.py +0 -24
  44. package/scripts/copy-python.mjs +0 -29
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ import io
6
7
  import itertools
7
8
  import json
9
+ import math
8
10
  import os
9
11
  import random
10
12
  import re
@@ -22,15 +24,21 @@ from . import planner
22
24
  from .action_types import CaptchaAction
23
25
  from .humanize import Humanizer, resolve as resolve_humanizer
24
26
  from .kinds import (ActionKind, FrameRole, HumanizationMode, KeyframeMode, PauseKind, Phase, PromptFamily,
25
- RecaptchaBanner, RetryMode, SettleVerdict, Vendor)
27
+ RecaptchaBanner, RetryMode, SettleVerdict, Vendor, Verdict)
26
28
  from .selectors import (ACCEPTED_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
27
29
  SUBMIT_SELECTORS, TEXT_INPUT_SELECTORS, TEXT_INPUT_VENDOR_SELECTORS, VENDORS, WIDGET_PROBES,
28
30
  VendorSelectors, WidgetProbe)
29
31
  from .solver import CaptchaSolver, UnsupportedCaptchaError
30
32
  from .timing import PhaseBudget, timings_enabled
33
+ from .verdicts import RoundVerdict, VerdictLog
31
34
 
32
35
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
33
36
  _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
37
+ _VENDOR_ENV = "CAPTCHA_KRAKEN_VENDOR"
38
+ _SITE_ENV = "CAPTCHA_KRAKEN_SITE"
39
+ _WIDGET_HOST_ENV = "CAPTCHA_KRAKEN_WIDGET_HOST"
40
+ # How often a page that has not drawn its widget yet is looked at again.
41
+ _DETECTION_POLL_MS = 250
34
42
 
35
43
 
36
44
  def _log(message: str) -> None:
@@ -70,6 +78,65 @@ def _sha1(path: str) -> str:
70
78
  return hashlib.sha1(fh.read()).hexdigest()
71
79
 
72
80
 
81
+ class CaptureRect(NamedTuple):
82
+ """Whole CSS pixels, in viewport coordinates."""
83
+
84
+ x: int
85
+ y: int
86
+ width: int
87
+ height: int
88
+
89
+
90
+ def enclosing_rect(box: Dict[str, float]) -> CaptureRect:
91
+ """The whole pixels a bounding box covers: the rect Playwright's element screenshot clips to."""
92
+ x, y = math.floor(box["x"] + 1e-3), math.floor(box["y"] + 1e-3)
93
+ return CaptureRect(x, y, math.ceil(box["x"] + box["width"] - 1e-3) - x,
94
+ math.ceil(box["y"] + box["height"] - 1e-3) - y)
95
+
96
+
97
+ def crop_png(png: bytes, rect: CaptureRect, css_width: float) -> bytes:
98
+ """Cut `rect` out of a viewport capture `css_width` CSS pixels wide, at the capture's own device pixel ratio."""
99
+ from PIL import Image
100
+
101
+ with Image.open(io.BytesIO(png)) as img:
102
+ scale = img.width / css_width
103
+ out = io.BytesIO()
104
+ img.crop((round(rect.x * scale), round(rect.y * scale), round((rect.x + rect.width) * scale),
105
+ round((rect.y + rect.height) * scale))).save(out, format="PNG", compress_level=1)
106
+ return out.getvalue()
107
+
108
+
109
+ _VIEWPORT_JS = "() => ({ width: window.innerWidth, height: window.innerHeight })"
110
+ _FRAME_JS = ("() => ({ width: window.innerWidth, height: window.innerHeight,"
111
+ " scale: window.visualViewport ? window.visualViewport.scale : 1 })")
112
+
113
+
114
+ def _viewport_of(page: Any) -> Dict[str, float]:
115
+ """`viewport_size` is None on a context launched without one (camoufox), where only the window knows."""
116
+ return page.viewport_size or page.evaluate(_VIEWPORT_JS)
117
+
118
+
119
+ def _layout_is_the_viewport(page: Any, view: Dict[str, float]) -> bool:
120
+ """Does a viewport capture speak the bounding box's coordinates? Not on a page laid out wider than the device and zoomed out to fit, which is every mobile layout of a desktop page: there the box is in layout pixels and the capture is not."""
121
+ frame = page.evaluate(_FRAME_JS)
122
+ return abs(frame.get("scale", 1) - 1) < 1e-3 and abs(frame["width"] - view["width"]) <= 1
123
+
124
+
125
+ def _within(rect: CaptureRect, view: Dict[str, float]) -> bool:
126
+ return rect.x >= 0 and rect.y >= 0 and rect.x + rect.width <= view["width"] and rect.y + rect.height <= view["height"]
127
+
128
+
129
+ def _off_board_point(box: Dict[str, float], at: Tuple[float, float],
130
+ view: Dict[str, float]) -> Optional[Tuple[float, float]]:
131
+ """A point just outside the board, past the edge nearest the pointer, that is still inside the viewport."""
132
+ x, y = at
133
+ gap = 24 + random.random() * 40
134
+ right, bottom = box["x"] + box["width"], box["y"] + box["height"]
135
+ exits = sorted([(x - box["x"], (box["x"] - gap, y)), (right - x, (right + gap, y)),
136
+ (y - box["y"], (x, box["y"] - gap)), (bottom - y, (x, bottom + gap))])
137
+ return next((p for _, p in exits if 0 <= p[0] < view["width"] and 0 <= p[1] < view["height"]), None)
138
+
139
+
73
140
  class CaptchaSolveError(Exception):
74
141
  pass
75
142
 
@@ -78,15 +145,6 @@ class AnimatedChallengeError(CaptchaSolveError):
78
145
  pass
79
146
 
80
147
 
81
- class NothingFilmedError(AnimatedChallengeError):
82
- """The burst caught no frame at all.
83
-
84
- `_burst` only returns nothing when EVERY screenshot in its window failed, so this is not a verdict
85
- about the board — a still photographs fine. It is a widget that would not screenshot, and the
86
- commonest reason for that is that it is closing, because the answer was accepted.
87
- """
88
-
89
-
90
148
  class UnsupportedChallengeError(CaptchaSolveError):
91
149
  pass
92
150
 
@@ -99,6 +157,10 @@ class PageClosedError(CaptchaSolveError):
99
157
  pass
100
158
 
101
159
 
160
+ class VendorBlockedError(CaptchaSolveError):
161
+ """The vendor refused to serve this client at all, so no further round can succeed."""
162
+
163
+
102
164
  #: `bounding box of captcha element` is in here because the JS port has always had it
103
165
  #: (`isStaleHandleError`): a widget with no box has moved on under us — hCaptcha swapped rounds, GeeTest
104
166
  #: closed on accept — and re-detecting is right where failing the attempt is not. Without it the Python
@@ -131,6 +193,8 @@ class SolveResult:
131
193
  final_mouse_position: Tuple[float, float]
132
194
  token_usage: List[Dict[str, Any]] = field(default_factory=list)
133
195
  phases: Dict[str, float] = field(default_factory=dict)
196
+ # What the vendor's own server answered, in order, for the vendors whose answer is readable.
197
+ verdicts: List[RoundVerdict] = field(default_factory=list)
134
198
 
135
199
 
136
200
  @dataclass
@@ -183,6 +247,9 @@ def measured_fps(frames: int, elapsed_ms: float, nominal_fps: float) -> float:
183
247
  _MOVED_DURING_INFERENCE_DIFF = 0.002
184
248
  _NOT_THIS_BOARD_DIFF = 0.5
185
249
  _NOT_THIS_BOARD_POLLS = 3
250
+ # How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read
251
+ # inside this window is wearing our own feedback, which is not a screen it ever showed by itself.
252
+ INPUT_SETTLE_MS = 400
186
253
 
187
254
 
188
255
  @dataclass
@@ -195,13 +262,20 @@ class PageSolverConfig:
195
262
  expert: Optional[PromptFamily] = None
196
263
  max_solve_loops: int = 6
197
264
  overall_solve_timeout_ms: int = 45_000
265
+ # A page just navigated has often not drawn its widget; this long is spent looking before "no captcha" is
266
+ # believed. It is not charged to overall_solve_timeout_ms, which starts when there is something to solve.
267
+ 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.
198
269
  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.
199
271
  post_solve_delay_ms: int = 1_200
200
272
  post_solve_outcome_timeout_ms: int = 1_000
201
273
  post_solve_outcome_poll_ms: int = 75
202
274
  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.
203
276
  max_unsupported_resolves: int = 3
204
277
  max_stale_element_retries: int = 3
278
+ # The pause before a failed round is tried again.
205
279
  stale_element_backoff_ms: int = 900
206
280
  stale_frame_resolve_enabled: bool = True
207
281
  stale_frame_diff_threshold: float = 0.02
@@ -354,6 +428,11 @@ class PageSolver:
354
428
  # Has this board EVER shown the same screen twice? A board that has not cannot prove it was replaced
355
429
  # and has no cycle left to find, so a re-ask neither re-films it at length nor throws its film away.
356
430
  self._film_cycled: bool = False
431
+ self._verdicts: Optional[VerdictLog] = None
432
+ # The page being solved: every capture is taken of its viewport. Set by `solve`.
433
+ self._page: Any = None
434
+ # When our last gesture ended; the board's reaction to it is never read as the board's own motion.
435
+ self._last_input_ms: float = -math.inf
357
436
  self._reset_animated_state()
358
437
 
359
438
  @property
@@ -372,6 +451,15 @@ class PageSolver:
372
451
  with self._budget.phase(name):
373
452
  yield
374
453
 
454
+ @contextmanager
455
+ def _gesture(self):
456
+ """A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`."""
457
+ try:
458
+ with self._phase(Phase.MOUSE):
459
+ yield
460
+ finally:
461
+ self._last_input_ms = _now()
462
+
375
463
  # ── per-solve state ──────────────────────────────────────────────────
376
464
 
377
465
  def _reset_animated_state(self) -> None:
@@ -380,7 +468,6 @@ class PageSolver:
380
468
  self._animated_probe_armed = False
381
469
  self._animated_probe_done = False
382
470
  self._video_budget_granted = False
383
- self._retried_unusable_answer = False
384
471
  self._stop_animated_film()
385
472
  self._keyframe_mode: Optional[KeyframeMode] = None
386
473
  self._keyframe_steady_screens = 0
@@ -406,8 +493,7 @@ class PageSolver:
406
493
  sig = self._answer_signature(actions, retry_mode)
407
494
  if sig is not None and sig == self._last_answer_sig:
408
495
  self._no_progress_rounds += 1
409
- _log(f"[no-progress] the model returned the same answer again "
410
- f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds})")
496
+ _log(f"[no-progress] the model returned the same answer again ({self._no_progress_rounds} in a row)")
411
497
  self._resample_level += 1
412
498
  self._apply_sampling()
413
499
  self._invalidate_animated_answer()
@@ -527,7 +613,7 @@ class PageSolver:
527
613
  # ── gestures ─────────────────────────────────────────────────────────
528
614
 
529
615
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
530
- with self._phase(Phase.MOUSE):
616
+ with self._gesture():
531
617
  self._human.move(page, (x, y))
532
618
 
533
619
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
@@ -552,7 +638,7 @@ class PageSolver:
552
638
 
553
639
  def _move_and_click(self, page: Any, element: Any) -> None:
554
640
  self._move_to_element(page, element)
555
- with self._phase(Phase.MOUSE):
641
+ with self._gesture():
556
642
  self._human.click(page, self._last_mouse)
557
643
 
558
644
  @staticmethod
@@ -582,11 +668,11 @@ class PageSolver:
582
668
  at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
583
669
  if await_keyframe:
584
670
  # Park on the target first, so only a mouse-down separates the right screen from the click.
585
- with self._phase(Phase.MOUSE):
671
+ with self._gesture():
586
672
  self._human.move(page, at)
587
673
  self._wait_for_keyframe(element, await_keyframe,
588
674
  (rel[0] / element_box["width"], rel[1] / element_box["height"]))
589
- with self._phase(Phase.MOUSE):
675
+ with self._gesture():
590
676
  self._human.click(page, at)
591
677
 
592
678
  def _click_when_frame_matches(self, page: Any, element: Any, action: Dict[str, Any],
@@ -600,7 +686,7 @@ class PageSolver:
600
686
  return (element_box["x"] + (float(bbox[0]) + float(bbox[2])) / 2 * element_box["width"],
601
687
  element_box["y"] + (float(bbox[1]) + float(bbox[3])) / 2 * element_box["height"])
602
688
 
603
- with self._phase(Phase.MOUSE):
689
+ with self._gesture():
604
690
  self._human.drag(page, center(action["source_bounding_box"]), center(action["target_bounding_box"]))
605
691
 
606
692
  def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
@@ -643,7 +729,9 @@ class PageSolver:
643
729
  _log("type action, but no text box in the widget; skipping")
644
730
  return False
645
731
  self._move_and_click(page, field_el)
646
- if not self._human.type_text(page, field_el, text):
732
+ typed = self._human.type_text(page, field_el, text)
733
+ self._last_input_ms = _now()
734
+ if not typed:
647
735
  return False
648
736
  _log(f"typed {len(text)} character(s) into the captcha field")
649
737
  return True
@@ -682,7 +770,7 @@ class PageSolver:
682
770
  _log("slide action, but the widget has neither a slider nor a draggable piece")
683
771
  return False
684
772
  target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
685
- with self._phase(Phase.MOUSE):
773
+ with self._gesture():
686
774
  self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
687
775
  (element_box["x"] + target_x, element_box["y"] + target_y))
688
776
  return True
@@ -746,6 +834,7 @@ class PageSolver:
746
834
  self._human.release(page)
747
835
  except Exception:
748
836
  pass
837
+ self._last_input_ms = _now()
749
838
  for shot in shots:
750
839
  _unlink(shot)
751
840
  return True
@@ -830,15 +919,55 @@ class PageSolver:
830
919
  blob = " ".join(str(n) for n in (names or []))
831
920
  return [vendor for vendor, s in VENDORS if any(h in blob for h in s.hosts)]
832
921
 
922
+ def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
923
+ """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))]
927
+
833
928
  def _no_widget_message(self, page: Any) -> str:
834
- base = "no interactive captcha widget detected"
929
+ base = f"no interactive captcha widget detected within {self.config.detection_timeout_ms}ms"
835
930
  loaded = self.vendors_on_the_wire(page)
836
931
  if not loaded:
837
932
  return (f"{base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / "
838
933
  "invisible, or a click-triggered challenge that has not been triggered)")
839
- return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on this page. The "
840
- "vendor's markup no longer matches anything in SELECTORS — the table needs "
841
- "re-measuring against the vendor's current markup, in both solver ports")
934
+ framed = self._unmatched_vendor_frames(page)
935
+ if framed:
936
+ return (f"{base}, BUT {'/'.join(framed)} is showing a frame that matches nothing in SELECTORS. The "
937
+ "vendor's markup has changed — the table needs re-measuring against the vendor's current "
938
+ "markup, in both solver ports")
939
+ return (f"{base}; {'/'.join(loaded)} code is loaded but drew no widget — the challenge may be invisible "
940
+ "or not triggered yet, or an in-page widget's markup has changed")
941
+
942
+ def _await_widget(self, page: Any) -> Tuple[Optional[Widget], float]:
943
+ """A page that has not drawn its widget yet is looked at again until `detection_timeout_ms`, and the time
944
+ spent is returned so the solve budget can start after it. Solving straight after `domcontentloaded` failed
945
+ 3/3 on a demo page whose frames arrived a moment later."""
946
+ started = _now()
947
+ widget = None
948
+ with self._phase(Phase.DETECT):
949
+ while (widget is None and _now() - started < self.config.detection_timeout_ms
950
+ and not self.is_captcha_solved(page)):
951
+ _delay(_DETECTION_POLL_MS)
952
+ widget = self.detect_captcha(page)
953
+ return widget, _now() - started
954
+
955
+ def _is_blocked(self, page: Any) -> bool:
956
+ """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
959
+ if probe.role != FrameRole.CHALLENGE or not blocked:
960
+ continue
961
+ at = next(iter(_visible(page, (probe.selector,))), None)
962
+ frame = _frame_of(at) if at is not None else None
963
+ if frame and any(self._visible_with_text(frame, selector) for selector in blocked):
964
+ return True
965
+ return False
966
+
967
+ def _vendor_verdict(self) -> Optional[Verdict]:
968
+ """What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer."""
969
+ fresh = [v.verdict for v in (self._verdicts.fresh() if self._verdicts else ()) if v.verdict != Verdict.NEW_CHALLENGE]
970
+ return Verdict.BLOCKED if Verdict.BLOCKED in fresh else (fresh[-1] if fresh else None)
842
971
 
843
972
  def is_captcha_solved(self, page: Any) -> bool:
844
973
  """The vendor's own done signal: a response token, a painted success state, or a checked box."""
@@ -898,6 +1027,69 @@ class PageSolver:
898
1027
  pass
899
1028
  return None
900
1029
 
1030
+ def _click_checkbox(self, page: Any, widget: Widget, frame: Any) -> None:
1031
+ """Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
1032
+
1033
+ Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering
1034
+ the checkbox picture with a drag.
1035
+ """
1036
+ box = self._checkbox_box(frame, widget.vendor)
1037
+ if box is not None:
1038
+ self._move_to_element(page, box)
1039
+ else:
1040
+ self._smooth_move(page, *self._find_checkbox_on_screen(widget.element))
1041
+ self._human.pause(PauseKind.BETWEEN)
1042
+ with self._gesture():
1043
+ self._human.click(page, self._last_mouse)
1044
+ self._await_what_the_checkbox_opened(page)
1045
+
1046
+ def _checkbox_box(self, frame: Any, vendor: Vendor) -> Optional[Any]:
1047
+ """The box in a checkbox frame once the frame has drawn it; None when the DOM cannot reach it.
1048
+
1049
+ Turnstile's box is in a closed shadow root, and a frame can refuse access; both are found on screen instead.
1050
+ """
1051
+ box = SELECTORS[vendor].box
1052
+ if frame is None or not box:
1053
+ return None
1054
+ try:
1055
+ return frame.wait_for_selector(", ".join(box), state="visible", timeout=self.config.detection_timeout_ms)
1056
+ except Exception as exc:
1057
+ _log(f"[checkbox] the box is out of the DOM's reach ({str(exc).splitlines()[0]}); finding it on screen")
1058
+ return None
1059
+
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."""
1062
+ from .tool_calls.find_checkbox import find_checkbox
1063
+
1064
+ box = element.bounding_box()
1065
+ if not box:
1066
+ raise CaptchaSolveError("could not get bounding box of captcha element")
1067
+ shot = _tmp_png("checkbox")
1068
+ try:
1069
+ with self._phase(Phase.SCREENSHOT):
1070
+ self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
1071
+ found = find_checkbox(shot)
1072
+ scale = self._shot_scale(shot, box["width"])
1073
+ finally:
1074
+ _unlink(shot)
1075
+ if found is None:
1076
+ raise UnsupportedCaptchaError("the checkbox widget shows no tick box to click")
1077
+ x, y, w, h = (v / scale for v in found)
1078
+ return box["x"] + x + w * (0.3 + random.random() * 0.4), box["y"] + y + h * (0.3 + random.random() * 0.4)
1079
+
1080
+ def _await_what_the_checkbox_opened(self, page: Any) -> None:
1081
+ """A clicked checkbox passes or opens a challenge, and both show in the DOM.
1082
+
1083
+ Without this wait the next round found the same checkbox still up and clicked it again.
1084
+ """
1085
+ deadline = _now() + self.config.post_submit_change_timeout_ms
1086
+ with self._phase(Phase.AWAIT_NEXT_ROUND):
1087
+ while _now() < deadline and not self.is_captcha_solved(page):
1088
+ widget = self.detect_captcha(page)
1089
+ if widget is None or widget.role != FrameRole.CHECKBOX:
1090
+ return
1091
+ _delay(self.config.post_solve_outcome_poll_ms)
1092
+
901
1093
  def _get_verify_button(self, scope: Any) -> Optional[Any]:
902
1094
  return self._find_control(scope, SUBMIT_SELECTORS)
903
1095
 
@@ -905,9 +1097,61 @@ class PageSolver:
905
1097
 
906
1098
  def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
907
1099
  animations: str = "disabled") -> None:
908
- """Short timeout; `disabled` freezes CSS animation, so bursts and the keyframe gate pass `allow`."""
909
- element.screenshot(path=path, timeout=2_500 if timeout_ms is None else timeout_ms,
910
- animations=animations)
1100
+ """Every capture the driver takes: the whole viewport, cropped here to the element.
1101
+
1102
+ Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at
1103
+ the clip's size for that frame, which the user watches as the page flashing and jumping; a viewport
1104
+ capture repaints nothing, and measured twice as fast. Short timeout; `disabled` freezes CSS animation,
1105
+ so bursts and the keyframe gate pass `allow`.
1106
+ """
1107
+ timeout = 2_500 if timeout_ms is None else timeout_ms
1108
+ page = self._page
1109
+ view = _viewport_of(page)
1110
+ rect = self._rect_in_view(element, view) if _layout_is_the_viewport(page, view) else None
1111
+ if rect is None:
1112
+ # Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
1113
+ # coordinates, so the element photographs itself.
1114
+ element.screenshot(path=path, timeout=timeout, animations=animations)
1115
+ return
1116
+ png = crop_png(page.screenshot(timeout=timeout, animations=animations), rect, view["width"])
1117
+ with open(path, "wb") as fh:
1118
+ fh.write(png)
1119
+
1120
+ @staticmethod
1121
+ def _rect_of(element: Any) -> CaptureRect:
1122
+ box = element.bounding_box()
1123
+ rect = enclosing_rect(box) if box else None
1124
+ if rect is None or rect.width <= 0 or rect.height <= 0:
1125
+ raise CaptchaSolveError("the element is not visible: it has no bounding box to photograph")
1126
+ return rect
1127
+
1128
+ def _rect_in_view(self, element: Any, view: Dict[str, float]) -> Optional[CaptureRect]:
1129
+ """Where the element sits in the viewport, scrolled in when it is merely off screen; None when it cannot fit."""
1130
+ rect = self._rect_of(element)
1131
+ if rect.width > view["width"] or rect.height > view["height"]:
1132
+ return None
1133
+ if not _within(rect, view):
1134
+ element.scroll_into_view_if_needed(timeout=2_000)
1135
+ rect = self._rect_of(element)
1136
+ return rect if _within(rect, view) else None
1137
+
1138
+ def _step_off_the_board(self, page: Any, element: Any) -> None:
1139
+ """Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
1140
+
1141
+ A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board
1142
+ was filmed as animated because of the Verify button under the cursor.
1143
+ """
1144
+ # Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has
1145
+ # nothing of ours to fade, and stepping off it cost every round its own gesture and settle window.
1146
+ if not self._acted_on_board:
1147
+ return
1148
+ box = element.bounding_box()
1149
+ x, y = self._last_mouse
1150
+ if box and self._human.hovers and box["x"] <= x <= box["x"] + box["width"] and box["y"] <= y <= box["y"] + box["height"]:
1151
+ spot = _off_board_point(box, (x, y), _viewport_of(page))
1152
+ if spot is not None:
1153
+ self._smooth_move(page, *spot)
1154
+ _delay(self._last_input_ms + INPUT_SETTLE_MS - _now())
911
1155
 
912
1156
  def _element_frame_hash(self, element: Any) -> Optional[str]:
913
1157
  path = _tmp_png("fh")
@@ -1145,12 +1389,14 @@ class PageSolver:
1145
1389
  raise CaptchaSolveError(
1146
1390
  f"the animated recording stalled: {len(frames)} frames in "
1147
1391
  f"{burst_hang_deadline_ms(cfg):.0f}ms. The widget is not screenshotting.")
1148
- try:
1149
- self._screenshot(element, shot, animations="allow")
1150
- img = cv2.imread(shot)
1151
- except Exception as exc:
1152
- _debug(f"burst frame failed: {exc}")
1153
- img = None
1392
+ img = None
1393
+ # A frame taken while the board still wears our last gesture is not a screen it showed by itself.
1394
+ if _now() - self._last_input_ms >= INPUT_SETTLE_MS:
1395
+ try:
1396
+ self._screenshot(element, shot, animations="allow")
1397
+ img = cv2.imread(shot)
1398
+ except Exception as exc:
1399
+ _debug(f"burst frame failed: {exc}")
1154
1400
  if img is not None:
1155
1401
  frames.append(img)
1156
1402
  d = _sha1(shot)
@@ -1254,13 +1500,9 @@ class PageSolver:
1254
1500
  element, known=self._film_digests,
1255
1501
  max_ms=float(cfg.video_burst_duration_ms) if reask and not self._film_cycled else None)
1256
1502
  if not frames:
1257
- # ONLY the speculative second look gets the soft landing. A board this solve has PROVEN
1258
- # animated is a real dead end when it will not film, and must fail loudly as it always
1259
- # has: measured, treating both the same took the two video types from 3 solved and 10
1260
- # keyframe calls to 0 and 0, because the first failed film spends `_animated_probe_done`
1261
- # and every round after it is answered as a still.
1262
- if not self._known_animated:
1263
- raise NothingFilmedError("could not record the animated challenge (no frame screenshotted)")
1503
+ # Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
1504
+ # whole window is usually closing because the answer was accepted, so the loop asks before it counts
1505
+ # the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
1264
1506
  raise AnimatedChallengeError("could not record the animated challenge (no frame screenshotted)")
1265
1507
  _log(f"[animated] recorded {len(frames)} frames in {burst_ms / 1000:.1f}s "
1266
1508
  f"({measured_fps(len(frames), burst_ms, cfg.video_burst_fps):.1f}fps)")
@@ -1587,9 +1829,20 @@ class PageSolver:
1587
1829
 
1588
1830
  def _solve_single(self, page: Any, widget: Widget, retry_mode: Optional[RetryMode]) -> Tuple[bool, List[Dict[str, Any]]]:
1589
1831
  element, puzzle_source, role = widget.element, widget.vendor, widget.role
1832
+ os.environ[_VENDOR_ENV] = puzzle_source.value
1590
1833
  frame = element.content_frame()
1834
+ # The widget's own host comes from its iframe; an inline widget has none to report.
1835
+ widget_host = _hostname(frame) if frame else ""
1836
+ if widget_host:
1837
+ os.environ[_WIDGET_HOST_ENV] = widget_host
1838
+ else:
1839
+ os.environ.pop(_WIDGET_HOST_ENV, None)
1591
1840
  scope = frame or widget.at
1592
1841
 
1842
+ if role == FrameRole.CHECKBOX:
1843
+ self._click_checkbox(page, widget, frame)
1844
+ return True, []
1845
+
1593
1846
  if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
1594
1847
  if self._last_submit_frame_hash:
1595
1848
  with self._phase(Phase.AWAIT_NEXT_ROUND):
@@ -1614,8 +1867,10 @@ class PageSolver:
1614
1867
  if text_mode:
1615
1868
  _log("widget has a text box; solving as a distorted-text captcha")
1616
1869
 
1617
- # A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
1618
- filmable = role != FrameRole.CHECKBOX and puzzle_source != Vendor.RECAPTCHA and not text_mode
1870
+ # A reCAPTCHA board is read by its grid below, not filmed.
1871
+ filmable = puzzle_source != Vendor.RECAPTCHA and not text_mode
1872
+ if filmable and not self._known_animated:
1873
+ self._step_off_the_board(page, element)
1619
1874
  is_animated = filmable and self._settle_or_animated(element)
1620
1875
  # hCaptcha keeps its challenge iframe visible ~2s after the final submit; read as a fresh puzzle it burned ~18s.
1621
1876
  if role == FrameRole.CHALLENGE and self.is_captcha_solved(page):
@@ -1770,12 +2025,21 @@ class PageSolver:
1770
2025
  usage: List[Dict[str, Any]] = []
1771
2026
  self._last_submit_frame_hash = None
1772
2027
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
2028
+ self._page = page
1773
2029
  self._human.reset(page)
1774
2030
  self._reset_animated_state()
1775
2031
  self._budget = PhaseBudget()
1776
- # One session id per solve groups its inference rounds into one billable attempt.
1777
- previous_session = os.environ.get(_SESSION_ENV)
2032
+ self._verdicts = None
2033
+ # One session id per solve groups its inference rounds into one billable attempt; vendor and site say
2034
+ # where it was, and are set as they become known.
2035
+ previous = {name: os.environ.get(name) for name in (_SESSION_ENV, _VENDOR_ENV, _SITE_ENV, _WIDGET_HOST_ENV)}
1778
2036
  session_id = os.environ[_SESSION_ENV] = str(uuid.uuid4())
2037
+ os.environ.pop(_VENDOR_ENV, None)
2038
+ os.environ.pop(_SITE_ENV, None)
2039
+ os.environ.pop(_WIDGET_HOST_ENV, None)
2040
+ site = _hostname(page)
2041
+ if site:
2042
+ os.environ[_SITE_ENV] = site
1779
2043
  solved = False
1780
2044
  try:
1781
2045
  result = self._solve_impl(page, start, usage)
@@ -1786,27 +2050,47 @@ class PageSolver:
1786
2050
  if timings_enabled():
1787
2051
  print(self._budget.report(), file=sys.stderr)
1788
2052
  self._deadline_ms = None
2053
+ if self._verdicts is not None:
2054
+ self._verdicts.close()
2055
+ # The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
2056
+ said = self._verdicts.decisive() if self._verdicts is not None else None
1789
2057
  try:
1790
2058
  # On by default: it is the only way a hosted account's failures can be found at all.
1791
- self._solver.planner.report_outcome(session_id, solved)
2059
+ self._solver.planner.report_outcome(session_id, solved if said is None else said == Verdict.ACCEPTED)
1792
2060
  except Exception as exc:
1793
2061
  _log(f"[outcome] could not report: {exc}")
1794
- if previous_session is None:
1795
- os.environ.pop(_SESSION_ENV, None)
1796
- else:
1797
- os.environ[_SESSION_ENV] = previous_session
2062
+ for name, value in previous.items():
2063
+ if value is None:
2064
+ os.environ.pop(name, None)
2065
+ else:
2066
+ os.environ[name] = value
1798
2067
 
1799
2068
  def _solve_impl(self, page: Any, start: float, usage: List[Dict[str, Any]]) -> SolveResult:
2069
+ """Give up only when the vendor refuses to serve us, or when every one of `max_solve_loops` has been spent.
2070
+
2071
+ Any other failed round — a stale handle, an unusable or repeated answer, a board that would not film —
2072
+ counts one loop, pauses, and goes again.
2073
+ """
1800
2074
  cfg = self.config
1801
2075
  pending_retry_mode: Optional[RetryMode] = None
1802
- retried_underselect = False
1803
- unsupported_retries = stale_retries = render_waits = 0
1804
2076
  has_interacted = False
1805
- # Strictly fewer than the loops, else the no-widget branch for invisible reCAPTCHA never fires.
1806
- max_render_waits = min(6, cfg.max_solve_loops - 1)
2077
+ last_failure: Optional[CaptchaSolveError] = None
2078
+ verdicts = self._verdicts = VerdictLog(page)
1807
2079
 
1808
2080
  def done() -> SolveResult:
1809
- return SolveResult(True, self._last_mouse, _aggregate(usage))
2081
+ return SolveResult(True, self._last_mouse, _aggregate(usage), verdicts=list(verdicts.verdicts))
2082
+
2083
+ def blocked(how: str) -> VendorBlockedError:
2084
+ return VendorBlockedError(f"the vendor refused to serve this client ({how}); no further round can succeed")
2085
+
2086
+ def again(failure: CaptchaSolveError, *, in_transition: bool = False) -> None:
2087
+ """Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated
2088
+ answer changed nothing on the page, and pausing after it only spends the solve's budget."""
2089
+ nonlocal last_failure
2090
+ last_failure = failure
2091
+ _log(f"{failure}; counting this loop and going again")
2092
+ if in_transition:
2093
+ _delay(cfg.stale_element_backoff_ms)
1810
2094
 
1811
2095
  for attempt in range(1, cfg.max_solve_loops + 1):
1812
2096
  # A round that failed arms the second look only while its board is still up. A board the vendor has
@@ -1823,6 +2107,12 @@ class PageSolver:
1823
2107
  raise CaptchaSolveError(
1824
2108
  f"captcha solve timed out after {deadline - start:.0f}ms (attempt {attempt}/{cfg.max_solve_loops})"
1825
2109
  + (f", including {granted:.0f}ms granted for recording an animated challenge" if granted > 0 else ""))
2110
+ said = self._vendor_verdict()
2111
+ if said == Verdict.ACCEPTED:
2112
+ _log("[verdict] the vendor accepted the answer; finishing.")
2113
+ return done()
2114
+ if said == Verdict.BLOCKED:
2115
+ raise blocked("its server answered 429")
1826
2116
  if has_interacted and self.is_captcha_solved(page):
1827
2117
  _log("captcha reports solved; finishing.")
1828
2118
  return done()
@@ -1830,105 +2120,88 @@ class PageSolver:
1830
2120
  with self._phase(Phase.DETECT):
1831
2121
  widget = self.detect_captcha(page)
1832
2122
  if not widget:
1833
- if has_interacted:
2123
+ # A widget that vanished right after the vendor refused the answer was not solved.
2124
+ if has_interacted and verdicts.decisive() != Verdict.REJECTED:
1834
2125
  _log("no supported captcha remains after interaction; considering solved.")
1835
2126
  return done()
1836
- if self.is_captcha_solved(page):
1837
- _log("captcha already satisfied; nothing to solve.")
1838
- return done()
1839
- if self.has_interactive_widget_in_dom(page) and render_waits < max_render_waits:
1840
- render_waits += 1
1841
- _log(f"widget in DOM but not yet rendered; waiting ({render_waits}/{max_render_waits}).")
1842
- _delay(800 + random.random() * 300)
1843
- continue
1844
- raise NoCaptchaFoundError(self._no_widget_message(page))
2127
+ widget, waited = self._await_widget(page)
2128
+ # The budget is for solving; the page drawing its widget is not charged to it.
2129
+ start += waited
2130
+ if self._deadline_ms is not None:
2131
+ self._deadline_ms += waited
2132
+ if widget is None:
2133
+ if self.is_captcha_solved(page):
2134
+ _log("captcha already satisfied; nothing to solve.")
2135
+ return done()
2136
+ if has_interacted:
2137
+ raise CaptchaSolveError(f"the vendor rejected the last answer and its widget did not come "
2138
+ f"back within {cfg.detection_timeout_ms}ms")
2139
+ raise NoCaptchaFoundError(self._no_widget_message(page))
1845
2140
 
1846
2141
  _log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
1847
2142
  retry_mode, pending_retry_mode = pending_retry_mode, None
1848
2143
  try:
1849
2144
  did_interact, round_usage = self._solve_single(page, widget, retry_mode)
1850
- except NothingFilmedError as nothing_filmed:
1851
- # A WIDGET THAT WILL NOT SCREENSHOT IS USUALLY A WIDGET THAT IS CLOSING, and it closes
1852
- # because the answer was accepted. Every other failure in this loop asks
1853
- # `is_captcha_solved` before giving up; this one re-raised through the branch below and
1854
- # threw away boards the vendor had already taken. Measured: prosopo_grid_3x3 was 8/8
1855
- # green across six runs on 09-12 and 09-13, then lost four attempts on 09-17 to exactly
1856
- # this — each one after its FIRST board came back from /fx/verify graded `solved: true`,
1857
- # with the solve dying on the second board the vendor dealt.
1858
- if has_interacted:
1859
- try:
1860
- if self.is_captcha_solved(page):
1861
- _log("nothing left to film because the board was accepted; finishing.")
1862
- return done()
1863
- except Exception:
1864
- pass
1865
- # Not solved: the handle is stale for the same reason it is unscreenshottable, so
1866
- # take the stale-handle recovery rather than ending a solve with loops still in it.
1867
- if stale_retries < cfg.max_stale_element_retries:
1868
- stale_retries += 1
1869
- _log(f"the widget would not screenshot; re-detecting "
1870
- f"({stale_retries}/{cfg.max_stale_element_retries}).")
1871
- _delay(cfg.stale_element_backoff_ms)
1872
- continue
1873
- raise
1874
- except AnimatedChallengeError:
1875
- raise
1876
2145
  except UnsupportedCaptchaError as unsupported:
1877
- # Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
1878
- if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
1879
- unsupported_retries += 1
1880
- again = self.detect_captcha(page)
1881
- with self._phase(Phase.SETTLE):
1882
- settled = again and self._wait_for_element_settled(again.element)
1883
- if settled == SettleVerdict.ANIMATED and not cfg.video_solve_enabled:
1884
- raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
1885
- _log(f'"unsupported" mid-solve; retrying ({unsupported_retries}/{cfg.max_unsupported_resolves}).')
1886
- continue
1887
- raise UnsupportedChallengeError(f"cannot solve this kind of captcha — {unsupported}") from unsupported
2146
+ # The model had nothing usable for this board. The next round re-asks, often of a board the
2147
+ # vendor has replaced.
2148
+ again(UnsupportedChallengeError(f"cannot solve this kind of captcha — {unsupported}"))
2149
+ continue
2150
+ except AnimatedChallengeError as animated:
2151
+ if not cfg.video_solve_enabled:
2152
+ raise
2153
+ # A widget that will not screenshot is usually one that is closing, because the answer was
2154
+ # accepted: measured, a vendor that deals several boards lost four attempts to exactly this, each
2155
+ # after its first board came back graded solved.
2156
+ if has_interacted and self.is_captcha_solved(page):
2157
+ _log("nothing left to film because the board was accepted; finishing.")
2158
+ return done()
2159
+ again(animated, in_transition=True)
2160
+ continue
1888
2161
  except Exception as exc:
1889
2162
  message = str(exc)
1890
2163
  closed = bool(_CLOSED_TARGET_RE.search(message))
1891
- if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
1892
- # The handle most often went stale because the answer was accepted.
1893
- try:
1894
- if self.is_captcha_solved(page):
1895
- _log("captcha reports solved; finishing.")
1896
- return done()
1897
- except Exception:
1898
- pass
1899
- if closed:
1900
- raise PageClosedError(
1901
- "the page, context or browser closed mid-solve, after the answer had been "
1902
- "submitted but before the vendor's verdict could be read — the solve may in "
1903
- "fact have succeeded") from exc
1904
- if stale_retries < cfg.max_stale_element_retries:
1905
- stale_retries += 1
1906
- _log(f"stale challenge handle after submit; re-detecting ({stale_retries}/{cfg.max_stale_element_retries}).")
1907
- _delay(cfg.stale_element_backoff_ms)
1908
- continue
1909
- raise
2164
+ if not (closed or _STALE_HANDLE_RE.search(message)):
2165
+ raise
2166
+ # The handle most often went stale because the answer was accepted.
2167
+ try:
2168
+ if has_interacted and self.is_captcha_solved(page):
2169
+ _log("captcha reports solved; finishing.")
2170
+ return done()
2171
+ except Exception:
2172
+ pass
2173
+ if closed:
2174
+ raise PageClosedError(
2175
+ "the page, context or browser closed mid-solve"
2176
+ + (", after the answer had been submitted but before the vendor's verdict could be read — "
2177
+ "the solve may in fact have succeeded" if has_interacted else "")) from exc
2178
+ again(CaptchaSolveError(f"the challenge handle went stale ({message.splitlines()[0]})"), in_transition=True)
2179
+ continue
1910
2180
 
1911
2181
  has_interacted = has_interacted or did_interact
1912
- if self._no_progress_rounds >= cfg.max_no_progress_rounds:
1913
- raise CaptchaSolveError(
1914
- f"no progress: the model returned the same answer {self._no_progress_rounds + 1} times "
1915
- f"running and the challenge is still up (attempt {attempt}/{cfg.max_solve_loops})")
1916
- render_waits = 0
1917
2182
  usage.extend(round_usage)
1918
2183
 
1919
2184
  # One polled wait per round: the vendor's verdict, the widget going away, or a fresh board. The flat
1920
- # sleep it replaced observed nothing and cost 1200-1500ms per finished round.
1921
- window_ms = (cfg.post_solve_outcome_timeout_ms if did_interact
1922
- else cfg.post_solve_delay_ms + random.random() * 300)
2185
+ # sleep it replaced observed nothing and cost 1200-1500ms per finished round. A round that performed
2186
+ # nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
2187
+ # refused or repeated answer the full window.
2188
+ window_ms = cfg.post_solve_outcome_timeout_ms if did_interact else 0
1923
2189
  deadline = _now() + window_ms
1924
2190
  t0 = time.perf_counter()
1925
2191
  solved = False
2192
+ said = None
1926
2193
  widget_gone = 0
1927
- while _now() < deadline:
2194
+ looked = False
2195
+ while not looked or _now() < deadline:
2196
+ looked = True
2197
+ said = self._vendor_verdict() or said
2198
+ if said in (Verdict.ACCEPTED, Verdict.BLOCKED):
2199
+ solved = said == Verdict.ACCEPTED
2200
+ break
1928
2201
  if self.is_captcha_solved(page):
1929
2202
  solved = True
1930
2203
  break
1931
- widget_gone = widget_gone + 1 if self.detect_captcha(page) is None else 0
2204
+ widget_gone = widget_gone + 1 if said != Verdict.REJECTED and self.detect_captcha(page) is None else 0
1932
2205
  if widget_gone >= 2:
1933
2206
  solved = True
1934
2207
  break
@@ -1942,34 +2215,48 @@ class PageSolver:
1942
2215
  if solved:
1943
2216
  _log(f"[verdict] success signal arrived after {verdict_ms:.0f}ms")
1944
2217
  return done()
2218
+ if said == Verdict.BLOCKED:
2219
+ raise blocked("its server answered 429")
2220
+ if self._is_blocked(page):
2221
+ raise blocked("it is showing its try-again-later screen")
2222
+ if said == Verdict.REJECTED:
2223
+ _log(f"[verdict] the vendor rejected the answer after {verdict_ms:.0f}ms")
1945
2224
 
1946
2225
  if self._banner_is_fatal_after_retry(self._banner_kind(page)):
1947
- if retried_underselect:
1948
- raise CaptchaSolveError(
1949
- "reCAPTCHA still showing the under-selection error after retry; aborting "
1950
- "(model unable to identify the missed tile)")
1951
- _log("reCAPTCHA under-selection error; retrying with missed-tiles prompt.")
1952
- pending_retry_mode, retried_underselect = RetryMode.MISSED_TILES, True
2226
+ _log("reCAPTCHA under-selection error; the next round asks for the missed tiles.")
2227
+ pending_retry_mode = RetryMode.MISSED_TILES
1953
2228
 
1954
- if not self.detect_captcha(page):
2229
+ if said != Verdict.REJECTED and not self.detect_captcha(page):
1955
2230
  return done()
1956
- if not did_interact and not self._no_progress_rounds:
1957
- # AN ANSWER WITH NOTHING TO EXECUTE IS NOT PROOF THE PAGE IS STUCK. The abort below
1958
- # exists for a driver that cannot act at all; an answer the driver could not use is a
1959
- # different thing, and on an animated board it is what a still expert returns when the
1960
- # board is not a still — measured on the hosted arms: a drag with no source box, "slide
1961
- # action, but the widget has neither a slider nor a draggable piece", solve over in 6s
1962
- # with the recording never taken. So buy the recording path one round first.
1963
- if self.config.video_solve_enabled and not self._retried_unusable_answer:
1964
- self._retried_unusable_answer = True
2231
+ if self._no_progress_rounds:
2232
+ stuck = CaptchaSolveError(f"no progress: the model returned the same answer "
2233
+ 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:
2237
+ raise stuck
2238
+ again(stuck)
2239
+ elif not did_interact:
2240
+ # An answer with nothing to execute is not proof the page is stuck; on an animated board it is
2241
+ # what a still expert returns when the board is not a still, so the next round takes a second look.
2242
+ if cfg.video_solve_enabled:
1965
2243
  self._arm_animated_probe()
1966
- _log("the answer had nothing this widget could execute; taking a second look "
1967
- "before giving up")
1968
- continue
1969
- raise CaptchaSolveError(
1970
- "captcha still detected but the solver performed no interactions; aborting to avoid an infinite loop")
2244
+ again(CaptchaSolveError("the solver performed no interactions this round"))
2245
+
2246
+ if isinstance(last_failure, UnsupportedChallengeError):
2247
+ raise UnsupportedChallengeError(f"{last_failure} (after {cfg.max_solve_loops} solve loops)")
2248
+ raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops"
2249
+ + (f"; the last failed round: {last_failure}" if last_failure else ""))
2250
+
2251
+
2252
+ def _hostname(page: Any) -> str:
2253
+ """The page's host alone: a path or query can carry a user's own data."""
2254
+ from urllib.parse import urlparse
1971
2255
 
1972
- raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops")
2256
+ try:
2257
+ return (urlparse(page.url).hostname or "").lower()
2258
+ except Exception:
2259
+ return ""
1973
2260
 
1974
2261
 
1975
2262
  def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]: