captchakraken 3.0.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 +118 -5
  17. package/dist/solver.js +840 -143
  18. package/dist/types.d.ts +12 -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 +651 -137
  30. package/python/src/captchakraken/planner.py +68 -12
  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
@@ -16,21 +18,27 @@ import uuid
16
18
  from concurrent.futures import ThreadPoolExecutor
17
19
  from contextlib import contextmanager
18
20
  from dataclasses import dataclass, field
19
- from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple
21
+ from typing import AbstractSet, Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple
20
22
 
21
23
  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
 
@@ -90,16 +157,34 @@ class PageClosedError(CaptchaSolveError):
90
157
  pass
91
158
 
92
159
 
93
- _STALE_HANDLE_RE = re.compile(r"Timeout .*exceeded|not visible|not attached|detached", re.IGNORECASE)
160
+ class VendorBlockedError(CaptchaSolveError):
161
+ """The vendor refused to serve this client at all, so no further round can succeed."""
162
+
163
+
164
+ #: `bounding box of captcha element` is in here because the JS port has always had it
165
+ #: (`isStaleHandleError`): a widget with no box has moved on under us — hCaptcha swapped rounds, GeeTest
166
+ #: closed on accept — and re-detecting is right where failing the attempt is not. Without it the Python
167
+ #: port turned an accepted board into a failed solve.
168
+ _STALE_HANDLE_RE = re.compile(
169
+ r"Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element",
170
+ re.IGNORECASE)
94
171
  # A closed target is not a stale handle: retried as one, a dead page was re-detected three times and the
95
172
  # error that finally escaped named whichever call ran last.
96
173
  _CLOSED_TARGET_RE = re.compile(
97
174
  r"Target (?:page, context or browser )?(?:has been )?closed|Session closed", re.IGNORECASE)
98
175
 
99
176
 
100
- def answer_needs_element_box(actions: List[Dict[str, Any]]) -> bool:
101
- """An allow-list of what needs nothing: the other direction raises instead of clicking at the origin."""
102
- return any((a or {}).get("action") != ActionKind.DONE for a in actions)
177
+ def answer_needs_element_box(actions: Sequence[Any]) -> bool:
178
+ """An allow-list of what needs nothing: the other direction raises instead of clicking at the origin.
179
+
180
+ Takes actions in EITHER shape. The planner returns typed actions (`ClickAction`, `TypeAction`,
181
+ `DoneAction`) and every other reader here goes through `_as_dict` first; this one called `.get` on
182
+ them. It only runs when the element has no bounding box, which is what a widget that is CLOSING
183
+ looks like — so a solve the vendor had just accepted ended as
184
+ `AttributeError: 'ClickAction' object has no attribute 'get'` instead of as a success. Tier 3 saw
185
+ it as a lost solve on five types.
186
+ """
187
+ return any(_as_dict(a).get("action") != ActionKind.DONE for a in actions if a is not None)
103
188
 
104
189
 
105
190
  @dataclass
@@ -108,6 +193,8 @@ class SolveResult:
108
193
  final_mouse_position: Tuple[float, float]
109
194
  token_usage: List[Dict[str, Any]] = field(default_factory=list)
110
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)
111
198
 
112
199
 
113
200
  @dataclass
@@ -160,6 +247,9 @@ def measured_fps(frames: int, elapsed_ms: float, nominal_fps: float) -> float:
160
247
  _MOVED_DURING_INFERENCE_DIFF = 0.002
161
248
  _NOT_THIS_BOARD_DIFF = 0.5
162
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
163
253
 
164
254
 
165
255
  @dataclass
@@ -172,13 +262,20 @@ class PageSolverConfig:
172
262
  expert: Optional[PromptFamily] = None
173
263
  max_solve_loops: int = 6
174
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.
175
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.
176
271
  post_solve_delay_ms: int = 1_200
177
272
  post_solve_outcome_timeout_ms: int = 1_000
178
273
  post_solve_outcome_poll_ms: int = 75
179
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.
180
276
  max_unsupported_resolves: int = 3
181
277
  max_stale_element_retries: int = 3
278
+ # The pause before a failed round is tried again.
182
279
  stale_element_backoff_ms: int = 900
183
280
  stale_frame_resolve_enabled: bool = True
184
281
  stale_frame_diff_threshold: float = 0.02
@@ -194,6 +291,9 @@ class PageSolverConfig:
194
291
  animated_probe_enabled: bool = True
195
292
  video_burst_duration_ms: int = 4_000
196
293
  video_burst_max_ms: int = 12_000
294
+ # Safety ceiling on the film an animated board accumulates across rounds. It is meant to end with the
295
+ # solve, not on this clock; frames are decoded images, so this bounds memory rather than the recording.
296
+ video_film_max_ms: int = 120_000
197
297
  speculative_burst_enabled: bool = True
198
298
  video_burst_fps: int = 10
199
299
  # GeeTest svg dwells up to 2.7s a screen, so a 3-screen cycle is 8.1s; 6s gave up one screen short.
@@ -320,6 +420,19 @@ class PageSolver:
320
420
  self._deadline_ms: Optional[float] = None
321
421
  self._budget: Optional[PhaseBudget] = None
322
422
  self._animated_plan: Optional[Tuple[List[str], str, Any, Any]] = None
423
+ self._film_frames: List[Any] = []
424
+ self._film_ms: float = 0.0
425
+ # The digests of every screen the film holds: what a later burst is checked against to answer
426
+ # "is this still the same board?" — see `_record_keyframes`.
427
+ self._film_digests: Set[str] = set()
428
+ # Has this board EVER shown the same screen twice? A board that has not cannot prove it was replaced
429
+ # and has no cycle left to find, so a re-ask neither re-films it at length nor throws its film away.
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
323
436
  self._reset_animated_state()
324
437
 
325
438
  @property
@@ -338,6 +451,15 @@ class PageSolver:
338
451
  with self._budget.phase(name):
339
452
  yield
340
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
+
341
463
  # ── per-solve state ──────────────────────────────────────────────────
342
464
 
343
465
  def _reset_animated_state(self) -> None:
@@ -346,7 +468,7 @@ class PageSolver:
346
468
  self._animated_probe_armed = False
347
469
  self._animated_probe_done = False
348
470
  self._video_budget_granted = False
349
- self._discard_animated_plan()
471
+ self._stop_animated_film()
350
472
  self._keyframe_mode: Optional[KeyframeMode] = None
351
473
  self._keyframe_steady_screens = 0
352
474
  self._last_answer_sig: Optional[str] = None
@@ -371,8 +493,7 @@ class PageSolver:
371
493
  sig = self._answer_signature(actions, retry_mode)
372
494
  if sig is not None and sig == self._last_answer_sig:
373
495
  self._no_progress_rounds += 1
374
- _log(f"[no-progress] the model returned the same answer again "
375
- 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)")
376
497
  self._resample_level += 1
377
498
  self._apply_sampling()
378
499
  self._invalidate_animated_answer()
@@ -382,6 +503,20 @@ class PageSolver:
382
503
  self._last_answer_sig = sig
383
504
  return False
384
505
 
506
+ def _bound_ask_to_the_budget(self) -> None:
507
+ """Give the planner what is LEFT of the solve, so one ask cannot outlive the whole thing.
508
+
509
+ A request in flight cannot be cancelled — `_check_deadline` is only read between steps — so the
510
+ only thing that bounds it is the timeout it was sent with. Measured on the hosted endpoint: one
511
+ ask hung and gave up after ~124s inside a 45s budget, and the attempt ran 143s.
512
+ """
513
+ target = getattr(self._solver, "planner", None)
514
+ if target is None or self._deadline_ms is None:
515
+ return
516
+ left_s = (self._deadline_ms - _now()) / 1000.0
517
+ target.request_timeout_s = max(planner.MIN_REQUEST_TIMEOUT_S,
518
+ min(planner.DEFAULT_REQUEST_TIMEOUT_S, left_s))
519
+
385
520
  def _apply_sampling(self) -> None:
386
521
  target = getattr(self._solver, "planner", None)
387
522
  if target is None:
@@ -391,6 +526,15 @@ class PageSolver:
391
526
  _log(f"[resample] temperature {sampling['temperature']}, seed {sampling['seed']}")
392
527
 
393
528
  def _fresh_board(self) -> None:
529
+ # ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer describes the
530
+ # board it was cut from, so replaying it here presses that board's coordinates onto this one: live, a
531
+ # keyframe answer from an earlier board landed on hCaptcha's reference photo and then pressed Skip,
532
+ # six rounds running.
533
+ self._stop_animated_film()
534
+ self._known_animated = False
535
+ # Its own second look, but only once IT fails: an arm set on the previous board is evidence about that one.
536
+ self._animated_probe_armed = False
537
+ self._animated_probe_done = False
394
538
  self._resample_level = 0
395
539
  self._acted_on_board = False
396
540
  self._apply_sampling()
@@ -469,7 +613,7 @@ class PageSolver:
469
613
  # ── gestures ─────────────────────────────────────────────────────────
470
614
 
471
615
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
472
- with self._phase(Phase.MOUSE):
616
+ with self._gesture():
473
617
  self._human.move(page, (x, y))
474
618
 
475
619
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
@@ -494,7 +638,7 @@ class PageSolver:
494
638
 
495
639
  def _move_and_click(self, page: Any, element: Any) -> None:
496
640
  self._move_to_element(page, element)
497
- with self._phase(Phase.MOUSE):
641
+ with self._gesture():
498
642
  self._human.click(page, self._last_mouse)
499
643
 
500
644
  @staticmethod
@@ -524,11 +668,11 @@ class PageSolver:
524
668
  at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
525
669
  if await_keyframe:
526
670
  # Park on the target first, so only a mouse-down separates the right screen from the click.
527
- with self._phase(Phase.MOUSE):
671
+ with self._gesture():
528
672
  self._human.move(page, at)
529
673
  self._wait_for_keyframe(element, await_keyframe,
530
674
  (rel[0] / element_box["width"], rel[1] / element_box["height"]))
531
- with self._phase(Phase.MOUSE):
675
+ with self._gesture():
532
676
  self._human.click(page, at)
533
677
 
534
678
  def _click_when_frame_matches(self, page: Any, element: Any, action: Dict[str, Any],
@@ -542,7 +686,7 @@ class PageSolver:
542
686
  return (element_box["x"] + (float(bbox[0]) + float(bbox[2])) / 2 * element_box["width"],
543
687
  element_box["y"] + (float(bbox[1]) + float(bbox[3])) / 2 * element_box["height"])
544
688
 
545
- with self._phase(Phase.MOUSE):
689
+ with self._gesture():
546
690
  self._human.drag(page, center(action["source_bounding_box"]), center(action["target_bounding_box"]))
547
691
 
548
692
  def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
@@ -585,7 +729,9 @@ class PageSolver:
585
729
  _log("type action, but no text box in the widget; skipping")
586
730
  return False
587
731
  self._move_and_click(page, field_el)
588
- 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:
589
735
  return False
590
736
  _log(f"typed {len(text)} character(s) into the captcha field")
591
737
  return True
@@ -624,7 +770,7 @@ class PageSolver:
624
770
  _log("slide action, but the widget has neither a slider nor a draggable piece")
625
771
  return False
626
772
  target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
627
- with self._phase(Phase.MOUSE):
773
+ with self._gesture():
628
774
  self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
629
775
  (element_box["x"] + target_x, element_box["y"] + target_y))
630
776
  return True
@@ -688,6 +834,7 @@ class PageSolver:
688
834
  self._human.release(page)
689
835
  except Exception:
690
836
  pass
837
+ self._last_input_ms = _now()
691
838
  for shot in shots:
692
839
  _unlink(shot)
693
840
  return True
@@ -772,15 +919,55 @@ class PageSolver:
772
919
  blob = " ".join(str(n) for n in (names or []))
773
920
  return [vendor for vendor, s in VENDORS if any(h in blob for h in s.hosts)]
774
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
+
775
928
  def _no_widget_message(self, page: Any) -> str:
776
- base = "no interactive captcha widget detected"
929
+ base = f"no interactive captcha widget detected within {self.config.detection_timeout_ms}ms"
777
930
  loaded = self.vendors_on_the_wire(page)
778
931
  if not loaded:
779
932
  return (f"{base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / "
780
933
  "invisible, or a click-triggered challenge that has not been triggered)")
781
- return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on this page. The "
782
- "vendor's markup no longer matches anything in SELECTORS — the table needs "
783
- "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)
784
971
 
785
972
  def is_captcha_solved(self, page: Any) -> bool:
786
973
  """The vendor's own done signal: a response token, a painted success state, or a checked box."""
@@ -840,6 +1027,69 @@ class PageSolver:
840
1027
  pass
841
1028
  return None
842
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
+
843
1093
  def _get_verify_button(self, scope: Any) -> Optional[Any]:
844
1094
  return self._find_control(scope, SUBMIT_SELECTORS)
845
1095
 
@@ -847,9 +1097,61 @@ class PageSolver:
847
1097
 
848
1098
  def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
849
1099
  animations: str = "disabled") -> None:
850
- """Short timeout; `disabled` freezes CSS animation, so bursts and the keyframe gate pass `allow`."""
851
- element.screenshot(path=path, timeout=2_500 if timeout_ms is None else timeout_ms,
852
- 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())
853
1155
 
854
1156
  def _element_frame_hash(self, element: Any) -> Optional[str]:
855
1157
  path = _tmp_png("fh")
@@ -1026,16 +1328,19 @@ class PageSolver:
1026
1328
  return True
1027
1329
  with self._phase(Phase.SETTLE):
1028
1330
  verdict = self._wait_for_element_settled(element)
1029
- if verdict != SettleVerdict.ANIMATED:
1030
- # 'settled' is not proof of static; a repeated answer arms one recording to find out.
1331
+ animated = verdict == SettleVerdict.ANIMATED
1332
+ if not animated:
1333
+ # 'settled' is not proof of static; a repeated answer arms one recording to find out. The clip
1334
+ # that recording brings back is what decides — `_known_animated` is set from it, not from here.
1031
1335
  if not self._animated_probe_armed or self._animated_probe_done:
1032
1336
  return False
1033
1337
  self._animated_probe_done, self._animated_probe_armed = True, False
1034
1338
  _log("[animated] a second look at a board that did not solve as a still — recording it")
1035
- self._known_animated = True
1339
+ self._known_animated = animated
1036
1340
  if not self.config.video_solve_enabled:
1037
1341
  raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
1038
- _log("[animated] challenge is animated — solving it from keyframes")
1342
+ if animated:
1343
+ _log("[animated] challenge is animated — solving it from keyframes")
1039
1344
  return True
1040
1345
 
1041
1346
  def _arm_animated_probe(self) -> None:
@@ -1049,10 +1354,15 @@ class PageSolver:
1049
1354
  return (cfg.video_solve_enabled and cfg.speculative_burst_enabled and not self._acted_on_board
1050
1355
  and puzzle_source != Vendor.RECAPTCHA and not text_mode)
1051
1356
 
1052
- def _burst(self, element: Any) -> Tuple[List[Any], List[str], bool, float]:
1357
+ def _burst(self, element: Any, known: AbstractSet[str] = frozenset(),
1358
+ max_ms: Optional[float] = None) -> Tuple[List[Any], List[str], bool, float, bool]:
1053
1359
  """Film the widget until a screen comes back (a cycle) or nothing new appears for a floor window.
1054
1360
 
1055
- Returns `(frames, distinct_digests, moved, elapsed_ms)`. `moved` is a closed cycle OR a board still
1361
+ `known` is the screens an earlier burst of this solve already holds. One of them coming back proves
1362
+ the board was not replaced, and the film in hand already describes it, so there is nothing left to
1363
+ film — that exit is the difference between a re-ask costing a frame and costing a whole window.
1364
+
1365
+ Returns `(frames, distinct_digests, moved, elapsed_ms, cycle_closed)`. `moved` is a closed cycle OR a board still
1056
1366
  producing new screens past the floor with more than BURST_ANIMATED_SCREENS seen: a continuous animation
1057
1367
  never repeats and never settles, so without that verdict it read as a still and was clicked as one.
1058
1368
  Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
@@ -1062,7 +1372,7 @@ class PageSolver:
1062
1372
 
1063
1373
  cfg = self.config
1064
1374
  floor_ms = float(cfg.video_burst_duration_ms)
1065
- max_ms = max(floor_ms, float(cfg.video_burst_max_ms))
1375
+ max_ms = max(floor_ms, float(cfg.video_burst_max_ms if max_ms is None else max_ms))
1066
1376
  interval = 1.0 / max(1, int(cfg.video_burst_fps))
1067
1377
  frames: List[Any] = []
1068
1378
  order: List[str] = []
@@ -1079,15 +1389,23 @@ class PageSolver:
1079
1389
  raise CaptchaSolveError(
1080
1390
  f"the animated recording stalled: {len(frames)} frames in "
1081
1391
  f"{burst_hang_deadline_ms(cfg):.0f}ms. The widget is not screenshotting.")
1082
- try:
1083
- self._screenshot(element, shot, animations="allow")
1084
- img = cv2.imread(shot)
1085
- except Exception as exc:
1086
- _debug(f"burst frame failed: {exc}")
1087
- 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}")
1088
1400
  if img is not None:
1089
1401
  frames.append(img)
1090
1402
  d = _sha1(shot)
1403
+ if d in known:
1404
+ _log("[animated] a screen the film already holds came back — the board is unchanged")
1405
+ cycle_closed = True
1406
+ if d not in order:
1407
+ order.append(d)
1408
+ break
1091
1409
  if d != last_digest:
1092
1410
  if d in order and len(order) >= 2:
1093
1411
  cycle_closed = True
@@ -1105,8 +1423,14 @@ class PageSolver:
1105
1423
  # Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
1106
1424
  # loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
1107
1425
  next_at = max(next_at + interval, time.monotonic())
1426
+ # END on the frame that would land past the ceiling rather than declining to sleep for it:
1427
+ # declining left the loop spinning at whatever rate the screenshots came back, which on a
1428
+ # ceiling that is not a whole number of intervals filmed thousands of junk frames into the
1429
+ # tail of the clip (measured: 2487 frames in a 150ms window).
1430
+ if (next_at - t0) * 1000.0 >= max_ms:
1431
+ break
1108
1432
  wait = next_at - time.monotonic()
1109
- if wait > 0 and (next_at - t0) * 1000.0 < max_ms:
1433
+ if wait > 0:
1110
1434
  time.sleep(wait)
1111
1435
  finally:
1112
1436
  _unlink(shot)
@@ -1120,7 +1444,7 @@ class PageSolver:
1120
1444
  why = "no new screen for a full floor window — it moved once and settled"
1121
1445
  _log(f"[animated] burst verdict after {elapsed_ms / 1000:.1f}s: "
1122
1446
  f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
1123
- return frames, order, cycle_closed or animating, elapsed_ms
1447
+ return frames, order, cycle_closed or animating, elapsed_ms, cycle_closed
1124
1448
 
1125
1449
  def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[List[str], str]:
1126
1450
  """Cut a burst with the training-side slicer; the model answers with a frame number into it."""
@@ -1134,10 +1458,31 @@ class PageSolver:
1134
1458
  _log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
1135
1459
  return paths, temp_dir
1136
1460
 
1137
- def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
1138
- """Record the widget and return `(keyframe_paths, temp_dir)`; the caller removes the dir.
1461
+ def _record_keyframes(self, element: Any) -> Tuple[List[str], str, bool]:
1462
+ """Record the widget and return `(keyframe_paths, temp_dir, moved)`; the caller removes the dir.
1139
1463
 
1140
1464
  Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
1465
+
1466
+ THE FILM IS NEVER THROWN AWAY MID-SOLVE. One burst can only show the model the screens that fell
1467
+ inside its window — measured, a 4s burst against a 5.3s cycle showed two screens of three — and the
1468
+ answer it produces is identical next round by construction, because sampling is greedy over the same
1469
+ frames. So a refused answer used to be re-pressed until the no-progress fence tripped. Every round
1470
+ now films ANOTHER chunk and slices the whole accumulation, so the next ask is a strictly better
1471
+ question: more repetitions for `_detect_cycle` to confirm, and, on a board that never repeats, the
1472
+ slicer's even picks spread across the whole solve rather than across four seconds.
1473
+
1474
+ WHAT IT IS NOT IS A FILM OF TWO BOARDS. Accumulating across a board the vendor replaced is worse
1475
+ than not accumulating at all — six keyframes cut across both states describe neither, `_detect_cycle`
1476
+ gives up on the extra screens, and the frame the model names is one the widget will never show
1477
+ again. A screen coming back is the proof the board is the same one; nothing coming back restarts
1478
+ the film below.
1479
+
1480
+ THE JS PORT FILMS CONTINUOUSLY AND THIS ONE CANNOT. There the recorder is an async loop on the same
1481
+ event loop, so it keeps filming through inference and through the verdict wait. Sync Playwright is
1482
+ thread-affine — `_speculate` says so where it hands only the HTTP call to a worker — so a background
1483
+ camera here would be a second thread touching the page, which Playwright forbids. Accumulating
1484
+ per-round chunks is the same contract with a sparser film: both ports re-ask a refused animated
1485
+ answer against strictly more of the board than the last ask saw.
1141
1486
  """
1142
1487
  cfg = self.config
1143
1488
  self._grant_video_budget()
@@ -1147,12 +1492,42 @@ class PageSolver:
1147
1492
  f"budget is left and an animated recording needs {cfg.video_burst_duration_ms}ms — not "
1148
1493
  "starting one that would be cut off mid-way. Raise overall_solve_timeout_ms or "
1149
1494
  "video_extra_inference_ms, or set video_solve_enabled=False.")
1150
- frames, _order, _closed, burst_ms = self._burst(element)
1495
+ # A board that has never repeated a screen has nothing further to show: another ceiling-long window
1496
+ # is more of the same animation, and three of them is most of the solve budget. Measured on the gate:
1497
+ # boards at 54.6s and 59.1s against a 49s ceiling, all of them boards that never repeat.
1498
+ reask = bool(self._film_frames)
1499
+ frames, order, moved, burst_ms, cycled = self._burst(
1500
+ element, known=self._film_digests,
1501
+ max_ms=float(cfg.video_burst_duration_ms) if reask and not self._film_cycled else None)
1151
1502
  if not frames:
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.
1152
1506
  raise AnimatedChallengeError("could not record the animated challenge (no frame screenshotted)")
1153
1507
  _log(f"[animated] recorded {len(frames)} frames in {burst_ms / 1000:.1f}s "
1154
1508
  f"({measured_fps(len(frames), burst_ms, cfg.video_burst_fps):.1f}fps)")
1155
- return self._slice(frames, burst_ms)
1509
+ # ONE FILM, ONE BOARD. A vendor that deals a fresh puzzle on a miss has replaced everything the film
1510
+ # holds, and keyframes cut across both name screens that no longer exist: the model answers with one
1511
+ # and the click waits out `keyframe_wait_timeout_ms` for a picture that is gone. Nothing coming back
1512
+ # is what says so, and the film restarts on the board that is actually there.
1513
+ # Cut ONLY on proof of a replacement: this burst repeated a screen, and none of what it repeated was
1514
+ # in the film. A board that never repeats cannot produce that proof either way and has not been
1515
+ # replaced — it has simply never repeated, and its film is the only record of it.
1516
+ if reask and cycled and not (self._film_digests & set(order)):
1517
+ _log("[animated] nothing the film holds came back — the board was replaced; the film restarts here")
1518
+ self._film_frames, self._film_ms, self._film_digests = [], 0.0, set()
1519
+ # The cap is a safety net on memory, not the working limit: frames are decoded images, and a board
1520
+ # that never settles would otherwise film for the whole solve budget.
1521
+ if self._film_ms < cfg.video_film_max_ms:
1522
+ self._film_frames.extend(frames)
1523
+ self._film_ms += burst_ms
1524
+ self._film_digests.update(order)
1525
+ self._film_cycled = self._film_cycled or cycled
1526
+ if len(self._film_frames) > len(frames):
1527
+ _log(f"[animated] slicing {len(self._film_frames)} frames filmed over "
1528
+ f"{self._film_ms / 1000:.1f}s — every round of this board, not just this one")
1529
+ paths, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
1530
+ return paths, temp_dir, moved
1156
1531
 
1157
1532
  def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
1158
1533
  text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
@@ -1164,7 +1539,7 @@ class PageSolver:
1164
1539
  try:
1165
1540
  fut = pool.submit(self._get_solution, shot, puzzle_source, retry_mode, text_mode)
1166
1541
  with self._phase(Phase.BURST):
1167
- frames, order, moved, burst_ms = self._burst(element)
1542
+ frames, order, moved, burst_ms, _cycled = self._burst(element)
1168
1543
  if not moved:
1169
1544
  with self._phase(Phase.INFERENCE):
1170
1545
  actions, usage = fut.result()
@@ -1178,6 +1553,7 @@ class PageSolver:
1178
1553
  _debug(f"settled re-read failed: {exc}")
1179
1554
  return actions, usage, None
1180
1555
  _log("[animated] the widget moved while the model was reading it — finishing the recording")
1556
+ self._known_animated = True
1181
1557
  fut.result()
1182
1558
  self._grant_video_budget()
1183
1559
  paths, keyframe_dir = self._slice(frames, burst_ms)
@@ -1200,11 +1576,25 @@ class PageSolver:
1200
1576
  self._animated_plan = (plan[0], plan[1], None, None)
1201
1577
 
1202
1578
  def _discard_animated_plan(self) -> None:
1579
+ """Drop the ANSWER, keep the film.
1580
+
1581
+ An answer the widget refused is spent: reusing it presses the same wrong thing until the fence trips
1582
+ three rounds in, with half the budget unspent. The frames are the opposite — every chunk added makes
1583
+ the next ask a better question — so only a board that is GONE clears them, via `_stop_animated_film`.
1584
+ """
1203
1585
  plan = self._animated_plan
1204
1586
  self._animated_plan = None
1205
1587
  if plan and plan[1]:
1206
1588
  shutil.rmtree(plan[1], ignore_errors=True)
1207
1589
 
1590
+ def _stop_animated_film(self) -> None:
1591
+ """The board is gone: its film can never describe the next one."""
1592
+ self._discard_animated_plan()
1593
+ self._film_frames = []
1594
+ self._film_ms = 0.0
1595
+ self._film_digests = set()
1596
+ self._film_cycled = False
1597
+
1208
1598
  def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
1209
1599
  """Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
1210
1600
  import cv2
@@ -1262,12 +1652,12 @@ class PageSolver:
1262
1652
  polls += 1
1263
1653
  if polls >= _NOT_THIS_BOARD_POLLS and best > _NOT_THIS_BOARD_DIFF:
1264
1654
  _log("[animated] the widget no longer resembles the recorded board")
1265
- self._discard_animated_plan()
1655
+ self._stop_animated_film()
1266
1656
  return False
1267
1657
  _delay(cfg.keyframe_wait_poll_ms)
1268
1658
  finally:
1269
1659
  _unlink(probe)
1270
- self._discard_animated_plan()
1660
+ self._stop_animated_film()
1271
1661
  _log(f"[animated] widget never matched the chosen keyframe within {wait_ms}ms (closest diff={best:.4f})")
1272
1662
  return False
1273
1663
 
@@ -1439,12 +1829,19 @@ class PageSolver:
1439
1829
 
1440
1830
  def _solve_single(self, page: Any, widget: Widget, retry_mode: Optional[RetryMode]) -> Tuple[bool, List[Dict[str, Any]]]:
1441
1831
  element, puzzle_source, role = widget.element, widget.vendor, widget.role
1832
+ os.environ[_VENDOR_ENV] = puzzle_source.value
1442
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)
1443
1840
  scope = frame or widget.at
1444
- # Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
1445
- text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
1446
- if text_mode:
1447
- _log("widget has a text box; solving as a distorted-text captcha")
1841
+
1842
+ if role == FrameRole.CHECKBOX:
1843
+ self._click_checkbox(page, widget, frame)
1844
+ return True, []
1448
1845
 
1449
1846
  if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
1450
1847
  if self._last_submit_frame_hash:
@@ -1454,8 +1851,26 @@ class PageSolver:
1454
1851
  with self._phase(Phase.HCAPTCHA_IMAGES):
1455
1852
  self._wait_for_board_images(frame, SELECTORS[puzzle_source])
1456
1853
 
1457
- # A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
1458
- filmable = role != FrameRole.CHECKBOX and puzzle_source != Vendor.RECAPTCHA and not text_mode
1854
+ # THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of
1855
+ # the DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet
1856
+ # has no text box to find and no motion but its own arrival.
1857
+ #
1858
+ # A reCAPTCHA board is asked NEITHER — it is never typed and never filmed — and it waits for its
1859
+ # board at the grid gate below, on the cells themselves. Waiting here as well put 0.8-1.8s in front
1860
+ # of that gate on every dynamic round, on the family with the tightest per-board budget.
1861
+ if puzzle_source != Vendor.RECAPTCHA:
1862
+ with self._phase(Phase.BOARD_PAINT):
1863
+ self._wait_for_board_painted(element)
1864
+
1865
+ # Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
1866
+ text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
1867
+ if text_mode:
1868
+ _log("widget has a text box; solving as a distorted-text captcha")
1869
+
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)
1459
1874
  is_animated = filmable and self._settle_or_animated(element)
1460
1875
  # hCaptcha keeps its challenge iframe visible ~2s after the final submit; read as a fresh puzzle it burned ~18s.
1461
1876
  if role == FrameRole.CHALLENGE and self.is_captcha_solved(page):
@@ -1471,8 +1886,10 @@ class PageSolver:
1471
1886
  if element_box:
1472
1887
  return self._solve_recaptcha_grid(page, element, retry_mode, grid, element_box)
1473
1888
 
1474
- with self._phase(Phase.BOARD_PAINT):
1475
- self._wait_for_board_painted(element)
1889
+ # The board a reCAPTCHA round photographs, for the rounds that do not take the grid path above.
1890
+ if puzzle_source == Vendor.RECAPTCHA:
1891
+ with self._phase(Phase.BOARD_PAINT):
1892
+ self._wait_for_board_painted(element)
1476
1893
 
1477
1894
  shot = _tmp_png("captcha")
1478
1895
  performed = slid = answered = have_shot = False
@@ -1483,18 +1900,30 @@ class PageSolver:
1483
1900
  if self._animated_plan is not None:
1484
1901
  keyframes, keyframe_dir, actions, all_usage = self._animated_plan
1485
1902
  reused = actions is not None
1903
+ if not reused:
1904
+ # Drop the ANSWER, keep the FRAMES — and the frames grow. Filming another chunk and
1905
+ # re-slicing the accumulation puts a better question up; re-asking the same frames
1906
+ # puts the same question up and gets the same answer back.
1907
+ with self._phase(Phase.BURST):
1908
+ keyframes, new_dir, moved = self._record_keyframes(element)
1909
+ shutil.rmtree(keyframe_dir, ignore_errors=True)
1910
+ keyframe_dir = new_dir
1911
+ self._known_animated = self._known_animated or moved
1486
1912
  _log("[animated] reusing the recorded answer" if reused
1487
- else "[animated] re-asking on the frames already recorded")
1913
+ else "[animated] re-asking on a film a round longer")
1488
1914
  else:
1489
1915
  reused = False
1490
1916
  with self._phase(Phase.BURST):
1491
- keyframes, keyframe_dir = self._record_keyframes(element)
1492
- if len(keyframes) < 2:
1493
- _log("[animated] the recording shows one picture; solving it as a still")
1917
+ keyframes, keyframe_dir, moved = self._record_keyframes(element)
1918
+ self._known_animated = self._known_animated or moved
1919
+ # The clip answers the question the probe asked: a board that never moved is a still, and the
1920
+ # video expert can only answer a still with a frame number the widget will not take.
1921
+ if len(keyframes) < 2 or not self._known_animated:
1922
+ _log("[animated] the recording shows a still board; solving it as a still")
1494
1923
  is_animated = False
1495
- shutil.copyfile(keyframes[0], shot)
1924
+ shutil.copyfile(keyframes[-1], shot)
1496
1925
  have_shot = True
1497
- self._discard_animated_plan()
1926
+ self._stop_animated_film()
1498
1927
  shutil.rmtree(keyframe_dir, ignore_errors=True)
1499
1928
  keyframe_dir = None
1500
1929
  elif not reused:
@@ -1517,11 +1946,19 @@ class PageSolver:
1517
1946
 
1518
1947
  element_box = element.bounding_box()
1519
1948
  if not element_box and answer_needs_element_box(actions):
1949
+ # A WIDGET WITH NO BOX IS USUALLY A WIDGET THAT IS CLOSING, and it closes because the
1950
+ # answer was accepted. Asking costs one call; not asking cost the solve — measured on
1951
+ # 2026-09-17, five types graded `solved: true` on the board and then ended the attempt
1952
+ # here, which Tier 3 reports as an answer the board would have taken, reported failed.
1953
+ if self.is_captcha_solved(page):
1954
+ _log("the widget has no box because it is closing on a solved board; finishing.")
1955
+ return False, all_usage
1520
1956
  raise CaptchaSolveError("could not get bounding box of captcha element")
1521
1957
 
1522
1958
  _log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
1523
1959
  # A repeated answer is not re-performed: the widget already refused it, and every extra press is
1524
1960
  # behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
1961
+ # `_note_answer` drops the refused animated answer on its way through, so the next round re-asks.
1525
1962
  if self._note_answer(actions, retry_mode):
1526
1963
  return False, all_usage
1527
1964
  _log(f"executing {len(actions)} action(s)")
@@ -1588,12 +2025,21 @@ class PageSolver:
1588
2025
  usage: List[Dict[str, Any]] = []
1589
2026
  self._last_submit_frame_hash = None
1590
2027
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
2028
+ self._page = page
1591
2029
  self._human.reset(page)
1592
2030
  self._reset_animated_state()
1593
2031
  self._budget = PhaseBudget()
1594
- # One session id per solve groups its inference rounds into one billable attempt.
1595
- 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)}
1596
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
1597
2043
  solved = False
1598
2044
  try:
1599
2045
  result = self._solve_impl(page, start, usage)
@@ -1604,34 +2050,69 @@ class PageSolver:
1604
2050
  if timings_enabled():
1605
2051
  print(self._budget.report(), file=sys.stderr)
1606
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
1607
2057
  try:
1608
2058
  # On by default: it is the only way a hosted account's failures can be found at all.
1609
- 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)
1610
2060
  except Exception as exc:
1611
2061
  _log(f"[outcome] could not report: {exc}")
1612
- if previous_session is None:
1613
- os.environ.pop(_SESSION_ENV, None)
1614
- else:
1615
- 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
1616
2067
 
1617
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
+ """
1618
2074
  cfg = self.config
1619
2075
  pending_retry_mode: Optional[RetryMode] = None
1620
- retried_underselect = False
1621
- unsupported_retries = stale_retries = render_waits = 0
1622
2076
  has_interacted = False
1623
- # Strictly fewer than the loops, else the no-widget branch for invisible reCAPTCHA never fires.
1624
- max_render_waits = min(6, cfg.max_solve_loops - 1)
2077
+ last_failure: Optional[CaptchaSolveError] = None
2078
+ verdicts = self._verdicts = VerdictLog(page)
1625
2079
 
1626
2080
  def done() -> SolveResult:
1627
- 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)
1628
2094
 
1629
2095
  for attempt in range(1, cfg.max_solve_loops + 1):
1630
- if attempt >= 2:
2096
+ # A round that failed arms the second look only while its board is still up. A board the vendor has
2097
+ # replaced (`_fresh_board` clears `_acted_on_board`) has failed nothing yet, and arming it anyway
2098
+ # filmed every still board after a solve's first miss: 4-8s each, a 41.8s session became 49s.
2099
+ if attempt >= 2 and self._acted_on_board:
1631
2100
  self._arm_animated_probe()
1632
- if _now() - start > cfg.overall_solve_timeout_ms:
2101
+ self._bound_ask_to_the_budget()
2102
+ # The deadline, not the bare config: a recording extends it once per solve (`_grant_video_budget`), and
2103
+ # a loop head reading only overall_solve_timeout_ms quit video solves at 45s with 29s still granted.
2104
+ deadline = self._deadline_ms if self._deadline_ms is not None else start + cfg.overall_solve_timeout_ms
2105
+ if _now() > deadline:
2106
+ granted = deadline - start - cfg.overall_solve_timeout_ms
1633
2107
  raise CaptchaSolveError(
1634
- f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms (attempt {attempt}/{cfg.max_solve_loops})")
2108
+ f"captcha solve timed out after {deadline - start:.0f}ms (attempt {attempt}/{cfg.max_solve_loops})"
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")
1635
2116
  if has_interacted and self.is_captcha_solved(page):
1636
2117
  _log("captcha reports solved; finishing.")
1637
2118
  return done()
@@ -1639,81 +2120,88 @@ class PageSolver:
1639
2120
  with self._phase(Phase.DETECT):
1640
2121
  widget = self.detect_captcha(page)
1641
2122
  if not widget:
1642
- 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:
1643
2125
  _log("no supported captcha remains after interaction; considering solved.")
1644
2126
  return done()
1645
- if self.is_captcha_solved(page):
1646
- _log("captcha already satisfied; nothing to solve.")
1647
- return done()
1648
- if self.has_interactive_widget_in_dom(page) and render_waits < max_render_waits:
1649
- render_waits += 1
1650
- _log(f"widget in DOM but not yet rendered; waiting ({render_waits}/{max_render_waits}).")
1651
- _delay(800 + random.random() * 300)
1652
- continue
1653
- 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))
1654
2140
 
1655
2141
  _log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
1656
2142
  retry_mode, pending_retry_mode = pending_retry_mode, None
1657
2143
  try:
1658
2144
  did_interact, round_usage = self._solve_single(page, widget, retry_mode)
1659
- except AnimatedChallengeError:
1660
- raise
1661
2145
  except UnsupportedCaptchaError as unsupported:
1662
- # Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
1663
- if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
1664
- unsupported_retries += 1
1665
- again = self.detect_captcha(page)
1666
- with self._phase(Phase.SETTLE):
1667
- settled = again and self._wait_for_element_settled(again.element)
1668
- if settled == SettleVerdict.ANIMATED and not cfg.video_solve_enabled:
1669
- raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
1670
- _log(f'"unsupported" mid-solve; retrying ({unsupported_retries}/{cfg.max_unsupported_resolves}).')
1671
- continue
1672
- 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
1673
2161
  except Exception as exc:
1674
2162
  message = str(exc)
1675
2163
  closed = bool(_CLOSED_TARGET_RE.search(message))
1676
- if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
1677
- # The handle most often went stale because the answer was accepted.
1678
- try:
1679
- if self.is_captcha_solved(page):
1680
- _log("captcha reports solved; finishing.")
1681
- return done()
1682
- except Exception:
1683
- pass
1684
- if closed:
1685
- raise PageClosedError(
1686
- "the page, context or browser closed mid-solve, after the answer had been "
1687
- "submitted but before the vendor's verdict could be read — the solve may in "
1688
- "fact have succeeded") from exc
1689
- if stale_retries < cfg.max_stale_element_retries:
1690
- stale_retries += 1
1691
- _log(f"stale challenge handle after submit; re-detecting ({stale_retries}/{cfg.max_stale_element_retries}).")
1692
- _delay(cfg.stale_element_backoff_ms)
1693
- continue
1694
- 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
1695
2180
 
1696
2181
  has_interacted = has_interacted or did_interact
1697
- if self._no_progress_rounds >= cfg.max_no_progress_rounds:
1698
- raise CaptchaSolveError(
1699
- f"no progress: the model returned the same answer {self._no_progress_rounds + 1} times "
1700
- f"running and the challenge is still up (attempt {attempt}/{cfg.max_solve_loops})")
1701
- render_waits = 0
1702
2182
  usage.extend(round_usage)
1703
2183
 
1704
2184
  # One polled wait per round: the vendor's verdict, the widget going away, or a fresh board. The flat
1705
- # sleep it replaced observed nothing and cost 1200-1500ms per finished round.
1706
- window_ms = (cfg.post_solve_outcome_timeout_ms if did_interact
1707
- 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
1708
2189
  deadline = _now() + window_ms
1709
2190
  t0 = time.perf_counter()
1710
2191
  solved = False
2192
+ said = None
1711
2193
  widget_gone = 0
1712
- 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
1713
2201
  if self.is_captcha_solved(page):
1714
2202
  solved = True
1715
2203
  break
1716
- 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
1717
2205
  if widget_gone >= 2:
1718
2206
  solved = True
1719
2207
  break
@@ -1727,22 +2215,48 @@ class PageSolver:
1727
2215
  if solved:
1728
2216
  _log(f"[verdict] success signal arrived after {verdict_ms:.0f}ms")
1729
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")
1730
2224
 
1731
2225
  if self._banner_is_fatal_after_retry(self._banner_kind(page)):
1732
- if retried_underselect:
1733
- raise CaptchaSolveError(
1734
- "reCAPTCHA still showing the under-selection error after retry; aborting "
1735
- "(model unable to identify the missed tile)")
1736
- _log("reCAPTCHA under-selection error; retrying with missed-tiles prompt.")
1737
- 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
1738
2228
 
1739
- if not self.detect_captcha(page):
2229
+ if said != Verdict.REJECTED and not self.detect_captcha(page):
1740
2230
  return done()
1741
- if not did_interact and not self._no_progress_rounds:
1742
- raise CaptchaSolveError(
1743
- "captcha still detected but the solver performed no interactions; aborting to avoid an infinite loop")
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:
2243
+ self._arm_animated_probe()
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
+
1744
2251
 
1745
- raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops")
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
2255
+
2256
+ try:
2257
+ return (urlparse(page.url).hostname or "").lower()
2258
+ except Exception:
2259
+ return ""
1746
2260
 
1747
2261
 
1748
2262
  def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]: