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.
- package/AGENTS.md +4 -4
- package/LICENSE +1 -1
- package/README.md +10 -13
- package/dist/index.d.ts +1 -0
- package/dist/kinds.d.ts +8 -0
- package/dist/kinds.js +3 -1
- package/dist/model-name.d.ts +6 -0
- package/dist/model-name.js +28 -0
- package/dist/playwright-types.d.ts +18 -0
- package/dist/png.d.ts +16 -0
- package/dist/png.js +119 -0
- package/dist/puppeteer-adapter.d.ts +7 -1
- package/dist/puppeteer-adapter.js +6 -0
- package/dist/selectors.d.ts +9 -0
- package/dist/selectors.js +11 -3
- package/dist/solver.d.ts +76 -4
- package/dist/solver.js +509 -176
- package/dist/types.d.ts +10 -5
- package/dist/verdicts.d.ts +45 -0
- package/dist/verdicts.js +125 -0
- package/package.json +7 -7
- package/python/AGENTS.md +13 -8
- package/python/LICENSE +1 -1
- package/python/README.md +10 -13
- package/python/pyproject.toml +6 -6
- package/python/src/captchakraken/__init__.py +5 -1
- package/python/src/captchakraken/cli.py +7 -1
- package/python/src/captchakraken/kinds.py +9 -0
- package/python/src/captchakraken/page_solver.py +472 -167
- package/python/src/captchakraken/planner.py +52 -11
- package/python/src/captchakraken/prompts.py +24 -0
- package/python/src/captchakraken/selectors.py +19 -2
- package/python/src/captchakraken/tool_calls/find_checkbox.py +4 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +22 -0
- package/python/src/captchakraken/verdicts.py +154 -0
- package/python/Dockerfile +0 -41
- package/python/examples/README.md +0 -114
- package/python/examples/_harness.py +0 -177
- package/python/examples/demoHcaptcha.py +0 -8
- package/python/examples/demoRecaptcha.py +0 -8
- package/python/examples/watch_playwright.py +0 -31
- package/python/examples/with_local_gguf.py +0 -28
- package/python/examples/with_playwright.py +0 -24
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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(
|
|
811
|
-
if at is None or self._already_accepted(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
"""
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
#
|
|
1258
|
-
#
|
|
1259
|
-
#
|
|
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
|
|
1618
|
-
filmable =
|
|
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
|
-
|
|
1777
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
if self.
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
#
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1948
|
-
|
|
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
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
#
|
|
1960
|
-
#
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
-
|
|
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]:
|