captchakraken 2.2.0 → 2.3.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/LICENSE +105 -0
- package/README.md +3 -1
- package/dist/playwright-types.d.ts +2 -0
- package/dist/solver.d.ts +67 -0
- package/dist/solver.js +377 -15
- package/dist/types.d.ts +103 -0
- package/package.json +4 -3
- package/python/LICENSE +105 -0
- package/python/README.md +3 -1
- package/python/pyproject.toml +6 -2
- package/python/src/captchakraken/__init__.py +13 -1
- package/python/src/captchakraken/cli.py +63 -0
- package/python/src/captchakraken/config.py +86 -4
- package/python/src/captchakraken/page_solver.py +1579 -0
- package/python/src/captchakraken/pinned_model.json +28 -0
- package/python/src/captchakraken/planner.py +248 -17
- package/python/src/captchakraken/server_manager.py +3 -2
- package/python/src/captchakraken/trajectory.py +172 -0
- package/python/src/captchakraken/updater.py +168 -0
- package/scripts/setup-python.js +16 -2
|
@@ -0,0 +1,1579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser page driver — the Python mirror of `js/src/solver.ts`.
|
|
3
|
+
|
|
4
|
+
WHAT THIS IS
|
|
5
|
+
The Python port used to be image-in / actions-out: you handed `CaptchaSolver` a
|
|
6
|
+
PNG and got back click/drag actions, and something else had to own the browser.
|
|
7
|
+
Everything that actually drives a page — finding the challenge iframe, waiting
|
|
8
|
+
for tiles to paint, clicking, submitting, deciding whether the vendor accepted
|
|
9
|
+
— lived only in the TypeScript driver. This module closes that gap so a Python
|
|
10
|
+
caller (camoufox's Python API, plain Playwright, patchright) can solve a captcha
|
|
11
|
+
end to end without a Node process.
|
|
12
|
+
|
|
13
|
+
THE SPLIT, WHICH IS THE SAME ON BOTH SIDES
|
|
14
|
+
vision / CV / prompting -> Python (`solver.py`, `planner.py`, `tool_calls/`)
|
|
15
|
+
page driving + clicking -> the driver (this file, or solver.ts)
|
|
16
|
+
|
|
17
|
+
The TS driver reaches the Python half by spawning the CLI and talking JSON over
|
|
18
|
+
a pipe. This driver calls the very same functions in-process — no subprocess, no
|
|
19
|
+
serialisation, no persistent CV worker to leak. That is the only intended
|
|
20
|
+
difference between the two drivers: everything about WHAT to click, WHEN a frame
|
|
21
|
+
is settled, and WHETHER a puzzle is supported is shared code, so the two cannot
|
|
22
|
+
drift on the parts that decide accuracy.
|
|
23
|
+
|
|
24
|
+
STRUCTURAL TYPING, NO BROWSER DEPENDENCY
|
|
25
|
+
Like `playwright-types.ts`, this module imports no browser package. The caller
|
|
26
|
+
passes whatever Playwright-compatible `page` they have; we duck-type the slice
|
|
27
|
+
we use. Importing `playwright` here would force it into every consumer's tree
|
|
28
|
+
and break across version skew, and camoufox users already have their own.
|
|
29
|
+
|
|
30
|
+
SYNC ONLY, FOR NOW
|
|
31
|
+
This mirrors the synchronous Playwright API (`Camoufox()`, `sync_playwright()`),
|
|
32
|
+
which is camoufox's headline Python interface. An async mirror is mechanical but
|
|
33
|
+
is NOT written yet — see `solve_captcha_on_page`'s docstring. Calling this from
|
|
34
|
+
an async event loop will not work, because sync Playwright handles cannot be
|
|
35
|
+
driven from one.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
import os
|
|
42
|
+
import random
|
|
43
|
+
import re
|
|
44
|
+
import tempfile
|
|
45
|
+
import time
|
|
46
|
+
import uuid
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
49
|
+
|
|
50
|
+
from .action_types import CaptchaAction
|
|
51
|
+
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
52
|
+
from .trajectory import generate_trajectory
|
|
53
|
+
|
|
54
|
+
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
55
|
+
|
|
56
|
+
# Read by planner.py, which turns it into the X-CK-Session header. Kept as a
|
|
57
|
+
# module constant so the name is defined in exactly one place per port.
|
|
58
|
+
_SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _log(message: str) -> None:
|
|
62
|
+
# flush=True is load-bearing, not tidiness. A solve is a minutes-long
|
|
63
|
+
# sequence of slow steps, and Python block-buffers stdout whenever it is not
|
|
64
|
+
# a TTY — so piped or redirected (CI logs, `> run.log`, a supervisor) the
|
|
65
|
+
# progress lines all appear at once when the process exits. A run that is
|
|
66
|
+
# working then looks indistinguishable from one that is hung, which is
|
|
67
|
+
# exactly the wrong signal from the one output people watch.
|
|
68
|
+
print(f"[captchakraken] {message}", flush=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _debug(message: str) -> None:
|
|
72
|
+
if DEBUG:
|
|
73
|
+
print(f"[captchakraken:debug] {message}", flush=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _delay(ms: float) -> None:
|
|
77
|
+
if ms > 0:
|
|
78
|
+
time.sleep(ms / 1000.0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tmp_png(prefix: str) -> str:
|
|
82
|
+
fd, path = tempfile.mkstemp(prefix=f"{prefix}_", suffix=".png")
|
|
83
|
+
os.close(fd)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _unlink(path: Optional[str]) -> None:
|
|
88
|
+
if path and os.path.exists(path):
|
|
89
|
+
try:
|
|
90
|
+
os.unlink(path)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass # best-effort; a leaked temp frame is not worth failing a solve
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
# Errors — the taxonomy the outer loop branches on. The TS driver tags plain
|
|
97
|
+
# Errors with `.animated` / `.unsupported` booleans; Python gets real types,
|
|
98
|
+
# which is the same distinction expressed properly.
|
|
99
|
+
# --------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CaptchaSolveError(Exception):
|
|
103
|
+
"""Base for every failure this driver raises."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AnimatedChallengeError(CaptchaSolveError):
|
|
107
|
+
"""The challenge never stops moving — a video/animated puzzle we can't read."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class UnsupportedChallengeError(CaptchaSolveError):
|
|
111
|
+
"""A settled frame the model reports it cannot solve (e.g. hCaptcha click/drag)."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class NoCaptchaFoundError(CaptchaSolveError):
|
|
115
|
+
"""No interactive widget — reCAPTCHA v3/invisible, or a click-triggered challenge."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Playwright surfaces these as messages, not types, so both drivers match on
|
|
119
|
+
# text. Kept as one pattern so the two lists can be diffed against each other.
|
|
120
|
+
_STALE_HANDLE_RE = re.compile(
|
|
121
|
+
r"Timeout .*exceeded|not visible|not attached|detached|Target closed",
|
|
122
|
+
re.IGNORECASE,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class SolveResult:
|
|
128
|
+
"""Mirror of the TS `SolveResult`."""
|
|
129
|
+
|
|
130
|
+
is_solved: bool
|
|
131
|
+
final_mouse_position: Tuple[float, float]
|
|
132
|
+
token_usage: List[Dict[str, Any]] = field(default_factory=list)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class _GridSession:
|
|
137
|
+
"""Cached geometry for one reCAPTCHA grid puzzle. Mirror of `GridSession`."""
|
|
138
|
+
|
|
139
|
+
grid_boxes: List[Sequence[int]]
|
|
140
|
+
element_box: Dict[str, float]
|
|
141
|
+
scale_x: float
|
|
142
|
+
scale_y: float
|
|
143
|
+
screenshot_w: int
|
|
144
|
+
screenshot_h: int
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class PageSolverConfig:
|
|
149
|
+
"""
|
|
150
|
+
Tunables, named to match the TS `CaptchaKrakenConfig` keys (snake_cased) so a
|
|
151
|
+
value tuned on one driver can be found on the other.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
max_solve_loops: int = 10
|
|
155
|
+
overall_solve_timeout_ms: int = 120_000
|
|
156
|
+
post_solve_delay_ms: int = 1_200
|
|
157
|
+
post_solve_outcome_timeout_ms: int = 2_500
|
|
158
|
+
element_screenshot_timeout_ms: int = 8_000
|
|
159
|
+
max_unsupported_resolves: int = 3
|
|
160
|
+
max_stale_element_retries: int = 3
|
|
161
|
+
stale_element_backoff_ms: int = 900
|
|
162
|
+
|
|
163
|
+
# Freshness guard — re-solve if the frame moved during inference.
|
|
164
|
+
stale_frame_resolve_enabled: bool = True
|
|
165
|
+
stale_frame_diff_threshold: float = 0.02
|
|
166
|
+
max_stale_frame_resolves: int = 2
|
|
167
|
+
|
|
168
|
+
# Pixel-settle monitor.
|
|
169
|
+
settle_poll_ms: int = 220
|
|
170
|
+
settle_frames: int = 2
|
|
171
|
+
settle_timeout_ms: int = 9_000
|
|
172
|
+
animated_challenge_after_ms: int = 4_500
|
|
173
|
+
settle_diff_threshold: float = 0.01
|
|
174
|
+
post_submit_change_timeout_ms: int = 4_000
|
|
175
|
+
|
|
176
|
+
# Grid load / dynamic-refresh timing.
|
|
177
|
+
grid_load_poll_interval_ms: int = 250
|
|
178
|
+
grid_load_timeout_ms: int = 8_000
|
|
179
|
+
recaptcha_max_dynamic_rounds: int = 8
|
|
180
|
+
recaptcha_fade_onset_grace_ms: int = 4_000
|
|
181
|
+
recaptcha_dynamic_fade_poll_ms: int = 250
|
|
182
|
+
recaptcha_dynamic_fade_wait_ms: int = 6_000
|
|
183
|
+
recaptcha_tile_hover_enabled: bool = True
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class PageSolver:
|
|
187
|
+
"""
|
|
188
|
+
Drives one Playwright-compatible `page` through a captcha.
|
|
189
|
+
|
|
190
|
+
from playwright.sync_api import sync_playwright
|
|
191
|
+
from captchakraken.page_solver import PageSolver
|
|
192
|
+
|
|
193
|
+
solver = PageSolver()
|
|
194
|
+
result = solver.solve(page)
|
|
195
|
+
if result.is_solved: ...
|
|
196
|
+
|
|
197
|
+
One instance is reusable across solves; state is reset per `solve()`.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
config: Optional[PageSolverConfig] = None,
|
|
203
|
+
solver: Optional[CaptchaSolver] = None,
|
|
204
|
+
**solver_kwargs: Any,
|
|
205
|
+
) -> None:
|
|
206
|
+
self.config = config or PageSolverConfig()
|
|
207
|
+
# One CaptchaSolver for the whole driver: it owns the planner, which
|
|
208
|
+
# accumulates token usage and holds the HTTP session to vLLM.
|
|
209
|
+
self._solver = solver or CaptchaSolver(**solver_kwargs)
|
|
210
|
+
self._last_mouse: Tuple[float, float] = (0.0, 0.0)
|
|
211
|
+
self._last_submit_frame_hash: Optional[str] = None
|
|
212
|
+
# Absolute deadline for the current solve, in the same clock as
|
|
213
|
+
# time.monotonic() * 1000. None outside a solve.
|
|
214
|
+
self._deadline_ms: Optional[float] = None
|
|
215
|
+
# Window size for clamping, resolved once per solve. See _viewport.
|
|
216
|
+
self._viewport_cache: Optional[Dict[str, float]] = None
|
|
217
|
+
|
|
218
|
+
def _check_deadline(self, where: str) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Enforce `overall_solve_timeout_ms` from INSIDE the long-running loops.
|
|
221
|
+
|
|
222
|
+
The TypeScript driver checks its budget only at the top of each attempt,
|
|
223
|
+
which means the budget is not really a budget: one slow attempt overruns
|
|
224
|
+
it without bound, because nothing looks at the clock again until the
|
|
225
|
+
attempt returns. Observed in practice — a camoufox session ran past ten
|
|
226
|
+
minutes against a nominal 120 s timeout, and the check at the top of the
|
|
227
|
+
loop never got a turn to fire.
|
|
228
|
+
|
|
229
|
+
Called at the points that can legitimately spin for a long time: each
|
|
230
|
+
action executed, and each round of the dynamic grid driver.
|
|
231
|
+
"""
|
|
232
|
+
if self._deadline_ms is None:
|
|
233
|
+
return
|
|
234
|
+
if time.monotonic() * 1000.0 > self._deadline_ms:
|
|
235
|
+
raise CaptchaSolveError(
|
|
236
|
+
f"captcha solve exceeded overall_solve_timeout_ms "
|
|
237
|
+
f"({self.config.overall_solve_timeout_ms}ms) during {where}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Shared-half bridges. Each of these is the in-process equivalent of one
|
|
242
|
+
# CLI subcommand the TS driver shells out for.
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def _find_grid(self, image_path: str) -> Optional[List[Sequence[int]]]:
|
|
246
|
+
"""`captchakraken find-grid` — row-major cell boxes in screenshot pixels."""
|
|
247
|
+
from .tool_calls.find_grid import find_grid
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
return find_grid(image_path)
|
|
251
|
+
except Exception as exc: # pragma: no cover - CV failure is never fatal
|
|
252
|
+
_debug(f"find_grid failed: {exc}")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
def _has_movement(self, path_a: str, path_b: str, threshold: float) -> bool:
|
|
256
|
+
"""`captchakraken check-movement`."""
|
|
257
|
+
from .image_processor import ImageProcessor
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return bool(ImageProcessor.detect_movement(path_a, path_b, threshold))
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
_debug(f"detect_movement failed: {exc}")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def _grid_cell_states(
|
|
266
|
+
self, path_a: str, path_b: str, grid_boxes: Sequence[Sequence[int]]
|
|
267
|
+
) -> Optional[Dict[str, List[int]]]:
|
|
268
|
+
"""
|
|
269
|
+
`captchakraken grid-cell-states-fixed`.
|
|
270
|
+
|
|
271
|
+
Always the FIXED variant: the dynamic refresh blanks tiles to near-white,
|
|
272
|
+
which makes find_grid fail on that frame, and a self-detecting call would
|
|
273
|
+
then report "no grid" — which a naive caller misreads as "nothing
|
|
274
|
+
loading", i.e. solved. Passing the cached boxes keeps empty/changing/
|
|
275
|
+
selected correct while tiles are blank.
|
|
276
|
+
"""
|
|
277
|
+
from .cli import _compute_grid_cell_states
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
return _compute_grid_cell_states(path_a, path_b, list(grid_boxes))
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
_debug(f"grid_cell_states failed: {exc}")
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
def _get_solution(
|
|
286
|
+
self, image_path: str, puzzle_source: str, retry_mode: Optional[str]
|
|
287
|
+
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
288
|
+
"""
|
|
289
|
+
The model query. In-process where TS spawns the CLI.
|
|
290
|
+
|
|
291
|
+
Token usage is read off the planner by DELTA rather than reset, because
|
|
292
|
+
the planner is shared across the whole solve and the caller wants a
|
|
293
|
+
per-round figure that still sums to the session total.
|
|
294
|
+
"""
|
|
295
|
+
planner = self._solver.planner
|
|
296
|
+
before = len(planner.token_usage)
|
|
297
|
+
actions = self._solver.solve(
|
|
298
|
+
image_path, puzzle_source=puzzle_source, retry_mode=retry_mode
|
|
299
|
+
)
|
|
300
|
+
usage = [dict(u) for u in planner.token_usage[before:]]
|
|
301
|
+
if not isinstance(actions, list):
|
|
302
|
+
actions = [actions]
|
|
303
|
+
return actions, usage
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
# Mouse
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
|
|
310
|
+
"""
|
|
311
|
+
The window we must keep the cursor inside, cached per solve.
|
|
312
|
+
|
|
313
|
+
MUST NOT be skipped and MUST NOT be guessed, because a mouse move to a
|
|
314
|
+
coordinate outside the window WEDGES camoufox. Its juggler humanises
|
|
315
|
+
each move into a trajectory, guards the intermediate points against the
|
|
316
|
+
bounds, and then dispatches the requested destination unguarded
|
|
317
|
+
("Always finish exactly on the requested destination"). An out-of-window
|
|
318
|
+
destination fires as an exit event instead of eMouseMove, so no
|
|
319
|
+
hit-renderer ack comes back; dispatch is serialised on a process-global
|
|
320
|
+
activation chain, so that one missing ack hangs every later input event
|
|
321
|
+
forever. Symptom: `page.mouse.move()` never returns, 0% CPU, solve
|
|
322
|
+
appears dead. Same failure family as camoufox #225.
|
|
323
|
+
|
|
324
|
+
`page.viewport_size` is None under camoufox (it uses the real window
|
|
325
|
+
rather than a spoofed viewport), which is why this falls back to asking
|
|
326
|
+
the page itself instead of assuming a size.
|
|
327
|
+
"""
|
|
328
|
+
if self._viewport_cache is not None:
|
|
329
|
+
return self._viewport_cache
|
|
330
|
+
try:
|
|
331
|
+
vp = page.viewport_size
|
|
332
|
+
if callable(vp): # some adapters expose it as a method
|
|
333
|
+
vp = vp()
|
|
334
|
+
if vp and vp.get("width") and vp.get("height"):
|
|
335
|
+
self._viewport_cache = {"width": float(vp["width"]), "height": float(vp["height"])}
|
|
336
|
+
return self._viewport_cache
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
try:
|
|
340
|
+
inner = page.evaluate("() => ({width: window.innerWidth, height: window.innerHeight})")
|
|
341
|
+
if inner and inner.get("width") and inner.get("height"):
|
|
342
|
+
self._viewport_cache = {
|
|
343
|
+
"width": float(inner["width"]),
|
|
344
|
+
"height": float(inner["height"]),
|
|
345
|
+
}
|
|
346
|
+
return self._viewport_cache
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
def _trace_path(self, page: Any, points: Sequence[Tuple[float, float]], timings: Sequence[float]) -> None:
|
|
352
|
+
# Clamp ONLY when the viewport is actually known.
|
|
353
|
+
#
|
|
354
|
+
# camoufox reports `viewport_size is None` (it uses the real window
|
|
355
|
+
# rather than a spoofed viewport). The obvious fallback — assume
|
|
356
|
+
# 1920x1080 — is actively harmful: it clamps coordinates to the edge of
|
|
357
|
+
# a viewport that is not the real one, and a coordinate sitting EXACTLY
|
|
358
|
+
# on the boundary is the input that deadlocks camoufox's humanised-mouse
|
|
359
|
+
# juggler patch (upstream #225, "humanize edge deadlock"). The symptom is
|
|
360
|
+
# a `page.mouse.move()` that never returns: the process sits at 0% CPU
|
|
361
|
+
# with no in-flight work and the solve appears hung, which is precisely
|
|
362
|
+
# what this driver did against camoufox until this was found.
|
|
363
|
+
#
|
|
364
|
+
# A guessed clamp buys nothing anyway — the points come from a
|
|
365
|
+
# trajectory between two on-screen elements, so they are already in
|
|
366
|
+
# range. When we do clamp, we inset by a pixel so a legitimately
|
|
367
|
+
# off-screen point lands just inside the edge instead of exactly on it.
|
|
368
|
+
viewport = self._viewport(page)
|
|
369
|
+
|
|
370
|
+
start = time.monotonic() * 1000.0
|
|
371
|
+
for i, (x, y) in enumerate(points):
|
|
372
|
+
try:
|
|
373
|
+
if viewport is None:
|
|
374
|
+
cx, cy = float(x), float(y)
|
|
375
|
+
else:
|
|
376
|
+
cx = max(1.0, min(float(x), float(viewport["width"]) - 1.0))
|
|
377
|
+
cy = max(1.0, min(float(y), float(viewport["height"]) - 1.0))
|
|
378
|
+
page.mouse.move(cx, cy)
|
|
379
|
+
self._last_mouse = (cx, cy)
|
|
380
|
+
if i < len(timings):
|
|
381
|
+
target = start + timings[i]
|
|
382
|
+
_delay(target - time.monotonic() * 1000.0)
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
msg = str(exc)
|
|
385
|
+
if "Target closed" in msg or "Session closed" in msg:
|
|
386
|
+
_log("could not move mouse; page or session closed")
|
|
387
|
+
return
|
|
388
|
+
# Any other per-sample failure is skipped rather than fatal —
|
|
389
|
+
# losing one mousemove must not lose the solve.
|
|
390
|
+
|
|
391
|
+
def _smooth_move(self, page: Any, x: float, y: float) -> None:
|
|
392
|
+
points, timings = generate_trajectory(self._last_mouse, (x, y), 60)
|
|
393
|
+
self._trace_path(page, points, timings)
|
|
394
|
+
|
|
395
|
+
def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
|
|
396
|
+
# BOUNDED. Playwright's default timeout is 30s, and this is called once
|
|
397
|
+
# per action plus once per submit — on a challenge iframe that is
|
|
398
|
+
# mid-animation, scrolling "waits for stability" and burns the full 30s
|
|
399
|
+
# every time, which turned a ~5s solve loop into minutes during live
|
|
400
|
+
# testing. The element is already on screen in every real case here (we
|
|
401
|
+
# just screenshotted it), so a short bound loses nothing: on timeout we
|
|
402
|
+
# move to wherever it currently is.
|
|
403
|
+
try:
|
|
404
|
+
element.scroll_into_view_if_needed(timeout=2_000)
|
|
405
|
+
except TypeError:
|
|
406
|
+
# An adapter whose signature takes no timeout (e.g. the Puppeteer
|
|
407
|
+
# bridge). Fall back rather than fail the solve.
|
|
408
|
+
try:
|
|
409
|
+
element.scroll_into_view_if_needed()
|
|
410
|
+
except Exception:
|
|
411
|
+
pass
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
box = element.bounding_box()
|
|
415
|
+
if not box:
|
|
416
|
+
raise CaptchaSolveError("element has no bounding box")
|
|
417
|
+
pad = padding_percentage / 100.0
|
|
418
|
+
pad_x, pad_y = box["width"] * pad, box["height"] * pad
|
|
419
|
+
target_x = box["x"] + pad_x + random.random() * (box["width"] - 2 * pad_x)
|
|
420
|
+
target_y = box["y"] + pad_y + random.random() * (box["height"] - 2 * pad_y)
|
|
421
|
+
self._smooth_move(page, target_x, target_y)
|
|
422
|
+
|
|
423
|
+
def _move_and_click(self, page: Any, element: Any) -> None:
|
|
424
|
+
self._move_to_element(page, element)
|
|
425
|
+
page.mouse.down()
|
|
426
|
+
_delay(random.random() * 20 + 20)
|
|
427
|
+
page.mouse.up()
|
|
428
|
+
|
|
429
|
+
def _execute_click(
|
|
430
|
+
self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
|
|
431
|
+
) -> None:
|
|
432
|
+
bbox = action.get("target_bounding_box")
|
|
433
|
+
coords = action.get("target_coordinates")
|
|
434
|
+
if bbox:
|
|
435
|
+
min_x, min_y, max_x, max_y = (float(v) for v in bbox)
|
|
436
|
+
px_min_x = min_x * element_box["width"]
|
|
437
|
+
px_max_x = max_x * element_box["width"]
|
|
438
|
+
px_min_y = min_y * element_box["height"]
|
|
439
|
+
px_max_y = max_y * element_box["height"]
|
|
440
|
+
# 10% inset, so a click never lands on a tile's border.
|
|
441
|
+
pad_x = (px_max_x - px_min_x) * 0.1
|
|
442
|
+
pad_y = (px_max_y - px_min_y) * 0.1
|
|
443
|
+
rel_x = (px_min_x + pad_x) + random.random() * ((px_max_x - pad_x) - (px_min_x + pad_x))
|
|
444
|
+
rel_y = (px_min_y + pad_y) + random.random() * ((px_max_y - pad_y) - (px_min_y + pad_y))
|
|
445
|
+
elif coords:
|
|
446
|
+
rel_x = float(coords[0]) * element_box["width"]
|
|
447
|
+
rel_y = float(coords[1]) * element_box["height"]
|
|
448
|
+
else:
|
|
449
|
+
_log("click action without coordinates or bounding box; skipping")
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
self._smooth_move(page, element_box["x"] + rel_x, element_box["y"] + rel_y)
|
|
453
|
+
page.mouse.down()
|
|
454
|
+
_delay(random.random() * 30 + 20)
|
|
455
|
+
page.mouse.up()
|
|
456
|
+
|
|
457
|
+
def _execute_drag(
|
|
458
|
+
self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
|
|
459
|
+
) -> None:
|
|
460
|
+
def center(bbox: Sequence[float]) -> Tuple[float, float]:
|
|
461
|
+
return (
|
|
462
|
+
element_box["x"] + ((float(bbox[0]) + float(bbox[2])) / 2) * element_box["width"],
|
|
463
|
+
element_box["y"] + ((float(bbox[1]) + float(bbox[3])) / 2) * element_box["height"],
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
src = center(action["source_bounding_box"])
|
|
467
|
+
dst = center(action["target_bounding_box"])
|
|
468
|
+
self._smooth_move(page, *src)
|
|
469
|
+
page.mouse.down()
|
|
470
|
+
_delay(random.random() * 50 + 50)
|
|
471
|
+
self._smooth_move(page, *dst)
|
|
472
|
+
_delay(random.random() * 50 + 50)
|
|
473
|
+
page.mouse.up()
|
|
474
|
+
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
# Detection
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _visible(element: Any) -> bool:
|
|
481
|
+
try:
|
|
482
|
+
return bool(element and element.is_visible())
|
|
483
|
+
except Exception:
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
def _has_non_empty_field_value(self, page: Any, selector: str) -> bool:
|
|
487
|
+
try:
|
|
488
|
+
element = page.query_selector(selector)
|
|
489
|
+
if not element:
|
|
490
|
+
return False
|
|
491
|
+
value = element.get_attribute("value")
|
|
492
|
+
if value is None:
|
|
493
|
+
value = page.eval_on_selector(
|
|
494
|
+
selector, "node => (typeof node.value === 'string' ? node.value : '')"
|
|
495
|
+
)
|
|
496
|
+
return bool(value and str(value).strip())
|
|
497
|
+
except Exception:
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
def _is_recaptcha_anchor_checked(self, anchor_iframe: Any) -> bool:
|
|
501
|
+
try:
|
|
502
|
+
frame = anchor_iframe.content_frame()
|
|
503
|
+
if not frame:
|
|
504
|
+
return False
|
|
505
|
+
checked = frame.query_selector(".recaptcha-checkbox-checked")
|
|
506
|
+
return self._visible(checked)
|
|
507
|
+
except Exception:
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
def _is_hcaptcha_anchor_checked(self, anchor_iframe: Any) -> bool:
|
|
511
|
+
# hCaptcha sets <div id="checkbox" aria-checked="true"> on success. Demo
|
|
512
|
+
# pages don't always populate h-captcha-response, so this visual state is
|
|
513
|
+
# the necessary tie-breaker rather than a nicety.
|
|
514
|
+
try:
|
|
515
|
+
frame = anchor_iframe.content_frame()
|
|
516
|
+
if not frame:
|
|
517
|
+
return False
|
|
518
|
+
return self._visible(frame.query_selector('#checkbox[aria-checked="true"]'))
|
|
519
|
+
except Exception:
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
def has_interactive_widget_in_dom(self, page: Any) -> bool:
|
|
523
|
+
"""
|
|
524
|
+
Broader than `detect_captcha`: is a widget in the DOM AT ALL, even if it
|
|
525
|
+
has not rendered? Distinguishes "still loading, wait for it" from
|
|
526
|
+
"reCAPTCHA v3 / invisible, fail fast" — two cases a null detection
|
|
527
|
+
cannot tell apart.
|
|
528
|
+
"""
|
|
529
|
+
try:
|
|
530
|
+
for anchor in page.query_selector_all('iframe[src*="recaptcha/api2/anchor"]'):
|
|
531
|
+
src = anchor.get_attribute("src") or ""
|
|
532
|
+
# v3 / invisible-v2 injects only an anchor, with size=invisible,
|
|
533
|
+
# and never a challenge frame. Excluded here so we fail fast.
|
|
534
|
+
if not re.search(r"[?&]size=invisible", src):
|
|
535
|
+
return True
|
|
536
|
+
if page.query_selector('iframe[src*="recaptcha/api2/bframe"]'):
|
|
537
|
+
return True
|
|
538
|
+
if page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]'):
|
|
539
|
+
return True
|
|
540
|
+
if page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]'):
|
|
541
|
+
return True
|
|
542
|
+
except Exception:
|
|
543
|
+
pass
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
def detect_captcha(self, page: Any) -> Optional[Any]:
|
|
547
|
+
"""Open challenges first, then unsolved checkboxes. Mirror of `detectCaptcha`."""
|
|
548
|
+
recaptcha_challenge = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
|
|
549
|
+
if self._visible(recaptcha_challenge):
|
|
550
|
+
return recaptcha_challenge
|
|
551
|
+
|
|
552
|
+
# Match the URL fragment, not the title: hCaptcha's ANCHOR title also
|
|
553
|
+
# says "hCaptcha security challenge", so a title match mis-classifies it.
|
|
554
|
+
hcaptcha_challenge = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
|
|
555
|
+
if self._visible(hcaptcha_challenge):
|
|
556
|
+
return hcaptcha_challenge
|
|
557
|
+
|
|
558
|
+
recaptcha_checkbox = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
|
|
559
|
+
if self._visible(recaptcha_checkbox) and not self._is_recaptcha_anchor_checked(
|
|
560
|
+
recaptcha_checkbox
|
|
561
|
+
):
|
|
562
|
+
return recaptcha_checkbox
|
|
563
|
+
|
|
564
|
+
hcaptcha_checkbox = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
|
|
565
|
+
if self._visible(hcaptcha_checkbox):
|
|
566
|
+
has_token = self._has_non_empty_field_value(page, '[name="h-captcha-response"]')
|
|
567
|
+
if not has_token and not self._is_hcaptcha_anchor_checked(hcaptcha_checkbox):
|
|
568
|
+
return hcaptcha_checkbox
|
|
569
|
+
|
|
570
|
+
turnstile_iframe = page.query_selector('iframe[src*="challenges.cloudflare.com"]')
|
|
571
|
+
if self._visible(turnstile_iframe) and not self._has_non_empty_field_value(
|
|
572
|
+
page, '[name="cf-turnstile-response"]'
|
|
573
|
+
):
|
|
574
|
+
return turnstile_iframe
|
|
575
|
+
|
|
576
|
+
# Closed shadow roots hide the iframe; the container is still findable.
|
|
577
|
+
turnstile_container = page.query_selector(".cf-turnstile")
|
|
578
|
+
if self._visible(turnstile_container) and not self._has_non_empty_field_value(
|
|
579
|
+
page, '[name="cf-turnstile-response"]'
|
|
580
|
+
):
|
|
581
|
+
return turnstile_container
|
|
582
|
+
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
def is_captcha_solved(self, page: Any) -> bool:
|
|
586
|
+
"""
|
|
587
|
+
The vendor's definitive DONE signal — anchor checked or token populated.
|
|
588
|
+
|
|
589
|
+
Needed because after the final submit hCaptcha keeps the challenge iframe
|
|
590
|
+
VISIBLE for a couple of seconds while it verifies. Treating that frame as
|
|
591
|
+
a fresh puzzle burns ~18s re-running the pipeline on a closing frame.
|
|
592
|
+
"""
|
|
593
|
+
try:
|
|
594
|
+
hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
|
|
595
|
+
if self._visible(hc):
|
|
596
|
+
if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
|
|
597
|
+
return True
|
|
598
|
+
if self._is_hcaptcha_anchor_checked(hc):
|
|
599
|
+
return True
|
|
600
|
+
rc = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
|
|
601
|
+
if self._visible(rc):
|
|
602
|
+
if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
|
|
603
|
+
return True
|
|
604
|
+
if self._is_recaptcha_anchor_checked(rc):
|
|
605
|
+
return True
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
return False
|
|
609
|
+
|
|
610
|
+
def _is_challenge_freshly_rendered(self, page: Any) -> bool:
|
|
611
|
+
"""
|
|
612
|
+
A NEXT ROUND (prompt painted) as opposed to a frame animating closed
|
|
613
|
+
(prompt already gone). Lets a multi-round solve move on immediately
|
|
614
|
+
instead of waiting out the full post-submit window.
|
|
615
|
+
"""
|
|
616
|
+
try:
|
|
617
|
+
hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
|
|
618
|
+
if self._visible(hc):
|
|
619
|
+
frame = hc.content_frame()
|
|
620
|
+
prompt = frame.query_selector(".prompt-text") if frame else None
|
|
621
|
+
if self._visible(prompt):
|
|
622
|
+
text = prompt.text_content() or ""
|
|
623
|
+
if text.strip():
|
|
624
|
+
return True
|
|
625
|
+
rc = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
|
|
626
|
+
if self._visible(rc):
|
|
627
|
+
frame = rc.content_frame()
|
|
628
|
+
instructions = (
|
|
629
|
+
frame.query_selector(".rc-imageselect-instructions, #rc-imageselect")
|
|
630
|
+
if frame
|
|
631
|
+
else None
|
|
632
|
+
)
|
|
633
|
+
if self._visible(instructions):
|
|
634
|
+
return True
|
|
635
|
+
except Exception:
|
|
636
|
+
pass
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
def _has_recaptcha_underselect_error(self, page: Any) -> bool:
|
|
640
|
+
"""
|
|
641
|
+
reCAPTCHA's "select all matching images" banner, shown after Verify with
|
|
642
|
+
an incomplete selection.
|
|
643
|
+
|
|
644
|
+
Critically, the tiles do NOT refresh on this error: without special
|
|
645
|
+
handling the model sees the same image, answers `done` (to it, everything
|
|
646
|
+
matching IS selected), we click Verify again, and the solve loops until
|
|
647
|
+
it times out. Detecting it switches the next call into missed-tiles mode.
|
|
648
|
+
"""
|
|
649
|
+
try:
|
|
650
|
+
bframe = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
|
|
651
|
+
if not bframe:
|
|
652
|
+
return False
|
|
653
|
+
frame = bframe.content_frame()
|
|
654
|
+
if not frame:
|
|
655
|
+
return False
|
|
656
|
+
for selector in (
|
|
657
|
+
".rc-imageselect-error-select-more",
|
|
658
|
+
".rc-imageselect-error-dynamic-more",
|
|
659
|
+
".rc-imageselect-incorrect-response",
|
|
660
|
+
):
|
|
661
|
+
element = frame.query_selector(selector)
|
|
662
|
+
if not element:
|
|
663
|
+
continue
|
|
664
|
+
# reCAPTCHA toggles these via aria-hidden on a wrapper, so
|
|
665
|
+
# presence + non-empty text is the reliable test.
|
|
666
|
+
if self._visible(element):
|
|
667
|
+
text = element.text_content() or ""
|
|
668
|
+
if text.strip():
|
|
669
|
+
return True
|
|
670
|
+
except Exception:
|
|
671
|
+
pass
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
def _get_verify_button(self, frame: Any) -> Optional[Any]:
|
|
675
|
+
for text in ("Verify", "Next", "Submit", "Skip"):
|
|
676
|
+
lowered = text.lower()
|
|
677
|
+
try:
|
|
678
|
+
button = frame.query_selector(
|
|
679
|
+
f"xpath=//button[contains(translate(., "
|
|
680
|
+
f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
|
|
681
|
+
f" | //div[@role='button' and contains(translate(., "
|
|
682
|
+
f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
|
|
683
|
+
)
|
|
684
|
+
if self._visible(button):
|
|
685
|
+
return button
|
|
686
|
+
except Exception:
|
|
687
|
+
pass
|
|
688
|
+
try:
|
|
689
|
+
recaptcha_verify = frame.query_selector("#recaptcha-verify-button")
|
|
690
|
+
if self._visible(recaptcha_verify):
|
|
691
|
+
return recaptcha_verify
|
|
692
|
+
hcaptcha_verify = frame.query_selector(".button-submit")
|
|
693
|
+
if self._visible(hcaptcha_verify):
|
|
694
|
+
return hcaptcha_verify
|
|
695
|
+
except Exception:
|
|
696
|
+
pass
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
# ------------------------------------------------------------------
|
|
700
|
+
# Frame readiness
|
|
701
|
+
# ------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None) -> None:
|
|
704
|
+
"""
|
|
705
|
+
Short timeout + animations disabled, always.
|
|
706
|
+
|
|
707
|
+
Playwright's default 30s stability wait hangs per-screenshot on a
|
|
708
|
+
closing/animating challenge element; that alone made a multi-round solve
|
|
709
|
+
take ~115s. Failing fast and skipping a frame is strictly better.
|
|
710
|
+
"""
|
|
711
|
+
element.screenshot(
|
|
712
|
+
path=path,
|
|
713
|
+
timeout=timeout_ms if timeout_ms is not None else 2_500,
|
|
714
|
+
animations="disabled",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def _element_frame_hash(self, element: Any) -> Optional[str]:
|
|
718
|
+
path = _tmp_png("fh")
|
|
719
|
+
try:
|
|
720
|
+
self._screenshot(element, path)
|
|
721
|
+
with open(path, "rb") as handle:
|
|
722
|
+
return hashlib.sha1(handle.read()).hexdigest()
|
|
723
|
+
except Exception:
|
|
724
|
+
return None
|
|
725
|
+
finally:
|
|
726
|
+
_unlink(path)
|
|
727
|
+
|
|
728
|
+
def _wait_for_element_settled(self, element: Any) -> str:
|
|
729
|
+
"""
|
|
730
|
+
Poll until the element's pixels stop changing.
|
|
731
|
+
|
|
732
|
+
Returns 'settled' | 'animated' | 'timeout'. This is a PIXEL settle; the
|
|
733
|
+
caller pairs it with the DOM-level image wait so a static loading frame
|
|
734
|
+
(a spinner on grey, below the diff threshold) isn't mistaken for painted
|
|
735
|
+
tiles.
|
|
736
|
+
"""
|
|
737
|
+
cfg = self.config
|
|
738
|
+
start = time.monotonic() * 1000.0
|
|
739
|
+
previous: Optional[str] = None
|
|
740
|
+
still_streak = 0
|
|
741
|
+
frames: List[str] = []
|
|
742
|
+
try:
|
|
743
|
+
while (time.monotonic() * 1000.0) - start < cfg.settle_timeout_ms:
|
|
744
|
+
self._check_deadline("waiting for the challenge to settle")
|
|
745
|
+
path = _tmp_png("settle")
|
|
746
|
+
try:
|
|
747
|
+
self._screenshot(element, path)
|
|
748
|
+
except Exception:
|
|
749
|
+
_unlink(path)
|
|
750
|
+
_delay(cfg.settle_poll_ms)
|
|
751
|
+
continue
|
|
752
|
+
frames.append(path)
|
|
753
|
+
if previous:
|
|
754
|
+
moved = self._has_movement(previous, path, cfg.settle_diff_threshold)
|
|
755
|
+
still_streak = 0 if moved else still_streak + 1
|
|
756
|
+
if len(frames) > 1:
|
|
757
|
+
_unlink(frames.pop(0))
|
|
758
|
+
if still_streak >= cfg.settle_frames:
|
|
759
|
+
return "settled"
|
|
760
|
+
# Still moving this late means it is not merely loading.
|
|
761
|
+
if moved and (time.monotonic() * 1000.0) - start >= cfg.animated_challenge_after_ms:
|
|
762
|
+
return "animated"
|
|
763
|
+
previous = frames[-1]
|
|
764
|
+
_delay(cfg.settle_poll_ms)
|
|
765
|
+
return "timeout"
|
|
766
|
+
finally:
|
|
767
|
+
for path in frames:
|
|
768
|
+
_unlink(path)
|
|
769
|
+
|
|
770
|
+
def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
|
|
771
|
+
"""After a submit the frame MUST change (next round, or closing)."""
|
|
772
|
+
cfg = self.config
|
|
773
|
+
start = time.monotonic() * 1000.0
|
|
774
|
+
while (time.monotonic() * 1000.0) - start < cfg.post_submit_change_timeout_ms:
|
|
775
|
+
current = self._element_frame_hash(element)
|
|
776
|
+
if current and current != since_hash:
|
|
777
|
+
return True
|
|
778
|
+
_delay(cfg.settle_poll_ms)
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
def _wait_for_hcaptcha_challenge_images(self, challenge_iframe: Any) -> None:
|
|
782
|
+
"""
|
|
783
|
+
Block until the challenge's task images have actually painted.
|
|
784
|
+
|
|
785
|
+
hCaptcha sets each tile's background-image once the asset loads, and
|
|
786
|
+
ships an empty `url("")` placeholder before it arrives. Best-effort: a
|
|
787
|
+
timeout falls through to the screenshot, where the existing fail-fast
|
|
788
|
+
path still covers a genuinely unsupported puzzle.
|
|
789
|
+
"""
|
|
790
|
+
try:
|
|
791
|
+
frame = challenge_iframe.content_frame()
|
|
792
|
+
if not frame:
|
|
793
|
+
return
|
|
794
|
+
frame.wait_for_selector(".prompt-text", state="visible", timeout=8_000)
|
|
795
|
+
frame.wait_for_function(
|
|
796
|
+
"""() => {
|
|
797
|
+
const tiles = Array.from(
|
|
798
|
+
document.querySelectorAll('.task-image .image, .task .image'));
|
|
799
|
+
if (tiles.length > 0) {
|
|
800
|
+
return tiles.every((el) => {
|
|
801
|
+
const bg = getComputedStyle(el).backgroundImage;
|
|
802
|
+
return bg && bg !== 'none' && !/url\\(["']?["']?\\)/.test(bg);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const canvas = document.querySelector('canvas');
|
|
806
|
+
if (canvas && canvas.width > 0 && canvas.height > 0) return true;
|
|
807
|
+
const example = document.querySelector(
|
|
808
|
+
'.challenge-example img, .image-wrapper img');
|
|
809
|
+
return !!(example && example.complete && example.naturalWidth > 0);
|
|
810
|
+
}""",
|
|
811
|
+
timeout=8_000,
|
|
812
|
+
)
|
|
813
|
+
except Exception:
|
|
814
|
+
pass # timed out or detached mid-load; screenshot anyway
|
|
815
|
+
|
|
816
|
+
def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
|
|
817
|
+
"""
|
|
818
|
+
reCAPTCHA fades new tiles in over ~1s, on first load and on every
|
|
819
|
+
in-place refresh. Screenshotting mid-fade feeds the model a partial grid.
|
|
820
|
+
"""
|
|
821
|
+
cfg = self.config
|
|
822
|
+
start = time.monotonic() * 1000.0
|
|
823
|
+
frames: List[str] = []
|
|
824
|
+
try:
|
|
825
|
+
while (time.monotonic() * 1000.0) - start < cfg.grid_load_timeout_ms:
|
|
826
|
+
self._check_deadline("waiting for grid cells to load")
|
|
827
|
+
path = _tmp_png("gridpoll")
|
|
828
|
+
try:
|
|
829
|
+
self._screenshot(element, path)
|
|
830
|
+
except Exception:
|
|
831
|
+
_unlink(path)
|
|
832
|
+
_delay(cfg.grid_load_poll_interval_ms)
|
|
833
|
+
continue
|
|
834
|
+
frames.append(path)
|
|
835
|
+
if len(frames) >= 2:
|
|
836
|
+
boxes = self._find_grid(frames[-1])
|
|
837
|
+
if boxes:
|
|
838
|
+
states = self._grid_cell_states(frames[-2], frames[-1], boxes)
|
|
839
|
+
if (
|
|
840
|
+
states
|
|
841
|
+
and not states["empty"]
|
|
842
|
+
and not states["changing"]
|
|
843
|
+
and states["loaded"]
|
|
844
|
+
):
|
|
845
|
+
return True
|
|
846
|
+
_unlink(frames.pop(0))
|
|
847
|
+
_delay(cfg.grid_load_poll_interval_ms)
|
|
848
|
+
return False
|
|
849
|
+
except Exception:
|
|
850
|
+
return False
|
|
851
|
+
finally:
|
|
852
|
+
for path in frames:
|
|
853
|
+
_unlink(path)
|
|
854
|
+
|
|
855
|
+
def _get_grid_boxes(self, element: Any) -> Optional[Dict[str, Any]]:
|
|
856
|
+
"""
|
|
857
|
+
Detect the grid ONCE per puzzle session. Geometry is stable across the
|
|
858
|
+
in-place dynamic refresh (only tile images change), so callers cache it.
|
|
859
|
+
"""
|
|
860
|
+
path = _tmp_png("findgrid")
|
|
861
|
+
try:
|
|
862
|
+
self._screenshot(element, path, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
863
|
+
boxes = self._find_grid(path)
|
|
864
|
+
if not boxes or len(boxes) not in (9, 16):
|
|
865
|
+
return None
|
|
866
|
+
dims = _read_png_dimensions(path)
|
|
867
|
+
if not dims:
|
|
868
|
+
return None
|
|
869
|
+
return {
|
|
870
|
+
"boxes": [list(b) for b in boxes],
|
|
871
|
+
"size": 4 if len(boxes) == 16 else 3,
|
|
872
|
+
"screenshot_w": dims[0],
|
|
873
|
+
"screenshot_h": dims[1],
|
|
874
|
+
}
|
|
875
|
+
except Exception:
|
|
876
|
+
return None
|
|
877
|
+
finally:
|
|
878
|
+
_unlink(path)
|
|
879
|
+
|
|
880
|
+
# ------------------------------------------------------------------
|
|
881
|
+
# Freshness guard
|
|
882
|
+
# ------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
def _frame_changed_since(self, element: Any, prior_path: str, threshold: float) -> bool:
|
|
885
|
+
probe = _tmp_png("freshcheck")
|
|
886
|
+
try:
|
|
887
|
+
self._screenshot(element, probe)
|
|
888
|
+
return self._has_movement(prior_path, probe, threshold)
|
|
889
|
+
except Exception:
|
|
890
|
+
return False
|
|
891
|
+
finally:
|
|
892
|
+
_unlink(probe)
|
|
893
|
+
|
|
894
|
+
def _solve_frame_freshness_guarded(
|
|
895
|
+
self,
|
|
896
|
+
element: Any,
|
|
897
|
+
initial_shot: str,
|
|
898
|
+
run_query: Callable[[str], Tuple[List[CaptchaAction], List[Dict[str, Any]]]],
|
|
899
|
+
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
900
|
+
"""
|
|
901
|
+
Never act on a stale frame.
|
|
902
|
+
|
|
903
|
+
If the frame changed WHILE the model was generating, its answer describes
|
|
904
|
+
an undeveloped frame whose tiles no longer line up — so re-screenshot and
|
|
905
|
+
re-solve on the developed one. Token usage from every attempt is merged,
|
|
906
|
+
because every attempt was really billed.
|
|
907
|
+
"""
|
|
908
|
+
cfg = self.config
|
|
909
|
+
owned: List[str] = []
|
|
910
|
+
merged_usage: List[Dict[str, Any]] = []
|
|
911
|
+
try:
|
|
912
|
+
current = initial_shot
|
|
913
|
+
actions, usage = run_query(current)
|
|
914
|
+
merged_usage.extend(usage)
|
|
915
|
+
if not cfg.stale_frame_resolve_enabled:
|
|
916
|
+
return actions, merged_usage
|
|
917
|
+
|
|
918
|
+
for attempt in range(cfg.max_stale_frame_resolves):
|
|
919
|
+
if not self._frame_changed_since(element, current, cfg.stale_frame_diff_threshold):
|
|
920
|
+
break # frame held still through inference — answer is valid
|
|
921
|
+
fresh = _tmp_png("freshsolve")
|
|
922
|
+
try:
|
|
923
|
+
self._screenshot(element, fresh)
|
|
924
|
+
except Exception:
|
|
925
|
+
_unlink(fresh)
|
|
926
|
+
break # can't grab a fresh frame — act on what we have
|
|
927
|
+
owned.append(fresh)
|
|
928
|
+
_log(
|
|
929
|
+
f"[freshness] frame changed during inference "
|
|
930
|
+
f"(re-solve {attempt + 1}/{cfg.max_stale_frame_resolves})"
|
|
931
|
+
)
|
|
932
|
+
current = fresh
|
|
933
|
+
actions, usage = run_query(current)
|
|
934
|
+
merged_usage.extend(usage)
|
|
935
|
+
return actions, merged_usage
|
|
936
|
+
finally:
|
|
937
|
+
for path in owned:
|
|
938
|
+
_unlink(path)
|
|
939
|
+
|
|
940
|
+
# ------------------------------------------------------------------
|
|
941
|
+
# reCAPTCHA 3x3 dynamic driver
|
|
942
|
+
# ------------------------------------------------------------------
|
|
943
|
+
|
|
944
|
+
def _cell_center_page(self, cell: int, session: _GridSession) -> Tuple[float, float]:
|
|
945
|
+
x1, y1, x2, y2 = session.grid_boxes[cell - 1]
|
|
946
|
+
return (
|
|
947
|
+
session.element_box["x"] + ((x1 + x2) / 2) * session.scale_x,
|
|
948
|
+
session.element_box["y"] + ((y1 + y2) / 2) * session.scale_y,
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
|
|
952
|
+
first = session.grid_boxes[0]
|
|
953
|
+
cell_w = (first[2] - first[0]) * session.scale_x
|
|
954
|
+
cell_h = (first[3] - first[1]) * session.scale_y
|
|
955
|
+
cx, cy = self._cell_center_page(cell, session)
|
|
956
|
+
self._smooth_move(
|
|
957
|
+
page,
|
|
958
|
+
cx + (random.random() - 0.5) * cell_w * 0.4,
|
|
959
|
+
cy + (random.random() - 0.5) * cell_h * 0.4,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
@staticmethod
|
|
963
|
+
def _bbox_to_cell(
|
|
964
|
+
bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]], width: int, height: int
|
|
965
|
+
) -> Optional[int]:
|
|
966
|
+
cx = ((float(bbox[0]) + float(bbox[2])) / 2) * width
|
|
967
|
+
cy = ((float(bbox[1]) + float(bbox[3])) / 2) * height
|
|
968
|
+
for index, (x1, y1, x2, y2) in enumerate(grid_boxes):
|
|
969
|
+
if x1 <= cx <= x2 and y1 <= cy <= y2:
|
|
970
|
+
return index + 1 # 1-indexed, row-major, matching find_grid
|
|
971
|
+
return None
|
|
972
|
+
|
|
973
|
+
@staticmethod
|
|
974
|
+
def _order_by_priority(loading: Sequence[int], priority: Sequence[int]) -> List[int]:
|
|
975
|
+
remaining = set(loading)
|
|
976
|
+
ordered: List[int] = []
|
|
977
|
+
for cell in priority:
|
|
978
|
+
if cell in remaining:
|
|
979
|
+
ordered.append(cell)
|
|
980
|
+
remaining.discard(cell)
|
|
981
|
+
ordered.extend(sorted(remaining))
|
|
982
|
+
return ordered
|
|
983
|
+
|
|
984
|
+
def _current_loading_cells(
|
|
985
|
+
self, page: Any, element: Any, session: _GridSession, priority: Sequence[int] = ()
|
|
986
|
+
) -> List[int]:
|
|
987
|
+
"""
|
|
988
|
+
Watch for the ONSET of the refresh over a grace window.
|
|
989
|
+
|
|
990
|
+
The blank/fade transition LAGS the click by a beat: reCAPTCHA holds a
|
|
991
|
+
clicked tile selected (old image visible) for ~1-3s and only then blanks
|
|
992
|
+
it. A single snapshot right after clicking therefore sees nothing and
|
|
993
|
+
concludes the puzzle is finished. Hovers a clicked tile each poll so the
|
|
994
|
+
cursor keeps moving during the wait.
|
|
995
|
+
"""
|
|
996
|
+
cfg = self.config
|
|
997
|
+
watch = list(priority) if priority else None
|
|
998
|
+
start = time.monotonic() * 1000.0
|
|
999
|
+
frames: List[str] = []
|
|
1000
|
+
hover_index = 0
|
|
1001
|
+
try:
|
|
1002
|
+
first = _tmp_png("loadchk")
|
|
1003
|
+
self._screenshot(element, first)
|
|
1004
|
+
frames.append(first)
|
|
1005
|
+
|
|
1006
|
+
while (time.monotonic() * 1000.0) - start < cfg.recaptcha_fade_onset_grace_ms:
|
|
1007
|
+
self._check_deadline("watching for the tile refresh")
|
|
1008
|
+
iteration_start = time.monotonic() * 1000.0
|
|
1009
|
+
if priority:
|
|
1010
|
+
try:
|
|
1011
|
+
self._hover_cell(page, session, priority[hover_index % len(priority)])
|
|
1012
|
+
except Exception:
|
|
1013
|
+
pass
|
|
1014
|
+
hover_index += 1
|
|
1015
|
+
# Enforce a minimum inter-frame gap: the in-process CV call is
|
|
1016
|
+
# near-instant, so without this the polls fire back-to-back on
|
|
1017
|
+
# near-identical frames and miss a slow fade.
|
|
1018
|
+
elapsed = (time.monotonic() * 1000.0) - iteration_start
|
|
1019
|
+
if elapsed < cfg.recaptcha_dynamic_fade_poll_ms:
|
|
1020
|
+
_delay(cfg.recaptcha_dynamic_fade_poll_ms - elapsed)
|
|
1021
|
+
|
|
1022
|
+
path = _tmp_png("loadchk")
|
|
1023
|
+
self._screenshot(element, path)
|
|
1024
|
+
frames.append(path)
|
|
1025
|
+
|
|
1026
|
+
states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
|
|
1027
|
+
in_scope = lambda c: watch is None or c in watch # noqa: E731
|
|
1028
|
+
empty = [c for c in (states or {}).get("empty", []) if in_scope(c)]
|
|
1029
|
+
changing = [c for c in (states or {}).get("changing", []) if in_scope(c)]
|
|
1030
|
+
loading = sorted(set(empty) | set(changing))
|
|
1031
|
+
if loading:
|
|
1032
|
+
return self._order_by_priority(loading, priority)
|
|
1033
|
+
_unlink(frames.pop(0))
|
|
1034
|
+
return []
|
|
1035
|
+
except Exception as exc:
|
|
1036
|
+
_debug(f"fade-onset error: {exc}")
|
|
1037
|
+
return []
|
|
1038
|
+
finally:
|
|
1039
|
+
for path in frames:
|
|
1040
|
+
_unlink(path)
|
|
1041
|
+
|
|
1042
|
+
def _wait_for_any_clicked_tile_loaded(
|
|
1043
|
+
self, page: Any, element: Any, session: _GridSession, fading_cells: Sequence[int]
|
|
1044
|
+
) -> bool:
|
|
1045
|
+
"""Wait for at least one blank/fading cell to finish reloading."""
|
|
1046
|
+
if not fading_cells:
|
|
1047
|
+
return True
|
|
1048
|
+
cfg = self.config
|
|
1049
|
+
start = time.monotonic() * 1000.0
|
|
1050
|
+
frames: List[str] = []
|
|
1051
|
+
hover_index = 0
|
|
1052
|
+
try:
|
|
1053
|
+
while (time.monotonic() * 1000.0) - start < cfg.recaptcha_dynamic_fade_wait_ms:
|
|
1054
|
+
self._check_deadline("waiting for a tile to reload")
|
|
1055
|
+
iteration_start = time.monotonic() * 1000.0
|
|
1056
|
+
if cfg.recaptcha_tile_hover_enabled:
|
|
1057
|
+
try:
|
|
1058
|
+
self._hover_cell(
|
|
1059
|
+
page, session, fading_cells[hover_index % len(fading_cells)]
|
|
1060
|
+
)
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
hover_index += 1
|
|
1064
|
+
elapsed = (time.monotonic() * 1000.0) - iteration_start
|
|
1065
|
+
if elapsed < cfg.recaptcha_dynamic_fade_poll_ms:
|
|
1066
|
+
_delay(cfg.recaptcha_dynamic_fade_poll_ms - elapsed)
|
|
1067
|
+
|
|
1068
|
+
path = _tmp_png("fadepoll")
|
|
1069
|
+
self._screenshot(element, path)
|
|
1070
|
+
frames.append(path)
|
|
1071
|
+
|
|
1072
|
+
if len(frames) >= 2:
|
|
1073
|
+
states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
|
|
1074
|
+
if states and [c for c in fading_cells if c in states["loaded"]]:
|
|
1075
|
+
return True
|
|
1076
|
+
_unlink(frames.pop(0))
|
|
1077
|
+
return False
|
|
1078
|
+
except Exception as exc:
|
|
1079
|
+
_debug(f"wait-load error: {exc}")
|
|
1080
|
+
return False
|
|
1081
|
+
finally:
|
|
1082
|
+
for path in frames:
|
|
1083
|
+
_unlink(path)
|
|
1084
|
+
|
|
1085
|
+
def _solve_recaptcha_grid(
|
|
1086
|
+
self,
|
|
1087
|
+
page: Any,
|
|
1088
|
+
element: Any,
|
|
1089
|
+
retry_mode: Optional[str],
|
|
1090
|
+
grid: Dict[str, Any],
|
|
1091
|
+
element_box: Dict[str, float],
|
|
1092
|
+
) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
1093
|
+
"""
|
|
1094
|
+
Multi-round driver for reCAPTCHA 3x3 dynamic puzzles. One call = one
|
|
1095
|
+
puzzle session.
|
|
1096
|
+
|
|
1097
|
+
The shared Python half is authoritative about WHAT to do — it runs the
|
|
1098
|
+
blue-badge detector, filters already-selected and still-loading tiles,
|
|
1099
|
+
and returns `click` / `wait` / `done`. This driver owns the human-like
|
|
1100
|
+
WAITING that logic cannot: after a click round it hovers the just-clicked
|
|
1101
|
+
tiles and waits for at least one to finish reloading before re-solving,
|
|
1102
|
+
so we never burn a model call on a grid that is still mid-fade.
|
|
1103
|
+
|
|
1104
|
+
Submits ONLY on `done` — never on a round-cap exit, which is left to the
|
|
1105
|
+
outer loop to re-detect and decide.
|
|
1106
|
+
"""
|
|
1107
|
+
cfg = self.config
|
|
1108
|
+
session = _GridSession(
|
|
1109
|
+
grid_boxes=grid["boxes"],
|
|
1110
|
+
element_box=element_box,
|
|
1111
|
+
scale_x=element_box["width"] / grid["screenshot_w"],
|
|
1112
|
+
scale_y=element_box["height"] / grid["screenshot_h"],
|
|
1113
|
+
screenshot_w=grid["screenshot_w"],
|
|
1114
|
+
screenshot_h=grid["screenshot_h"],
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
clicked_order: List[int] = []
|
|
1118
|
+
performed_action = False
|
|
1119
|
+
should_submit = False
|
|
1120
|
+
all_usage: List[Dict[str, Any]] = []
|
|
1121
|
+
pending_retry = retry_mode
|
|
1122
|
+
|
|
1123
|
+
for round_index in range(1, cfg.recaptcha_max_dynamic_rounds + 1):
|
|
1124
|
+
# Each round can legitimately spend ~10s waiting on fades, so eight
|
|
1125
|
+
# of them plus the model calls can outlast the whole solve budget.
|
|
1126
|
+
self._check_deadline(f"recaptcha grid round {round_index}")
|
|
1127
|
+
self._wait_for_grid_cells_loaded(element)
|
|
1128
|
+
shot = _tmp_png("recap")
|
|
1129
|
+
action: Optional[Dict[str, Any]] = None
|
|
1130
|
+
try:
|
|
1131
|
+
self._screenshot(element, shot, timeout_ms=cfg.element_screenshot_timeout_ms)
|
|
1132
|
+
retry_for_round = pending_retry
|
|
1133
|
+
pending_retry = None # only round 1 carries the inbound hint
|
|
1134
|
+
actions, usage = self._solve_frame_freshness_guarded(
|
|
1135
|
+
element,
|
|
1136
|
+
shot,
|
|
1137
|
+
lambda image_path: self._get_solution(image_path, "recaptcha", retry_for_round),
|
|
1138
|
+
)
|
|
1139
|
+
all_usage.extend(usage)
|
|
1140
|
+
action = _as_dict(actions[0]) if actions else None
|
|
1141
|
+
finally:
|
|
1142
|
+
_unlink(shot)
|
|
1143
|
+
|
|
1144
|
+
if not action or action.get("action") == "done":
|
|
1145
|
+
_log(f"[recaptcha-grid] round {round_index}: done; submitting.")
|
|
1146
|
+
should_submit = True
|
|
1147
|
+
break
|
|
1148
|
+
|
|
1149
|
+
if action.get("action") == "wait":
|
|
1150
|
+
_log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
|
|
1151
|
+
loading = self._current_loading_cells(page, element, session, clicked_order)
|
|
1152
|
+
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
1153
|
+
continue
|
|
1154
|
+
|
|
1155
|
+
if action.get("action") == "click":
|
|
1156
|
+
bboxes = action.get("target_bounding_boxes") or (
|
|
1157
|
+
[action["target_bounding_box"]] if action.get("target_bounding_box") else []
|
|
1158
|
+
)
|
|
1159
|
+
if not bboxes:
|
|
1160
|
+
# Malformed click: treat as a soft wait so we never submit
|
|
1161
|
+
# prematurely, and re-solve next round.
|
|
1162
|
+
_log(f"[recaptcha-grid] round {round_index}: click with no bboxes; re-solving.")
|
|
1163
|
+
_delay(500)
|
|
1164
|
+
continue
|
|
1165
|
+
|
|
1166
|
+
clicked_this_round: List[int] = []
|
|
1167
|
+
for bbox in bboxes:
|
|
1168
|
+
cell = self._bbox_to_cell(
|
|
1169
|
+
bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h
|
|
1170
|
+
)
|
|
1171
|
+
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
1172
|
+
if cell is not None:
|
|
1173
|
+
clicked_order.append(cell)
|
|
1174
|
+
clicked_this_round.append(cell)
|
|
1175
|
+
_delay(random.random() * 80 + 80)
|
|
1176
|
+
performed_action = True
|
|
1177
|
+
_log(
|
|
1178
|
+
f"[recaptcha-grid] round {round_index}: clicked {len(bboxes)} tile(s) "
|
|
1179
|
+
f"-> cells {clicked_this_round}."
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
loading = self._current_loading_cells(page, element, session, clicked_this_round)
|
|
1183
|
+
if not loading:
|
|
1184
|
+
# Nothing faded within the grace window → the model fully
|
|
1185
|
+
# solved it; submit rather than burning another round.
|
|
1186
|
+
_log(f"[recaptcha-grid] round {round_index}: nothing loading; submitting.")
|
|
1187
|
+
should_submit = True
|
|
1188
|
+
break
|
|
1189
|
+
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
1190
|
+
continue
|
|
1191
|
+
|
|
1192
|
+
_log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
|
|
1193
|
+
|
|
1194
|
+
if should_submit:
|
|
1195
|
+
frame = element.content_frame()
|
|
1196
|
+
if frame:
|
|
1197
|
+
verify = self._get_verify_button(frame)
|
|
1198
|
+
if verify:
|
|
1199
|
+
_log("[recaptcha-grid] clicking Verify to submit.")
|
|
1200
|
+
self._move_and_click(page, verify)
|
|
1201
|
+
|
|
1202
|
+
return performed_action, all_usage
|
|
1203
|
+
|
|
1204
|
+
# ------------------------------------------------------------------
|
|
1205
|
+
# One pass over a rendered challenge
|
|
1206
|
+
# ------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
def _solve_single(
|
|
1209
|
+
self, page: Any, element: Any, retry_mode: Optional[str]
|
|
1210
|
+
) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
1211
|
+
src = None
|
|
1212
|
+
try:
|
|
1213
|
+
src = element.get_attribute("src")
|
|
1214
|
+
except Exception:
|
|
1215
|
+
pass
|
|
1216
|
+
src = src or ""
|
|
1217
|
+
|
|
1218
|
+
# The vendor hint routes to the right pipeline. It matters: hCaptcha
|
|
1219
|
+
# click puzzles must never go through grid detection, because find_grid
|
|
1220
|
+
# false-positives on the header/footer bands.
|
|
1221
|
+
if "hcaptcha.com" in src:
|
|
1222
|
+
puzzle_source = "hcaptcha"
|
|
1223
|
+
elif "recaptcha/api2" in src:
|
|
1224
|
+
puzzle_source = "recaptcha"
|
|
1225
|
+
else:
|
|
1226
|
+
puzzle_source = "unknown"
|
|
1227
|
+
|
|
1228
|
+
# hCaptcha REUSES the challenge iframe across rounds: after a submit it
|
|
1229
|
+
# briefly shows the previous round, then a spinner, then the next one.
|
|
1230
|
+
# Screenshotting any of those transitional frames feeds the model a
|
|
1231
|
+
# blank/stale grid it correctly calls "unsupported" — which used to abort
|
|
1232
|
+
# the whole solve on round 2.
|
|
1233
|
+
if puzzle_source == "hcaptcha" and "frame=challenge" in src:
|
|
1234
|
+
if self._last_submit_frame_hash:
|
|
1235
|
+
self._wait_for_change_since(element, self._last_submit_frame_hash)
|
|
1236
|
+
self._last_submit_frame_hash = None
|
|
1237
|
+
self._wait_for_hcaptcha_challenge_images(element)
|
|
1238
|
+
if self._wait_for_element_settled(element) == "animated":
|
|
1239
|
+
raise AnimatedChallengeError(
|
|
1240
|
+
"the challenge never settles (likely a video/animated puzzle)"
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
# Only the image-challenge frame holds a grid. Running grid detection on
|
|
1244
|
+
# the anchor checkbox just burns an 8s timeout before the click.
|
|
1245
|
+
is_recaptcha_challenge = puzzle_source == "recaptcha" and "recaptcha/api2/bframe" in src
|
|
1246
|
+
is_recaptcha_one_shot = False
|
|
1247
|
+
if is_recaptcha_challenge:
|
|
1248
|
+
self._wait_for_grid_cells_loaded(element)
|
|
1249
|
+
grid = self._get_grid_boxes(element)
|
|
1250
|
+
if grid and grid["size"] == 3:
|
|
1251
|
+
# 3x3 refreshes tiles in place, so it needs the multi-round
|
|
1252
|
+
# driver. 4x4 only ever returns `checked` and is one-shot.
|
|
1253
|
+
element_box = element.bounding_box()
|
|
1254
|
+
if element_box:
|
|
1255
|
+
return self._solve_recaptcha_grid(
|
|
1256
|
+
page, element, retry_mode, grid, element_box
|
|
1257
|
+
)
|
|
1258
|
+
elif grid and grid["size"] == 4:
|
|
1259
|
+
is_recaptcha_one_shot = True
|
|
1260
|
+
|
|
1261
|
+
shot = _tmp_png("captcha")
|
|
1262
|
+
performed_action = False
|
|
1263
|
+
all_usage: List[Dict[str, Any]] = []
|
|
1264
|
+
try:
|
|
1265
|
+
self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1266
|
+
|
|
1267
|
+
actions, all_usage = self._solve_frame_freshness_guarded(
|
|
1268
|
+
element,
|
|
1269
|
+
shot,
|
|
1270
|
+
lambda image_path: self._get_solution(image_path, puzzle_source, retry_mode),
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
element_box = element.bounding_box()
|
|
1274
|
+
if not element_box:
|
|
1275
|
+
raise CaptchaSolveError("could not get bounding box of captcha element")
|
|
1276
|
+
|
|
1277
|
+
_log(f"executing {len(actions)} action(s)")
|
|
1278
|
+
frame = element.content_frame()
|
|
1279
|
+
verify_button = None
|
|
1280
|
+
|
|
1281
|
+
for raw_action in actions:
|
|
1282
|
+
self._check_deadline("action execution")
|
|
1283
|
+
action = _as_dict(raw_action)
|
|
1284
|
+
kind = action.get("action")
|
|
1285
|
+
if kind == "click":
|
|
1286
|
+
bboxes = action.get("target_bounding_boxes") or (
|
|
1287
|
+
[action["target_bounding_box"]]
|
|
1288
|
+
if action.get("target_bounding_box")
|
|
1289
|
+
else []
|
|
1290
|
+
)
|
|
1291
|
+
if not bboxes and not action.get("target_coordinates"):
|
|
1292
|
+
_log("click action has no bboxes or coordinates; skipping")
|
|
1293
|
+
continue
|
|
1294
|
+
if bboxes:
|
|
1295
|
+
for bbox in bboxes:
|
|
1296
|
+
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
1297
|
+
_delay(random.random() * 80 + 80)
|
|
1298
|
+
else:
|
|
1299
|
+
self._execute_click(page, action, element_box)
|
|
1300
|
+
performed_action = True
|
|
1301
|
+
elif kind == "drag":
|
|
1302
|
+
self._execute_drag(page, action, element_box)
|
|
1303
|
+
performed_action = True
|
|
1304
|
+
elif kind == "wait":
|
|
1305
|
+
duration = int(action.get("duration_ms") or 0)
|
|
1306
|
+
if duration > 0:
|
|
1307
|
+
_delay(duration)
|
|
1308
|
+
performed_action = True
|
|
1309
|
+
|
|
1310
|
+
if frame:
|
|
1311
|
+
verify_button = self._get_verify_button(frame)
|
|
1312
|
+
if verify_button:
|
|
1313
|
+
self._move_to_element(page, verify_button)
|
|
1314
|
+
|
|
1315
|
+
# Submit policy:
|
|
1316
|
+
# hCaptcha — every puzzle is one-shot; Verify submits it.
|
|
1317
|
+
# reCAPTCHA 4x4 — one-shot too (never fades), so submit now.
|
|
1318
|
+
# no action/done — submit to advance.
|
|
1319
|
+
# (reCAPTCHA 3x3 never reaches here; it returned above.)
|
|
1320
|
+
should_submit = (
|
|
1321
|
+
not performed_action or puzzle_source == "hcaptcha" or is_recaptcha_one_shot
|
|
1322
|
+
)
|
|
1323
|
+
if should_submit and frame and verify_button:
|
|
1324
|
+
_log(f"clicking Verify to submit ({puzzle_source}).")
|
|
1325
|
+
self._move_and_click(page, verify_button)
|
|
1326
|
+
# Snapshot at submit time so the NEXT attempt waits for the real
|
|
1327
|
+
# transition before treating whatever is on screen as fresh.
|
|
1328
|
+
self._last_submit_frame_hash = self._element_frame_hash(element)
|
|
1329
|
+
finally:
|
|
1330
|
+
_unlink(shot)
|
|
1331
|
+
|
|
1332
|
+
return performed_action, all_usage
|
|
1333
|
+
|
|
1334
|
+
# ------------------------------------------------------------------
|
|
1335
|
+
# Public entry point
|
|
1336
|
+
# ------------------------------------------------------------------
|
|
1337
|
+
|
|
1338
|
+
def solve(self, page: Any) -> SolveResult:
|
|
1339
|
+
"""
|
|
1340
|
+
Solve whatever captcha is on `page`.
|
|
1341
|
+
|
|
1342
|
+
Returns a `SolveResult`; raises `NoCaptchaFoundError`,
|
|
1343
|
+
`UnsupportedChallengeError`, `AnimatedChallengeError`, or
|
|
1344
|
+
`CaptchaSolveError` on the failure modes named in each type.
|
|
1345
|
+
"""
|
|
1346
|
+
start = time.monotonic() * 1000.0
|
|
1347
|
+
cumulative_usage: List[Dict[str, Any]] = []
|
|
1348
|
+
self._last_submit_frame_hash = None
|
|
1349
|
+
self._deadline_ms = start + self.config.overall_solve_timeout_ms
|
|
1350
|
+
self._viewport_cache = None
|
|
1351
|
+
|
|
1352
|
+
# Mint one session id for the WHOLE solve, exactly as the TS driver does
|
|
1353
|
+
# per `solve()`. The planner turns it into `X-CK-Session`, which is what
|
|
1354
|
+
# lets the hosted gateway group this captcha's 1..N inference rounds into
|
|
1355
|
+
# a single billable attempt. Without it a multi-round dynamic 3x3 bills
|
|
1356
|
+
# as several attempts instead of one, so this is a billing-correctness
|
|
1357
|
+
# requirement, not telemetry.
|
|
1358
|
+
#
|
|
1359
|
+
# Restored rather than left set: a stale id leaking into the NEXT solve
|
|
1360
|
+
# would merge two separate captchas into one attempt — the opposite
|
|
1361
|
+
# error, and the one that under-bills.
|
|
1362
|
+
previous_session = os.environ.get(_SESSION_ENV)
|
|
1363
|
+
os.environ[_SESSION_ENV] = str(uuid.uuid4())
|
|
1364
|
+
try:
|
|
1365
|
+
return self._solve_impl(page, start, cumulative_usage)
|
|
1366
|
+
finally:
|
|
1367
|
+
self._deadline_ms = None
|
|
1368
|
+
if previous_session is None:
|
|
1369
|
+
os.environ.pop(_SESSION_ENV, None)
|
|
1370
|
+
else:
|
|
1371
|
+
os.environ[_SESSION_ENV] = previous_session
|
|
1372
|
+
|
|
1373
|
+
def _solve_impl(
|
|
1374
|
+
self,
|
|
1375
|
+
page: Any,
|
|
1376
|
+
start: float,
|
|
1377
|
+
cumulative_usage: List[Dict[str, Any]],
|
|
1378
|
+
) -> SolveResult:
|
|
1379
|
+
cfg = self.config
|
|
1380
|
+
|
|
1381
|
+
pending_retry_mode: Optional[str] = None
|
|
1382
|
+
already_retried_underselect = False
|
|
1383
|
+
unsupported_retries = 0
|
|
1384
|
+
stale_element_retries = 0
|
|
1385
|
+
has_interacted = False
|
|
1386
|
+
render_waits = 0
|
|
1387
|
+
max_render_waits = 6
|
|
1388
|
+
|
|
1389
|
+
for attempt in range(1, cfg.max_solve_loops + 1):
|
|
1390
|
+
if (time.monotonic() * 1000.0) - start > cfg.overall_solve_timeout_ms:
|
|
1391
|
+
raise CaptchaSolveError(
|
|
1392
|
+
f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms "
|
|
1393
|
+
f"(attempt {attempt}/{cfg.max_solve_loops})"
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
element = self.detect_captcha(page)
|
|
1397
|
+
if not element:
|
|
1398
|
+
# Two-stage. A null detection splits into two very different
|
|
1399
|
+
# cases and treating them alike is how you either hang on a
|
|
1400
|
+
# v3 page or give up on a slow-rendering widget.
|
|
1401
|
+
if has_interacted:
|
|
1402
|
+
_log("no supported captcha remains after interaction; considering solved.")
|
|
1403
|
+
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
1404
|
+
# Already satisfied before we touched anything — the widget is
|
|
1405
|
+
# present but the vendor has passed it (anchor checked / token
|
|
1406
|
+
# populated). Common with a good stealth browser: camoufox often
|
|
1407
|
+
# clears reCAPTCHA on the checkbox alone, with no challenge ever
|
|
1408
|
+
# shown. Without this, the render-wait branch below sits for
|
|
1409
|
+
# ~6s and then raises "no interactive captcha widget", turning
|
|
1410
|
+
# the BEST outcome into an exception the caller has to catch.
|
|
1411
|
+
if self.is_captcha_solved(page):
|
|
1412
|
+
_log("captcha already satisfied; nothing to solve.")
|
|
1413
|
+
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
1414
|
+
if self.has_interactive_widget_in_dom(page) and render_waits < max_render_waits:
|
|
1415
|
+
render_waits += 1
|
|
1416
|
+
_log(
|
|
1417
|
+
f"widget in DOM but not yet rendered; waiting "
|
|
1418
|
+
f"({render_waits}/{max_render_waits})."
|
|
1419
|
+
)
|
|
1420
|
+
_delay(800 + random.random() * 300)
|
|
1421
|
+
continue
|
|
1422
|
+
raise NoCaptchaFoundError(
|
|
1423
|
+
"no interactive captcha widget detected (likely reCAPTCHA v3 / "
|
|
1424
|
+
"invisible or a click-triggered challenge)"
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
_log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
|
|
1428
|
+
retry_mode_this_loop = pending_retry_mode
|
|
1429
|
+
pending_retry_mode = None
|
|
1430
|
+
|
|
1431
|
+
try:
|
|
1432
|
+
did_interact, usage = self._solve_single(page, element, retry_mode_this_loop)
|
|
1433
|
+
except AnimatedChallengeError:
|
|
1434
|
+
raise
|
|
1435
|
+
except UnsupportedCaptchaError:
|
|
1436
|
+
# A settled frame the model cannot solve is normally definitive.
|
|
1437
|
+
# BUT mid-solve, a transitional blank frame produces the same
|
|
1438
|
+
# verdict — that was the "solves round 1, dies on round 2" bug.
|
|
1439
|
+
if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
|
|
1440
|
+
unsupported_retries += 1
|
|
1441
|
+
current = self.detect_captcha(page)
|
|
1442
|
+
if current and self._wait_for_element_settled(current) == "animated":
|
|
1443
|
+
raise AnimatedChallengeError(
|
|
1444
|
+
"the challenge never settles (likely a video puzzle)"
|
|
1445
|
+
)
|
|
1446
|
+
_log(
|
|
1447
|
+
f'"unsupported" mid-solve; settled and retrying '
|
|
1448
|
+
f"({unsupported_retries}/{cfg.max_unsupported_resolves})."
|
|
1449
|
+
)
|
|
1450
|
+
continue
|
|
1451
|
+
raise UnsupportedChallengeError(
|
|
1452
|
+
"cannot solve this kind of captcha — the rendered puzzle is not a "
|
|
1453
|
+
"supported grid or checkbox (likely an hCaptcha click/drag puzzle)"
|
|
1454
|
+
)
|
|
1455
|
+
except Exception as exc:
|
|
1456
|
+
# A stale/detached handle after a submit is a TRANSITION, not a
|
|
1457
|
+
# dead puzzle: hCaptcha swapped in the next round while we held
|
|
1458
|
+
# the old iframe. Only after interacting — a first-frame failure
|
|
1459
|
+
# is a genuine problem worth surfacing.
|
|
1460
|
+
message = str(exc)
|
|
1461
|
+
if (
|
|
1462
|
+
has_interacted
|
|
1463
|
+
and stale_element_retries < cfg.max_stale_element_retries
|
|
1464
|
+
and _STALE_HANDLE_RE.search(message)
|
|
1465
|
+
):
|
|
1466
|
+
stale_element_retries += 1
|
|
1467
|
+
_log(
|
|
1468
|
+
f"stale challenge handle after submit; re-detecting next round "
|
|
1469
|
+
f"({stale_element_retries}/{cfg.max_stale_element_retries})."
|
|
1470
|
+
)
|
|
1471
|
+
_delay(cfg.stale_element_backoff_ms)
|
|
1472
|
+
continue
|
|
1473
|
+
raise
|
|
1474
|
+
|
|
1475
|
+
has_interacted = has_interacted or did_interact
|
|
1476
|
+
render_waits = 0
|
|
1477
|
+
cumulative_usage.extend(usage)
|
|
1478
|
+
|
|
1479
|
+
if did_interact:
|
|
1480
|
+
# Poll for the vendor's SOLVED signal before re-entering the
|
|
1481
|
+
# pipeline. hCaptcha keeps the challenge visible for a couple of
|
|
1482
|
+
# seconds while verifying; without this the loop re-solves that
|
|
1483
|
+
# closing frame and burns ~18s. Only ever early-RETURNS on a
|
|
1484
|
+
# definitive signal, so it cannot loop.
|
|
1485
|
+
deadline = time.monotonic() * 1000.0 + cfg.post_solve_outcome_timeout_ms
|
|
1486
|
+
solved = False
|
|
1487
|
+
while time.monotonic() * 1000.0 < deadline:
|
|
1488
|
+
if self.is_captcha_solved(page):
|
|
1489
|
+
solved = True
|
|
1490
|
+
break
|
|
1491
|
+
if self._is_challenge_freshly_rendered(page):
|
|
1492
|
+
break # next round is up; go solve it now
|
|
1493
|
+
_delay(200)
|
|
1494
|
+
if solved:
|
|
1495
|
+
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
1496
|
+
else:
|
|
1497
|
+
_delay(cfg.post_solve_delay_ms + random.random() * 300)
|
|
1498
|
+
|
|
1499
|
+
if self._has_recaptcha_underselect_error(page):
|
|
1500
|
+
if already_retried_underselect:
|
|
1501
|
+
raise CaptchaSolveError(
|
|
1502
|
+
"reCAPTCHA still showing the under-selection error after retry; "
|
|
1503
|
+
"aborting (model unable to identify the missed tile)"
|
|
1504
|
+
)
|
|
1505
|
+
_log("reCAPTCHA under-selection error; retrying with missed-tiles prompt.")
|
|
1506
|
+
pending_retry_mode = "missed-tiles"
|
|
1507
|
+
already_retried_underselect = True
|
|
1508
|
+
|
|
1509
|
+
if not self.detect_captcha(page):
|
|
1510
|
+
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
1511
|
+
|
|
1512
|
+
if not did_interact:
|
|
1513
|
+
raise CaptchaSolveError(
|
|
1514
|
+
"captcha still detected but the solver performed no interactions; "
|
|
1515
|
+
"aborting to avoid an infinite loop"
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
raise CaptchaSolveError(
|
|
1519
|
+
f"captcha still detected after {cfg.max_solve_loops} solve loops"
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
# --------------------------------------------------------------------------
|
|
1524
|
+
# Helpers
|
|
1525
|
+
# --------------------------------------------------------------------------
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _as_dict(action: Any) -> Dict[str, Any]:
|
|
1529
|
+
"""Actions arrive as pydantic models in-process, or dicts from JSON."""
|
|
1530
|
+
if isinstance(action, dict):
|
|
1531
|
+
return action
|
|
1532
|
+
for method in ("model_dump", "dict"):
|
|
1533
|
+
if hasattr(action, method):
|
|
1534
|
+
try:
|
|
1535
|
+
return getattr(action, method)()
|
|
1536
|
+
except Exception:
|
|
1537
|
+
pass
|
|
1538
|
+
return {"action": getattr(action, "action", None)}
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _aggregate(usage: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1542
|
+
"""Mirror of `aggregateTokenUsage`: one summed row, plus the raw rounds."""
|
|
1543
|
+
if not usage:
|
|
1544
|
+
return []
|
|
1545
|
+
total = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
1546
|
+
for entry in usage:
|
|
1547
|
+
for key in total:
|
|
1548
|
+
total[key] += int(entry.get(key) or 0)
|
|
1549
|
+
return [{"rounds": len(usage), **total}]
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _read_png_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
|
1553
|
+
"""
|
|
1554
|
+
Width/height from the IHDR chunk, so no image-size dependency is needed.
|
|
1555
|
+
PNG signature is 8 bytes, IHDR length+type another 8, then two big-endian
|
|
1556
|
+
uint32s at offsets 16 and 20.
|
|
1557
|
+
"""
|
|
1558
|
+
try:
|
|
1559
|
+
with open(path, "rb") as handle:
|
|
1560
|
+
header = handle.read(24)
|
|
1561
|
+
if len(header) < 24 or header[1:4] != b"PNG":
|
|
1562
|
+
return None
|
|
1563
|
+
width = int.from_bytes(header[16:20], "big")
|
|
1564
|
+
height = int.from_bytes(header[20:24], "big")
|
|
1565
|
+
return (width, height) if width and height else None
|
|
1566
|
+
except OSError:
|
|
1567
|
+
return None
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def solve_captcha_on_page(page: Any, **kwargs: Any) -> SolveResult:
|
|
1571
|
+
"""
|
|
1572
|
+
One-shot convenience wrapper mirroring the TS `new CaptchaKrakenSolver().solve(page)`.
|
|
1573
|
+
|
|
1574
|
+
Synchronous only. The async Playwright API needs a parallel implementation
|
|
1575
|
+
(`await` at every call site) rather than a wrapper — sync Playwright handles
|
|
1576
|
+
cannot be driven from inside an event loop, so `asyncio.to_thread` would not
|
|
1577
|
+
save this. Async support is not yet written.
|
|
1578
|
+
"""
|
|
1579
|
+
return PageSolver(**kwargs).solve(page)
|