captchakraken 2.2.0 → 2.4.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.
@@ -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)