captchakraken 3.1.0 → 3.2.1

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 +9 -0
  15. package/dist/selectors.js +11 -3
  16. package/dist/solver.d.ts +76 -4
  17. package/dist/solver.js +509 -176
  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 +472 -167
  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 +19 -2
  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)
26
- from .selectors import (ACCEPTED_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
27
+ RecaptchaBanner, RetryMode, SettleVerdict, Vendor, Verdict)
28
+ from .selectors import (ACCEPTED_SELECTORS, ENCLAVE_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
@@ -292,6 +366,16 @@ def _frame_of(at: Any) -> Optional[Any]:
292
366
  return None
293
367
 
294
368
 
369
+ def _host_scopes(page: Any) -> List[Any]:
370
+ """Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave
371
+ 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)]
373
+
374
+
375
+ def _visible_in(scopes: Sequence[Any], selectors: Sequence[str]) -> List[Any]:
376
+ return [at for scope in scopes for at in _visible(scope, selectors)]
377
+
378
+
295
379
  def _input_value(handle: Any) -> str:
296
380
  try:
297
381
  return str(handle.input_value() or "")
@@ -354,6 +438,11 @@ class PageSolver:
354
438
  # Has this board EVER shown the same screen twice? A board that has not cannot prove it was replaced
355
439
  # and has no cycle left to find, so a re-ask neither re-films it at length nor throws its film away.
356
440
  self._film_cycled: bool = False
441
+ self._verdicts: Optional[VerdictLog] = None
442
+ # The page being solved: every capture is taken of its viewport. Set by `solve`.
443
+ self._page: Any = None
444
+ # When our last gesture ended; the board's reaction to it is never read as the board's own motion.
445
+ self._last_input_ms: float = -math.inf
357
446
  self._reset_animated_state()
358
447
 
359
448
  @property
@@ -372,6 +461,15 @@ class PageSolver:
372
461
  with self._budget.phase(name):
373
462
  yield
374
463
 
464
+ @contextmanager
465
+ def _gesture(self):
466
+ """A pointer gesture, timed as MOUSE and remembered: see `INPUT_SETTLE_MS`."""
467
+ try:
468
+ with self._phase(Phase.MOUSE):
469
+ yield
470
+ finally:
471
+ self._last_input_ms = _now()
472
+
375
473
  # ── per-solve state ──────────────────────────────────────────────────
376
474
 
377
475
  def _reset_animated_state(self) -> None:
@@ -380,7 +478,6 @@ class PageSolver:
380
478
  self._animated_probe_armed = False
381
479
  self._animated_probe_done = False
382
480
  self._video_budget_granted = False
383
- self._retried_unusable_answer = False
384
481
  self._stop_animated_film()
385
482
  self._keyframe_mode: Optional[KeyframeMode] = None
386
483
  self._keyframe_steady_screens = 0
@@ -406,8 +503,7 @@ class PageSolver:
406
503
  sig = self._answer_signature(actions, retry_mode)
407
504
  if sig is not None and sig == self._last_answer_sig:
408
505
  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})")
506
+ _log(f"[no-progress] the model returned the same answer again ({self._no_progress_rounds} in a row)")
411
507
  self._resample_level += 1
412
508
  self._apply_sampling()
413
509
  self._invalidate_animated_answer()
@@ -527,7 +623,7 @@ class PageSolver:
527
623
  # ── gestures ─────────────────────────────────────────────────────────
528
624
 
529
625
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
530
- with self._phase(Phase.MOUSE):
626
+ with self._gesture():
531
627
  self._human.move(page, (x, y))
532
628
 
533
629
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
@@ -552,7 +648,7 @@ class PageSolver:
552
648
 
553
649
  def _move_and_click(self, page: Any, element: Any) -> None:
554
650
  self._move_to_element(page, element)
555
- with self._phase(Phase.MOUSE):
651
+ with self._gesture():
556
652
  self._human.click(page, self._last_mouse)
557
653
 
558
654
  @staticmethod
@@ -582,11 +678,11 @@ class PageSolver:
582
678
  at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
583
679
  if await_keyframe:
584
680
  # Park on the target first, so only a mouse-down separates the right screen from the click.
585
- with self._phase(Phase.MOUSE):
681
+ with self._gesture():
586
682
  self._human.move(page, at)
587
683
  self._wait_for_keyframe(element, await_keyframe,
588
684
  (rel[0] / element_box["width"], rel[1] / element_box["height"]))
589
- with self._phase(Phase.MOUSE):
685
+ with self._gesture():
590
686
  self._human.click(page, at)
591
687
 
592
688
  def _click_when_frame_matches(self, page: Any, element: Any, action: Dict[str, Any],
@@ -600,7 +696,7 @@ class PageSolver:
600
696
  return (element_box["x"] + (float(bbox[0]) + float(bbox[2])) / 2 * element_box["width"],
601
697
  element_box["y"] + (float(bbox[1]) + float(bbox[3])) / 2 * element_box["height"])
602
698
 
603
- with self._phase(Phase.MOUSE):
699
+ with self._gesture():
604
700
  self._human.drag(page, center(action["source_bounding_box"]), center(action["target_bounding_box"]))
605
701
 
606
702
  def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
@@ -643,7 +739,9 @@ class PageSolver:
643
739
  _log("type action, but no text box in the widget; skipping")
644
740
  return False
645
741
  self._move_and_click(page, field_el)
646
- if not self._human.type_text(page, field_el, text):
742
+ typed = self._human.type_text(page, field_el, text)
743
+ self._last_input_ms = _now()
744
+ if not typed:
647
745
  return False
648
746
  _log(f"typed {len(text)} character(s) into the captcha field")
649
747
  return True
@@ -682,7 +780,7 @@ class PageSolver:
682
780
  _log("slide action, but the widget has neither a slider nor a draggable piece")
683
781
  return False
684
782
  target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
685
- with self._phase(Phase.MOUSE):
783
+ with self._gesture():
686
784
  self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
687
785
  (element_box["x"] + target_x, element_box["y"] + target_y))
688
786
  return True
@@ -746,6 +844,7 @@ class PageSolver:
746
844
  self._human.release(page)
747
845
  except Exception:
748
846
  pass
847
+ self._last_input_ms = _now()
749
848
  for shot in shots:
750
849
  _unlink(shot)
751
850
  return True
@@ -775,13 +874,15 @@ class PageSolver:
775
874
 
776
875
  # ── detection ────────────────────────────────────────────────────────
777
876
 
778
- def _has_value(self, page: Any, selector: str) -> bool:
877
+ def _has_value(self, scopes: Sequence[Any], selector: str) -> bool:
779
878
  """Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter."""
780
- try:
781
- fields = _handles(page.locator(selector).all())
782
- except Exception:
783
- return False
784
- return any(_input_value(f).strip() for f in fields)
879
+ def fields(scope: Any) -> List[Any]:
880
+ try:
881
+ return _handles(scope.locator(selector).all())
882
+ except Exception:
883
+ return []
884
+
885
+ return any(_input_value(f).strip() for scope in scopes for f in fields(scope))
785
886
 
786
887
  def _visible_with_text(self, scope: Any, selector: str) -> bool:
787
888
  """A visible match carrying text; an empty banner or prompt is a placeholder, not a signal."""
@@ -806,20 +907,21 @@ class PageSolver:
806
907
 
807
908
  def detect_captcha(self, page: Any) -> Optional[Widget]:
808
909
  """Open challenges first, then unsolved checkboxes, then the inline vendors."""
910
+ scopes = _host_scopes(page)
809
911
  for probe in WIDGET_PROBES:
810
- at = next(iter(_visible(page, (probe.selector,))), None)
811
- if at is None or self._already_accepted(page, at, probe):
912
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
913
+ if at is None or self._already_accepted(scopes, at, probe):
812
914
  continue
813
915
  element = _handle_of(at)
814
916
  return Widget(element, at, probe.vendor, probe.role) if element is not None else None
815
917
  return None
816
918
 
817
- def _already_accepted(self, page: Any, at: Any, probe: WidgetProbe) -> bool:
919
+ def _already_accepted(self, scopes: Sequence[Any], at: Any, probe: WidgetProbe) -> bool:
818
920
  """A checkbox the vendor has already accepted is not a captcha to solve."""
819
921
  s = SELECTORS[probe.vendor]
820
922
  if probe.role != FrameRole.CHECKBOX:
821
923
  return False
822
- return bool(s.response and self._has_value(page, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
924
+ return bool(s.response and self._has_value(scopes, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
823
925
 
824
926
  def vendors_on_the_wire(self, page: Any) -> List[Vendor]:
825
927
  """Which vendors' code the page loaded. Resource timing, because a request listener would have to have been attached before navigation."""
@@ -830,26 +932,69 @@ class PageSolver:
830
932
  blob = " ".join(str(n) for n in (names or []))
831
933
  return [vendor for vendor, s in VENDORS if any(h in blob for h in s.hosts)]
832
934
 
935
+ def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
936
+ """Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame
937
+ (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]))
940
+ > len(_visible_in(scopes, s.passive))]
941
+
833
942
  def _no_widget_message(self, page: Any) -> str:
834
- base = "no interactive captcha widget detected"
943
+ base = f"no interactive captcha widget detected within {self.config.detection_timeout_ms}ms"
835
944
  loaded = self.vendors_on_the_wire(page)
836
945
  if not loaded:
837
946
  return (f"{base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / "
838
947
  "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")
948
+ framed = self._unmatched_vendor_frames(page)
949
+ if framed:
950
+ return (f"{base}, BUT {'/'.join(framed)} is showing a frame that matches nothing in SELECTORS. The "
951
+ "vendor's markup has changed — the table needs re-measuring against the vendor's current "
952
+ "markup, in both solver ports")
953
+ return (f"{base}; {'/'.join(loaded)} code is loaded but drew no widget — the challenge may be invisible "
954
+ "or not triggered yet, or an in-page widget's markup has changed")
955
+
956
+ def _await_widget(self, page: Any) -> Tuple[Optional[Widget], float]:
957
+ """A page that has not drawn its widget yet is looked at again until `detection_timeout_ms`, and the time
958
+ spent is returned so the solve budget can start after it. Solving straight after `domcontentloaded` failed
959
+ 3/3 on a demo page whose frames arrived a moment later."""
960
+ started = _now()
961
+ widget = None
962
+ with self._phase(Phase.DETECT):
963
+ while (widget is None and _now() - started < self.config.detection_timeout_ms
964
+ and not self.is_captcha_solved(page)):
965
+ _delay(_DETECTION_POLL_MS)
966
+ widget = self.detect_captcha(page)
967
+ return widget, _now() - started
968
+
969
+ def _is_blocked(self, page: Any) -> bool:
970
+ """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
974
+ if probe.role != FrameRole.CHALLENGE or not blocked:
975
+ continue
976
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
977
+ frame = _frame_of(at) if at is not None else None
978
+ if frame and any(self._visible_with_text(frame, selector) for selector in blocked):
979
+ return True
980
+ return False
981
+
982
+ def _vendor_verdict(self) -> Optional[Verdict]:
983
+ """What the vendor's server said since the last look: BLOCKED outranks everything, else the latest answer."""
984
+ fresh = [v.verdict for v in (self._verdicts.fresh() if self._verdicts else ()) if v.verdict != Verdict.NEW_CHALLENGE]
985
+ return Verdict.BLOCKED if Verdict.BLOCKED in fresh else (fresh[-1] if fresh else None)
842
986
 
843
987
  def is_captcha_solved(self, page: Any) -> bool:
844
988
  """The vendor's own done signal: a response token, a painted success state, or a checked box."""
845
989
  try:
846
- if any(self._has_value(page, s) for s in RESPONSE_SELECTORS) or _visible(page, ACCEPTED_SELECTORS):
990
+ scopes = _host_scopes(page)
991
+ if any(self._has_value(scopes, s) for s in RESPONSE_SELECTORS) or _visible_in(scopes, ACCEPTED_SELECTORS):
847
992
  return True
848
993
  for probe in WIDGET_PROBES:
849
994
  checked = SELECTORS[probe.vendor].checked
850
995
  if probe.role != FrameRole.CHECKBOX or not checked:
851
996
  continue
852
- at = next(iter(_visible(page, (probe.selector,))), None)
997
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
853
998
  if at is not None and self._checked_in(at, checked):
854
999
  return True
855
1000
  except Exception:
@@ -859,11 +1004,12 @@ class PageSolver:
859
1004
  def _is_challenge_freshly_rendered(self, page: Any) -> bool:
860
1005
  """A next round has painted, as opposed to the answered frame animating closed."""
861
1006
  try:
1007
+ scopes = _host_scopes(page)
862
1008
  for probe in WIDGET_PROBES:
863
1009
  fresh = SELECTORS[probe.vendor].fresh
864
1010
  if probe.role != FrameRole.CHALLENGE or not fresh:
865
1011
  continue
866
- at = next(iter(_visible(page, (probe.selector,))), None)
1012
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
867
1013
  element = _handle_of(at) if at is not None else None
868
1014
  if element is None:
869
1015
  continue
@@ -883,11 +1029,12 @@ class PageSolver:
883
1029
  def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
884
1030
  """Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
885
1031
  try:
1032
+ scopes = _host_scopes(page)
886
1033
  for probe in WIDGET_PROBES:
887
1034
  banners = SELECTORS[probe.vendor].banners
888
1035
  if probe.role != FrameRole.CHALLENGE or not banners:
889
1036
  continue
890
- at = next(iter(_visible(page, (probe.selector,))), None)
1037
+ at = next(iter(_visible_in(scopes, (probe.selector,))), None)
891
1038
  frame = _frame_of(at) if at is not None else None
892
1039
  if not frame:
893
1040
  continue
@@ -898,6 +1045,69 @@ class PageSolver:
898
1045
  pass
899
1046
  return None
900
1047
 
1048
+ def _click_checkbox(self, page: Any, widget: Widget, frame: Any) -> None:
1049
+ """Tick the box and wait for what it opens. A checkbox is not a board: it is never polled, filmed or asked about.
1050
+
1051
+ Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering
1052
+ the checkbox picture with a drag.
1053
+ """
1054
+ box = self._checkbox_box(frame, widget.vendor)
1055
+ if box is not None:
1056
+ self._move_to_element(page, box)
1057
+ else:
1058
+ self._smooth_move(page, *self._find_checkbox_on_screen(widget.element))
1059
+ self._human.pause(PauseKind.BETWEEN)
1060
+ with self._gesture():
1061
+ self._human.click(page, self._last_mouse)
1062
+ self._await_what_the_checkbox_opened(page)
1063
+
1064
+ def _checkbox_box(self, frame: Any, vendor: Vendor) -> Optional[Any]:
1065
+ """The box in a checkbox frame once the frame has drawn it; None when the DOM cannot reach it.
1066
+
1067
+ Turnstile's box is in a closed shadow root, and a frame can refuse access; both are found on screen instead.
1068
+ """
1069
+ box = SELECTORS[vendor].box
1070
+ if frame is None or not box:
1071
+ return None
1072
+ try:
1073
+ return frame.wait_for_selector(", ".join(box), state="visible", timeout=self.config.detection_timeout_ms)
1074
+ except Exception as exc:
1075
+ _log(f"[checkbox] the box is out of the DOM's reach ({str(exc).splitlines()[0]}); finding it on screen")
1076
+ return None
1077
+
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."""
1080
+ from .tool_calls.find_checkbox import find_checkbox
1081
+
1082
+ box = element.bounding_box()
1083
+ if not box:
1084
+ raise CaptchaSolveError("could not get bounding box of captcha element")
1085
+ shot = _tmp_png("checkbox")
1086
+ try:
1087
+ with self._phase(Phase.SCREENSHOT):
1088
+ self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
1089
+ found = find_checkbox(shot)
1090
+ scale = self._shot_scale(shot, box["width"])
1091
+ finally:
1092
+ _unlink(shot)
1093
+ if found is None:
1094
+ raise UnsupportedCaptchaError("the checkbox widget shows no tick box to click")
1095
+ x, y, w, h = (v / scale for v in found)
1096
+ return box["x"] + x + w * (0.3 + random.random() * 0.4), box["y"] + y + h * (0.3 + random.random() * 0.4)
1097
+
1098
+ def _await_what_the_checkbox_opened(self, page: Any) -> None:
1099
+ """A clicked checkbox passes or opens a challenge, and both show in the DOM.
1100
+
1101
+ Without this wait the next round found the same checkbox still up and clicked it again.
1102
+ """
1103
+ deadline = _now() + self.config.post_submit_change_timeout_ms
1104
+ with self._phase(Phase.AWAIT_NEXT_ROUND):
1105
+ while _now() < deadline and not self.is_captcha_solved(page):
1106
+ widget = self.detect_captcha(page)
1107
+ if widget is None or widget.role != FrameRole.CHECKBOX:
1108
+ return
1109
+ _delay(self.config.post_solve_outcome_poll_ms)
1110
+
901
1111
  def _get_verify_button(self, scope: Any) -> Optional[Any]:
902
1112
  return self._find_control(scope, SUBMIT_SELECTORS)
903
1113
 
@@ -905,9 +1115,61 @@ class PageSolver:
905
1115
 
906
1116
  def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
907
1117
  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)
1118
+ """Every capture the driver takes: the whole viewport, cropped here to the element.
1119
+
1120
+ Never a clipped capture. An element screenshot, or any `clip`, makes a headed Chromium repaint the page at
1121
+ the clip's size for that frame, which the user watches as the page flashing and jumping; a viewport
1122
+ capture repaints nothing, and measured twice as fast. Short timeout; `disabled` freezes CSS animation,
1123
+ so bursts and the keyframe gate pass `allow`.
1124
+ """
1125
+ timeout = 2_500 if timeout_ms is None else timeout_ms
1126
+ page = self._page
1127
+ view = _viewport_of(page)
1128
+ rect = self._rect_in_view(element, view) if _layout_is_the_viewport(page, view) else None
1129
+ if rect is None:
1130
+ # Bigger than the viewport, or a zoomed-out mobile layout: no viewport capture holds it in its own
1131
+ # coordinates, so the element photographs itself.
1132
+ element.screenshot(path=path, timeout=timeout, animations=animations)
1133
+ return
1134
+ png = crop_png(page.screenshot(timeout=timeout, animations=animations), rect, view["width"])
1135
+ with open(path, "wb") as fh:
1136
+ fh.write(png)
1137
+
1138
+ @staticmethod
1139
+ def _rect_of(element: Any) -> CaptureRect:
1140
+ box = element.bounding_box()
1141
+ rect = enclosing_rect(box) if box else None
1142
+ if rect is None or rect.width <= 0 or rect.height <= 0:
1143
+ raise CaptchaSolveError("the element is not visible: it has no bounding box to photograph")
1144
+ return rect
1145
+
1146
+ def _rect_in_view(self, element: Any, view: Dict[str, float]) -> Optional[CaptureRect]:
1147
+ """Where the element sits in the viewport, scrolled in when it is merely off screen; None when it cannot fit."""
1148
+ rect = self._rect_of(element)
1149
+ if rect.width > view["width"] or rect.height > view["height"]:
1150
+ return None
1151
+ if not _within(rect, view):
1152
+ element.scroll_into_view_if_needed(timeout=2_000)
1153
+ rect = self._rect_of(element)
1154
+ return rect if _within(rect, view) else None
1155
+
1156
+ def _step_off_the_board(self, page: Any, element: Any) -> None:
1157
+ """Take the pointer off the board and let our own feedback fade before the board is judged still or animated.
1158
+
1159
+ A hovered or pressed control repaints, and a board wearing our highlight read as motion: a still board
1160
+ was filmed as animated because of the Verify button under the cursor.
1161
+ """
1162
+ # Only a board we pointed at wears our feedback. One the vendor dealt under a resting pointer has
1163
+ # nothing of ours to fade, and stepping off it cost every round its own gesture and settle window.
1164
+ if not self._acted_on_board:
1165
+ return
1166
+ box = element.bounding_box()
1167
+ x, y = self._last_mouse
1168
+ if box and self._human.hovers and box["x"] <= x <= box["x"] + box["width"] and box["y"] <= y <= box["y"] + box["height"]:
1169
+ spot = _off_board_point(box, (x, y), _viewport_of(page))
1170
+ if spot is not None:
1171
+ self._smooth_move(page, *spot)
1172
+ _delay(self._last_input_ms + INPUT_SETTLE_MS - _now())
911
1173
 
912
1174
  def _element_frame_hash(self, element: Any) -> Optional[str]:
913
1175
  path = _tmp_png("fh")
@@ -1145,12 +1407,14 @@ class PageSolver:
1145
1407
  raise CaptchaSolveError(
1146
1408
  f"the animated recording stalled: {len(frames)} frames in "
1147
1409
  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
1410
+ img = None
1411
+ # A frame taken while the board still wears our last gesture is not a screen it showed by itself.
1412
+ if _now() - self._last_input_ms >= INPUT_SETTLE_MS:
1413
+ try:
1414
+ self._screenshot(element, shot, animations="allow")
1415
+ img = cv2.imread(shot)
1416
+ except Exception as exc:
1417
+ _debug(f"burst frame failed: {exc}")
1154
1418
  if img is not None:
1155
1419
  frames.append(img)
1156
1420
  d = _sha1(shot)
@@ -1254,13 +1518,9 @@ class PageSolver:
1254
1518
  element, known=self._film_digests,
1255
1519
  max_ms=float(cfg.video_burst_duration_ms) if reask and not self._film_cycled else None)
1256
1520
  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)")
1521
+ # Not a verdict about the board — a still photographs fine. A widget that would not screenshot for a
1522
+ # whole window is usually closing because the answer was accepted, so the loop asks before it counts
1523
+ # the round. A board proven animated keeps that verdict, and is filmed again rather than read as a still.
1264
1524
  raise AnimatedChallengeError("could not record the animated challenge (no frame screenshotted)")
1265
1525
  _log(f"[animated] recorded {len(frames)} frames in {burst_ms / 1000:.1f}s "
1266
1526
  f"({measured_fps(len(frames), burst_ms, cfg.video_burst_fps):.1f}fps)")
@@ -1587,9 +1847,20 @@ class PageSolver:
1587
1847
 
1588
1848
  def _solve_single(self, page: Any, widget: Widget, retry_mode: Optional[RetryMode]) -> Tuple[bool, List[Dict[str, Any]]]:
1589
1849
  element, puzzle_source, role = widget.element, widget.vendor, widget.role
1850
+ os.environ[_VENDOR_ENV] = puzzle_source.value
1590
1851
  frame = element.content_frame()
1852
+ # The widget's own host comes from its iframe; an inline widget has none to report.
1853
+ widget_host = _hostname(frame) if frame else ""
1854
+ if widget_host:
1855
+ os.environ[_WIDGET_HOST_ENV] = widget_host
1856
+ else:
1857
+ os.environ.pop(_WIDGET_HOST_ENV, None)
1591
1858
  scope = frame or widget.at
1592
1859
 
1860
+ if role == FrameRole.CHECKBOX:
1861
+ self._click_checkbox(page, widget, frame)
1862
+ return True, []
1863
+
1593
1864
  if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
1594
1865
  if self._last_submit_frame_hash:
1595
1866
  with self._phase(Phase.AWAIT_NEXT_ROUND):
@@ -1614,8 +1885,10 @@ class PageSolver:
1614
1885
  if text_mode:
1615
1886
  _log("widget has a text box; solving as a distorted-text captcha")
1616
1887
 
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
1888
+ # A reCAPTCHA board is read by its grid below, not filmed.
1889
+ filmable = puzzle_source != Vendor.RECAPTCHA and not text_mode
1890
+ if filmable and not self._known_animated:
1891
+ self._step_off_the_board(page, element)
1619
1892
  is_animated = filmable and self._settle_or_animated(element)
1620
1893
  # hCaptcha keeps its challenge iframe visible ~2s after the final submit; read as a fresh puzzle it burned ~18s.
1621
1894
  if role == FrameRole.CHALLENGE and self.is_captcha_solved(page):
@@ -1770,12 +2043,21 @@ class PageSolver:
1770
2043
  usage: List[Dict[str, Any]] = []
1771
2044
  self._last_submit_frame_hash = None
1772
2045
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
2046
+ self._page = page
1773
2047
  self._human.reset(page)
1774
2048
  self._reset_animated_state()
1775
2049
  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)
2050
+ self._verdicts = None
2051
+ # One session id per solve groups its inference rounds into one billable attempt; vendor and site say
2052
+ # where it was, and are set as they become known.
2053
+ previous = {name: os.environ.get(name) for name in (_SESSION_ENV, _VENDOR_ENV, _SITE_ENV, _WIDGET_HOST_ENV)}
1778
2054
  session_id = os.environ[_SESSION_ENV] = str(uuid.uuid4())
2055
+ os.environ.pop(_VENDOR_ENV, None)
2056
+ os.environ.pop(_SITE_ENV, None)
2057
+ os.environ.pop(_WIDGET_HOST_ENV, None)
2058
+ site = _hostname(page)
2059
+ if site:
2060
+ os.environ[_SITE_ENV] = site
1779
2061
  solved = False
1780
2062
  try:
1781
2063
  result = self._solve_impl(page, start, usage)
@@ -1786,27 +2068,47 @@ class PageSolver:
1786
2068
  if timings_enabled():
1787
2069
  print(self._budget.report(), file=sys.stderr)
1788
2070
  self._deadline_ms = None
2071
+ if self._verdicts is not None:
2072
+ self._verdicts.close()
2073
+ # The vendor's own verdict when it was readable; the DOM's done-signal only when it was not.
2074
+ said = self._verdicts.decisive() if self._verdicts is not None else None
1789
2075
  try:
1790
2076
  # 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)
2077
+ self._solver.planner.report_outcome(session_id, solved if said is None else said == Verdict.ACCEPTED)
1792
2078
  except Exception as exc:
1793
2079
  _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
2080
+ for name, value in previous.items():
2081
+ if value is None:
2082
+ os.environ.pop(name, None)
2083
+ else:
2084
+ os.environ[name] = value
1798
2085
 
1799
2086
  def _solve_impl(self, page: Any, start: float, usage: List[Dict[str, Any]]) -> SolveResult:
2087
+ """Give up only when the vendor refuses to serve us, or when every one of `max_solve_loops` has been spent.
2088
+
2089
+ Any other failed round — a stale handle, an unusable or repeated answer, a board that would not film —
2090
+ counts one loop, pauses, and goes again.
2091
+ """
1800
2092
  cfg = self.config
1801
2093
  pending_retry_mode: Optional[RetryMode] = None
1802
- retried_underselect = False
1803
- unsupported_retries = stale_retries = render_waits = 0
1804
2094
  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)
2095
+ last_failure: Optional[CaptchaSolveError] = None
2096
+ verdicts = self._verdicts = VerdictLog(page)
1807
2097
 
1808
2098
  def done() -> SolveResult:
1809
- return SolveResult(True, self._last_mouse, _aggregate(usage))
2099
+ return SolveResult(True, self._last_mouse, _aggregate(usage), verdicts=list(verdicts.verdicts))
2100
+
2101
+ def blocked(how: str) -> VendorBlockedError:
2102
+ return VendorBlockedError(f"the vendor refused to serve this client ({how}); no further round can succeed")
2103
+
2104
+ def again(failure: CaptchaSolveError, *, in_transition: bool = False) -> None:
2105
+ """Count the loop. Only a widget caught mid-transition is worth waiting out; a refused or repeated
2106
+ answer changed nothing on the page, and pausing after it only spends the solve's budget."""
2107
+ nonlocal last_failure
2108
+ last_failure = failure
2109
+ _log(f"{failure}; counting this loop and going again")
2110
+ if in_transition:
2111
+ _delay(cfg.stale_element_backoff_ms)
1810
2112
 
1811
2113
  for attempt in range(1, cfg.max_solve_loops + 1):
1812
2114
  # A round that failed arms the second look only while its board is still up. A board the vendor has
@@ -1823,6 +2125,12 @@ class PageSolver:
1823
2125
  raise CaptchaSolveError(
1824
2126
  f"captcha solve timed out after {deadline - start:.0f}ms (attempt {attempt}/{cfg.max_solve_loops})"
1825
2127
  + (f", including {granted:.0f}ms granted for recording an animated challenge" if granted > 0 else ""))
2128
+ said = self._vendor_verdict()
2129
+ if said == Verdict.ACCEPTED:
2130
+ _log("[verdict] the vendor accepted the answer; finishing.")
2131
+ return done()
2132
+ if said == Verdict.BLOCKED:
2133
+ raise blocked("its server answered 429")
1826
2134
  if has_interacted and self.is_captcha_solved(page):
1827
2135
  _log("captcha reports solved; finishing.")
1828
2136
  return done()
@@ -1830,105 +2138,88 @@ class PageSolver:
1830
2138
  with self._phase(Phase.DETECT):
1831
2139
  widget = self.detect_captcha(page)
1832
2140
  if not widget:
1833
- if has_interacted:
2141
+ # A widget that vanished right after the vendor refused the answer was not solved.
2142
+ if has_interacted and verdicts.decisive() != Verdict.REJECTED:
1834
2143
  _log("no supported captcha remains after interaction; considering solved.")
1835
2144
  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))
2145
+ widget, waited = self._await_widget(page)
2146
+ # The budget is for solving; the page drawing its widget is not charged to it.
2147
+ start += waited
2148
+ if self._deadline_ms is not None:
2149
+ self._deadline_ms += waited
2150
+ if widget is None:
2151
+ if self.is_captcha_solved(page):
2152
+ _log("captcha already satisfied; nothing to solve.")
2153
+ return done()
2154
+ if has_interacted:
2155
+ raise CaptchaSolveError(f"the vendor rejected the last answer and its widget did not come "
2156
+ f"back within {cfg.detection_timeout_ms}ms")
2157
+ raise NoCaptchaFoundError(self._no_widget_message(page))
1845
2158
 
1846
2159
  _log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
1847
2160
  retry_mode, pending_retry_mode = pending_retry_mode, None
1848
2161
  try:
1849
2162
  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
2163
  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
2164
+ # The model had nothing usable for this board. The next round re-asks, often of a board the
2165
+ # vendor has replaced.
2166
+ again(UnsupportedChallengeError(f"cannot solve this kind of captcha — {unsupported}"))
2167
+ continue
2168
+ except AnimatedChallengeError as animated:
2169
+ if not cfg.video_solve_enabled:
2170
+ raise
2171
+ # A widget that will not screenshot is usually one that is closing, because the answer was
2172
+ # accepted: measured, a vendor that deals several boards lost four attempts to exactly this, each
2173
+ # after its first board came back graded solved.
2174
+ if has_interacted and self.is_captcha_solved(page):
2175
+ _log("nothing left to film because the board was accepted; finishing.")
2176
+ return done()
2177
+ again(animated, in_transition=True)
2178
+ continue
1888
2179
  except Exception as exc:
1889
2180
  message = str(exc)
1890
2181
  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
2182
+ if not (closed or _STALE_HANDLE_RE.search(message)):
2183
+ raise
2184
+ # The handle most often went stale because the answer was accepted.
2185
+ try:
2186
+ if has_interacted and self.is_captcha_solved(page):
2187
+ _log("captcha reports solved; finishing.")
2188
+ return done()
2189
+ except Exception:
2190
+ pass
2191
+ if closed:
2192
+ raise PageClosedError(
2193
+ "the page, context or browser closed mid-solve"
2194
+ + (", after the answer had been submitted but before the vendor's verdict could be read — "
2195
+ "the solve may in fact have succeeded" if has_interacted else "")) from exc
2196
+ again(CaptchaSolveError(f"the challenge handle went stale ({message.splitlines()[0]})"), in_transition=True)
2197
+ continue
1910
2198
 
1911
2199
  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
2200
  usage.extend(round_usage)
1918
2201
 
1919
2202
  # 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)
2203
+ # sleep it replaced observed nothing and cost 1200-1500ms per finished round. A round that performed
2204
+ # nothing gave the page nothing to react to, so it looks once and goes on: waiting there cost every
2205
+ # refused or repeated answer the full window.
2206
+ window_ms = cfg.post_solve_outcome_timeout_ms if did_interact else 0
1923
2207
  deadline = _now() + window_ms
1924
2208
  t0 = time.perf_counter()
1925
2209
  solved = False
2210
+ said = None
1926
2211
  widget_gone = 0
1927
- while _now() < deadline:
2212
+ looked = False
2213
+ while not looked or _now() < deadline:
2214
+ looked = True
2215
+ said = self._vendor_verdict() or said
2216
+ if said in (Verdict.ACCEPTED, Verdict.BLOCKED):
2217
+ solved = said == Verdict.ACCEPTED
2218
+ break
1928
2219
  if self.is_captcha_solved(page):
1929
2220
  solved = True
1930
2221
  break
1931
- widget_gone = widget_gone + 1 if self.detect_captcha(page) is None else 0
2222
+ widget_gone = widget_gone + 1 if said != Verdict.REJECTED and self.detect_captcha(page) is None else 0
1932
2223
  if widget_gone >= 2:
1933
2224
  solved = True
1934
2225
  break
@@ -1942,34 +2233,48 @@ class PageSolver:
1942
2233
  if solved:
1943
2234
  _log(f"[verdict] success signal arrived after {verdict_ms:.0f}ms")
1944
2235
  return done()
2236
+ if said == Verdict.BLOCKED:
2237
+ raise blocked("its server answered 429")
2238
+ if self._is_blocked(page):
2239
+ raise blocked("it is showing its try-again-later screen")
2240
+ if said == Verdict.REJECTED:
2241
+ _log(f"[verdict] the vendor rejected the answer after {verdict_ms:.0f}ms")
1945
2242
 
1946
2243
  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
2244
+ _log("reCAPTCHA under-selection error; the next round asks for the missed tiles.")
2245
+ pending_retry_mode = RetryMode.MISSED_TILES
1953
2246
 
1954
- if not self.detect_captcha(page):
2247
+ if said != Verdict.REJECTED and not self.detect_captcha(page):
1955
2248
  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
2249
+ if self._no_progress_rounds:
2250
+ stuck = CaptchaSolveError(f"no progress: the model returned the same answer "
2251
+ 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:
2255
+ raise stuck
2256
+ again(stuck)
2257
+ elif not did_interact:
2258
+ # An answer with nothing to execute is not proof the page is stuck; on an animated board it is
2259
+ # what a still expert returns when the board is not a still, so the next round takes a second look.
2260
+ if cfg.video_solve_enabled:
1965
2261
  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")
2262
+ again(CaptchaSolveError("the solver performed no interactions this round"))
2263
+
2264
+ if isinstance(last_failure, UnsupportedChallengeError):
2265
+ raise UnsupportedChallengeError(f"{last_failure} (after {cfg.max_solve_loops} solve loops)")
2266
+ raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops"
2267
+ + (f"; the last failed round: {last_failure}" if last_failure else ""))
2268
+
2269
+
2270
+ def _hostname(page: Any) -> str:
2271
+ """The page's host alone: a path or query can carry a user's own data."""
2272
+ from urllib.parse import urlparse
1971
2273
 
1972
- raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops")
2274
+ try:
2275
+ return (urlparse(page.url).hostname or "").lower()
2276
+ except Exception:
2277
+ return ""
1973
2278
 
1974
2279
 
1975
2280
  def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]: