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.
- 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 +6 -0
- package/dist/selectors.js +4 -0
- package/dist/solver.d.ts +118 -5
- package/dist/solver.js +840 -143
- package/dist/types.d.ts +12 -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 +651 -137
- package/python/src/captchakraken/planner.py +68 -12
- package/python/src/captchakraken/prompts.py +24 -0
- package/python/src/captchakraken/selectors.py +10 -0
- 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
|
|
@@ -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
|
-
|
|
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:
|
|
101
|
-
"""An allow-list of what needs nothing: the other direction raises instead of clicking at the origin.
|
|
102
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
"""
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
#
|
|
1458
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
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
|
|
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
|
-
|
|
1493
|
-
|
|
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[
|
|
1924
|
+
shutil.copyfile(keyframes[-1], shot)
|
|
1496
1925
|
have_shot = True
|
|
1497
|
-
self.
|
|
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
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
if self.
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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
|
-
#
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1733
|
-
|
|
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
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
-
|
|
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]:
|