captchakraken 2.8.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- package/python/src/captchakraken/trajectory.py +0 -284
|
@@ -1,43 +1,10 @@
|
|
|
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
|
-
"""
|
|
1
|
+
"""Browser page driver: the Python mirror of js/src/solver.ts. Sync Playwright only."""
|
|
37
2
|
|
|
38
3
|
from __future__ import annotations
|
|
39
4
|
|
|
40
5
|
import hashlib
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
41
8
|
import os
|
|
42
9
|
import random
|
|
43
10
|
import re
|
|
@@ -46,29 +13,27 @@ import sys
|
|
|
46
13
|
import tempfile
|
|
47
14
|
import time
|
|
48
15
|
import uuid
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
49
17
|
from contextlib import contextmanager
|
|
50
18
|
from dataclasses import dataclass, field
|
|
51
|
-
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
19
|
+
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple
|
|
52
20
|
|
|
21
|
+
from . import planner
|
|
53
22
|
from .action_types import CaptchaAction
|
|
23
|
+
from .humanize import Humanizer, resolve as resolve_humanizer
|
|
24
|
+
from .kinds import (ActionKind, FrameRole, HumanizationMode, KeyframeMode, PauseKind, Phase, PromptFamily,
|
|
25
|
+
RecaptchaBanner, RetryMode, SettleVerdict, Vendor)
|
|
26
|
+
from .selectors import (ACCEPTED_SELECTORS, PIECE_SELECTORS, RESPONSE_SELECTORS, SELECTORS, SLIDER_HANDLE_SELECTORS,
|
|
27
|
+
SUBMIT_SELECTORS, TEXT_INPUT_SELECTORS, TEXT_INPUT_VENDOR_SELECTORS, VENDORS, WIDGET_PROBES,
|
|
28
|
+
VendorSelectors, WidgetProbe)
|
|
54
29
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
55
30
|
from .timing import PhaseBudget, timings_enabled
|
|
56
|
-
from .humanize import Humanizer, resolve as resolve_humanizer
|
|
57
31
|
|
|
58
32
|
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
59
|
-
|
|
60
|
-
# Read by planner.py, which turns it into the X-CK-Session header. Kept as a
|
|
61
|
-
# module constant so the name is defined in exactly one place per port.
|
|
62
33
|
_SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
|
|
63
34
|
|
|
64
35
|
|
|
65
36
|
def _log(message: str) -> None:
|
|
66
|
-
# flush=True is load-bearing, not tidiness. A solve is a minutes-long
|
|
67
|
-
# sequence of slow steps, and Python block-buffers stdout whenever it is not
|
|
68
|
-
# a TTY — so piped or redirected (CI logs, `> run.log`, a supervisor) the
|
|
69
|
-
# progress lines all appear at once when the process exits. A run that is
|
|
70
|
-
# working then looks indistinguishable from one that is hung, which is
|
|
71
|
-
# exactly the wrong signal from the one output people watch.
|
|
72
37
|
print(f"[captchakraken] {message}", flush=True)
|
|
73
38
|
|
|
74
39
|
|
|
@@ -82,6 +47,10 @@ def _delay(ms: float) -> None:
|
|
|
82
47
|
time.sleep(ms / 1000.0)
|
|
83
48
|
|
|
84
49
|
|
|
50
|
+
def _now() -> float:
|
|
51
|
+
return time.monotonic() * 1000.0
|
|
52
|
+
|
|
53
|
+
|
|
85
54
|
def _tmp_png(prefix: str) -> str:
|
|
86
55
|
fd, path = tempfile.mkstemp(prefix=f"{prefix}_", suffix=".png")
|
|
87
56
|
os.close(fd)
|
|
@@ -93,90 +62,56 @@ def _unlink(path: Optional[str]) -> None:
|
|
|
93
62
|
try:
|
|
94
63
|
os.unlink(path)
|
|
95
64
|
except OSError:
|
|
96
|
-
pass
|
|
65
|
+
pass
|
|
97
66
|
|
|
98
67
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# which is the same distinction expressed properly.
|
|
103
|
-
# --------------------------------------------------------------------------
|
|
68
|
+
def _sha1(path: str) -> str:
|
|
69
|
+
with open(path, "rb") as fh:
|
|
70
|
+
return hashlib.sha1(fh.read()).hexdigest()
|
|
104
71
|
|
|
105
72
|
|
|
106
73
|
class CaptchaSolveError(Exception):
|
|
107
|
-
|
|
74
|
+
pass
|
|
108
75
|
|
|
109
76
|
|
|
110
77
|
class AnimatedChallengeError(CaptchaSolveError):
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
Note the narrowed meaning. "The challenge never stops moving" is no longer a
|
|
114
|
-
failure: `video_solve_enabled` records the widget, slices the recording into
|
|
115
|
-
keyframes and solves those. This is raised only when that path cannot get an
|
|
116
|
-
artifact to work with — the element refuses to screenshot, or the recording
|
|
117
|
-
decodes to nothing.
|
|
118
|
-
"""
|
|
78
|
+
pass
|
|
119
79
|
|
|
120
80
|
|
|
121
81
|
class UnsupportedChallengeError(CaptchaSolveError):
|
|
122
|
-
|
|
82
|
+
pass
|
|
123
83
|
|
|
124
84
|
|
|
125
85
|
class NoCaptchaFoundError(CaptchaSolveError):
|
|
126
|
-
|
|
86
|
+
pass
|
|
127
87
|
|
|
128
88
|
|
|
129
89
|
class PageClosedError(CaptchaSolveError):
|
|
130
|
-
|
|
90
|
+
pass
|
|
131
91
|
|
|
132
|
-
Split out from staleness deliberately. These two used to share one pattern,
|
|
133
|
-
which meant a DEAD target was retried three times as if it were a live one
|
|
134
|
-
mid-transition, and the error that finally escaped named whichever call ran
|
|
135
|
-
last — `ElementHandle.screenshot`, usually — instead of saying the page had
|
|
136
|
-
closed. That cost a real debugging session on the headed reCAPTCHA run.
|
|
137
|
-
"""
|
|
138
92
|
|
|
93
|
+
_STALE_HANDLE_RE = re.compile(r"Timeout .*exceeded|not visible|not attached|detached", re.IGNORECASE)
|
|
94
|
+
# A closed target is not a stale handle: retried as one, a dead page was re-detected three times and the
|
|
95
|
+
# error that finally escaped named whichever call ran last.
|
|
96
|
+
_CLOSED_TARGET_RE = re.compile(
|
|
97
|
+
r"Target (?:page, context or browser )?(?:has been )?closed|Session closed", re.IGNORECASE)
|
|
139
98
|
|
|
140
|
-
# Playwright surfaces these as messages, not types, so both drivers match on
|
|
141
|
-
# text. Kept as one pattern so the two lists can be diffed against each other.
|
|
142
|
-
#
|
|
143
|
-
# "Target closed" is NOT here on purpose — see `_CLOSED_TARGET_RE`. Everything in
|
|
144
|
-
# this list describes a handle that went stale while the PAGE IS STILL ALIVE, so
|
|
145
|
-
# re-detecting next round is the right response.
|
|
146
|
-
_STALE_HANDLE_RE = re.compile(
|
|
147
|
-
r"Timeout .*exceeded|not visible|not attached|detached",
|
|
148
|
-
re.IGNORECASE,
|
|
149
|
-
)
|
|
150
99
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
r"Target (?:page, context or browser )?(?:has been )?closed|Session closed",
|
|
155
|
-
re.IGNORECASE,
|
|
156
|
-
)
|
|
100
|
+
def answer_needs_element_box(actions: List[Dict[str, Any]]) -> bool:
|
|
101
|
+
"""An allow-list of what needs nothing: the other direction raises instead of clicking at the origin."""
|
|
102
|
+
return any((a or {}).get("action") != ActionKind.DONE for a in actions)
|
|
157
103
|
|
|
158
104
|
|
|
159
105
|
@dataclass
|
|
160
106
|
class SolveResult:
|
|
161
|
-
"""Mirror of the TS `SolveResult`."""
|
|
162
|
-
|
|
163
107
|
is_solved: bool
|
|
164
108
|
final_mouse_position: Tuple[float, float]
|
|
165
109
|
token_usage: List[Dict[str, Any]] = field(default_factory=list)
|
|
166
|
-
#: Where this solve's wall-clock went, in milliseconds per phase — the same
|
|
167
|
-
#: partition `CAPTCHA_TIMINGS=1` prints, handed to the caller instead of to
|
|
168
|
-
#: stderr. `{}` when the result was built outside a solve.
|
|
169
|
-
#:
|
|
170
|
-
#: Returned rather than only logged because "the solve took 12s" is not
|
|
171
|
-
#: actionable and "the settle monitor spent 4s of it" is, and a caller
|
|
172
|
-
#: measuring a fleet cannot scrape stderr from inside its own process.
|
|
173
110
|
phases: Dict[str, float] = field(default_factory=dict)
|
|
174
111
|
|
|
175
112
|
|
|
176
113
|
@dataclass
|
|
177
114
|
class _GridSession:
|
|
178
|
-
"""Cached geometry for one reCAPTCHA grid puzzle. Mirror of `GridSession`."""
|
|
179
|
-
|
|
180
115
|
grid_boxes: List[Sequence[int]]
|
|
181
116
|
element_box: Dict[str, float]
|
|
182
117
|
scale_x: float
|
|
@@ -185,508 +120,210 @@ class _GridSession:
|
|
|
185
120
|
screenshot_h: int
|
|
186
121
|
|
|
187
122
|
|
|
188
|
-
|
|
189
|
-
|
|
123
|
+
# Mirrors the JS port: past the floor window, this many distinct screens still arriving is an animation
|
|
124
|
+
# even though no screen has come back yet.
|
|
125
|
+
BURST_ANIMATED_SCREENS = 6
|
|
126
|
+
|
|
190
127
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
128
|
+
def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int,
|
|
129
|
+
motion_streak: int = 0) -> SettleVerdict:
|
|
130
|
+
"""The pixel-settle rule over `(elapsed_ms, moved)` polls.
|
|
194
131
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
ends the wait as 'settled' first. Every animated puzzle we ship rests
|
|
198
|
-
between screens for longer than that, which is why the caller also probes.
|
|
132
|
+
`motion_streak` exits early: rotating_obj_video changes every 133-171ms, and without it 4.5s went on
|
|
133
|
+
proving that before a fresh 4s burst filmed it again.
|
|
199
134
|
"""
|
|
200
|
-
|
|
201
|
-
for elapsed_ms,
|
|
202
|
-
if
|
|
203
|
-
|
|
204
|
-
if elapsed_ms >= animated_after_ms:
|
|
205
|
-
return
|
|
135
|
+
still = moved = 0
|
|
136
|
+
for elapsed_ms, did_move in samples:
|
|
137
|
+
if did_move:
|
|
138
|
+
still, moved = 0, moved + 1
|
|
139
|
+
if elapsed_ms >= animated_after_ms or (motion_streak and moved >= motion_streak):
|
|
140
|
+
return SettleVerdict.ANIMATED
|
|
206
141
|
else:
|
|
207
|
-
|
|
208
|
-
if
|
|
209
|
-
return
|
|
210
|
-
return
|
|
142
|
+
moved, still = 0, still + 1
|
|
143
|
+
if still >= settle_frames:
|
|
144
|
+
return SettleVerdict.SETTLED
|
|
145
|
+
return SettleVerdict.TIMEOUT
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def burst_hang_deadline_ms(cfg) -> float:
|
|
149
|
+
return 3.0 * float(cfg.video_burst_max_ms) + 5_000.0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def measured_fps(frames: int, elapsed_ms: float, nominal_fps: float) -> float:
|
|
153
|
+
if frames <= 0 or elapsed_ms <= 0:
|
|
154
|
+
return float(nominal_fps)
|
|
155
|
+
return frames / (elapsed_ms / 1000.0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Frame-diff thresholds; two screens of one board differ by ~0.0056, a different board by ~0.77.
|
|
159
|
+
# 0.002 sits above the 0.001 noise floor; reusing stale_frame_diff_threshold (0.02) was blind to GeeTest svg.
|
|
160
|
+
_MOVED_DURING_INFERENCE_DIFF = 0.002
|
|
161
|
+
_NOT_THIS_BOARD_DIFF = 0.5
|
|
162
|
+
_NOT_THIS_BOARD_POLLS = 3
|
|
211
163
|
|
|
212
164
|
|
|
213
165
|
@dataclass
|
|
214
166
|
class PageSolverConfig:
|
|
215
|
-
|
|
216
|
-
Tunables, named to match the TS `CaptchaKrakenConfig` keys (snake_cased) so a
|
|
217
|
-
value tuned on one driver can be found on the other.
|
|
218
|
-
"""
|
|
219
|
-
|
|
220
|
-
# ── how the driver moves ───────────────────────────────────────────────
|
|
221
|
-
#: "mouse" (default), "mobile" or "none". See `humanize.py`.
|
|
222
|
-
#:
|
|
223
|
-
#: Not a realism dial — a choice of INPUT DEVICE. "mobile" dispatches touch
|
|
224
|
-
#: events with finger kinematics, and on a touch-only widget that is the
|
|
225
|
-
#: difference between the page's handlers firing and not. "none" is the
|
|
226
|
-
#: shortest legal path to the same DOM effect, for fixtures and for callers
|
|
227
|
-
#: who humanise somewhere else in their stack.
|
|
228
|
-
#:
|
|
229
|
-
#: None means unset: `CAPTCHA_HUMANIZATION`, else "mouse".
|
|
230
|
-
humanization: Optional[str] = None
|
|
231
|
-
#: Your own `humanize.Humanizer`. Overrides `humanization` entirely — the
|
|
232
|
-
#: driver then makes no decisions about pointer motion at all.
|
|
167
|
+
humanization: Optional[HumanizationMode] = None
|
|
233
168
|
humanizer: Optional[Humanizer] = None
|
|
234
|
-
#: mobile only. The thing that is actually TOUCHED, when it is not the page
|
|
235
|
-
#: object — an Appium/Selenium WebDriver on a real handset. Left None, the
|
|
236
|
-
#: mode dispatches CDP touch events at the page it was given.
|
|
237
169
|
touch_driver: Optional[Any] = None
|
|
238
|
-
#: mobile only. CSS-pixel -> device-pixel transform for `touch_driver`,
|
|
239
|
-
#: e.g. `{"scale": 3.0, "origin": (0, 132)}`. See AppiumTouchBackend; the
|
|
240
|
-
#: default identity is right for browser emulation.
|
|
241
170
|
touch_transform: Optional[Dict[str, Any]] = None
|
|
242
|
-
#: Where the pointer starts. Setting it stops the first gesture of a solve
|
|
243
|
-
#: crossing the whole window from the origin.
|
|
244
171
|
starting_mouse_position: Optional[Tuple[float, float]] = None
|
|
245
|
-
|
|
246
|
-
# ── which model answers ────────────────────────────────────────────────
|
|
247
|
-
#: Force ONE expert of a routed model — "pixel", "grid", "video" or "text".
|
|
248
|
-
#:
|
|
249
|
-
#: A routed model (Abyss) is four adapters behind one endpoint, and the
|
|
250
|
-
#: router is the prompt family the request is about to send, so the default
|
|
251
|
-
#: (None) already reaches the right one with no help from the caller. This
|
|
252
|
-
#: is the override: serve one arm, drive only the puzzles it owns, which is
|
|
253
|
-
#: what a per-arm benchmark needs and what a licence holder pinning a single
|
|
254
|
-
#: expert wants.
|
|
255
|
-
#:
|
|
256
|
-
#: Refused at construction against a model that serves one adapter — every
|
|
257
|
-
#: model published so far — because a run that quietly measured the
|
|
258
|
-
#: generalist and reported it as the expert is a number nobody can catch.
|
|
259
|
-
#: None means unset: `CAPTCHA_EXPERT`, else route by family.
|
|
260
|
-
expert: Optional[str] = None
|
|
261
|
-
|
|
262
|
-
# 45s, and the loop count that fits inside it rather than one that needs
|
|
263
|
-
# policing by the clock. A round costs ~4-7s once the waits below are paid,
|
|
264
|
-
# so six rounds is the budget; ten never fitted and only ever expressed
|
|
265
|
-
# itself as a timeout. The cap is a BACKSTOP — `max_no_progress_rounds` is
|
|
266
|
-
# what is supposed to end a hopeless solve, and a solve that reaches this
|
|
267
|
-
# number is a bug report, not a normal outcome.
|
|
172
|
+
expert: Optional[PromptFamily] = None
|
|
268
173
|
max_solve_loops: int = 6
|
|
269
174
|
overall_solve_timeout_ms: int = 45_000
|
|
270
|
-
#: Consecutive rounds producing the SAME answer before the solve is
|
|
271
|
-
#: abandoned.
|
|
272
|
-
#:
|
|
273
|
-
#: At temperature 0 the model is a function of the picture, so an identical
|
|
274
|
-
#: answer means an identical picture — and every answer this driver produces
|
|
275
|
-
#: is EXECUTED, so the previous one already ran and moved nothing. Repeating
|
|
276
|
-
#: it cannot do better; it just spends a round.
|
|
277
|
-
#:
|
|
278
|
-
#: Measured on recaptcha_grid_4x4 (fixture seed 20260730): the model answers
|
|
279
|
-
#: `[2,6,7,9,10]` on round 1 and then `[2,6,7,10]` on rounds 2 THROUGH 10 —
|
|
280
|
-
#: nine identical click sets, each one clicked, each one rejected, ending in
|
|
281
|
-
#: "still detected after 10 solve loops" at 66.1s. Of that, 39.0s was pure
|
|
282
|
-
#: waiting. Stopping on the second repeat ends it at round 4.
|
|
283
|
-
#:
|
|
284
|
-
#: 2 rather than 1: the first repeat also ARMS the animated probe, which is
|
|
285
|
-
#: the one recovery worth trying (a cycling board reads as a still and
|
|
286
|
-
#: answers the same way every round). One more round buys that chance.
|
|
287
175
|
max_no_progress_rounds: int = 2
|
|
288
176
|
post_solve_delay_ms: int = 1_200
|
|
289
|
-
# How long to keep watching for a SUCCESS after submitting.
|
|
290
|
-
#
|
|
291
|
-
# That is the window's whole job, and it is why a wrong answer spends all of
|
|
292
|
-
# it: the vendors emit nothing on a wrong answer, they just re-deal. Sizing
|
|
293
|
-
# it therefore means asking how late a real success can arrive.
|
|
294
|
-
#
|
|
295
|
-
# MEASURED over 34 successful rounds across 20 puzzle types (fixture server,
|
|
296
|
-
# two seeds each): min 3ms, p50 360ms, p90 378ms, max 528ms.
|
|
297
|
-
#
|
|
298
|
-
# Read that with care — those values are quantized by `..._poll_ms`, not by
|
|
299
|
-
# any vendor. The clusters sit at one poll (~200ms), two polls (~370ms, the
|
|
300
|
-
# `widget_gone >= 2` confirmation) and three (~520ms). So the measurement
|
|
301
|
-
# mostly describes THIS loop, and the fixture's verdict is a local fetch,
|
|
302
|
-
# which means real hCaptcha/reCAPTCHA round-trips are absent from it
|
|
303
|
-
# entirely.
|
|
304
|
-
#
|
|
305
|
-
# 1000ms is therefore the measured worst case (528ms, ~264ms once the poll
|
|
306
|
-
# below is halved) plus deliberate headroom for a vendor round-trip nobody
|
|
307
|
-
# has measured yet. Down from 2500ms, which was spent in full on every
|
|
308
|
-
# unsuccessful round — 25.5s of one 66.1s recaptcha_grid_4x4 solve.
|
|
309
|
-
#
|
|
310
|
-
# To replace the headroom with a number: drive the real vendor pages via
|
|
311
|
-
# `tests/live-solve/src/demo-targets.ts` and re-read this distribution.
|
|
312
177
|
post_solve_outcome_timeout_ms: int = 1_000
|
|
313
|
-
#: Poll interval inside that window.
|
|
314
|
-
#:
|
|
315
|
-
#: The success signals are polled, so detection latency is a MULTIPLE of
|
|
316
|
-
#: this — and the one that matters most, "the widget is gone", needs two
|
|
317
|
-
#: consecutive polls to confirm, so it cannot be seen in less than 2x. At
|
|
318
|
-
#: 150ms that put a 300ms floor under every inline vendor's success. 75ms
|
|
319
|
-
#: halves it, and the poll is cheap: `detect_captcha` measures ~3ms.
|
|
320
178
|
post_solve_outcome_poll_ms: int = 75
|
|
321
179
|
element_screenshot_timeout_ms: int = 8_000
|
|
322
180
|
max_unsupported_resolves: int = 3
|
|
323
181
|
max_stale_element_retries: int = 3
|
|
324
182
|
stale_element_backoff_ms: int = 900
|
|
325
|
-
|
|
326
|
-
# Freshness guard — re-solve if the frame moved during inference.
|
|
327
183
|
stale_frame_resolve_enabled: bool = True
|
|
328
184
|
stale_frame_diff_threshold: float = 0.02
|
|
329
185
|
max_stale_frame_resolves: int = 2
|
|
330
|
-
|
|
331
|
-
# Pixel-settle monitor.
|
|
332
186
|
settle_poll_ms: int = 220
|
|
333
187
|
settle_frames: int = 2
|
|
334
188
|
settle_timeout_ms: int = 9_000
|
|
335
189
|
animated_challenge_after_ms: int = 4_500
|
|
190
|
+
animated_motion_streak: int = 5
|
|
336
191
|
settle_diff_threshold: float = 0.01
|
|
337
192
|
post_submit_change_timeout_ms: int = 4_000
|
|
338
|
-
|
|
339
|
-
# ── Animated challenges ────────────────────────────────────────────────
|
|
340
|
-
# A challenge that never settles is RECORDED and solved from keyframes rather
|
|
341
|
-
# than abandoned. Off switch for callers who would rather fail fast than spend
|
|
342
|
-
# the recording time.
|
|
343
193
|
video_solve_enabled: bool = True
|
|
344
|
-
# Whether a repeated answer may escalate to a RECORDING of the widget.
|
|
345
|
-
#
|
|
346
|
-
# A board that cycles reads as static to `_wait_for_element_settled` (it
|
|
347
|
-
# rests between screens for far longer than the 440ms that rule stops at),
|
|
348
|
-
# gets answered from whichever screen the screenshot caught, and then
|
|
349
|
-
# answers identically every round. That repeat is the signal, and the
|
|
350
|
-
# response is to record a burst and let the SLICER decide: a widget that
|
|
351
|
-
# really is static slices to one keyframe and is solved as the still it is,
|
|
352
|
-
# so the escalation costs one burst and can never produce a worse answer.
|
|
353
|
-
#
|
|
354
|
-
# Off restores the old behaviour for a caller who would rather re-read a
|
|
355
|
-
# still than spend `video_burst_duration_ms` finding out.
|
|
356
194
|
animated_probe_enabled: bool = True
|
|
357
|
-
# Burst geometry. Deliberately identical to the collector's
|
|
358
|
-
# (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the finetune repo), so a
|
|
359
|
-
# challenge recorded here is the same shape of artifact the model trained on —
|
|
360
|
-
# same clip length, same frame rate, therefore the same keyframe slicing.
|
|
361
195
|
video_burst_duration_ms: int = 4_000
|
|
196
|
+
video_burst_max_ms: int = 12_000
|
|
197
|
+
speculative_burst_enabled: bool = True
|
|
362
198
|
video_burst_fps: int = 10
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
# puzzles cycle, so the state DOES come back — but if the recording caught a
|
|
366
|
-
# one-off transition it never will, and a click on the model's coordinates is
|
|
367
|
-
# still a better use of the remaining budget than a timeout.
|
|
368
|
-
keyframe_wait_timeout_ms: int = 6_000
|
|
199
|
+
# GeeTest svg dwells up to 2.7s a screen, so a 3-screen cycle is 8.1s; 6s gave up one screen short.
|
|
200
|
+
keyframe_wait_timeout_ms: int = 9_000
|
|
369
201
|
keyframe_wait_poll_ms: int = 120
|
|
370
|
-
#: Extra wall clock granted ONCE, the first time a solve escalates to a
|
|
371
|
-
#: recording. NOT a looser `overall_solve_timeout_ms`.
|
|
372
|
-
#:
|
|
373
|
-
#: That budget is sized for ROUNDS — "a round costs ~4-7s, so six is the
|
|
374
|
-
#: budget". The recording path is not a round. It is a fixed extra stage
|
|
375
|
-
#: costing the burst, the slice, one MULTI-IMAGE inference (six keyframes,
|
|
376
|
-
#: several times a still's) and the wait for the widget to come back round
|
|
377
|
-
#: to the frame the model chose. Nothing in the 45s was ever set aside for
|
|
378
|
-
#: it, so an escalation started at ~35s ran the clock out mid-burst and
|
|
379
|
-
#: reported a TIMEOUT — which reads as a slow model rather than as a budget
|
|
380
|
-
#: with no room for the thing the solver had just decided to do.
|
|
381
|
-
#:
|
|
382
|
-
#: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on
|
|
383
|
-
#: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
|
|
384
|
-
#: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
|
|
385
|
-
#: recording the animated challenge", at 45.7-52.7s elapsed. The same three
|
|
386
|
-
#: fixtures solve in 11-20s on the rounds where the still path happens to
|
|
387
|
-
#: answer them, so it is the escalation that does not fit, not the puzzle.
|
|
388
|
-
#:
|
|
389
|
-
#: Granted only when `video_solve_enabled` — a caller who wants a hard
|
|
390
|
-
#: deadline turns recording off, which is the switch that already means
|
|
391
|
-
#: "fail fast rather than spend the recording time".
|
|
392
202
|
video_extra_inference_ms: int = 8_000
|
|
393
|
-
|
|
394
|
-
def video_budget_ms(self) -> int:
|
|
395
|
-
"""What one escalation to recording is allowed to cost, on top.
|
|
396
|
-
|
|
397
|
-
DERIVED, so a longer burst or a longer keyframe wait carries its own
|
|
398
|
-
budget instead of quietly reintroducing the timeout this exists to fix.
|
|
399
|
-
"""
|
|
400
|
-
return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms
|
|
401
|
-
+ self.video_extra_inference_ms)
|
|
402
|
-
|
|
403
|
-
# Grid load / dynamic-refresh timing.
|
|
404
|
-
# How long to wait for hCaptcha's task images to paint before screenshotting
|
|
405
|
-
# anyway. Best-effort by design — the screenshot happens either way — so it
|
|
406
|
-
# is bounded by what a loading tile plausibly costs, not by a generous
|
|
407
|
-
# default. 8s was two-thirds of what a whole solve is now allowed to take.
|
|
408
203
|
hcaptcha_images_timeout_ms: int = 3_000
|
|
409
|
-
|
|
410
204
|
grid_load_poll_interval_ms: int = 250
|
|
411
205
|
grid_load_timeout_ms: int = 8_000
|
|
206
|
+
board_paint_poll_ms: int = 180
|
|
207
|
+
board_paint_timeout_ms: int = 2_500
|
|
208
|
+
board_paint_floor: Optional[float] = None
|
|
412
209
|
recaptcha_max_dynamic_rounds: int = 8
|
|
413
210
|
recaptcha_fade_onset_grace_ms: int = 4_000
|
|
414
211
|
recaptcha_dynamic_fade_poll_ms: int = 250
|
|
415
212
|
recaptcha_dynamic_fade_wait_ms: int = 6_000
|
|
416
213
|
recaptcha_tile_hover_enabled: bool = True
|
|
417
|
-
|
|
418
|
-
# ── puzzle-piece sliders (see _execute_slide) ──────────────────────────
|
|
419
|
-
# How far to nudge the handle, in px, to learn the piece's width and how
|
|
420
|
-
# fast it follows. Two probes because two unknowns; far enough apart that
|
|
421
|
-
# the difference between the two widths is signal rather than rounding, and
|
|
422
|
-
# both small enough to stay on the shortest track observed (~250px).
|
|
423
|
-
slide_probe_offsets_px: Tuple[float, ...] = (24.0, 64.0)
|
|
424
|
-
# Stop steering once the piece is this close. Tighter than any vendor's
|
|
425
|
-
# accept window, so the limit on solving is the model's slot estimate.
|
|
426
214
|
slide_tolerance_px: float = 2.0
|
|
427
|
-
|
|
428
|
-
# down; two is enough for a linear system, and the third would only be
|
|
429
|
-
# chasing a measurement that is not going to converge.
|
|
430
|
-
slide_max_corrections: int = 2
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
# Vendors with no checkbox/challenge split — one container, one interactive
|
|
434
|
-
# surface. Checked in detect_captcha() after the five hard-coded reCAPTCHA /
|
|
435
|
-
# hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
|
|
436
|
-
# lifted from src/captchaCollection/sources.py, which already drives these 8
|
|
437
|
-
# vendors nightly in the collector. Mirror of VENDOR_WIDGET_LOCATORS in
|
|
438
|
-
# solver.ts — keep both in the same order with the same selectors.
|
|
439
|
-
VENDOR_WIDGET_LOCATORS = [
|
|
440
|
-
{"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
|
|
441
|
-
# Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
|
|
442
|
-
# `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first,
|
|
443
|
-
# because the iframe build is still deployed on sites pinning an older
|
|
444
|
-
# widget and costs nothing to keep. Dropping the in-page selectors is what
|
|
445
|
-
# made the client blind to every live Tencent captcha for twelve days.
|
|
446
|
-
#
|
|
447
|
-
# `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
|
|
448
|
-
# MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
|
|
449
|
-
# Tencent's entry was silently the only thing detecting MTCaptcha at all,
|
|
450
|
-
# which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
|
|
451
|
-
# matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
|
|
452
|
-
{"puzzle_source": "tencent", "selectors": ['#tcaptcha_transform_dy', '#tCaptchaDyContent', '.tencent-captcha-dy__content', 'iframe#tcaptcha_iframe_dy', 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
|
|
453
|
-
{"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
|
|
454
|
-
# Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
|
|
455
|
-
# that frame's document — `query_selector` does not cross a frame boundary,
|
|
456
|
-
# so the host page never carried it and this entry matched nothing, ever.
|
|
457
|
-
# Challenge frame first, anchor second, exactly like reCAPTCHA above.
|
|
458
|
-
# `.CheckboxCaptcha` stays for an inline embed that renders it directly.
|
|
459
|
-
{"puzzle_source": "yandex", "selectors": ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', ".CheckboxCaptcha"]},
|
|
460
|
-
{"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
|
|
461
|
-
{"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
|
|
462
|
-
# MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
|
|
463
|
-
# id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
|
|
464
|
-
# is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
|
|
465
|
-
# class selector matches whole tokens, so it never matched the host page's
|
|
466
|
-
# `class="mtcaptcha"` either. Kept last for an inline embed.
|
|
467
|
-
{"puzzle_source": "mtcaptcha", "selectors": ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', ".mtcaptcha", ".mtcap"]},
|
|
468
|
-
{"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
|
|
469
|
-
]
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
# ── which vendor's code the page LOADED, as opposed to what it rendered ─────
|
|
473
|
-
#
|
|
474
|
-
# A selector is a claim about markup, and markup is the half of a captcha the
|
|
475
|
-
# vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
|
|
476
|
-
# the point. The REQUEST is far more stable: Tencent moved its widget out of an
|
|
477
|
-
# iframe and renamed every class on 2026-08-11, and went on fetching from
|
|
478
|
-
# `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
|
|
479
|
-
# substring `captcha.gtimg.com` — already in the table above, on a selector that
|
|
480
|
-
# stopped matching — never left the wire.
|
|
481
|
-
#
|
|
482
|
-
# This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
|
|
483
|
-
# page; it cannot say where the widget is, and the solver needs an ELEMENT to
|
|
484
|
-
# screenshot, to scope its controls against, and to drag. What it buys is the
|
|
485
|
-
# difference between the two things a null detection can mean:
|
|
486
|
-
#
|
|
487
|
-
# · no captcha here (reCAPTCHA v3 / invisible / not triggered yet) — fine, and
|
|
488
|
-
# what the message used to guess unconditionally;
|
|
489
|
-
# · the vendor's code is loaded and running and NOTHING WE LOOK FOR MATCHES —
|
|
490
|
-
# i.e. the DOM moved under us. That is a bug report, and it took twelve days
|
|
491
|
-
# to notice the last time because the client said the first thing while
|
|
492
|
-
# meaning the second.
|
|
493
|
-
#
|
|
494
|
-
# Hosts measured 2026-08-24 by loading each vendor's demo page and recording
|
|
495
|
-
# every third-party request (see scripts/check_vendor_selectors.py --hosts).
|
|
496
|
-
#: The two vendors with bespoke handling in this file. EVERYTHING else shares the
|
|
497
|
-
#: generic path, and the two behaviours below are gated on "not one of these" —
|
|
498
|
-
#: spelled as this set rather than as `== "unknown"`, which is what they used to
|
|
499
|
-
#: say. The two were identical only while `unknown` was the sole third value, so
|
|
500
|
-
#: the day anyone reports a vendor by name — to constrain which grid shapes it may
|
|
501
|
-
#: be solved as, say — `== "unknown"` silently turns OFF typed-challenge detection
|
|
502
|
-
#: for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
|
|
503
|
-
#: animated settle probe for GeeTest and Tencent. Neither failure raises; the text
|
|
504
|
-
#: captcha just becomes unsolvable and the animated board gets answered from one
|
|
505
|
-
#: arbitrary frame.
|
|
506
|
-
VENDORS_WITH_BESPOKE_HANDLING = frozenset({"hcaptcha", "recaptcha"})
|
|
507
|
-
|
|
508
|
-
VENDOR_URL_MARKERS = [
|
|
509
|
-
{"puzzle_source": "hcaptcha", "hosts": ["hcaptcha.com"]},
|
|
510
|
-
{"puzzle_source": "recaptcha", "hosts": ["google.com/recaptcha", "recaptcha.net"]},
|
|
511
|
-
{"puzzle_source": "turnstile", "hosts": ["challenges.cloudflare.com"]},
|
|
512
|
-
{"puzzle_source": "geetest", "hosts": ["geetest.com"]},
|
|
513
|
-
{"puzzle_source": "tencent", "hosts": ["captcha.gtimg.com", "captcha.qcloud.com"]},
|
|
514
|
-
{"puzzle_source": "yidun", "hosts": ["dun.163.com", "cstaticdun.126.net",
|
|
515
|
-
"necaptcha.nosdn.127.net"]},
|
|
516
|
-
{"puzzle_source": "yandex", "hosts": ["smartcaptcha.yandexcloud.net"]},
|
|
517
|
-
{"puzzle_source": "lemin", "hosts": ["leminnow.com"]},
|
|
518
|
-
{"puzzle_source": "prosopo", "hosts": ["prosopo.io"]},
|
|
519
|
-
{"puzzle_source": "mtcaptcha", "hosts": ["mtcaptcha.com"]},
|
|
520
|
-
# BotDetect is deliberately absent and must stay absent. It is a SELF-HOSTED
|
|
521
|
-
# library — the image comes from the application's own origin
|
|
522
|
-
# (`BotDetectCaptcha.ashx`), so there is no vendor host to see and a URL
|
|
523
|
-
# tripwire cannot cover it. Recording that is the point: an empty result for
|
|
524
|
-
# a BotDetect page means "this check does not apply", not "no captcha".
|
|
525
|
-
]
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
# ── where the answer goes, when it is not a click ───────────────────────────
|
|
529
|
-
#
|
|
530
|
-
# Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
|
|
531
|
-
# first visible match. That order is the whole design: a named vendor selector
|
|
532
|
-
# is unambiguous, while the generic patterns are guesses that happen to be right
|
|
533
|
-
# most of the time. Trying the guess first would, on a page that hosts a captcha
|
|
534
|
-
# *and* a login form, type the captcha's answer into the username box.
|
|
535
|
-
#
|
|
536
|
-
# The generic tail is not a nicety either — it is what actually fires on most
|
|
537
|
-
# pages. Vendors rename these classes without notice (they are anti-bot
|
|
538
|
-
# surfaces, so churn is the point), and our own Tier 3 fixtures render neither
|
|
539
|
-
# vendor's DOM. Anything that only worked via the vendor list would be a feature
|
|
540
|
-
# that passes review and fails in the field.
|
|
541
|
-
|
|
542
|
-
# A distorted-text captcha's answer box. The three vendor entries are the three
|
|
543
|
-
# types in instructions.py::TEXT_TYPES.
|
|
544
|
-
#
|
|
545
|
-
# Split in two because the two halves are safe in different places. The NAMED
|
|
546
|
-
# half identifies a captcha's answer box wherever it sits; the GENERIC half only
|
|
547
|
-
# means "the text box in this widget" and is trustworthy only when the scope
|
|
548
|
-
# already is the widget. `_answer_box` widens past the widget for BotDetect and
|
|
549
|
-
# may take the named half with it, never the generic one.
|
|
550
|
-
TEXT_INPUT_VENDOR_SELECTORS = [
|
|
551
|
-
# BotDetect — the input is application-defined, so match the id fragment its
|
|
552
|
-
# own docs and samples use. These three are what the nightly collector
|
|
553
|
-
# already drives (src/captchaCollection/sources.py).
|
|
554
|
-
"input[id*=captchaCode]",
|
|
555
|
-
"input#captchaCode",
|
|
556
|
-
"input[id*=validateCaptcha]",
|
|
557
|
-
".BDC_CaptchaDiv input[type=text]",
|
|
558
|
-
# MTCaptcha
|
|
559
|
-
"input.mtcap-inputtext",
|
|
560
|
-
".mtcap input[type=text]",
|
|
561
|
-
# Yandex SmartCaptcha
|
|
562
|
-
".AdvancedCaptcha-Input input",
|
|
563
|
-
"input.Textinput-Control",
|
|
564
|
-
'input[name="rep"]',
|
|
565
|
-
]
|
|
566
|
-
|
|
567
|
-
TEXT_INPUT_GENERIC_SELECTORS = [
|
|
568
|
-
# Generic — an input the page itself labels as the captcha answer.
|
|
569
|
-
'input[name*="captcha" i]',
|
|
570
|
-
'input[id*="captcha" i]',
|
|
571
|
-
'input[aria-label*="captcha" i]',
|
|
572
|
-
'input[placeholder*="code" i]',
|
|
573
|
-
'input[autocomplete="off"][type=text]',
|
|
574
|
-
# Last resort: the only text box in the widget. Scoped to the challenge
|
|
575
|
-
# frame/container by the caller, never to the whole page — see _find_control.
|
|
576
|
-
"input[type=text]",
|
|
577
|
-
"input:not([type])",
|
|
578
|
-
"input[type=tel]",
|
|
579
|
-
"textarea",
|
|
580
|
-
]
|
|
581
|
-
|
|
582
|
-
TEXT_INPUT_SELECTORS = TEXT_INPUT_VENDOR_SELECTORS + TEXT_INPUT_GENERIC_SELECTORS
|
|
583
|
-
|
|
584
|
-
# The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
|
|
585
|
-
# these vendors the piece is inert decoration that the handle carries, so a
|
|
586
|
-
# drag starting on the piece moves nothing at all.
|
|
587
|
-
SLIDER_HANDLE_SELECTORS = [
|
|
588
|
-
# GeeTest v3 / v4
|
|
589
|
-
".geetest_slider_button",
|
|
590
|
-
".geetest_btn",
|
|
591
|
-
".geetest_slider .geetest_arrow",
|
|
592
|
-
# Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
|
|
593
|
-
# no aria-valuenow — so every generic fallback below misses it and it has to
|
|
594
|
-
# be named. The three after it are the pre-redesign widget.
|
|
595
|
-
".tencent-captcha-dy__slider-block",
|
|
596
|
-
"#tcaptcha_drag_thumb",
|
|
597
|
-
".tc-slider-normal",
|
|
598
|
-
"[id*=slideBlock]",
|
|
599
|
-
# Yidun (NetEase)
|
|
600
|
-
".yidun_slider",
|
|
601
|
-
".yidun_jigsaw",
|
|
602
|
-
# Lemin
|
|
603
|
-
".lemin-slider-handle",
|
|
604
|
-
"#lemin-cropped-captcha .slider",
|
|
605
|
-
# Generic — an ARIA slider, or a class that says handle/thumb/button on a
|
|
606
|
-
# track. `[draggable=true]` is deliberately absent: it is the HTML5
|
|
607
|
-
# drag-and-drop opt-in, which fires dragstart rather than pointermove, and
|
|
608
|
-
# no slider captcha uses it.
|
|
609
|
-
'[role="slider"]',
|
|
610
|
-
"[aria-valuenow]",
|
|
611
|
-
'[class*="slider"][class*="btn"]',
|
|
612
|
-
'[class*="slider"][class*="button"]',
|
|
613
|
-
'[class*="slide"][class*="handle"]',
|
|
614
|
-
'[class*="drag"][class*="thumb"]',
|
|
615
|
-
]
|
|
616
|
-
|
|
617
|
-
# Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
|
|
618
|
-
# has no track at all — you drag the piece itself onto the gap — and the model
|
|
619
|
-
# answers it with the same sourceless drag, because from the picture the two are
|
|
620
|
-
# indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
|
|
621
|
-
DRAGGABLE_PIECE_SELECTORS = [
|
|
622
|
-
".lemin-cropped-puzzle-piece",
|
|
623
|
-
"#lemin-cropped-captcha canvas + canvas",
|
|
624
|
-
'[class*="puzzle"][class*="piece"]',
|
|
625
|
-
'[class*="jigsaw"]',
|
|
626
|
-
]
|
|
215
|
+
slide_max_corrections: int = 3
|
|
627
216
|
|
|
217
|
+
def video_budget_ms(self) -> int:
|
|
218
|
+
return (self.video_burst_max_ms + self.keyframe_wait_timeout_ms
|
|
219
|
+
+ self.video_extra_inference_ms)
|
|
628
220
|
|
|
629
|
-
class PageSolver:
|
|
630
|
-
"""
|
|
631
|
-
Drives one Playwright-compatible `page` through a captcha.
|
|
632
221
|
|
|
633
|
-
|
|
634
|
-
|
|
222
|
+
class Widget(NamedTuple):
|
|
223
|
+
"""What detection found: the handle to photograph, the locator to search inside, and which table entry named it."""
|
|
635
224
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
225
|
+
element: Any
|
|
226
|
+
at: Any
|
|
227
|
+
vendor: Vendor
|
|
228
|
+
role: FrameRole
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# A named set rather than `== UNKNOWN`: naming a new vendor must not silently switch off typed-challenge
|
|
232
|
+
# detection for the text vendors and the animated probe for GeeTest and Tencent.
|
|
233
|
+
VENDORS_WITH_BESPOKE_HANDLING: frozenset[Vendor] = frozenset({Vendor.HCAPTCHA, Vendor.RECAPTCHA})
|
|
234
|
+
|
|
235
|
+
# Long enough to resolve a locator `all()` just returned, short enough that a widget gone in between reads as stale, not hung.
|
|
236
|
+
HANDLE_TIMEOUT_MS = 1000
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _visible(scope: Any, selectors: Sequence[str]) -> List[Any]:
|
|
240
|
+
"""Every visible match of `selectors`, in selector order. A selector this adapter can't parse is skipped, not fatal."""
|
|
241
|
+
found: List[Any] = []
|
|
242
|
+
for selector in selectors:
|
|
243
|
+
try:
|
|
244
|
+
found.extend(scope.locator(selector).filter(visible=True).all())
|
|
245
|
+
except Exception:
|
|
246
|
+
continue
|
|
247
|
+
return found
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _handle_of(at: Any) -> Optional[Any]:
|
|
251
|
+
try:
|
|
252
|
+
return at.element_handle(timeout=HANDLE_TIMEOUT_MS)
|
|
253
|
+
except Exception:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _handles(ats: Sequence[Any]) -> List[Any]:
|
|
258
|
+
return [h for h in map(_handle_of, ats) if h is not None]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _frame_of(at: Any) -> Optional[Any]:
|
|
262
|
+
handle = _handle_of(at)
|
|
263
|
+
try:
|
|
264
|
+
return handle.content_frame() if handle else None
|
|
265
|
+
except Exception:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _input_value(handle: Any) -> str:
|
|
270
|
+
try:
|
|
271
|
+
return str(handle.input_value() or "")
|
|
272
|
+
except Exception:
|
|
273
|
+
return ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _has_text(handle: Any) -> bool:
|
|
277
|
+
try:
|
|
278
|
+
return bool((handle.text_content() or "").strip())
|
|
279
|
+
except Exception:
|
|
280
|
+
return False
|
|
639
281
|
|
|
640
|
-
One instance is reusable across solves; state is reset per `solve()`.
|
|
641
|
-
"""
|
|
642
282
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
283
|
+
SLIDE_PIECE_MIN_PX = 3.0
|
|
284
|
+
SLIDE_PIECE_MAX_FRACTION = 0.6
|
|
285
|
+
|
|
286
|
+
# No prompt and no pictures both read as ready: waiting for `.prompt-text` to exist once paid the whole timeout
|
|
287
|
+
# before every board of a challenge that draws none (tower_stack: 18s a round, 2/2 -> 0/2).
|
|
288
|
+
_BOARD_IMAGES_READY_JS = """({ prompt, images }) => {
|
|
289
|
+
const vis = (el) => !!el && el.getClientRects().length > 0
|
|
290
|
+
&& getComputedStyle(el).visibility !== 'hidden';
|
|
291
|
+
const p = prompt && document.querySelector(prompt);
|
|
292
|
+
if (p && !vis(p)) return false;
|
|
293
|
+
return Array.from(document.querySelectorAll(images)).every((el) => {
|
|
294
|
+
if (el instanceof HTMLImageElement) return el.complete && el.naturalWidth > 0;
|
|
295
|
+
const bg = getComputedStyle(el).backgroundImage;
|
|
296
|
+
return !!bg && bg !== 'none' && !/url\\(["']?["']?\\)/.test(bg);
|
|
297
|
+
});
|
|
298
|
+
}"""
|
|
299
|
+
_RESOURCES_JS = """() => {
|
|
300
|
+
const out = [];
|
|
301
|
+
try { for (const e of performance.getEntriesByType('resource')) out.push(e.name); } catch (err) {}
|
|
302
|
+
for (const el of document.querySelectorAll('script[src],iframe[src],link[href],img[src]')) {
|
|
303
|
+
out.push(el.getAttribute('src') || el.getAttribute('href') || '');
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}"""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class PageSolver:
|
|
310
|
+
"""Drives one Playwright-compatible `page` through a captcha. Reusable across solves."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, config: Optional[PageSolverConfig] = None,
|
|
313
|
+
solver: Optional[CaptchaSolver] = None, **solver_kwargs: Any) -> None:
|
|
649
314
|
self.config = config or PageSolverConfig()
|
|
650
|
-
# One CaptchaSolver for the whole driver: it owns the planner, which
|
|
651
|
-
# accumulates token usage and holds the HTTP session to vLLM.
|
|
652
|
-
#
|
|
653
|
-
# `expert` reaches the solver from the config, so the knob is in the
|
|
654
|
-
# same object as every other tunable and matches the TS port's
|
|
655
|
-
# `CaptchaKrakenConfig.expert`. An explicit kwarg still wins — a caller
|
|
656
|
-
# who built the argument list themselves has said the more specific
|
|
657
|
-
# thing — and a caller who passed their OWN solver has already made
|
|
658
|
-
# this decision inside it.
|
|
659
315
|
if self.config.expert is not None:
|
|
660
316
|
solver_kwargs.setdefault("expert", self.config.expert)
|
|
661
317
|
self._solver = solver or CaptchaSolver(**solver_kwargs)
|
|
662
|
-
#: Every gesture goes through here, and it owns the pointer position.
|
|
663
|
-
#: See humanize.py — the driver names gestures, this decides what
|
|
664
|
-
#: events they are and how long they take.
|
|
665
318
|
self._human: Humanizer = resolve_humanizer(self.config)
|
|
666
319
|
self._last_submit_frame_hash: Optional[str] = None
|
|
667
|
-
# Absolute deadline for the current solve, in the same clock as
|
|
668
|
-
# time.monotonic() * 1000. None outside a solve.
|
|
669
320
|
self._deadline_ms: Optional[float] = None
|
|
670
|
-
# Animation detection, all reset per solve by `_reset_animated_state`.
|
|
671
|
-
# `_known_animated` latches: once a widget has been SEEN to animate,
|
|
672
|
-
# every later round takes the keyframe path without re-probing.
|
|
673
|
-
self._known_animated = False
|
|
674
|
-
self._animated_probe_armed = False
|
|
675
|
-
self._animated_probe_done = False
|
|
676
|
-
# One-shot: the recording path buys its own budget the first time it is
|
|
677
|
-
# entered, and never again in the same solve. See video_budget_ms.
|
|
678
|
-
self._video_budget_granted = False
|
|
679
|
-
self._keyframe_mode: Optional[str] = None
|
|
680
|
-
# Repeat detection; see `max_no_progress_rounds`.
|
|
681
|
-
self._last_answer_sig: Optional[str] = None
|
|
682
|
-
self._no_progress_rounds = 0
|
|
683
|
-
# Per-solve phase accounting; see `_phase`.
|
|
684
321
|
self._budget: Optional[PhaseBudget] = None
|
|
322
|
+
self._animated_plan: Optional[Tuple[List[str], str, Any, Any]] = None
|
|
323
|
+
self._reset_animated_state()
|
|
685
324
|
|
|
686
325
|
@property
|
|
687
326
|
def _last_mouse(self) -> Tuple[float, float]:
|
|
688
|
-
"""Where the pointer is. Owned by the humanizer, because a mode that
|
|
689
|
-
dispatches no motion (mobile, between taps) still has to answer this."""
|
|
690
327
|
return self._human.at
|
|
691
328
|
|
|
692
329
|
@_last_mouse.setter
|
|
@@ -694,111 +331,99 @@ class PageSolver:
|
|
|
694
331
|
self._human.at = (float(at[0]), float(at[1]))
|
|
695
332
|
|
|
696
333
|
@contextmanager
|
|
697
|
-
def _phase(self, name:
|
|
698
|
-
"""Attribute the enclosed wall-clock to `name` for this solve's budget.
|
|
699
|
-
|
|
700
|
-
A no-op outside a solve, so helpers stay callable from tests and from
|
|
701
|
-
the CLI without a budget having been opened.
|
|
702
|
-
"""
|
|
334
|
+
def _phase(self, name: Phase):
|
|
703
335
|
if self._budget is None:
|
|
704
336
|
yield
|
|
705
337
|
return
|
|
706
338
|
with self._budget.phase(name):
|
|
707
339
|
yield
|
|
708
340
|
|
|
709
|
-
|
|
710
|
-
def _answer_signature(actions: Sequence[Any], retry_mode: Optional[str]) -> Optional[str]:
|
|
711
|
-
"""A stable identity for "the answer this round is about to execute".
|
|
341
|
+
# ── per-solve state ──────────────────────────────────────────────────
|
|
712
342
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
343
|
+
def _reset_animated_state(self) -> None:
|
|
344
|
+
self._acted_on_board = False
|
|
345
|
+
self._known_animated = False
|
|
346
|
+
self._animated_probe_armed = False
|
|
347
|
+
self._animated_probe_done = False
|
|
348
|
+
self._video_budget_granted = False
|
|
349
|
+
self._discard_animated_plan()
|
|
350
|
+
self._keyframe_mode: Optional[KeyframeMode] = None
|
|
351
|
+
self._keyframe_steady_screens = 0
|
|
352
|
+
self._last_answer_sig: Optional[str] = None
|
|
353
|
+
self._no_progress_rounds = 0
|
|
354
|
+
self._resample_level = 0
|
|
355
|
+
self._apply_sampling()
|
|
717
356
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
"""
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _answer_signature(actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> Optional[str]:
|
|
359
|
+
"""Keyed on retry mode: the missed-tiles answer legitimately overlaps the previous one."""
|
|
722
360
|
try:
|
|
723
|
-
parts = [
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
a.get("action"),
|
|
728
|
-
_round_pts(a.get("target_bounding_boxes")),
|
|
729
|
-
_round_pts(a.get("target_bounding_box")),
|
|
730
|
-
_round_pts(a.get("target_coordinates")),
|
|
731
|
-
_round_pts(a.get("source_bounding_box")),
|
|
732
|
-
a.get("text"),
|
|
733
|
-
))
|
|
361
|
+
parts = [(a.get("action"), _round_pts(a.get("target_bounding_boxes")),
|
|
362
|
+
_round_pts(a.get("target_bounding_box")), _round_pts(a.get("target_coordinates")),
|
|
363
|
+
_round_pts(a.get("source_bounding_box")), a.get("text"))
|
|
364
|
+
for a in map(_as_dict, actions)]
|
|
734
365
|
return repr((retry_mode, parts))
|
|
735
|
-
except Exception:
|
|
366
|
+
except Exception:
|
|
736
367
|
return None
|
|
737
368
|
|
|
738
|
-
def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[
|
|
739
|
-
"""
|
|
369
|
+
def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> bool:
|
|
370
|
+
"""True when this answer already ran and changed nothing: resample, re-ask, and arm the probe."""
|
|
740
371
|
sig = self._answer_signature(actions, retry_mode)
|
|
741
372
|
if sig is not None and sig == self._last_answer_sig:
|
|
742
373
|
self._no_progress_rounds += 1
|
|
743
374
|
_log(f"[no-progress] the model returned the same answer again "
|
|
744
|
-
f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds})
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
# chance before giving up on the solve entirely.
|
|
375
|
+
f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds})")
|
|
376
|
+
self._resample_level += 1
|
|
377
|
+
self._apply_sampling()
|
|
378
|
+
self._invalidate_animated_answer()
|
|
749
379
|
self._arm_animated_probe()
|
|
750
|
-
|
|
751
|
-
self._no_progress_rounds = 0
|
|
752
|
-
self._last_answer_sig = sig
|
|
753
|
-
|
|
754
|
-
def _reset_animated_state(self) -> None:
|
|
755
|
-
self._known_animated = False
|
|
756
|
-
self._animated_probe_armed = False
|
|
757
|
-
self._animated_probe_done = False
|
|
758
|
-
self._video_budget_granted = False
|
|
759
|
-
self._keyframe_mode = None
|
|
760
|
-
self._last_answer_sig: Optional[str] = None
|
|
380
|
+
return True
|
|
761
381
|
self._no_progress_rounds = 0
|
|
382
|
+
self._last_answer_sig = sig
|
|
383
|
+
return False
|
|
762
384
|
|
|
763
|
-
def
|
|
764
|
-
""
|
|
765
|
-
|
|
385
|
+
def _apply_sampling(self) -> None:
|
|
386
|
+
target = getattr(self._solver, "planner", None)
|
|
387
|
+
if target is None:
|
|
388
|
+
return
|
|
389
|
+
target.sampling = sampling = planner.sampling_for_level(self._resample_level)
|
|
390
|
+
if sampling:
|
|
391
|
+
_log(f"[resample] temperature {sampling['temperature']}, seed {sampling['seed']}")
|
|
766
392
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
minutes against a nominal 120 s timeout, and the check at the top of the
|
|
772
|
-
loop never got a turn to fire.
|
|
393
|
+
def _fresh_board(self) -> None:
|
|
394
|
+
self._resample_level = 0
|
|
395
|
+
self._acted_on_board = False
|
|
396
|
+
self._apply_sampling()
|
|
773
397
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if self._deadline_ms is None:
|
|
778
|
-
return
|
|
779
|
-
if time.monotonic() * 1000.0 > self._deadline_ms:
|
|
398
|
+
def _check_deadline(self, where: str) -> None:
|
|
399
|
+
# Called inside loops too: a camoufox session once ran ten minutes past a 120s timeout because only
|
|
400
|
+
# the loop head checked.
|
|
401
|
+
if self._deadline_ms is not None and _now() > self._deadline_ms:
|
|
780
402
|
raise CaptchaSolveError(
|
|
781
403
|
f"captcha solve exceeded overall_solve_timeout_ms "
|
|
782
|
-
f"({self.config.overall_solve_timeout_ms}ms) during {where}"
|
|
783
|
-
)
|
|
404
|
+
f"({self.config.overall_solve_timeout_ms}ms) during {where}")
|
|
784
405
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
406
|
+
def _grant_video_budget(self) -> None:
|
|
407
|
+
"""The recording path buys its own budget once per solve."""
|
|
408
|
+
cfg = self.config
|
|
409
|
+
if cfg.video_solve_enabled and not self._video_budget_granted:
|
|
410
|
+
self._video_budget_granted = True
|
|
411
|
+
if self._deadline_ms is not None:
|
|
412
|
+
self._deadline_ms += cfg.video_budget_ms()
|
|
413
|
+
_log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
|
|
414
|
+
|
|
415
|
+
# ── the shared half, in-process ──────────────────────────────────────
|
|
789
416
|
|
|
790
417
|
def _find_grid(self, image_path: str) -> Optional[List[Sequence[int]]]:
|
|
791
|
-
"""`captchakraken find-grid` — row-major cell boxes in screenshot pixels."""
|
|
792
418
|
from .tool_calls.find_grid import find_grid
|
|
793
419
|
|
|
794
420
|
try:
|
|
795
421
|
return find_grid(image_path)
|
|
796
|
-
except Exception as exc:
|
|
422
|
+
except Exception as exc:
|
|
797
423
|
_debug(f"find_grid failed: {exc}")
|
|
798
424
|
return None
|
|
799
425
|
|
|
800
426
|
def _has_movement(self, path_a: str, path_b: str, threshold: float) -> bool:
|
|
801
|
-
"""`captchakraken check-movement`."""
|
|
802
427
|
from .image_processor import ImageProcessor
|
|
803
428
|
|
|
804
429
|
try:
|
|
@@ -807,90 +432,51 @@ class PageSolver:
|
|
|
807
432
|
_debug(f"detect_movement failed: {exc}")
|
|
808
433
|
return False
|
|
809
434
|
|
|
810
|
-
def _grid_cell_states(
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
"""
|
|
814
|
-
`captchakraken grid-cell-states-fixed`.
|
|
815
|
-
|
|
816
|
-
Always the FIXED variant: the dynamic refresh blanks tiles to near-white,
|
|
817
|
-
which makes find_grid fail on that frame, and a self-detecting call would
|
|
818
|
-
then report "no grid" — which a naive caller misreads as "nothing
|
|
819
|
-
loading", i.e. solved. Passing the cached boxes keeps empty/changing/
|
|
820
|
-
selected correct while tiles are blank.
|
|
821
|
-
"""
|
|
822
|
-
from .cli import _compute_grid_cell_states
|
|
435
|
+
def _grid_cell_states(self, path_a: str, path_b: str,
|
|
436
|
+
grid_boxes: Sequence[Sequence[int]]) -> Optional[Dict[str, List[int]]]:
|
|
437
|
+
from .cli import grid_cell_states
|
|
823
438
|
|
|
824
439
|
try:
|
|
825
|
-
return
|
|
440
|
+
return grid_cell_states(path_a, path_b, list(grid_boxes))
|
|
826
441
|
except Exception as exc:
|
|
827
442
|
_debug(f"grid_cell_states failed: {exc}")
|
|
828
443
|
return None
|
|
829
444
|
|
|
830
|
-
def
|
|
831
|
-
|
|
832
|
-
text_mode: bool = False,
|
|
833
|
-
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
834
|
-
"""
|
|
835
|
-
The model query. In-process where TS spawns the CLI.
|
|
445
|
+
def _board_painted(self, path: str) -> Optional[bool]:
|
|
446
|
+
from .tool_calls.board_painted import board_is_painted
|
|
836
447
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
)
|
|
847
|
-
usage = [dict(u) for u in
|
|
848
|
-
if
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
with all of them in a single context. Per-frame queries would also cost N
|
|
860
|
-
billable rounds for one puzzle.
|
|
861
|
-
"""
|
|
862
|
-
planner = self._solver.planner
|
|
863
|
-
before = len(planner.token_usage)
|
|
864
|
-
actions = self._solver.solve_keyframes(keyframe_paths)
|
|
865
|
-
usage = [dict(u) for u in planner.token_usage[before:]]
|
|
866
|
-
if not isinstance(actions, list):
|
|
867
|
-
actions = [actions]
|
|
868
|
-
return actions, usage
|
|
869
|
-
|
|
870
|
-
# ------------------------------------------------------------------
|
|
871
|
-
# Mouse
|
|
872
|
-
# ------------------------------------------------------------------
|
|
448
|
+
try:
|
|
449
|
+
return board_is_painted(path, self.config.board_paint_floor)["painted"]
|
|
450
|
+
except Exception:
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
def _ask(self, query: Callable[[], Any]) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
454
|
+
"""One model query; usage is read off the shared planner by delta."""
|
|
455
|
+
p = self._solver.planner
|
|
456
|
+
before = len(p.token_usage)
|
|
457
|
+
actions = query()
|
|
458
|
+
usage = [dict(u) for u in p.token_usage[before:]]
|
|
459
|
+
return (actions if isinstance(actions, list) else [actions]), usage
|
|
460
|
+
|
|
461
|
+
def _get_solution(self, image_path: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
462
|
+
text_mode: bool = False):
|
|
463
|
+
return self._ask(lambda: self._solver.solve(
|
|
464
|
+
image_path, puzzle_source=puzzle_source, retry_mode=retry_mode, text_mode=text_mode))
|
|
465
|
+
|
|
466
|
+
def _get_keyframe_solution(self, keyframe_paths: Sequence[str]):
|
|
467
|
+
return self._ask(lambda: self._solver.solve_keyframes(keyframe_paths))
|
|
468
|
+
|
|
469
|
+
# ── gestures ─────────────────────────────────────────────────────────
|
|
873
470
|
|
|
874
471
|
def _smooth_move(self, page: Any, x: float, y: float) -> None:
|
|
875
|
-
|
|
876
|
-
a touchscreen with no finger down it is a bookkeeping update and emits
|
|
877
|
-
nothing at all."""
|
|
878
|
-
with self._phase("mouse"):
|
|
472
|
+
with self._phase(Phase.MOUSE):
|
|
879
473
|
self._human.move(page, (x, y))
|
|
880
474
|
|
|
881
475
|
def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
|
|
882
|
-
#
|
|
883
|
-
# per action plus once per submit — on a challenge iframe that is
|
|
884
|
-
# mid-animation, scrolling "waits for stability" and burns the full 30s
|
|
885
|
-
# every time, which turned a ~5s solve loop into minutes during live
|
|
886
|
-
# testing. The element is already on screen in every real case here (we
|
|
887
|
-
# just screenshotted it), so a short bound loses nothing: on timeout we
|
|
888
|
-
# move to wherever it currently is.
|
|
476
|
+
# Bounded: Playwright's default 30s stability wait hangs on an animating frame.
|
|
889
477
|
try:
|
|
890
478
|
element.scroll_into_view_if_needed(timeout=2_000)
|
|
891
479
|
except TypeError:
|
|
892
|
-
# An adapter whose signature takes no timeout (e.g. the Puppeteer
|
|
893
|
-
# bridge). Fall back rather than fail the solve.
|
|
894
480
|
try:
|
|
895
481
|
element.scroll_into_view_if_needed()
|
|
896
482
|
except Exception:
|
|
@@ -901,319 +487,202 @@ class PageSolver:
|
|
|
901
487
|
if not box:
|
|
902
488
|
raise CaptchaSolveError("element has no bounding box")
|
|
903
489
|
pad = padding_percentage / 100.0
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
490
|
+
self._smooth_move(
|
|
491
|
+
page,
|
|
492
|
+
box["x"] + box["width"] * (pad + random.random() * (1 - 2 * pad)),
|
|
493
|
+
box["y"] + box["height"] * (pad + random.random() * (1 - 2 * pad)))
|
|
908
494
|
|
|
909
495
|
def _move_and_click(self, page: Any, element: Any) -> None:
|
|
910
496
|
self._move_to_element(page, element)
|
|
911
|
-
with self._phase(
|
|
497
|
+
with self._phase(Phase.MOUSE):
|
|
912
498
|
self._human.click(page, self._last_mouse)
|
|
913
499
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _click_point_for(action: Dict[str, Any],
|
|
502
|
+
element_box: Dict[str, float]) -> Optional[Tuple[float, float]]:
|
|
503
|
+
"""Element-relative click point: a random spot inside the box, inset 10% off its border."""
|
|
917
504
|
bbox = action.get("target_bounding_box")
|
|
918
505
|
coords = action.get("target_coordinates")
|
|
919
506
|
if bbox:
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
507
|
+
x1, y1, x2, y2 = (float(v) for v in bbox)
|
|
508
|
+
w, h = element_box["width"], element_box["height"]
|
|
509
|
+
px, py = (x2 - x1) * w * 0.1, (y2 - y1) * h * 0.1
|
|
510
|
+
return (x1 * w + px + random.random() * ((x2 - x1) * w - 2 * px),
|
|
511
|
+
y1 * h + py + random.random() * ((y2 - y1) * h - 2 * py))
|
|
512
|
+
if coords:
|
|
513
|
+
return float(coords[0]) * element_box["width"], float(coords[1]) * element_box["height"]
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
def _execute_click(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float],
|
|
517
|
+
element: Any = None, await_keyframe: Optional[str] = None) -> None:
|
|
518
|
+
# Every gesture marks the board acted on so the speculative burst is never re-run over a touched board.
|
|
519
|
+
self._acted_on_board = True
|
|
520
|
+
rel = self._click_point_for(action, element_box)
|
|
521
|
+
if rel is None:
|
|
934
522
|
_log("click action without coordinates or bounding box; skipping")
|
|
935
523
|
return
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
524
|
+
at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
|
|
525
|
+
if await_keyframe:
|
|
526
|
+
# Park on the target first, so only a mouse-down separates the right screen from the click.
|
|
527
|
+
with self._phase(Phase.MOUSE):
|
|
528
|
+
self._human.move(page, at)
|
|
529
|
+
self._wait_for_keyframe(element, await_keyframe,
|
|
530
|
+
(rel[0] / element_box["width"], rel[1] / element_box["height"]))
|
|
531
|
+
with self._phase(Phase.MOUSE):
|
|
532
|
+
self._human.click(page, at)
|
|
533
|
+
|
|
534
|
+
def _click_when_frame_matches(self, page: Any, element: Any, action: Dict[str, Any],
|
|
535
|
+
element_box: Dict[str, float], await_keyframe: str) -> None:
|
|
536
|
+
self._execute_click(page, action, element_box, element, await_keyframe)
|
|
537
|
+
|
|
538
|
+
def _execute_drag(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]) -> None:
|
|
539
|
+
self._acted_on_board = True
|
|
540
|
+
|
|
541
|
+
def center(bbox):
|
|
542
|
+
return (element_box["x"] + (float(bbox[0]) + float(bbox[2])) / 2 * element_box["width"],
|
|
543
|
+
element_box["y"] + (float(bbox[1]) + float(bbox[3])) / 2 * element_box["height"])
|
|
544
|
+
|
|
545
|
+
with self._phase(Phase.MOUSE):
|
|
546
|
+
self._human.drag(page, center(action["source_bounding_box"]), center(action["target_bounding_box"]))
|
|
957
547
|
|
|
958
548
|
def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
|
|
959
|
-
|
|
549
|
+
first = next(iter(_visible(scope, selectors)), None)
|
|
550
|
+
return _handle_of(first) if first is not None else None
|
|
551
|
+
|
|
552
|
+
def _measure_piece_box(self, scope: Any, widget_width: float) -> Optional[Dict[str, float]]:
|
|
553
|
+
"""The slider piece's box: the first visible match small enough to be a piece.
|
|
960
554
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
the page: the generic tail of both selector tables would otherwise
|
|
964
|
-
happily match a login form's text box or a carousel's drag handle
|
|
965
|
-
somewhere else on the document, and the answer would go there.
|
|
555
|
+
Runs during detection, so it must not mark the board acted on: doing so once disabled the
|
|
556
|
+
speculative burst on every slide solve.
|
|
966
557
|
"""
|
|
967
|
-
for
|
|
558
|
+
for candidate in _handles(_visible(scope, PIECE_SELECTORS)):
|
|
968
559
|
try:
|
|
969
|
-
|
|
560
|
+
b = candidate.bounding_box()
|
|
970
561
|
except Exception:
|
|
971
|
-
continue
|
|
972
|
-
if
|
|
973
|
-
return
|
|
562
|
+
continue
|
|
563
|
+
if b and SLIDE_PIECE_MIN_PX <= b["width"] <= widget_width * SLIDE_PIECE_MAX_FRACTION:
|
|
564
|
+
return b
|
|
974
565
|
return None
|
|
975
566
|
|
|
976
|
-
def _answer_box(self, scope: Any,
|
|
977
|
-
"""
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
VENDOR-NAMED box only.
|
|
982
|
-
|
|
983
|
-
BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
|
|
984
|
-
LIBRARY, so the host application owns the layout: measured on
|
|
985
|
-
captcha.com 2026-08-24, `.BDC_CaptchaDiv` — the element `detect_captcha`
|
|
986
|
-
returns — is 280x50 and holds the image alone, while `#captchaCode` sits
|
|
987
|
-
in a sibling `<div class="validationDiv">` under the enclosing
|
|
988
|
-
<fieldset>. Scoped to the widget the solver found no box, `text_mode`
|
|
989
|
-
stayed False, and it read the code correctly and never typed it. A clean
|
|
990
|
-
solve thrown away, and indistinguishable in any report from a model that
|
|
991
|
-
cannot read warped text.
|
|
992
|
-
|
|
993
|
-
The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on
|
|
994
|
-
purpose. The generic tail (`input[type=text]`, `textarea`,
|
|
995
|
-
`input:not([type])`) is what makes the in-widget lookup work for every
|
|
996
|
-
other vendor, and OUTSIDE the widget it is exactly how a captcha's
|
|
997
|
-
answer ends up in a login form's username box — the failure
|
|
998
|
-
`_find_control`'s docstring already warns about. One level, named
|
|
999
|
-
selectors, and never the page.
|
|
567
|
+
def _answer_box(self, scope: Any, at: Any = None) -> Optional[Any]:
|
|
568
|
+
"""The text box inside the widget, else a vendor-named one in its enclosing fieldset/form.
|
|
569
|
+
|
|
570
|
+
Only vendor-named selectors widen: BotDetect's `.BDC_CaptchaDiv` is 280x50 and holds only the image,
|
|
571
|
+
with `#captchaCode` in a sibling div. The generic tail there would take a login form's own box.
|
|
1000
572
|
"""
|
|
1001
573
|
inside = self._find_control(scope, TEXT_INPUT_SELECTORS)
|
|
1002
|
-
if inside is not None or
|
|
574
|
+
if inside is not None or at is None:
|
|
1003
575
|
return inside
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"):
|
|
1008
|
-
try:
|
|
1009
|
-
host = element.query_selector(f"xpath={axis}")
|
|
1010
|
-
except Exception:
|
|
1011
|
-
continue
|
|
1012
|
-
if host is None:
|
|
1013
|
-
continue
|
|
1014
|
-
found = self._find_control(host, TEXT_INPUT_VENDOR_SELECTORS)
|
|
1015
|
-
if found is not None:
|
|
1016
|
-
_log("widget holds no answer box; using the vendor-named one "
|
|
1017
|
-
"in its enclosing form")
|
|
1018
|
-
return found
|
|
1019
|
-
return None
|
|
576
|
+
around = (self._find_control(at.locator(f"xpath={axis}"), TEXT_INPUT_VENDOR_SELECTORS)
|
|
577
|
+
for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"))
|
|
578
|
+
return next((found for found in around if found is not None), None)
|
|
1020
579
|
|
|
1021
|
-
def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any],
|
|
1022
|
-
|
|
1023
|
-
"""Put the model's reading of a distorted-text captcha into its box."""
|
|
580
|
+
def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any], at: Any = None) -> bool:
|
|
581
|
+
self._acted_on_board = True
|
|
1024
582
|
text = str(action.get("text") or "")
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
field = self._answer_box(scope, element)
|
|
1028
|
-
if field is None:
|
|
583
|
+
field_el = text and self._answer_box(scope, at)
|
|
584
|
+
if not field_el:
|
|
1029
585
|
_log("type action, but no text box in the widget; skipping")
|
|
1030
586
|
return False
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
# raises the keyboard, so this is not decoration on either device.
|
|
1034
|
-
self._move_and_click(page, field)
|
|
1035
|
-
if not self._human.type_text(page, field, text):
|
|
587
|
+
self._move_and_click(page, field_el)
|
|
588
|
+
if not self._human.type_text(page, field_el, text):
|
|
1036
589
|
return False
|
|
1037
590
|
_log(f"typed {len(text)} character(s) into the captcha field")
|
|
1038
591
|
return True
|
|
1039
592
|
|
|
1040
|
-
def _track_piece(
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
"""`captchakraken track-piece` — box of what moved, handle masked out."""
|
|
1044
|
-
from .tool_calls.track_piece import changed_bbox
|
|
593
|
+
def _track_piece(self, element: Any, before: str, after: str, exclude: Sequence[float],
|
|
594
|
+
travel: float = 0.0) -> Optional[Dict[str, Any]]:
|
|
595
|
+
from .tool_calls.track_piece import changed_bbox, locate_piece
|
|
1045
596
|
|
|
1046
597
|
try:
|
|
1047
598
|
self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1048
|
-
|
|
599
|
+
bbox = changed_bbox(before, after, exclude)
|
|
600
|
+
if bbox is None:
|
|
601
|
+
return None
|
|
602
|
+
return {"bbox": bbox, "piece": locate_piece(before, after, travel, exclude)}
|
|
1049
603
|
except Exception as exc:
|
|
1050
604
|
_debug(f"track_piece failed: {exc}")
|
|
1051
605
|
return None
|
|
1052
606
|
|
|
1053
607
|
@staticmethod
|
|
1054
608
|
def _shot_scale(shot: str, css_width: float) -> float:
|
|
1055
|
-
"""Device pixels per CSS pixel, read off the shot we are about to
|
|
1056
|
-
measure. 1.0 when the image cannot be read — an unreadable shot is
|
|
1057
|
-
already the `_track_piece` returns-None path, and guessing a ratio
|
|
1058
|
-
would steer the handle by it."""
|
|
1059
609
|
dims = _read_png_dimensions(shot)
|
|
1060
|
-
if
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
self
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
scope: Any,
|
|
1069
|
-
action: Dict[str, Any],
|
|
1070
|
-
element_box: Dict[str, float],
|
|
1071
|
-
) -> bool:
|
|
1072
|
-
"""Drive a puzzle-piece slider until the PIECE reaches the model's slot.
|
|
1073
|
-
|
|
1074
|
-
The model is asked for one thing here — the centre of the gap — because
|
|
1075
|
-
it is the only thing the picture can tell it. What it cannot know is how
|
|
1076
|
-
far the handle must travel to put the piece there: the handle is
|
|
1077
|
-
elsewhere on the widget, and the ratio between the two is a vendor
|
|
1078
|
-
implementation detail that several of them deliberately vary.
|
|
1079
|
-
|
|
1080
|
-
So this is closed-loop, not a calculation. Press the handle, nudge it
|
|
1081
|
-
twice by known amounts, and watch the screen:
|
|
1082
|
-
|
|
1083
|
-
union(before, after) spans the piece's ORIGINAL left edge to its
|
|
1084
|
-
CURRENT right edge, so its width is piece_width + ratio × nudge.
|
|
1085
|
-
|
|
1086
|
-
Two nudges, two widths, two unknowns — solve for both, then steer the
|
|
1087
|
-
remaining distance and re-measure. The mouse is not released until the
|
|
1088
|
-
piece is home, because on every one of these puzzles releasing IS the
|
|
1089
|
-
submit; there is no Verify button to reconsider at.
|
|
1090
|
-
|
|
1091
|
-
Returns False if there is nothing here to drag, leaving the caller's
|
|
1092
|
-
normal no-op handling to deal with it.
|
|
1093
|
-
"""
|
|
1094
|
-
target_x = (
|
|
1095
|
-
(float(action["target_bounding_box"][0]) + float(action["target_bounding_box"][2])) / 2
|
|
1096
|
-
) * element_box["width"]
|
|
610
|
+
return dims[0] / css_width if dims and css_width > 0 else 1.0
|
|
611
|
+
|
|
612
|
+
def _execute_slide(self, page: Any, element: Any, scope: Any, action: Dict[str, Any],
|
|
613
|
+
element_box: Dict[str, float]) -> bool:
|
|
614
|
+
"""Aim the handle at the slot once, then look and correct until the piece is home."""
|
|
615
|
+
self._acted_on_board = True
|
|
616
|
+
tb = action["target_bounding_box"]
|
|
617
|
+
target_x = (float(tb[0]) + float(tb[2])) / 2 * element_box["width"]
|
|
1097
618
|
|
|
1098
619
|
handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
|
|
1099
620
|
if handle is None:
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
# model, because the two look identical; different gesture. Nothing
|
|
1103
|
-
# to close a loop on, since the piece is under the cursor and moves
|
|
1104
|
-
# with it one for one.
|
|
1105
|
-
piece = self._find_control(scope, DRAGGABLE_PIECE_SELECTORS)
|
|
1106
|
-
if piece is None:
|
|
1107
|
-
_log("slide action, but the widget has neither a slider nor a draggable piece")
|
|
1108
|
-
return False
|
|
1109
|
-
box = piece.bounding_box()
|
|
621
|
+
piece = self._find_control(scope, PIECE_SELECTORS)
|
|
622
|
+
box = piece and piece.bounding_box()
|
|
1110
623
|
if not box:
|
|
624
|
+
_log("slide action, but the widget has neither a slider nor a draggable piece")
|
|
1111
625
|
return False
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
# the slot, on every attempt.
|
|
1117
|
-
target_y = (
|
|
1118
|
-
(float(action["target_bounding_box"][1])
|
|
1119
|
-
+ float(action["target_bounding_box"][3])) / 2
|
|
1120
|
-
) * element_box["height"]
|
|
1121
|
-
_log("no slider track; dragging the piece to the slot directly")
|
|
1122
|
-
with self._phase("mouse"):
|
|
1123
|
-
self._human.drag(
|
|
1124
|
-
page,
|
|
1125
|
-
(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
|
|
1126
|
-
(element_box["x"] + target_x, element_box["y"] + target_y),
|
|
1127
|
-
)
|
|
626
|
+
target_y = (float(tb[1]) + float(tb[3])) / 2 * element_box["height"]
|
|
627
|
+
with self._phase(Phase.MOUSE):
|
|
628
|
+
self._human.drag(page, (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
|
|
629
|
+
(element_box["x"] + target_x, element_box["y"] + target_y))
|
|
1128
630
|
return True
|
|
1129
631
|
|
|
1130
632
|
hbox = handle.bounding_box()
|
|
1131
633
|
if not hbox:
|
|
1132
634
|
return False
|
|
1133
|
-
start_x = hbox["x"] + hbox["width"] / 2
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
# Mask the whole horizontal BAND the handle runs in, not just where it
|
|
1137
|
-
# is now: it is about to move across that band, and most vendors fill
|
|
1138
|
-
# the track behind it as it goes. Either would otherwise be the largest
|
|
1139
|
-
# moving thing in frame, and we would track the handle instead of the
|
|
1140
|
-
# piece.
|
|
635
|
+
start_x, hold_y = hbox["x"] + hbox["width"] / 2, hbox["y"] + hbox["height"] / 2
|
|
636
|
+
# Mask from the handle's band to the widget's bottom: the filled track moves too.
|
|
1141
637
|
pad = max(4.0, hbox["height"] * 0.35)
|
|
1142
|
-
band = [
|
|
1143
|
-
|
|
1144
|
-
hbox["y"] - element_box["y"] - pad,
|
|
1145
|
-
element_box["width"],
|
|
1146
|
-
hbox["y"] + hbox["height"] - element_box["y"] + pad,
|
|
1147
|
-
]
|
|
1148
|
-
|
|
1149
|
-
shots = [_tmp_png("slide") for _ in range(4)]
|
|
638
|
+
band = [0.0, hbox["y"] - element_box["y"] - pad, element_box["width"], element_box["height"]]
|
|
639
|
+
shots = [_tmp_png("slide") for _ in range(2)]
|
|
1150
640
|
try:
|
|
1151
641
|
self._move_to_element(page, handle, padding_percentage=30.0)
|
|
1152
642
|
self._human.press(page)
|
|
1153
|
-
self._human.pause(
|
|
1154
|
-
self._screenshot(element, shots[0],
|
|
1155
|
-
timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1156
|
-
|
|
1157
|
-
# CSS pixels out here, DEVICE pixels inside the shots. The same
|
|
1158
|
-
# number on a 1x desktop and 2.625x apart on a phone, which is why
|
|
1159
|
-
# this loop measured a slider correctly for a year and then missed
|
|
1160
|
-
# every attempt the moment Tier 3 grew a mobile arm: the mask landed
|
|
1161
|
-
# above the handle, the widths came back 2.6x too wide, and
|
|
1162
|
-
# `_solve_slide_geometry` threw them out as wider than the widget.
|
|
1163
|
-
# Measured from the shot rather than asked of the page, for the same
|
|
1164
|
-
# reason the grid path does it (`_GridSession.scale_x`): what the CV
|
|
1165
|
-
# reads is the image, whatever the window thinks its ratio is.
|
|
643
|
+
self._human.pause(PauseKind.GRAB)
|
|
644
|
+
self._screenshot(element, shots[0], timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1166
645
|
scale = self._shot_scale(shots[0], element_box["width"])
|
|
1167
646
|
exclude = [v * scale for v in band]
|
|
1168
647
|
|
|
1169
|
-
|
|
648
|
+
def live_center():
|
|
649
|
+
b = self._measure_piece_box(scope, element_box["width"])
|
|
650
|
+
return None if b is None else b["x"] + b["width"] / 2.0 - element_box["x"]
|
|
651
|
+
|
|
652
|
+
rest = live_center()
|
|
653
|
+
offset = target_x - (start_x - element_box["x"] if rest is None else rest)
|
|
654
|
+
self._smooth_move(page, start_x + offset, hold_y)
|
|
655
|
+
|
|
1170
656
|
widths: List[Tuple[float, float]] = []
|
|
1171
|
-
last_box = None
|
|
1172
|
-
|
|
657
|
+
last_box = last_piece = None
|
|
658
|
+
ratio, steered = 1.0, False
|
|
659
|
+
for _ in range(self.config.slide_max_corrections):
|
|
660
|
+
self._human.pause(PauseKind.PROBE)
|
|
661
|
+
seen = self._track_piece(element, shots[0], shots[1], exclude, travel=offset * ratio * scale)
|
|
662
|
+
if seen is not None:
|
|
663
|
+
box = seen["bbox"]
|
|
664
|
+
widths.append((offset, float(box[2] - box[0]) / scale))
|
|
665
|
+
last_box, last_piece = box, seen["piece"]
|
|
666
|
+
piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
|
|
667
|
+
live = live_center()
|
|
668
|
+
if live is not None:
|
|
669
|
+
center = live
|
|
670
|
+
elif last_piece is not None:
|
|
671
|
+
center = last_piece["centre"] / scale
|
|
672
|
+
elif last_box is not None and piece_w is not None:
|
|
673
|
+
center = last_box[2] / scale - piece_w / 2.0
|
|
674
|
+
else:
|
|
675
|
+
continue
|
|
676
|
+
steered = True
|
|
677
|
+
error = target_x - center
|
|
678
|
+
_debug(f"slider: center={center:.1f} target={target_x:.1f} error={error:+.1f} ratio={ratio:.3f}")
|
|
679
|
+
if abs(error) <= self.config.slide_tolerance_px:
|
|
680
|
+
break
|
|
681
|
+
offset += error / ratio
|
|
1173
682
|
self._smooth_move(page, start_x + offset, hold_y)
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
widths.append((float(offset), float(box[2] - box[0]) / scale))
|
|
1178
|
-
last_box = box
|
|
1179
|
-
|
|
1180
|
-
piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
|
|
1181
|
-
if last_box is None or piece_w is None:
|
|
1182
|
-
# Never saw the piece — a canvas the screenshot cannot separate,
|
|
1183
|
-
# a widget that redraws wholesale, or a press the handle refused.
|
|
1184
|
-
# Fall back on the geometry every one of these puzzles shares:
|
|
1185
|
-
# piece and handle both start flush left, so the handle's travel
|
|
1186
|
-
# is the piece's travel.
|
|
1187
|
-
_log("slider: piece never resolved on screen; steering by handle travel alone")
|
|
1188
|
-
self._smooth_move(page, start_x + (target_x - (start_x - element_box["x"])), hold_y)
|
|
1189
|
-
else:
|
|
1190
|
-
# The offset `last_box` was MEASURED at — not `probes[-1]`, and
|
|
1191
|
-
# not indexed by how many measurements succeeded. If the first
|
|
1192
|
-
# probe failed to resolve and the second worked, those two
|
|
1193
|
-
# disagree, and steering from a base the reading does not belong
|
|
1194
|
-
# to sends the piece somewhere neither the model nor the screen
|
|
1195
|
-
# asked for.
|
|
1196
|
-
offset = float(widths[-1][0])
|
|
1197
|
-
for _ in range(self.config.slide_max_corrections):
|
|
1198
|
-
piece_center = (last_box[2] / scale - piece_w / 2.0)
|
|
1199
|
-
error = target_x - piece_center
|
|
1200
|
-
if abs(error) <= self.config.slide_tolerance_px:
|
|
1201
|
-
break
|
|
1202
|
-
offset += error / ratio
|
|
1203
|
-
self._smooth_move(page, start_x + offset, hold_y)
|
|
1204
|
-
self._human.pause("probe")
|
|
1205
|
-
box = self._track_piece(element, shots[0], shots[3], exclude)
|
|
1206
|
-
if box is None:
|
|
1207
|
-
break # ran out of track; release where we are
|
|
1208
|
-
last_box = box
|
|
1209
|
-
_debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} scale={scale:.3f} "
|
|
1210
|
-
f"final_center={last_box[2] / scale - piece_w / 2.0:.1f} "
|
|
1211
|
-
f"target={target_x:.1f}")
|
|
1212
|
-
|
|
1213
|
-
# Settle before letting go. A release in the same tick as the last
|
|
1214
|
-
# move reads as a machine, and some vendors sample the final
|
|
1215
|
-
# milliseconds of the gesture.
|
|
1216
|
-
self._human.pause("settle")
|
|
683
|
+
if not steered:
|
|
684
|
+
_log("slider: the piece never resolved on screen; released where the opening sweep put it")
|
|
685
|
+
self._human.pause(PauseKind.SETTLE)
|
|
1217
686
|
finally:
|
|
1218
687
|
try:
|
|
1219
688
|
self._human.release(page)
|
|
@@ -1224,857 +693,280 @@ class PageSolver:
|
|
|
1224
693
|
return True
|
|
1225
694
|
|
|
1226
695
|
@staticmethod
|
|
1227
|
-
def _solve_slide_geometry(
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
With only one usable measurement the system is underdetermined, so ratio
|
|
1236
|
-
is ASSUMED to be 1 — true of every vendor observed, and the assumption
|
|
1237
|
-
is stated here rather than buried as a default. A ratio solved from
|
|
1238
|
-
implausible measurements (a redraw, a piece that hit the wall between
|
|
1239
|
-
probes) is rejected the same way: better a 1:1 guess that overshoots and
|
|
1240
|
-
gets corrected than a ratio of 0.02 that sends the handle off the track.
|
|
696
|
+
def _solve_slide_geometry(widths: Sequence[Tuple[float, float]],
|
|
697
|
+
widget_width: float) -> Tuple[Optional[float], float]:
|
|
698
|
+
"""Piece width and handle-to-piece ratio from `(offset, changed width)` readings.
|
|
699
|
+
|
|
700
|
+
The widest-apart pair over a 16px spread: changed widths are ±1px, so the ratio error is ±0.13 at the
|
|
701
|
+
floor and ±0.5 at 4px. An implausible ratio falls back to 1:1 (true of every vendor observed) rather
|
|
702
|
+
than a 0.02 that sends the handle off the track and camoufox into a move that never returns.
|
|
1241
703
|
"""
|
|
1242
704
|
if not widths:
|
|
1243
705
|
return None, 1.0
|
|
1244
|
-
piece_w
|
|
1245
|
-
|
|
1246
|
-
if
|
|
1247
|
-
(
|
|
1248
|
-
|
|
1249
|
-
candidate = (w2 - w1) / (o2 - o1)
|
|
1250
|
-
if 0.2 <= candidate <= 3.0:
|
|
1251
|
-
ratio = candidate
|
|
1252
|
-
piece_w = w1 - ratio * o1
|
|
706
|
+
piece_w, ratio = None, 1.0
|
|
707
|
+
(o1, w1), (o2, w2) = min(widths, key=lambda m: m[0]), max(widths, key=lambda m: m[0])
|
|
708
|
+
if o2 - o1 >= 16.0 and 0.2 <= (w2 - w1) / (o2 - o1) <= 3.0:
|
|
709
|
+
ratio = (w2 - w1) / (o2 - o1)
|
|
710
|
+
piece_w = w1 - ratio * o1
|
|
1253
711
|
if piece_w is None:
|
|
1254
712
|
o, w = widths[-1]
|
|
1255
713
|
piece_w = w - ratio * o
|
|
1256
|
-
|
|
1257
|
-
# a measurement of something else.
|
|
1258
|
-
if not 3.0 <= piece_w <= widget_width * 0.6:
|
|
714
|
+
if not (SLIDE_PIECE_MIN_PX <= piece_w <= widget_width * SLIDE_PIECE_MAX_FRACTION):
|
|
1259
715
|
return None, ratio
|
|
1260
716
|
return piece_w, ratio
|
|
1261
717
|
|
|
1262
|
-
#
|
|
1263
|
-
# Detection
|
|
1264
|
-
# ------------------------------------------------------------------
|
|
1265
|
-
|
|
1266
|
-
@staticmethod
|
|
1267
|
-
def _visible(element: Any) -> bool:
|
|
1268
|
-
try:
|
|
1269
|
-
return bool(element and element.is_visible())
|
|
1270
|
-
except Exception:
|
|
1271
|
-
return False
|
|
718
|
+
# ── detection ────────────────────────────────────────────────────────
|
|
1272
719
|
|
|
1273
|
-
def
|
|
720
|
+
def _has_value(self, page: Any, selector: str) -> bool:
|
|
721
|
+
"""Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter."""
|
|
1274
722
|
try:
|
|
1275
|
-
|
|
1276
|
-
if not element:
|
|
1277
|
-
return False
|
|
1278
|
-
value = element.get_attribute("value")
|
|
1279
|
-
if value is None:
|
|
1280
|
-
value = page.eval_on_selector(
|
|
1281
|
-
selector, "node => (typeof node.value === 'string' ? node.value : '')"
|
|
1282
|
-
)
|
|
1283
|
-
return bool(value and str(value).strip())
|
|
723
|
+
fields = _handles(page.locator(selector).all())
|
|
1284
724
|
except Exception:
|
|
1285
725
|
return False
|
|
726
|
+
return any(_input_value(f).strip() for f in fields)
|
|
1286
727
|
|
|
1287
|
-
def
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if not frame:
|
|
1291
|
-
return False
|
|
1292
|
-
checked = frame.query_selector(".recaptcha-checkbox-checked")
|
|
1293
|
-
return self._visible(checked)
|
|
1294
|
-
except Exception:
|
|
1295
|
-
return False
|
|
728
|
+
def _visible_with_text(self, scope: Any, selector: str) -> bool:
|
|
729
|
+
"""A visible match carrying text; an empty banner or prompt is a placeholder, not a signal."""
|
|
730
|
+
return any(map(_has_text, _handles(_visible(scope, (selector,)))))
|
|
1296
731
|
|
|
1297
|
-
def
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
# the necessary tie-breaker rather than a nicety.
|
|
1301
|
-
try:
|
|
1302
|
-
frame = anchor_iframe.content_frame()
|
|
1303
|
-
if not frame:
|
|
1304
|
-
return False
|
|
1305
|
-
return self._visible(frame.query_selector('#checkbox[aria-checked="true"]'))
|
|
1306
|
-
except Exception:
|
|
1307
|
-
return False
|
|
732
|
+
def _checked_in(self, checkbox: Any, checked: str) -> bool:
|
|
733
|
+
frame = _frame_of(checkbox)
|
|
734
|
+
return bool(frame) and bool(_visible(frame, (checked,)))
|
|
1308
735
|
|
|
1309
736
|
def has_interactive_widget_in_dom(self, page: Any) -> bool:
|
|
737
|
+
"""Is a widget in the DOM at all, rendered or not? Invisible reCAPTCHA is excluded by its selector.
|
|
738
|
+
|
|
739
|
+
The inline vendors count: without them the JS port failed fast in under a second on every GeeTest/Yidun page.
|
|
1310
740
|
"""
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
try:
|
|
1317
|
-
for anchor in page.query_selector_all('iframe[src*="recaptcha/api2/anchor"]'):
|
|
1318
|
-
src = anchor.get_attribute("src") or ""
|
|
1319
|
-
# v3 / invisible-v2 injects only an anchor, with size=invisible,
|
|
1320
|
-
# and never a challenge frame. Excluded here so we fail fast.
|
|
1321
|
-
if not re.search(r"[?&]size=invisible", src):
|
|
1322
|
-
return True
|
|
1323
|
-
if page.query_selector('iframe[src*="recaptcha/api2/bframe"]'):
|
|
1324
|
-
return True
|
|
1325
|
-
if page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]'):
|
|
1326
|
-
return True
|
|
1327
|
-
if page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]'):
|
|
1328
|
-
return True
|
|
1329
|
-
except Exception:
|
|
1330
|
-
pass
|
|
1331
|
-
return False
|
|
741
|
+
def count(selector: str) -> int:
|
|
742
|
+
try:
|
|
743
|
+
return page.locator(selector).count()
|
|
744
|
+
except Exception:
|
|
745
|
+
return 0
|
|
1332
746
|
|
|
1333
|
-
|
|
1334
|
-
"""Open challenges first, then unsolved checkboxes. Mirror of `detectCaptcha`."""
|
|
1335
|
-
recaptcha_challenge = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
|
|
1336
|
-
if self._visible(recaptcha_challenge):
|
|
1337
|
-
return recaptcha_challenge
|
|
1338
|
-
|
|
1339
|
-
# Match the URL fragment, not the title: hCaptcha's ANCHOR title also
|
|
1340
|
-
# says "hCaptcha security challenge", so a title match mis-classifies it.
|
|
1341
|
-
hcaptcha_challenge = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
|
|
1342
|
-
if self._visible(hcaptcha_challenge):
|
|
1343
|
-
return hcaptcha_challenge
|
|
1344
|
-
|
|
1345
|
-
recaptcha_checkbox = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
|
|
1346
|
-
if self._visible(recaptcha_checkbox) and not self._is_recaptcha_anchor_checked(
|
|
1347
|
-
recaptcha_checkbox
|
|
1348
|
-
):
|
|
1349
|
-
return recaptcha_checkbox
|
|
1350
|
-
|
|
1351
|
-
hcaptcha_checkbox = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
|
|
1352
|
-
if self._visible(hcaptcha_checkbox):
|
|
1353
|
-
has_token = self._has_non_empty_field_value(page, '[name="h-captcha-response"]')
|
|
1354
|
-
if not has_token and not self._is_hcaptcha_anchor_checked(hcaptcha_checkbox):
|
|
1355
|
-
return hcaptcha_checkbox
|
|
1356
|
-
|
|
1357
|
-
turnstile_iframe = page.query_selector('iframe[src*="challenges.cloudflare.com"]')
|
|
1358
|
-
if self._visible(turnstile_iframe) and not self._has_non_empty_field_value(
|
|
1359
|
-
page, '[name="cf-turnstile-response"]'
|
|
1360
|
-
):
|
|
1361
|
-
return turnstile_iframe
|
|
1362
|
-
|
|
1363
|
-
# Closed shadow roots hide the iframe; the container is still findable.
|
|
1364
|
-
turnstile_container = page.query_selector(".cf-turnstile")
|
|
1365
|
-
if self._visible(turnstile_container) and not self._has_non_empty_field_value(
|
|
1366
|
-
page, '[name="cf-turnstile-response"]'
|
|
1367
|
-
):
|
|
1368
|
-
return turnstile_container
|
|
1369
|
-
|
|
1370
|
-
# Vendors with one interactive surface (no checkbox/challenge split) —
|
|
1371
|
-
# GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
|
|
1372
|
-
for entry in VENDOR_WIDGET_LOCATORS:
|
|
1373
|
-
for selector in entry["selectors"]:
|
|
1374
|
-
el = page.query_selector(selector)
|
|
1375
|
-
if self._visible(el):
|
|
1376
|
-
return el
|
|
747
|
+
return any(count(p.selector) > 0 for p in WIDGET_PROBES)
|
|
1377
748
|
|
|
749
|
+
def detect_captcha(self, page: Any) -> Optional[Widget]:
|
|
750
|
+
"""Open challenges first, then unsolved checkboxes, then the inline vendors."""
|
|
751
|
+
for probe in WIDGET_PROBES:
|
|
752
|
+
at = next(iter(_visible(page, (probe.selector,))), None)
|
|
753
|
+
if at is None or self._already_accepted(page, at, probe):
|
|
754
|
+
continue
|
|
755
|
+
element = _handle_of(at)
|
|
756
|
+
return Widget(element, at, probe.vendor, probe.role) if element is not None else None
|
|
1378
757
|
return None
|
|
1379
758
|
|
|
1380
|
-
def
|
|
1381
|
-
"""
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
time a solve fails that ship has sailed.
|
|
759
|
+
def _already_accepted(self, page: Any, at: Any, probe: WidgetProbe) -> bool:
|
|
760
|
+
"""A checkbox the vendor has already accepted is not a captcha to solve."""
|
|
761
|
+
s = SELECTORS[probe.vendor]
|
|
762
|
+
if probe.role != FrameRole.CHECKBOX:
|
|
763
|
+
return False
|
|
764
|
+
return bool(s.response and self._has_value(page, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
|
|
1387
765
|
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
captcha here".
|
|
1391
|
-
"""
|
|
766
|
+
def vendors_on_the_wire(self, page: Any) -> List[Vendor]:
|
|
767
|
+
"""Which vendors' code the page loaded. Resource timing, because a request listener would have to have been attached before navigation."""
|
|
1392
768
|
try:
|
|
1393
|
-
names = page.evaluate(
|
|
1394
|
-
"""() => {
|
|
1395
|
-
const out = [];
|
|
1396
|
-
try {
|
|
1397
|
-
for (const e of performance.getEntriesByType('resource')) out.push(e.name);
|
|
1398
|
-
} catch (err) {}
|
|
1399
|
-
for (const el of document.querySelectorAll('script[src],iframe[src],link[href],img[src]')) {
|
|
1400
|
-
out.push(el.getAttribute('src') || el.getAttribute('href') || '');
|
|
1401
|
-
}
|
|
1402
|
-
return out;
|
|
1403
|
-
}"""
|
|
1404
|
-
)
|
|
769
|
+
names = page.evaluate(_RESOURCES_JS)
|
|
1405
770
|
except Exception:
|
|
1406
771
|
return []
|
|
1407
772
|
blob = " ".join(str(n) for n in (names or []))
|
|
1408
|
-
return [
|
|
1409
|
-
if any(host in blob for host in entry["hosts"])]
|
|
773
|
+
return [vendor for vendor, s in VENDORS if any(h in blob for h in s.hosts)]
|
|
1410
774
|
|
|
1411
775
|
def _no_widget_message(self, page: Any) -> str:
|
|
1412
|
-
"""Why nothing was found — the two cases told apart.
|
|
1413
|
-
|
|
1414
|
-
The old text guessed "reCAPTCHA v3 / invisible" every time, including
|
|
1415
|
-
the times a vendor had simply moved its DOM. See VENDOR_URL_MARKERS.
|
|
1416
|
-
"""
|
|
1417
776
|
base = "no interactive captcha widget detected"
|
|
1418
777
|
loaded = self.vendors_on_the_wire(page)
|
|
1419
778
|
if not loaded:
|
|
1420
|
-
return (f"{base} (no vendor captcha code loaded on this page — "
|
|
1421
|
-
"
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
"
|
|
1425
|
-
"VENDOR_WIDGET_LOCATORS — re-measure with "
|
|
1426
|
-
"scripts/check_vendor_selectors.py and update BOTH solver ports")
|
|
779
|
+
return (f"{base} (no vendor captcha code loaded on this page — likely reCAPTCHA v3 / "
|
|
780
|
+
"invisible, or a click-triggered challenge that has not been triggered)")
|
|
781
|
+
return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on this page. The "
|
|
782
|
+
"vendor's markup no longer matches anything in SELECTORS — the table needs "
|
|
783
|
+
"re-measuring against the vendor's current markup, in both solver ports")
|
|
1427
784
|
|
|
1428
785
|
def is_captcha_solved(self, page: Any) -> bool:
|
|
1429
|
-
"""
|
|
1430
|
-
The vendor's definitive DONE signal — anchor checked or token populated.
|
|
1431
|
-
|
|
1432
|
-
Needed because after the final submit hCaptcha keeps the challenge iframe
|
|
1433
|
-
VISIBLE for a couple of seconds while it verifies. Treating that frame as
|
|
1434
|
-
a fresh puzzle burns ~18s re-running the pipeline on a closing frame.
|
|
1435
|
-
"""
|
|
786
|
+
"""The vendor's own done signal: a response token, a painted success state, or a checked box."""
|
|
1436
787
|
try:
|
|
1437
|
-
|
|
1438
|
-
# PAGE, not inside the widget, so it never needed the anchor iframe
|
|
1439
|
-
# to be on screen — and the moment it matters most is precisely when
|
|
1440
|
-
# the anchor is NOT on screen, because hCaptcha keeps its challenge
|
|
1441
|
-
# overlay up for a couple of seconds after the winning submit.
|
|
1442
|
-
# Gating this on `_visible(anchor)` meant the one signal that was
|
|
1443
|
-
# already true went unread, and the loop ground on against a frame
|
|
1444
|
-
# being torn down. See tests/test_solved_detection.py.
|
|
1445
|
-
if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
|
|
1446
|
-
return True
|
|
1447
|
-
if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
|
|
1448
|
-
return True
|
|
1449
|
-
# Turnstile. `detect_captcha` already reads this exact field to
|
|
1450
|
-
# decide a Turnstile widget is UNSOLVED, so the signal was known to
|
|
1451
|
-
# one half of the driver and ignored by the other: a solved
|
|
1452
|
-
# Turnstile reported unsolved here and the loop ground through the
|
|
1453
|
-
# readiness waits and a wasted inference until the widget happened
|
|
1454
|
-
# to disappear. Same defect the hCaptcha token had.
|
|
1455
|
-
if self._has_non_empty_field_value(page, '[name="cf-turnstile-response"]'):
|
|
1456
|
-
return True
|
|
1457
|
-
|
|
1458
|
-
# Anchor state is the fallback, and this one DOES need the iframe:
|
|
1459
|
-
# it is read out of the anchor's own document.
|
|
1460
|
-
hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
|
|
1461
|
-
if self._visible(hc) and self._is_hcaptcha_anchor_checked(hc):
|
|
1462
|
-
return True
|
|
1463
|
-
rc = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
|
|
1464
|
-
if self._visible(rc) and self._is_recaptcha_anchor_checked(rc):
|
|
788
|
+
if any(self._has_value(page, s) for s in RESPONSE_SELECTORS) or _visible(page, ACCEPTED_SELECTORS):
|
|
1465
789
|
return True
|
|
790
|
+
for probe in WIDGET_PROBES:
|
|
791
|
+
checked = SELECTORS[probe.vendor].checked
|
|
792
|
+
if probe.role != FrameRole.CHECKBOX or not checked:
|
|
793
|
+
continue
|
|
794
|
+
at = next(iter(_visible(page, (probe.selector,))), None)
|
|
795
|
+
if at is not None and self._checked_in(at, checked):
|
|
796
|
+
return True
|
|
1466
797
|
except Exception:
|
|
1467
798
|
pass
|
|
1468
799
|
return False
|
|
1469
800
|
|
|
1470
801
|
def _is_challenge_freshly_rendered(self, page: Any) -> bool:
|
|
1471
|
-
"""
|
|
1472
|
-
A NEXT ROUND (prompt painted) as opposed to a frame animating closed
|
|
1473
|
-
(prompt already gone). Lets a multi-round solve move on immediately
|
|
1474
|
-
instead of waiting out the full post-submit window.
|
|
1475
|
-
"""
|
|
802
|
+
"""A next round has painted, as opposed to the answered frame animating closed."""
|
|
1476
803
|
try:
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
if current and current == self._last_submit_frame_hash:
|
|
1490
|
-
return False
|
|
1491
|
-
frame = hc.content_frame()
|
|
1492
|
-
prompt = frame.query_selector(".prompt-text") if frame else None
|
|
1493
|
-
if self._visible(prompt):
|
|
1494
|
-
text = prompt.text_content() or ""
|
|
1495
|
-
if text.strip():
|
|
1496
|
-
return True
|
|
1497
|
-
rc = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
|
|
1498
|
-
if self._visible(rc):
|
|
1499
|
-
frame = rc.content_frame()
|
|
1500
|
-
instructions = (
|
|
1501
|
-
frame.query_selector(".rc-imageselect-instructions, #rc-imageselect")
|
|
1502
|
-
if frame
|
|
1503
|
-
else None
|
|
1504
|
-
)
|
|
1505
|
-
if self._visible(instructions):
|
|
804
|
+
for probe in WIDGET_PROBES:
|
|
805
|
+
fresh = SELECTORS[probe.vendor].fresh
|
|
806
|
+
if probe.role != FrameRole.CHALLENGE or not fresh:
|
|
807
|
+
continue
|
|
808
|
+
at = next(iter(_visible(page, (probe.selector,))), None)
|
|
809
|
+
element = _handle_of(at) if at is not None else None
|
|
810
|
+
if element is None:
|
|
811
|
+
continue
|
|
812
|
+
if self._last_submit_frame_hash and self._element_frame_hash(element) == self._last_submit_frame_hash:
|
|
813
|
+
continue
|
|
814
|
+
frame = element.content_frame()
|
|
815
|
+
if frame and self._visible_with_text(frame, fresh):
|
|
1506
816
|
return True
|
|
1507
817
|
except Exception:
|
|
1508
818
|
pass
|
|
1509
819
|
return False
|
|
1510
820
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
an incomplete selection.
|
|
821
|
+
@staticmethod
|
|
822
|
+
def _banner_is_fatal_after_retry(kind: Optional[RecaptchaBanner]) -> bool:
|
|
823
|
+
return kind in (RecaptchaBanner.SELECT_MORE, RecaptchaBanner.REJECTED)
|
|
1515
824
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
matching IS selected), we click Verify again, and the solve loops until
|
|
1519
|
-
it times out. Detecting it switches the next call into missed-tiles mode.
|
|
1520
|
-
"""
|
|
825
|
+
def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
|
|
826
|
+
"""Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
|
|
1521
827
|
try:
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
frame = bframe.content_frame()
|
|
1526
|
-
if not frame:
|
|
1527
|
-
return False
|
|
1528
|
-
for selector in (
|
|
1529
|
-
".rc-imageselect-error-select-more",
|
|
1530
|
-
".rc-imageselect-error-dynamic-more",
|
|
1531
|
-
".rc-imageselect-incorrect-response",
|
|
1532
|
-
):
|
|
1533
|
-
element = frame.query_selector(selector)
|
|
1534
|
-
if not element:
|
|
828
|
+
for probe in WIDGET_PROBES:
|
|
829
|
+
banners = SELECTORS[probe.vendor].banners
|
|
830
|
+
if probe.role != FrameRole.CHALLENGE or not banners:
|
|
1535
831
|
continue
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
if
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
pass
|
|
1544
|
-
return False
|
|
1545
|
-
|
|
1546
|
-
def _get_verify_button(self, frame: Any) -> Optional[Any]:
|
|
1547
|
-
# `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
|
|
1548
|
-
# markup on the host page rather than a vendor iframe (every
|
|
1549
|
-
# distorted-text captcha), and a document-rooted `//button` does not
|
|
1550
|
-
# resolve against an element handle: the query returned None even with
|
|
1551
|
-
# the button sitting inside that very element, so a typed code was never
|
|
1552
|
-
# submitted. On a Frame the context node is the document, where `.//` and
|
|
1553
|
-
# `//` mean the same thing, so the vendor paths are unaffected.
|
|
1554
|
-
for text in ("Verify", "Next", "Submit", "Skip"):
|
|
1555
|
-
lowered = text.lower()
|
|
1556
|
-
try:
|
|
1557
|
-
button = frame.query_selector(
|
|
1558
|
-
f"xpath=.//button[contains(translate(., "
|
|
1559
|
-
f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
|
|
1560
|
-
f" | .//div[@role='button' and contains(translate(., "
|
|
1561
|
-
f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
|
|
1562
|
-
)
|
|
1563
|
-
if self._visible(button):
|
|
1564
|
-
return button
|
|
1565
|
-
except Exception:
|
|
1566
|
-
pass
|
|
1567
|
-
try:
|
|
1568
|
-
recaptcha_verify = frame.query_selector("#recaptcha-verify-button")
|
|
1569
|
-
if self._visible(recaptcha_verify):
|
|
1570
|
-
return recaptcha_verify
|
|
1571
|
-
hcaptcha_verify = frame.query_selector(".button-submit")
|
|
1572
|
-
if self._visible(hcaptcha_verify):
|
|
1573
|
-
return hcaptcha_verify
|
|
1574
|
-
# GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
|
|
1575
|
-
#
|
|
1576
|
-
# Invisible to BOTH shapes above — a bare div carries no
|
|
1577
|
-
# role="button", and "OK" is on none of the four word lists. So
|
|
1578
|
-
# nothing was ever pressed on the puzzles that need pressing, and a
|
|
1579
|
-
# GeeTest board does not grade until you do: the solve loop re-read
|
|
1580
|
-
# the same unchanged panel and re-answered it identically until the
|
|
1581
|
-
# round cap. Ordered icon-click scored 0/31 and 0/13 that way while
|
|
1582
|
-
# the model was answering CORRECTLY (measured 2026-08-19). A driver
|
|
1583
|
-
# that discards a right answer is indistinguishable from a model
|
|
1584
|
-
# that cannot solve the puzzle, which is how this hid.
|
|
1585
|
-
#
|
|
1586
|
-
# Matched by CLASS, not by the word: `geetest_submit_tips` sits
|
|
1587
|
-
# beside it and also reads "OK", and pressing the tooltip does
|
|
1588
|
-
# nothing. `.geetest_submit` is a distinct token, so it cannot
|
|
1589
|
-
# match it. Mirrors getVerifyButton in js/src/solver.ts — CLAUDE.md
|
|
1590
|
-
# 1c, the two ports must behave the same.
|
|
1591
|
-
geetest_submit = frame.query_selector(".geetest_submit")
|
|
1592
|
-
if self._visible(geetest_submit):
|
|
1593
|
-
return geetest_submit
|
|
832
|
+
at = next(iter(_visible(page, (probe.selector,))), None)
|
|
833
|
+
frame = _frame_of(at) if at is not None else None
|
|
834
|
+
if not frame:
|
|
835
|
+
continue
|
|
836
|
+
shown = next((kind for selector, kind in banners if self._visible_with_text(frame, selector)), None)
|
|
837
|
+
if shown is not None:
|
|
838
|
+
return shown
|
|
1594
839
|
except Exception:
|
|
1595
840
|
pass
|
|
1596
841
|
return None
|
|
1597
842
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
843
|
+
def _get_verify_button(self, scope: Any) -> Optional[Any]:
|
|
844
|
+
return self._find_control(scope, SUBMIT_SELECTORS)
|
|
845
|
+
|
|
846
|
+
# ── frames and polling ───────────────────────────────────────────────
|
|
1601
847
|
|
|
1602
848
|
def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
|
|
1603
849
|
animations: str = "disabled") -> None:
|
|
1604
|
-
"""
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
Playwright's default 30s stability wait hangs per-screenshot on a
|
|
1608
|
-
closing/animating challenge element; that alone made a multi-round solve
|
|
1609
|
-
take ~115s. Failing fast and skipping a frame is strictly better.
|
|
1610
|
-
|
|
1611
|
-
`animations` is 'disabled' everywhere the goal is a STABLE STILL, which
|
|
1612
|
-
is almost everywhere. It is not a formality: the flag fast-forwards
|
|
1613
|
-
finite animations and FREEZES infinite ones, which is exactly what a
|
|
1614
|
-
model-facing screenshot wants and exactly what a recording must not do.
|
|
1615
|
-
The burst and the animation probe pass 'allow' — see `solver.ts`
|
|
1616
|
-
`recordKeyframeBurst`, which learned this first: GeeTest's svg board
|
|
1617
|
-
cycles in CSS, so a burst taken with animations disabled captured the
|
|
1618
|
-
same picture forty times, the slicer honestly reported `mode=static`,
|
|
1619
|
-
and the solve went back to answering a single still. hCaptcha hid it,
|
|
1620
|
-
because it animates in canvas and the flag does not touch canvas.
|
|
1621
|
-
"""
|
|
1622
|
-
element.screenshot(
|
|
1623
|
-
path=path,
|
|
1624
|
-
timeout=timeout_ms if timeout_ms is not None else 2_500,
|
|
1625
|
-
animations=animations,
|
|
1626
|
-
)
|
|
850
|
+
"""Short timeout; `disabled` freezes CSS animation, so bursts and the keyframe gate pass `allow`."""
|
|
851
|
+
element.screenshot(path=path, timeout=2_500 if timeout_ms is None else timeout_ms,
|
|
852
|
+
animations=animations)
|
|
1627
853
|
|
|
1628
854
|
def _element_frame_hash(self, element: Any) -> Optional[str]:
|
|
1629
855
|
path = _tmp_png("fh")
|
|
1630
856
|
try:
|
|
1631
857
|
self._screenshot(element, path)
|
|
1632
|
-
|
|
1633
|
-
return hashlib.sha1(handle.read()).hexdigest()
|
|
858
|
+
return _sha1(path)
|
|
1634
859
|
except Exception:
|
|
1635
860
|
return None
|
|
1636
861
|
finally:
|
|
1637
862
|
_unlink(path)
|
|
1638
863
|
|
|
1639
|
-
def
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
caller pairs it with the DOM-level image wait so a static loading frame
|
|
1645
|
-
(a spinner on grey, below the diff threshold) isn't mistaken for painted
|
|
1646
|
-
tiles.
|
|
1647
|
-
"""
|
|
1648
|
-
cfg = self.config
|
|
1649
|
-
start = time.monotonic() * 1000.0
|
|
1650
|
-
previous: Optional[str] = None
|
|
864
|
+
def _poll(self, element: Any, timeout_ms: float, interval_ms: float,
|
|
865
|
+
judge: Callable[[List[str], float], Any], where: str,
|
|
866
|
+
before: Optional[Callable[[], None]] = None) -> Any:
|
|
867
|
+
"""Screenshot `element` every `interval_ms` until `judge(last_two_frames, elapsed_ms)` answers."""
|
|
868
|
+
start = _now()
|
|
1651
869
|
frames: List[str] = []
|
|
1652
|
-
# This loop only COLLECTS polls; the verdict is `settle_verdict`'s, so
|
|
1653
|
-
# the recorded timelines in tests/test_animated_is_detected.py exercise
|
|
1654
|
-
# the same rule that runs here rather than a copy of it that can drift.
|
|
1655
|
-
samples: List[Tuple[float, bool]] = []
|
|
1656
870
|
try:
|
|
1657
|
-
while (
|
|
1658
|
-
self._check_deadline(
|
|
1659
|
-
|
|
871
|
+
while _now() - start < timeout_ms:
|
|
872
|
+
self._check_deadline(where)
|
|
873
|
+
t0 = _now()
|
|
874
|
+
if before is not None:
|
|
875
|
+
before()
|
|
876
|
+
path = _tmp_png("poll")
|
|
1660
877
|
try:
|
|
1661
878
|
self._screenshot(element, path)
|
|
1662
879
|
except Exception:
|
|
1663
880
|
_unlink(path)
|
|
1664
|
-
_delay(
|
|
881
|
+
_delay(interval_ms)
|
|
1665
882
|
continue
|
|
1666
883
|
frames.append(path)
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
settle_frames=cfg.settle_frames,
|
|
1675
|
-
animated_after_ms=cfg.animated_challenge_after_ms,
|
|
1676
|
-
)
|
|
1677
|
-
if verdict != "timeout":
|
|
1678
|
-
return verdict
|
|
1679
|
-
previous = frames[-1]
|
|
1680
|
-
_delay(cfg.settle_poll_ms)
|
|
1681
|
-
return "timeout"
|
|
884
|
+
verdict = judge(frames, _now() - start)
|
|
885
|
+
if verdict is not None:
|
|
886
|
+
return verdict
|
|
887
|
+
if len(frames) > 1:
|
|
888
|
+
_unlink(frames.pop(0))
|
|
889
|
+
_delay(interval_ms - (_now() - t0))
|
|
890
|
+
return None
|
|
1682
891
|
finally:
|
|
1683
892
|
for path in frames:
|
|
1684
893
|
_unlink(path)
|
|
1685
894
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
def _settle_or_animated(self, element: Any) -> bool:
|
|
1691
|
-
"""Wait for the widget to settle; return whether it is animated instead.
|
|
895
|
+
def _wait_for_element_settled(self, element: Any) -> SettleVerdict:
|
|
896
|
+
cfg = self.config
|
|
897
|
+
samples: List[Tuple[float, bool]] = []
|
|
1692
898
|
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
899
|
+
def judge(frames, elapsed):
|
|
900
|
+
if len(frames) < 2:
|
|
901
|
+
return None
|
|
902
|
+
samples.append((elapsed, self._has_movement(frames[0], frames[1], cfg.settle_diff_threshold)))
|
|
903
|
+
verdict = settle_verdict(samples, settle_frames=cfg.settle_frames,
|
|
904
|
+
animated_after_ms=cfg.animated_challenge_after_ms,
|
|
905
|
+
motion_streak=cfg.animated_motion_streak)
|
|
906
|
+
return None if verdict == SettleVerdict.TIMEOUT else verdict
|
|
1698
907
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
"""
|
|
1702
|
-
# Already established for this widget — don't re-probe every round.
|
|
1703
|
-
if self._known_animated:
|
|
1704
|
-
return True
|
|
908
|
+
return self._poll(element, cfg.settle_timeout_ms, cfg.settle_poll_ms, judge,
|
|
909
|
+
"waiting for the challenge to settle") or SettleVerdict.TIMEOUT
|
|
1705
910
|
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
if verdict != "animated":
|
|
1709
|
-
# 'settled' is not proof of static. The settle rule stops at the
|
|
1710
|
-
# first `settle_frames` of stillness, and every animated puzzle we
|
|
1711
|
-
# ship rests between screens for longer than that, so it calls all
|
|
1712
|
-
# of them settled (tests/test_animated_is_detected.py has the
|
|
1713
|
-
# measured timelines).
|
|
1714
|
-
#
|
|
1715
|
-
# What decides it instead is a RECORDING, taken once a round has
|
|
1716
|
-
# already answered the same thing twice — the signature of a
|
|
1717
|
-
# cycling board read as a still. The recording is self-checking: if
|
|
1718
|
-
# the widget really is static it slices to one keyframe and the
|
|
1719
|
-
# caller solves that frame as the still it is. So there is nothing
|
|
1720
|
-
# to be careful about here, and nothing to observe first.
|
|
1721
|
-
if not self._animated_probe_armed or self._animated_probe_done:
|
|
1722
|
-
return False
|
|
1723
|
-
self._animated_probe_done = True
|
|
1724
|
-
self._animated_probe_armed = False
|
|
1725
|
-
_log("[animated] the same answer came back twice — recording the "
|
|
1726
|
-
"challenge to see whether it is cycling")
|
|
1727
|
-
|
|
1728
|
-
# Latch either route. Re-deciding every round would re-record a widget
|
|
1729
|
-
# we have already SEEN animate, and on a puzzle that rests between
|
|
1730
|
-
# screens a later recording can land inside a hold, slice to one frame,
|
|
1731
|
-
# and flip the solve back onto the still path halfway through.
|
|
1732
|
-
self._known_animated = True
|
|
911
|
+
def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
|
|
912
|
+
cfg = self.config
|
|
1733
913
|
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
)
|
|
1738
|
-
|
|
1739
|
-
|
|
914
|
+
def judge(frames, _elapsed):
|
|
915
|
+
if len(frames) < 2:
|
|
916
|
+
return None
|
|
917
|
+
boxes = self._find_grid(frames[1])
|
|
918
|
+
states = boxes and self._grid_cell_states(frames[0], frames[1], boxes)
|
|
919
|
+
return True if states and not states["empty"] and not states["changing"] and states["loaded"] else None
|
|
1740
920
|
|
|
1741
|
-
|
|
1742
|
-
|
|
921
|
+
return bool(self._poll(element, cfg.grid_load_timeout_ms, cfg.grid_load_poll_interval_ms,
|
|
922
|
+
judge, "waiting for grid cells to load"))
|
|
1743
923
|
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
(`keyframes.extract_keyframes`). That shared code path is the whole point:
|
|
1758
|
-
the model answers with a frame NUMBER, and a number only means something if
|
|
1759
|
-
the live set was sliced the way the trained set was.
|
|
1760
|
-
|
|
1761
|
-
Frames are kept in memory and sliced from there — no intermediate mp4. The
|
|
1762
|
-
old pipeline encoded one, and every clip it produced was mp4v, a codec the
|
|
1763
|
-
serving side may or may not decode. Skipping the encode removes that whole
|
|
1764
|
-
class of silent failure along with the disk round-trip.
|
|
1765
|
-
|
|
1766
|
-
The caller owns `temp_dir` and must remove it once the actions are done
|
|
1767
|
-
with: the returned paths are read back by the wait gate on every poll, so
|
|
1768
|
-
cleaning up any earlier would break the click.
|
|
1769
|
-
|
|
1770
|
-
Raises AnimatedChallengeError if nothing could be captured.
|
|
1771
|
-
"""
|
|
1772
|
-
from .keyframes import extract_keyframes, write_keyframes
|
|
1773
|
-
|
|
1774
|
-
cfg = self.config
|
|
1775
|
-
fps = max(1, int(cfg.video_burst_fps))
|
|
1776
|
-
total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
|
|
1777
|
-
interval = 1.0 / fps
|
|
1778
|
-
|
|
1779
|
-
# THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
|
|
1780
|
-
#
|
|
1781
|
-
# `overall_solve_timeout_ms` counts rounds, and this is not a round —
|
|
1782
|
-
# see `video_budget_ms` for the arithmetic and for what it cost not to
|
|
1783
|
-
# have it. Granted here rather than where the probe arms because this is
|
|
1784
|
-
# the one place every path into a recording goes through.
|
|
1785
|
-
if cfg.video_solve_enabled and not self._video_budget_granted:
|
|
1786
|
-
self._video_budget_granted = True
|
|
1787
|
-
if self._deadline_ms is not None:
|
|
1788
|
-
self._deadline_ms += cfg.video_budget_ms()
|
|
1789
|
-
_log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
|
|
1790
|
-
|
|
1791
|
-
# Checked ONCE, before the first frame — never per frame.
|
|
1792
|
-
#
|
|
1793
|
-
# A HALF-RECORDED BURST IS WORTHLESS: the slicer reads a clip's temporal
|
|
1794
|
-
# structure, so stopping at frame 27 of 40 does not produce a shorter
|
|
1795
|
-
# answer, it produces a recording that may not contain the screen the
|
|
1796
|
-
# answer is on. Aborting mid-way therefore threw away the whole burst
|
|
1797
|
-
# AND the ~3s already spent making it, to report a timeout. The burst is
|
|
1798
|
-
# also fixed-length and short, so it is not one of the places that "can
|
|
1799
|
-
# legitimately spin for a long time" that `_check_deadline` is for.
|
|
1800
|
-
#
|
|
1801
|
-
# If the budget cannot fit one, say THAT — a caller who has tightened
|
|
1802
|
-
# `overall_solve_timeout_ms` below what a recording costs has asked for
|
|
1803
|
-
# something impossible, and should hear it rather than watch a burst die
|
|
1804
|
-
# partway through every time.
|
|
1805
|
-
if self._deadline_ms is not None:
|
|
1806
|
-
left = self._deadline_ms - time.monotonic() * 1000.0
|
|
1807
|
-
if left < cfg.video_burst_duration_ms:
|
|
1808
|
-
raise CaptchaSolveError(
|
|
1809
|
-
f"only {left:.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
|
|
1810
|
-
f"budget is left and an animated recording needs "
|
|
1811
|
-
f"{cfg.video_burst_duration_ms}ms — not starting one that would "
|
|
1812
|
-
f"be cut off mid-way. Raise overall_solve_timeout_ms or "
|
|
1813
|
-
f"video_extra_inference_ms, or set video_solve_enabled=False."
|
|
1814
|
-
)
|
|
1815
|
-
|
|
1816
|
-
frames: List[Any] = []
|
|
1817
|
-
|
|
1818
|
-
remaining = max(0, total - len(frames))
|
|
1819
|
-
shot = _tmp_png("burst")
|
|
1820
|
-
# A burst that runs far past its own length is a hung screenshot, not a
|
|
1821
|
-
# tight budget — bounded separately so the two cannot be confused.
|
|
1822
|
-
burst_deadline = (time.monotonic() * 1000.0
|
|
1823
|
-
+ 3 * cfg.video_burst_duration_ms + 5_000)
|
|
1824
|
-
try:
|
|
1825
|
-
for i in range(remaining):
|
|
1826
|
-
if time.monotonic() * 1000.0 > burst_deadline:
|
|
1827
|
-
raise CaptchaSolveError(
|
|
1828
|
-
f"the animated recording stalled: {i} of {remaining} frames "
|
|
1829
|
-
f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
|
|
1830
|
-
f"is not screenshotting."
|
|
1831
|
-
)
|
|
1832
|
-
start = time.monotonic()
|
|
1833
|
-
try:
|
|
1834
|
-
# animations ALLOWED — see `_screenshot`. The JS port has
|
|
1835
|
-
# done this since the GeeTest svg burst came back as 40
|
|
1836
|
-
# copies of one picture; this port never did, so every
|
|
1837
|
-
# CSS-animated vendor was recorded frozen.
|
|
1838
|
-
self._screenshot(element, shot, animations="allow")
|
|
1839
|
-
except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
|
|
1840
|
-
_debug(f"burst frame {i} failed: {exc}")
|
|
1841
|
-
else:
|
|
1842
|
-
import cv2
|
|
1843
|
-
|
|
1844
|
-
img = cv2.imread(shot)
|
|
1845
|
-
if img is not None:
|
|
1846
|
-
frames.append(img)
|
|
1847
|
-
# Drift-corrected: a slow screenshot must not stretch the clip, or
|
|
1848
|
-
# the recording covers more wall-clock than the model trained on and
|
|
1849
|
-
# a cycle's period lands differently across the frames.
|
|
1850
|
-
wait = interval - (time.monotonic() - start)
|
|
1851
|
-
if wait > 0 and i < remaining - 1:
|
|
1852
|
-
time.sleep(wait)
|
|
1853
|
-
finally:
|
|
1854
|
-
_unlink(shot)
|
|
1855
|
-
|
|
1856
|
-
if not frames:
|
|
1857
|
-
raise AnimatedChallengeError(
|
|
1858
|
-
"could not record the animated challenge (no frame screenshotted)"
|
|
1859
|
-
)
|
|
1860
|
-
_log(f"[animated] recorded {len(frames)} frames at {fps}fps")
|
|
1861
|
-
|
|
1862
|
-
kfset = extract_keyframes(frames, fps=float(fps))
|
|
1863
|
-
temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
|
|
1864
|
-
paths = write_keyframes(kfset, temp_dir, stem="challenge")
|
|
1865
|
-
_log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
|
|
1866
|
-
# The wait gate consults this: `even` means the extractor found no state
|
|
1867
|
-
# that RECURS, so there is nothing for the page to come back to.
|
|
1868
|
-
self._keyframe_mode = kfset.mode
|
|
1869
|
-
return [str(p) for p in paths], temp_dir
|
|
1870
|
-
|
|
1871
|
-
def _wait_for_keyframe(self, element: Any, keyframe_path: str,
|
|
1872
|
-
point_norm: Tuple[float, float]) -> bool:
|
|
1873
|
-
"""Hold until the widget looks like `keyframe_path` around `point_norm`.
|
|
1874
|
-
|
|
1875
|
-
This is the reason an animated answer names a frame. The model picked the
|
|
1876
|
-
moment its target was visible; the coordinates are only correct at that
|
|
1877
|
-
moment. Clicking as soon as the answer arrives lands on whatever the sprite
|
|
1878
|
-
happens to be doing — for a cross-fade, usually background.
|
|
1879
|
-
|
|
1880
|
-
Compares only the neighbourhood of the action point, with the same box and
|
|
1881
|
-
the same metric the training label was chosen with
|
|
1882
|
-
(`keyframes.region_box` / `region_diff_ratio`). Local rather than
|
|
1883
|
-
whole-frame because everything ELSE in these puzzles is also moving: a
|
|
1884
|
-
whole-frame match would need every unrelated sprite to align too, and would
|
|
1885
|
-
essentially never open.
|
|
1886
|
-
|
|
1887
|
-
Returns whether the state was reached. On timeout the caller clicks anyway
|
|
1888
|
-
— see `keyframe_wait_timeout_ms`.
|
|
1889
|
-
|
|
1890
|
-
NOT ATTEMPTED on an `even` clip. `keyframes.py` picks that mode precisely
|
|
1891
|
-
when the clip never revisits a picture it has already shown — a slow
|
|
1892
|
-
one-way change, a rotation, a cross-fade — so there is no state for the
|
|
1893
|
-
page to come BACK to and the wait can only ever run out. It is not a
|
|
1894
|
-
cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
|
|
1895
|
-
PER CLICK before giving up and clicking anyway.
|
|
1896
|
-
And `even` is not the rare case. All 116 real clips under
|
|
1897
|
-
`cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
|
|
1898
|
-
fired on real footage, which keyframes.py itself records ("real state
|
|
1899
|
-
separations top out around 0.007, well under this"). So on real traffic
|
|
1900
|
-
this gate was 6s of dead time on every animated click, always followed by
|
|
1901
|
-
the same click it would have made immediately. Measured on
|
|
1902
|
-
`hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
|
|
1903
|
-
0.0721 against a 0.05 tolerance, then it clicked and solved.
|
|
1904
|
-
|
|
1905
|
-
The gate stays for `cycle`/`static`, where a state genuinely does recur
|
|
1906
|
-
and waiting is the difference between clicking the sprite and clicking
|
|
1907
|
-
the background.
|
|
1908
|
-
"""
|
|
1909
|
-
import cv2
|
|
1910
|
-
|
|
1911
|
-
from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
|
|
1912
|
-
|
|
1913
|
-
if self._keyframe_mode == "even":
|
|
1914
|
-
_log("[animated] clip is mode=even (no state recurs); "
|
|
1915
|
-
"acting on the model's frame without waiting")
|
|
1916
|
-
return False
|
|
1917
|
-
|
|
1918
|
-
ref = cv2.imread(keyframe_path)
|
|
1919
|
-
if ref is None:
|
|
1920
|
-
_debug(f"keyframe {keyframe_path} unreadable; not waiting")
|
|
1921
|
-
return False
|
|
1922
|
-
box = region_box(ref.shape[1::-1], point_norm)
|
|
1923
|
-
|
|
1924
|
-
cfg = self.config
|
|
1925
|
-
deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
|
|
1926
|
-
probe = _tmp_png("kfwait")
|
|
1927
|
-
best = 1.0
|
|
1928
|
-
try:
|
|
1929
|
-
while (time.monotonic() * 1000.0) < deadline:
|
|
1930
|
-
self._check_deadline("waiting for the challenge keyframe")
|
|
1931
|
-
try:
|
|
1932
|
-
self._screenshot(element, probe)
|
|
1933
|
-
live = cv2.imread(probe)
|
|
1934
|
-
except Exception as exc: # noqa: BLE001
|
|
1935
|
-
_debug(f"keyframe probe failed: {exc}")
|
|
1936
|
-
live = None
|
|
1937
|
-
if live is not None:
|
|
1938
|
-
d = region_diff_ratio(ref, live, box)
|
|
1939
|
-
best = min(best, d)
|
|
1940
|
-
if d <= MATCH_REGION_TOLERANCE:
|
|
1941
|
-
_log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
|
|
1942
|
-
return True
|
|
1943
|
-
_delay(cfg.keyframe_wait_poll_ms)
|
|
1944
|
-
finally:
|
|
1945
|
-
_unlink(probe)
|
|
1946
|
-
_log(f"[animated] widget never matched the chosen keyframe within "
|
|
1947
|
-
f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
|
|
1948
|
-
f"clicking on the model's coordinates anyway")
|
|
1949
|
-
return False
|
|
924
|
+
def _wait_for_board_painted(self, element: Any) -> int:
|
|
925
|
+
"""A still board is not a loaded board: hold until the panel's centre carries structure."""
|
|
926
|
+
cfg = self.config
|
|
927
|
+
start = _now()
|
|
928
|
+
painted = self._poll(element, cfg.board_paint_timeout_ms, cfg.board_paint_poll_ms,
|
|
929
|
+
lambda frames, _e: True if self._board_painted(frames[-1]) is not False else None,
|
|
930
|
+
"waiting for the board to paint")
|
|
931
|
+
waited = int(_now() - start)
|
|
932
|
+
if not painted:
|
|
933
|
+
_log(f"[board] the widget never painted a puzzle in {cfg.board_paint_timeout_ms}ms")
|
|
934
|
+
elif waited >= cfg.board_paint_poll_ms:
|
|
935
|
+
_log(f"[board] waited {waited}ms for the widget to paint its puzzle")
|
|
936
|
+
return waited
|
|
1950
937
|
|
|
1951
938
|
def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
start = time.monotonic() * 1000.0
|
|
1955
|
-
while (time.monotonic() * 1000.0) - start < cfg.post_submit_change_timeout_ms:
|
|
939
|
+
start = _now()
|
|
940
|
+
while _now() - start < self.config.post_submit_change_timeout_ms:
|
|
1956
941
|
current = self._element_frame_hash(element)
|
|
1957
942
|
if current and current != since_hash:
|
|
1958
943
|
return True
|
|
1959
|
-
_delay(
|
|
944
|
+
_delay(self.config.settle_poll_ms)
|
|
1960
945
|
return False
|
|
1961
946
|
|
|
1962
|
-
def
|
|
1963
|
-
"""
|
|
1964
|
-
Block until the challenge's task images have actually painted.
|
|
1965
|
-
|
|
1966
|
-
hCaptcha sets each tile's background-image once the asset loads, and
|
|
1967
|
-
ships an empty `url("")` placeholder before it arrives. Best-effort: a
|
|
1968
|
-
timeout falls through to the screenshot, where the existing fail-fast
|
|
1969
|
-
path still covers a genuinely unsupported puzzle.
|
|
1970
|
-
|
|
1971
|
-
"NOTHING TO WAIT FOR" IS READY. The last clause used to be
|
|
1972
|
-
`return !!(example && ...)`, which is false when there is no example
|
|
1973
|
-
image — so a challenge with no tile grid, no canvas and no example
|
|
1974
|
-
polled until the timeout and then carried on regardless. Measured on
|
|
1975
|
-
`hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
|
|
1976
|
-
full 8s three times over, more than half the budget spent asking a
|
|
1977
|
-
question about elements that were not on the page. A readiness gate can
|
|
1978
|
-
only report on what it can see; with nothing to check it has no opinion,
|
|
1979
|
-
and "no opinion" must not read as "not ready".
|
|
1980
|
-
"""
|
|
1981
|
-
cfg = self.config
|
|
1982
|
-
try:
|
|
1983
|
-
frame = challenge_iframe.content_frame()
|
|
1984
|
-
if not frame:
|
|
1985
|
-
return
|
|
1986
|
-
frame.wait_for_selector(".prompt-text", state="visible",
|
|
1987
|
-
timeout=cfg.hcaptcha_images_timeout_ms)
|
|
1988
|
-
frame.wait_for_function(
|
|
1989
|
-
"""() => {
|
|
1990
|
-
const tiles = Array.from(
|
|
1991
|
-
document.querySelectorAll('.task-image .image, .task .image'));
|
|
1992
|
-
if (tiles.length > 0) {
|
|
1993
|
-
return tiles.every((el) => {
|
|
1994
|
-
const bg = getComputedStyle(el).backgroundImage;
|
|
1995
|
-
return bg && bg !== 'none' && !/url\\(["']?["']?\\)/.test(bg);
|
|
1996
|
-
});
|
|
1997
|
-
}
|
|
1998
|
-
const canvas = document.querySelector('canvas');
|
|
1999
|
-
if (canvas && canvas.width > 0 && canvas.height > 0) return true;
|
|
2000
|
-
const example = document.querySelector(
|
|
2001
|
-
'.challenge-example img, .image-wrapper img');
|
|
2002
|
-
if (example) return example.complete && example.naturalWidth > 0;
|
|
2003
|
-
return true; // nothing here to be waited for
|
|
2004
|
-
}""",
|
|
2005
|
-
timeout=cfg.hcaptcha_images_timeout_ms,
|
|
2006
|
-
)
|
|
2007
|
-
except Exception:
|
|
2008
|
-
pass # timed out or detached mid-load; screenshot anyway
|
|
2009
|
-
|
|
2010
|
-
def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
|
|
2011
|
-
"""
|
|
2012
|
-
reCAPTCHA fades new tiles in over ~1s, on first load and on every
|
|
2013
|
-
in-place refresh. Screenshotting mid-fade feeds the model a partial grid.
|
|
2014
|
-
"""
|
|
2015
|
-
cfg = self.config
|
|
2016
|
-
start = time.monotonic() * 1000.0
|
|
2017
|
-
frames: List[str] = []
|
|
947
|
+
def _wait_for_board_images(self, frame: Any, selectors: VendorSelectors) -> None:
|
|
948
|
+
"""Best-effort: hold until the board's pictures have painted."""
|
|
2018
949
|
try:
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
path = _tmp_png("gridpoll")
|
|
2022
|
-
try:
|
|
2023
|
-
self._screenshot(element, path)
|
|
2024
|
-
except Exception:
|
|
2025
|
-
_unlink(path)
|
|
2026
|
-
_delay(cfg.grid_load_poll_interval_ms)
|
|
2027
|
-
continue
|
|
2028
|
-
frames.append(path)
|
|
2029
|
-
if len(frames) >= 2:
|
|
2030
|
-
boxes = self._find_grid(frames[-1])
|
|
2031
|
-
if boxes:
|
|
2032
|
-
states = self._grid_cell_states(frames[-2], frames[-1], boxes)
|
|
2033
|
-
if (
|
|
2034
|
-
states
|
|
2035
|
-
and not states["empty"]
|
|
2036
|
-
and not states["changing"]
|
|
2037
|
-
and states["loaded"]
|
|
2038
|
-
):
|
|
2039
|
-
return True
|
|
2040
|
-
_unlink(frames.pop(0))
|
|
2041
|
-
_delay(cfg.grid_load_poll_interval_ms)
|
|
2042
|
-
return False
|
|
950
|
+
frame.wait_for_function(_BOARD_IMAGES_READY_JS, arg={"prompt": selectors.fresh or "", "images": ",".join(selectors.images)},
|
|
951
|
+
timeout=self.config.hcaptcha_images_timeout_ms)
|
|
2043
952
|
except Exception:
|
|
2044
|
-
|
|
2045
|
-
finally:
|
|
2046
|
-
for path in frames:
|
|
2047
|
-
_unlink(path)
|
|
953
|
+
pass
|
|
2048
954
|
|
|
2049
955
|
def _get_grid_boxes(self, element: Any) -> Optional[Dict[str, Any]]:
|
|
2050
|
-
"""
|
|
2051
|
-
Detect the grid ONCE per puzzle session. Geometry is stable across the
|
|
2052
|
-
in-place dynamic refresh (only tile images change), so callers cache it.
|
|
2053
|
-
"""
|
|
2054
956
|
path = _tmp_png("findgrid")
|
|
2055
957
|
try:
|
|
2056
958
|
self._screenshot(element, path, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
2057
959
|
boxes = self._find_grid(path)
|
|
2058
|
-
if not boxes or len(boxes) not in (9, 16):
|
|
2059
|
-
return None
|
|
2060
960
|
dims = _read_png_dimensions(path)
|
|
2061
|
-
if not dims:
|
|
961
|
+
if not boxes or len(boxes) not in (9, 16) or not dims:
|
|
2062
962
|
return None
|
|
2063
|
-
return {
|
|
2064
|
-
|
|
2065
|
-
"size": 4 if len(boxes) == 16 else 3,
|
|
2066
|
-
"screenshot_w": dims[0],
|
|
2067
|
-
"screenshot_h": dims[1],
|
|
2068
|
-
}
|
|
963
|
+
return {"boxes": [list(b) for b in boxes], "size": 4 if len(boxes) == 16 else 3,
|
|
964
|
+
"screenshot_w": dims[0], "screenshot_h": dims[1]}
|
|
2069
965
|
except Exception:
|
|
2070
966
|
return None
|
|
2071
967
|
finally:
|
|
2072
968
|
_unlink(path)
|
|
2073
969
|
|
|
2074
|
-
# ------------------------------------------------------------------
|
|
2075
|
-
# Freshness guard
|
|
2076
|
-
# ------------------------------------------------------------------
|
|
2077
|
-
|
|
2078
970
|
def _frame_changed_since(self, element: Any, prior_path: str, threshold: float) -> bool:
|
|
2079
971
|
probe = _tmp_png("freshcheck")
|
|
2080
972
|
try:
|
|
@@ -2085,1099 +977,781 @@ class PageSolver:
|
|
|
2085
977
|
finally:
|
|
2086
978
|
_unlink(probe)
|
|
2087
979
|
|
|
2088
|
-
def _solve_frame_freshness_guarded(
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
initial_shot: str,
|
|
2092
|
-
run_query: Callable[[str], Tuple[List[CaptchaAction], List[Dict[str, Any]]]],
|
|
2093
|
-
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
2094
|
-
"""
|
|
2095
|
-
Never act on a stale frame.
|
|
2096
|
-
|
|
2097
|
-
If the frame changed WHILE the model was generating, its answer describes
|
|
2098
|
-
an undeveloped frame whose tiles no longer line up — so re-screenshot and
|
|
2099
|
-
re-solve on the developed one. Token usage from every attempt is merged,
|
|
2100
|
-
because every attempt was really billed.
|
|
2101
|
-
"""
|
|
980
|
+
def _solve_frame_freshness_guarded(self, element: Any, initial_shot: str,
|
|
981
|
+
run_query: Callable[[str], Tuple[List[CaptchaAction], List[Dict[str, Any]]]]):
|
|
982
|
+
"""Re-solve when the frame changed during inference; twice means the board cycles."""
|
|
2102
983
|
cfg = self.config
|
|
2103
984
|
owned: List[str] = []
|
|
2104
|
-
|
|
985
|
+
merged: List[Dict[str, Any]] = []
|
|
2105
986
|
try:
|
|
2106
987
|
current = initial_shot
|
|
2107
988
|
actions, usage = run_query(current)
|
|
2108
|
-
|
|
989
|
+
merged.extend(usage)
|
|
2109
990
|
if not cfg.stale_frame_resolve_enabled:
|
|
2110
|
-
return actions,
|
|
2111
|
-
|
|
991
|
+
return actions, merged
|
|
992
|
+
if self._frame_changed_since(element, current, _MOVED_DURING_INFERENCE_DIFF):
|
|
993
|
+
self._arm_animated_probe()
|
|
994
|
+
_log("[freshness] the widget moved while the model was reading it")
|
|
995
|
+
changes = 0
|
|
2112
996
|
for attempt in range(cfg.max_stale_frame_resolves):
|
|
2113
997
|
if not self._frame_changed_since(element, current, cfg.stale_frame_diff_threshold):
|
|
2114
|
-
break
|
|
998
|
+
break
|
|
999
|
+
changes += 1
|
|
1000
|
+
if changes >= 2:
|
|
1001
|
+
self._arm_animated_probe()
|
|
1002
|
+
_log("[freshness] the frame changed twice during inference — this board cycles")
|
|
1003
|
+
return actions, merged
|
|
1004
|
+
self._wait_for_board_painted(element)
|
|
2115
1005
|
fresh = _tmp_png("freshsolve")
|
|
2116
1006
|
try:
|
|
2117
1007
|
self._screenshot(element, fresh)
|
|
2118
1008
|
except Exception:
|
|
2119
1009
|
_unlink(fresh)
|
|
2120
|
-
break
|
|
1010
|
+
break
|
|
2121
1011
|
owned.append(fresh)
|
|
2122
|
-
_log(
|
|
2123
|
-
f"[freshness] frame changed during inference "
|
|
2124
|
-
f"(re-solve {attempt + 1}/{cfg.max_stale_frame_resolves})"
|
|
2125
|
-
)
|
|
1012
|
+
_log(f"[freshness] frame changed during inference (re-solve {attempt + 1}/{cfg.max_stale_frame_resolves})")
|
|
2126
1013
|
current = fresh
|
|
2127
1014
|
actions, usage = run_query(current)
|
|
2128
|
-
|
|
2129
|
-
return actions,
|
|
1015
|
+
merged.extend(usage)
|
|
1016
|
+
return actions, merged
|
|
2130
1017
|
finally:
|
|
2131
1018
|
for path in owned:
|
|
2132
1019
|
_unlink(path)
|
|
2133
1020
|
|
|
2134
|
-
#
|
|
2135
|
-
|
|
2136
|
-
|
|
1021
|
+
# ── animated challenges ──────────────────────────────────────────────
|
|
1022
|
+
|
|
1023
|
+
def _settle_or_animated(self, element: Any) -> bool:
|
|
1024
|
+
"""Wait for the widget to settle; True routes the caller to the recording path."""
|
|
1025
|
+
if self._known_animated:
|
|
1026
|
+
return True
|
|
1027
|
+
with self._phase(Phase.SETTLE):
|
|
1028
|
+
verdict = self._wait_for_element_settled(element)
|
|
1029
|
+
if verdict != SettleVerdict.ANIMATED:
|
|
1030
|
+
# 'settled' is not proof of static; a repeated answer arms one recording to find out.
|
|
1031
|
+
if not self._animated_probe_armed or self._animated_probe_done:
|
|
1032
|
+
return False
|
|
1033
|
+
self._animated_probe_done, self._animated_probe_armed = True, False
|
|
1034
|
+
_log("[animated] a second look at a board that did not solve as a still — recording it")
|
|
1035
|
+
self._known_animated = True
|
|
1036
|
+
if not self.config.video_solve_enabled:
|
|
1037
|
+
raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
|
|
1038
|
+
_log("[animated] challenge is animated — solving it from keyframes")
|
|
1039
|
+
return True
|
|
1040
|
+
|
|
1041
|
+
def _arm_animated_probe(self) -> None:
|
|
1042
|
+
if self.config.animated_probe_enabled and not self._animated_probe_done:
|
|
1043
|
+
self._animated_probe_armed = True
|
|
1044
|
+
|
|
1045
|
+
def _should_speculate(self, puzzle_source: Vendor, text_mode: bool) -> bool:
|
|
1046
|
+
# reCAPTCHA is excluded: its dynamic 3x3 replaces tiles in place, so a burst would film a fade and call
|
|
1047
|
+
# it a cycle. A text round has nothing to film.
|
|
1048
|
+
cfg = self.config
|
|
1049
|
+
return (cfg.video_solve_enabled and cfg.speculative_burst_enabled and not self._acted_on_board
|
|
1050
|
+
and puzzle_source != Vendor.RECAPTCHA and not text_mode)
|
|
1051
|
+
|
|
1052
|
+
def _burst(self, element: Any) -> Tuple[List[Any], List[str], bool, float]:
|
|
1053
|
+
"""Film the widget until a screen comes back (a cycle) or nothing new appears for a floor window.
|
|
1054
|
+
|
|
1055
|
+
Returns `(frames, distinct_digests, moved, elapsed_ms)`. `moved` is a closed cycle OR a board still
|
|
1056
|
+
producing new screens past the floor with more than BURST_ANIMATED_SCREENS seen: a continuous animation
|
|
1057
|
+
never repeats and never settles, so without that verdict it read as a still and was clicked as one.
|
|
1058
|
+
Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
|
|
1059
|
+
number_with_highest_value_video and failed every seed either way.
|
|
1060
|
+
"""
|
|
1061
|
+
import cv2
|
|
1062
|
+
|
|
1063
|
+
cfg = self.config
|
|
1064
|
+
floor_ms = float(cfg.video_burst_duration_ms)
|
|
1065
|
+
max_ms = max(floor_ms, float(cfg.video_burst_max_ms))
|
|
1066
|
+
interval = 1.0 / max(1, int(cfg.video_burst_fps))
|
|
1067
|
+
frames: List[Any] = []
|
|
1068
|
+
order: List[str] = []
|
|
1069
|
+
last_digest: Optional[str] = None
|
|
1070
|
+
cycle_closed = False
|
|
1071
|
+
last_new_ms = 0.0
|
|
1072
|
+
shot = _tmp_png("burst")
|
|
1073
|
+
t0 = time.monotonic()
|
|
1074
|
+
next_at = t0
|
|
1075
|
+
hang_deadline = t0 * 1000.0 + burst_hang_deadline_ms(cfg)
|
|
1076
|
+
try:
|
|
1077
|
+
while (time.monotonic() - t0) * 1000.0 < max_ms:
|
|
1078
|
+
if time.monotonic() * 1000.0 > hang_deadline:
|
|
1079
|
+
raise CaptchaSolveError(
|
|
1080
|
+
f"the animated recording stalled: {len(frames)} frames in "
|
|
1081
|
+
f"{burst_hang_deadline_ms(cfg):.0f}ms. The widget is not screenshotting.")
|
|
1082
|
+
try:
|
|
1083
|
+
self._screenshot(element, shot, animations="allow")
|
|
1084
|
+
img = cv2.imread(shot)
|
|
1085
|
+
except Exception as exc:
|
|
1086
|
+
_debug(f"burst frame failed: {exc}")
|
|
1087
|
+
img = None
|
|
1088
|
+
if img is not None:
|
|
1089
|
+
frames.append(img)
|
|
1090
|
+
d = _sha1(shot)
|
|
1091
|
+
if d != last_digest:
|
|
1092
|
+
if d in order and len(order) >= 2:
|
|
1093
|
+
cycle_closed = True
|
|
1094
|
+
elif d not in order:
|
|
1095
|
+
order.append(d)
|
|
1096
|
+
last_new_ms = (time.monotonic() - t0) * 1000.0
|
|
1097
|
+
last_digest = d
|
|
1098
|
+
elapsed_ms = (time.monotonic() - t0) * 1000.0
|
|
1099
|
+
if cycle_closed and elapsed_ms >= floor_ms:
|
|
1100
|
+
_log(f"[animated] cycle closed after {elapsed_ms / 1000:.1f}s ({len(order)} screens)")
|
|
1101
|
+
break
|
|
1102
|
+
if elapsed_ms >= floor_ms and elapsed_ms - last_new_ms >= floor_ms:
|
|
1103
|
+
_log(f"[animated] no new screen for {floor_ms / 1000:.1f}s ({len(order)} seen) — settled")
|
|
1104
|
+
break
|
|
1105
|
+
# Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
|
|
1106
|
+
# loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
|
|
1107
|
+
next_at = max(next_at + interval, time.monotonic())
|
|
1108
|
+
wait = next_at - time.monotonic()
|
|
1109
|
+
if wait > 0 and (next_at - t0) * 1000.0 < max_ms:
|
|
1110
|
+
time.sleep(wait)
|
|
1111
|
+
finally:
|
|
1112
|
+
_unlink(shot)
|
|
1113
|
+
elapsed_ms = (time.monotonic() - t0) * 1000.0
|
|
1114
|
+
animating = len(order) > BURST_ANIMATED_SCREENS and elapsed_ms - last_new_ms < floor_ms
|
|
1115
|
+
if cycle_closed:
|
|
1116
|
+
why = "a screen came back"
|
|
1117
|
+
elif animating:
|
|
1118
|
+
why = f"{len(order)} screens and still arriving — animating continuously"
|
|
1119
|
+
else:
|
|
1120
|
+
why = "no new screen for a full floor window — it moved once and settled"
|
|
1121
|
+
_log(f"[animated] burst verdict after {elapsed_ms / 1000:.1f}s: "
|
|
1122
|
+
f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
|
|
1123
|
+
return frames, order, cycle_closed or animating, elapsed_ms
|
|
1124
|
+
|
|
1125
|
+
def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[List[str], str]:
|
|
1126
|
+
"""Cut a burst with the training-side slicer; the model answers with a frame number into it."""
|
|
1127
|
+
from .keyframes import extract_keyframes, write_keyframes
|
|
1128
|
+
|
|
1129
|
+
kfset = extract_keyframes(frames, fps=measured_fps(len(frames), burst_ms, self.config.video_burst_fps))
|
|
1130
|
+
self._keyframe_mode = kfset.mode
|
|
1131
|
+
self._keyframe_steady_screens = kfset.steady_screens
|
|
1132
|
+
temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
|
|
1133
|
+
paths = [str(p) for p in write_keyframes(kfset, temp_dir, stem="challenge")]
|
|
1134
|
+
_log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
|
|
1135
|
+
return paths, temp_dir
|
|
1136
|
+
|
|
1137
|
+
def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
|
|
1138
|
+
"""Record the widget and return `(keyframe_paths, temp_dir)`; the caller removes the dir.
|
|
1139
|
+
|
|
1140
|
+
Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
|
|
1141
|
+
"""
|
|
1142
|
+
cfg = self.config
|
|
1143
|
+
self._grant_video_budget()
|
|
1144
|
+
if self._deadline_ms is not None and self._deadline_ms - _now() < cfg.video_burst_duration_ms:
|
|
1145
|
+
raise CaptchaSolveError(
|
|
1146
|
+
f"only {self._deadline_ms - _now():.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
|
|
1147
|
+
f"budget is left and an animated recording needs {cfg.video_burst_duration_ms}ms — not "
|
|
1148
|
+
"starting one that would be cut off mid-way. Raise overall_solve_timeout_ms or "
|
|
1149
|
+
"video_extra_inference_ms, or set video_solve_enabled=False.")
|
|
1150
|
+
frames, _order, _closed, burst_ms = self._burst(element)
|
|
1151
|
+
if not frames:
|
|
1152
|
+
raise AnimatedChallengeError("could not record the animated challenge (no frame screenshotted)")
|
|
1153
|
+
_log(f"[animated] recorded {len(frames)} frames in {burst_ms / 1000:.1f}s "
|
|
1154
|
+
f"({measured_fps(len(frames), burst_ms, cfg.video_burst_fps):.1f}fps)")
|
|
1155
|
+
return self._slice(frames, burst_ms)
|
|
1156
|
+
|
|
1157
|
+
def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
1158
|
+
text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
|
|
1159
|
+
"""Ask the still and film the widget at once; `keyframe_dir` is None when the board is still.
|
|
1160
|
+
|
|
1161
|
+
Only the HTTP request crosses to the worker thread: sync Playwright is thread-affine.
|
|
1162
|
+
"""
|
|
1163
|
+
pool = ThreadPoolExecutor(max_workers=1)
|
|
1164
|
+
try:
|
|
1165
|
+
fut = pool.submit(self._get_solution, shot, puzzle_source, retry_mode, text_mode)
|
|
1166
|
+
with self._phase(Phase.BURST):
|
|
1167
|
+
frames, order, moved, burst_ms = self._burst(element)
|
|
1168
|
+
if not moved:
|
|
1169
|
+
with self._phase(Phase.INFERENCE):
|
|
1170
|
+
actions, usage = fut.result()
|
|
1171
|
+
if len(order) > 1:
|
|
1172
|
+
# Moved once and settled: re-read the screen it came to rest on.
|
|
1173
|
+
try:
|
|
1174
|
+
self._screenshot(element, shot)
|
|
1175
|
+
actions, extra = self._get_solution(shot, puzzle_source, retry_mode, text_mode)
|
|
1176
|
+
usage = list(usage) + list(extra)
|
|
1177
|
+
except Exception as exc:
|
|
1178
|
+
_debug(f"settled re-read failed: {exc}")
|
|
1179
|
+
return actions, usage, None
|
|
1180
|
+
_log("[animated] the widget moved while the model was reading it — finishing the recording")
|
|
1181
|
+
fut.result()
|
|
1182
|
+
self._grant_video_budget()
|
|
1183
|
+
paths, keyframe_dir = self._slice(frames, burst_ms)
|
|
1184
|
+
with self._phase(Phase.INFERENCE):
|
|
1185
|
+
actions, usage = self._get_keyframe_solution(paths)
|
|
1186
|
+
self._animated_plan = (paths, keyframe_dir, actions, usage)
|
|
1187
|
+
return actions, usage, keyframe_dir
|
|
1188
|
+
finally:
|
|
1189
|
+
pool.shutdown(wait=True)
|
|
1190
|
+
|
|
1191
|
+
def _invalidate_animated_answer(self) -> None:
|
|
1192
|
+
"""Keep the frames and drop the answer, unless the clip has no steady screen to re-ask."""
|
|
1193
|
+
plan = self._animated_plan
|
|
1194
|
+
if plan is None or plan[2] is None:
|
|
1195
|
+
return
|
|
1196
|
+
if self._keyframe_steady_screens < 2:
|
|
1197
|
+
self._discard_animated_plan()
|
|
1198
|
+
self._keyframe_mode, self._keyframe_steady_screens = None, 0
|
|
1199
|
+
return
|
|
1200
|
+
self._animated_plan = (plan[0], plan[1], None, None)
|
|
1201
|
+
|
|
1202
|
+
def _discard_animated_plan(self) -> None:
|
|
1203
|
+
plan = self._animated_plan
|
|
1204
|
+
self._animated_plan = None
|
|
1205
|
+
if plan and plan[1]:
|
|
1206
|
+
shutil.rmtree(plan[1], ignore_errors=True)
|
|
1207
|
+
|
|
1208
|
+
def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
|
|
1209
|
+
"""Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
|
|
1210
|
+
import cv2
|
|
1211
|
+
|
|
1212
|
+
from .keyframes import MATCH_REGION_TOLERANCE, region_diff_ratio
|
|
1213
|
+
|
|
1214
|
+
folder, base = os.path.dirname(keyframe_path), os.path.basename(keyframe_path)
|
|
1215
|
+
prefix = re.sub(r"_\d+\.png$", "_", base, flags=re.IGNORECASE)
|
|
1216
|
+
if prefix == base:
|
|
1217
|
+
return False
|
|
1218
|
+
try:
|
|
1219
|
+
siblings = [os.path.join(folder, f) for f in sorted(os.listdir(folder))
|
|
1220
|
+
if f.startswith(prefix) and f.lower().endswith(".png") and f != base]
|
|
1221
|
+
except OSError:
|
|
1222
|
+
return False
|
|
1223
|
+
for other in siblings:
|
|
1224
|
+
img = cv2.imread(other)
|
|
1225
|
+
if img is not None and img.shape == ref.shape and region_diff_ratio(ref, img, box) <= MATCH_REGION_TOLERANCE:
|
|
1226
|
+
return True
|
|
1227
|
+
return False
|
|
1228
|
+
|
|
1229
|
+
def _wait_for_keyframe(self, element: Any, keyframe_path: str, point_norm: Tuple[float, float]) -> bool:
|
|
1230
|
+
"""Hold until the widget looks like the keyframe around the action point, or the wait is spent."""
|
|
1231
|
+
import cv2
|
|
1232
|
+
|
|
1233
|
+
from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
|
|
1234
|
+
|
|
1235
|
+
ref = cv2.imread(keyframe_path)
|
|
1236
|
+
if ref is None:
|
|
1237
|
+
return False
|
|
1238
|
+
box = region_box(ref.shape[1::-1], point_norm)
|
|
1239
|
+
steady = self._keyframe_steady_screens >= 2
|
|
1240
|
+
if not steady and not self._answer_region_recurs(keyframe_path, ref, box):
|
|
1241
|
+
_log("[animated] the answer area is unique to the chosen frame; acting without waiting")
|
|
1242
|
+
return False
|
|
1243
|
+
cfg = self.config
|
|
1244
|
+
wait_ms = cfg.keyframe_wait_timeout_ms if steady else min(cfg.keyframe_wait_timeout_ms, cfg.video_burst_duration_ms)
|
|
1245
|
+
deadline = _now() + wait_ms
|
|
1246
|
+
probe = _tmp_png("kfwait")
|
|
1247
|
+
best, polls = 1.0, 0
|
|
1248
|
+
try:
|
|
1249
|
+
while _now() < deadline:
|
|
1250
|
+
self._check_deadline("waiting for the challenge keyframe")
|
|
1251
|
+
try:
|
|
1252
|
+
self._screenshot(element, probe, animations="allow")
|
|
1253
|
+
live = cv2.imread(probe)
|
|
1254
|
+
except Exception:
|
|
1255
|
+
live = None
|
|
1256
|
+
if live is not None:
|
|
1257
|
+
d = region_diff_ratio(ref, live, box)
|
|
1258
|
+
best = min(best, d)
|
|
1259
|
+
if d <= MATCH_REGION_TOLERANCE:
|
|
1260
|
+
_log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
|
|
1261
|
+
return True
|
|
1262
|
+
polls += 1
|
|
1263
|
+
if polls >= _NOT_THIS_BOARD_POLLS and best > _NOT_THIS_BOARD_DIFF:
|
|
1264
|
+
_log("[animated] the widget no longer resembles the recorded board")
|
|
1265
|
+
self._discard_animated_plan()
|
|
1266
|
+
return False
|
|
1267
|
+
_delay(cfg.keyframe_wait_poll_ms)
|
|
1268
|
+
finally:
|
|
1269
|
+
_unlink(probe)
|
|
1270
|
+
self._discard_animated_plan()
|
|
1271
|
+
_log(f"[animated] widget never matched the chosen keyframe within {wait_ms}ms (closest diff={best:.4f})")
|
|
1272
|
+
return False
|
|
1273
|
+
|
|
1274
|
+
# ── reCAPTCHA 3x3 dynamic driver ─────────────────────────────────────
|
|
2137
1275
|
|
|
2138
1276
|
def _cell_center_page(self, cell: int, session: _GridSession) -> Tuple[float, float]:
|
|
2139
1277
|
x1, y1, x2, y2 = session.grid_boxes[cell - 1]
|
|
2140
|
-
return (
|
|
2141
|
-
|
|
2142
|
-
session.element_box["y"] + ((y1 + y2) / 2) * session.scale_y,
|
|
2143
|
-
)
|
|
1278
|
+
return (session.element_box["x"] + (x1 + x2) / 2 * session.scale_x,
|
|
1279
|
+
session.element_box["y"] + (y1 + y2) / 2 * session.scale_y)
|
|
2144
1280
|
|
|
2145
1281
|
def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
|
|
2146
|
-
# A hover is mimicry of a resting CURSOR, so on a device that has none
|
|
2147
|
-
# it is not weaker mimicry — it is a mousemove at a touch-only widget.
|
|
2148
1282
|
if not self._human.hovers:
|
|
2149
1283
|
return
|
|
2150
1284
|
first = session.grid_boxes[0]
|
|
2151
|
-
cell_w = (first[2] - first[0]) * session.scale_x
|
|
2152
|
-
cell_h = (first[3] - first[1]) * session.scale_y
|
|
2153
1285
|
cx, cy = self._cell_center_page(cell, session)
|
|
2154
|
-
self._smooth_move(
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
cy + (random.random() - 0.5) * cell_h * 0.4,
|
|
2158
|
-
)
|
|
1286
|
+
self._smooth_move(page,
|
|
1287
|
+
cx + (random.random() - 0.5) * (first[2] - first[0]) * session.scale_x * 0.4,
|
|
1288
|
+
cy + (random.random() - 0.5) * (first[3] - first[1]) * session.scale_y * 0.4)
|
|
2159
1289
|
|
|
2160
1290
|
@staticmethod
|
|
2161
|
-
def _bbox_to_cell(
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
cy = ((float(bbox[1]) + float(bbox[3])) / 2) * height
|
|
1291
|
+
def _bbox_to_cell(bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]],
|
|
1292
|
+
width: int, height: int) -> Optional[int]:
|
|
1293
|
+
cx = (float(bbox[0]) + float(bbox[2])) / 2 * width
|
|
1294
|
+
cy = (float(bbox[1]) + float(bbox[3])) / 2 * height
|
|
2166
1295
|
for index, (x1, y1, x2, y2) in enumerate(grid_boxes):
|
|
2167
1296
|
if x1 <= cx <= x2 and y1 <= cy <= y2:
|
|
2168
|
-
return index + 1
|
|
1297
|
+
return index + 1
|
|
2169
1298
|
return None
|
|
2170
1299
|
|
|
2171
1300
|
@staticmethod
|
|
2172
1301
|
def _order_by_priority(loading: Sequence[int], priority: Sequence[int]) -> List[int]:
|
|
2173
1302
|
remaining = set(loading)
|
|
2174
|
-
ordered
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
A widget that swaps one clicked cell swaps them all, so the two never
|
|
2199
|
-
share a board and one look at the tiles we just clicked settles it. The
|
|
2200
|
-
chip is what `detect_selected_cells` reports as `selected` — it looks for
|
|
2201
|
-
it in the top-left corner only, behind a circularity and a centroid test
|
|
2202
|
-
a centred check fails — and we already read it every poll.
|
|
2203
|
-
|
|
2204
|
-
`chipped` needs EVERY watched tile, because a partial reading is a
|
|
2205
|
-
misread and the two mistakes do not cost the same: calling a swapping
|
|
2206
|
-
board finished submits half an answer and burns the attempt, while
|
|
2207
|
-
calling a chipped board unfinished costs one inference. It is also only
|
|
2208
|
-
ever OUR click that a chip can be reporting: tiles already wearing one
|
|
2209
|
-
are filtered out of the model's answer before we click, so a chip on a
|
|
2210
|
-
watched tile arrived in response to this round.
|
|
2211
|
-
|
|
2212
|
-
The blank/fade transition LAGS the click by a beat: reCAPTCHA holds a
|
|
2213
|
-
clicked tile selected (old image visible) for ~1-3s and only then blanks
|
|
2214
|
-
it. A single snapshot right after clicking therefore sees nothing and
|
|
2215
|
-
concludes the puzzle is finished. Hovers a clicked tile each poll so the
|
|
2216
|
-
cursor keeps moving during the wait.
|
|
1303
|
+
ordered = [c for c in priority if c in remaining and not remaining.discard(c)]
|
|
1304
|
+
return ordered + sorted(remaining)
|
|
1305
|
+
|
|
1306
|
+
def _hover_loop(self, page: Any, session: _GridSession, cells: Sequence[int]) -> Optional[Callable[[], None]]:
|
|
1307
|
+
if not cells:
|
|
1308
|
+
return None
|
|
1309
|
+
cycle = itertools.cycle(cells)
|
|
1310
|
+
|
|
1311
|
+
def hover():
|
|
1312
|
+
try:
|
|
1313
|
+
self._hover_cell(page, session, next(cycle))
|
|
1314
|
+
except Exception:
|
|
1315
|
+
pass
|
|
1316
|
+
|
|
1317
|
+
return hover
|
|
1318
|
+
|
|
1319
|
+
def _watch_clicked_tiles(self, page: Any, element: Any, session: _GridSession,
|
|
1320
|
+
priority: Sequence[int] = ()) -> Tuple[List[int], bool]:
|
|
1321
|
+
"""`(loading, chipped)`: a chip means the photo was kept, a blank or fade means it is being swapped.
|
|
1322
|
+
|
|
1323
|
+
A grace window, not a snapshot: reCAPTCHA holds a clicked tile selected for ~1-3s and only then blanks
|
|
1324
|
+
it. The chip is tested first, because a chip zooms the photo and reads as `changing` on the frame that
|
|
1325
|
+
shows it. `chipped` needs every clicked tile, since the two misreads cost differently: calling a
|
|
1326
|
+
swapping board finished submits half an answer, calling a chipped board unfinished costs one inference.
|
|
2217
1327
|
"""
|
|
2218
1328
|
cfg = self.config
|
|
2219
|
-
watch =
|
|
2220
|
-
start = time.monotonic() * 1000.0
|
|
2221
|
-
frames: List[str] = []
|
|
2222
|
-
hover_index = 0
|
|
2223
|
-
try:
|
|
2224
|
-
first = _tmp_png("loadchk")
|
|
2225
|
-
self._screenshot(element, first)
|
|
2226
|
-
frames.append(first)
|
|
2227
|
-
|
|
2228
|
-
while (time.monotonic() * 1000.0) - start < cfg.recaptcha_fade_onset_grace_ms:
|
|
2229
|
-
self._check_deadline("watching for the tile refresh")
|
|
2230
|
-
iteration_start = time.monotonic() * 1000.0
|
|
2231
|
-
if priority:
|
|
2232
|
-
try:
|
|
2233
|
-
self._hover_cell(page, session, priority[hover_index % len(priority)])
|
|
2234
|
-
except Exception:
|
|
2235
|
-
pass
|
|
2236
|
-
hover_index += 1
|
|
2237
|
-
# Enforce a minimum inter-frame gap: the in-process CV call is
|
|
2238
|
-
# near-instant, so without this the polls fire back-to-back on
|
|
2239
|
-
# near-identical frames and miss a slow fade.
|
|
2240
|
-
elapsed = (time.monotonic() * 1000.0) - iteration_start
|
|
2241
|
-
if elapsed < cfg.recaptcha_dynamic_fade_poll_ms:
|
|
2242
|
-
_delay(cfg.recaptcha_dynamic_fade_poll_ms - elapsed)
|
|
2243
|
-
|
|
2244
|
-
path = _tmp_png("loadchk")
|
|
2245
|
-
self._screenshot(element, path)
|
|
2246
|
-
frames.append(path)
|
|
2247
|
-
|
|
2248
|
-
states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
|
|
2249
|
-
# Chip first: a chip landing on a tile ZOOMS its photo out, which
|
|
2250
|
-
# reads as `changing` on the same frame that shows the chip. Test
|
|
2251
|
-
# the swap first and every chipped board looks like a swapping
|
|
2252
|
-
# one for as long as the animation runs.
|
|
2253
|
-
if priority and set((states or {}).get("selected", [])).issuperset(priority):
|
|
2254
|
-
return [], True
|
|
2255
|
-
in_scope = lambda c: watch is None or c in watch # noqa: E731
|
|
2256
|
-
empty = [c for c in (states or {}).get("empty", []) if in_scope(c)]
|
|
2257
|
-
changing = [c for c in (states or {}).get("changing", []) if in_scope(c)]
|
|
2258
|
-
loading = sorted(set(empty) | set(changing))
|
|
2259
|
-
if loading:
|
|
2260
|
-
return self._order_by_priority(loading, priority), False
|
|
2261
|
-
_unlink(frames.pop(0))
|
|
2262
|
-
return [], False
|
|
2263
|
-
except Exception as exc:
|
|
2264
|
-
_debug(f"fade-onset error: {exc}")
|
|
2265
|
-
return [], False
|
|
2266
|
-
finally:
|
|
2267
|
-
for path in frames:
|
|
2268
|
-
_unlink(path)
|
|
1329
|
+
watch = set(priority) or None
|
|
2269
1330
|
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
1331
|
+
def judge(frames, _elapsed):
|
|
1332
|
+
if len(frames) < 2:
|
|
1333
|
+
return None
|
|
1334
|
+
states = self._grid_cell_states(frames[0], frames[1], session.grid_boxes) or {}
|
|
1335
|
+
if priority and set(states.get("selected", [])).issuperset(priority):
|
|
1336
|
+
return [], True
|
|
1337
|
+
loading = sorted({c for c in states.get("empty", []) + states.get("changing", [])
|
|
1338
|
+
if watch is None or c in watch})
|
|
1339
|
+
return (self._order_by_priority(loading, priority), False) if loading else None
|
|
1340
|
+
|
|
1341
|
+
return self._poll(element, cfg.recaptcha_fade_onset_grace_ms, cfg.recaptcha_dynamic_fade_poll_ms,
|
|
1342
|
+
judge, "watching for the tile refresh",
|
|
1343
|
+
self._hover_loop(page, session, priority)) or ([], False)
|
|
1344
|
+
|
|
1345
|
+
def _wait_for_any_clicked_tile_loaded(self, page: Any, element: Any, session: _GridSession,
|
|
1346
|
+
fading_cells: Sequence[int]) -> bool:
|
|
2274
1347
|
if not fading_cells:
|
|
2275
1348
|
return True
|
|
2276
1349
|
cfg = self.config
|
|
2277
|
-
start = time.monotonic() * 1000.0
|
|
2278
|
-
frames: List[str] = []
|
|
2279
|
-
hover_index = 0
|
|
2280
|
-
try:
|
|
2281
|
-
while (time.monotonic() * 1000.0) - start < cfg.recaptcha_dynamic_fade_wait_ms:
|
|
2282
|
-
self._check_deadline("waiting for a tile to reload")
|
|
2283
|
-
iteration_start = time.monotonic() * 1000.0
|
|
2284
|
-
if cfg.recaptcha_tile_hover_enabled:
|
|
2285
|
-
try:
|
|
2286
|
-
self._hover_cell(
|
|
2287
|
-
page, session, fading_cells[hover_index % len(fading_cells)]
|
|
2288
|
-
)
|
|
2289
|
-
except Exception:
|
|
2290
|
-
pass
|
|
2291
|
-
hover_index += 1
|
|
2292
|
-
elapsed = (time.monotonic() * 1000.0) - iteration_start
|
|
2293
|
-
if elapsed < cfg.recaptcha_dynamic_fade_poll_ms:
|
|
2294
|
-
_delay(cfg.recaptcha_dynamic_fade_poll_ms - elapsed)
|
|
2295
1350
|
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
1351
|
+
def judge(frames, _elapsed):
|
|
1352
|
+
if len(frames) < 2:
|
|
1353
|
+
return None
|
|
1354
|
+
states = self._grid_cell_states(frames[0], frames[1], session.grid_boxes)
|
|
1355
|
+
return True if states and any(c in states["loaded"] for c in fading_cells) else None
|
|
2299
1356
|
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
return True
|
|
2304
|
-
_unlink(frames.pop(0))
|
|
2305
|
-
return False
|
|
2306
|
-
except Exception as exc:
|
|
2307
|
-
_debug(f"wait-load error: {exc}")
|
|
2308
|
-
return False
|
|
2309
|
-
finally:
|
|
2310
|
-
for path in frames:
|
|
2311
|
-
_unlink(path)
|
|
1357
|
+
hover = self._hover_loop(page, session, fading_cells) if cfg.recaptcha_tile_hover_enabled else None
|
|
1358
|
+
return bool(self._poll(element, cfg.recaptcha_dynamic_fade_wait_ms, cfg.recaptcha_dynamic_fade_poll_ms,
|
|
1359
|
+
judge, "waiting for a tile to reload", hover))
|
|
2312
1360
|
|
|
2313
|
-
def _solve_recaptcha_grid(
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
element: Any,
|
|
2317
|
-
retry_mode: Optional[str],
|
|
2318
|
-
grid: Dict[str, Any],
|
|
2319
|
-
element_box: Dict[str, float],
|
|
2320
|
-
) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
2321
|
-
"""
|
|
2322
|
-
Multi-round driver for reCAPTCHA 3x3 dynamic puzzles. One call = one
|
|
2323
|
-
puzzle session.
|
|
2324
|
-
|
|
2325
|
-
The shared Python half is authoritative about WHAT to do — it runs the
|
|
2326
|
-
blue-badge detector, filters already-selected and still-loading tiles,
|
|
2327
|
-
and returns `click` / `wait` / `done`. This driver owns the human-like
|
|
2328
|
-
WAITING that logic cannot: after a click round it hovers the just-clicked
|
|
2329
|
-
tiles and waits for at least one to finish reloading before re-solving,
|
|
2330
|
-
so we never burn a model call on a grid that is still mid-fade.
|
|
2331
|
-
|
|
2332
|
-
Only a board that SWAPS a clicked tile out is worth those extra rounds.
|
|
2333
|
-
One that ticks the tile and keeps the photo has been fully answered by
|
|
2334
|
-
the round that clicked it — same as the 4x4 — so `_watch_clicked_tiles`
|
|
2335
|
-
reports the chip and this submits there and then. Rounds 2..N exist for
|
|
2336
|
-
the fading board and nothing else.
|
|
2337
|
-
|
|
2338
|
-
Submits on `done`, and on a board that ticked our clicks — never on a
|
|
2339
|
-
round-cap exit, which is left to the outer loop to re-detect and decide.
|
|
2340
|
-
"""
|
|
1361
|
+
def _solve_recaptcha_grid(self, page: Any, element: Any, retry_mode: Optional[RetryMode],
|
|
1362
|
+
grid: Dict[str, Any], element_box: Dict[str, float]) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
1363
|
+
"""Click, watch the clicked tiles, re-solve while they swap, submit on `done` or a chipped board."""
|
|
2341
1364
|
cfg = self.config
|
|
2342
|
-
session = _GridSession(
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
scale_y=element_box["height"] / grid["screenshot_h"],
|
|
2347
|
-
screenshot_w=grid["screenshot_w"],
|
|
2348
|
-
screenshot_h=grid["screenshot_h"],
|
|
2349
|
-
)
|
|
2350
|
-
|
|
1365
|
+
session = _GridSession(grid["boxes"], element_box,
|
|
1366
|
+
element_box["width"] / grid["screenshot_w"],
|
|
1367
|
+
element_box["height"] / grid["screenshot_h"],
|
|
1368
|
+
grid["screenshot_w"], grid["screenshot_h"])
|
|
2351
1369
|
clicked_order: List[int] = []
|
|
2352
|
-
|
|
2353
|
-
should_submit = False
|
|
1370
|
+
performed = should_submit = False
|
|
2354
1371
|
all_usage: List[Dict[str, Any]] = []
|
|
2355
1372
|
pending_retry = retry_mode
|
|
2356
1373
|
|
|
2357
1374
|
for round_index in range(1, cfg.recaptcha_max_dynamic_rounds + 1):
|
|
2358
|
-
# Each round can legitimately spend ~10s waiting on fades, so eight
|
|
2359
|
-
# of them plus the model calls can outlast the whole solve budget.
|
|
2360
1375
|
self._check_deadline(f"recaptcha grid round {round_index}")
|
|
2361
|
-
# Round 1 skips it: `_solve_single` has just waited for these cells,
|
|
2362
|
-
# read the grid boxes off the loaded board and handed over, and
|
|
2363
|
-
# nothing has touched the widget in between. Rounds 2..N still wait,
|
|
2364
|
-
# because by then THIS driver has clicked and the tiles really are
|
|
2365
|
-
# reloading. Measured 2148ms x2 on one solve.
|
|
2366
1376
|
if round_index > 1:
|
|
2367
|
-
with self._phase(
|
|
1377
|
+
with self._phase(Phase.GRID_LOAD):
|
|
2368
1378
|
self._wait_for_grid_cells_loaded(element)
|
|
2369
1379
|
shot = _tmp_png("recap")
|
|
2370
|
-
action: Optional[Dict[str, Any]] = None
|
|
2371
1380
|
try:
|
|
2372
|
-
with self._phase(
|
|
2373
|
-
self._screenshot(element, shot,
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
pending_retry = None # only round 1 carries the inbound hint
|
|
2377
|
-
with self._phase("inference"):
|
|
1381
|
+
with self._phase(Phase.SCREENSHOT):
|
|
1382
|
+
self._screenshot(element, shot, timeout_ms=cfg.element_screenshot_timeout_ms)
|
|
1383
|
+
retry_for_round, pending_retry = pending_retry, None
|
|
1384
|
+
with self._phase(Phase.INFERENCE):
|
|
2378
1385
|
actions, usage = self._solve_frame_freshness_guarded(
|
|
2379
|
-
element,
|
|
2380
|
-
shot,
|
|
2381
|
-
lambda image_path: self._get_solution(
|
|
2382
|
-
image_path, "recaptcha", retry_for_round),
|
|
2383
|
-
)
|
|
1386
|
+
element, shot, lambda p: self._get_solution(p, Vendor.RECAPTCHA, retry_for_round))
|
|
2384
1387
|
all_usage.extend(usage)
|
|
2385
1388
|
action = _as_dict(actions[0]) if actions else None
|
|
2386
1389
|
finally:
|
|
2387
1390
|
_unlink(shot)
|
|
2388
1391
|
|
|
2389
|
-
|
|
1392
|
+
kind = (action or {}).get("action")
|
|
1393
|
+
if not action or kind == ActionKind.DONE:
|
|
2390
1394
|
_log(f"[recaptcha-grid] round {round_index}: done; submitting.")
|
|
2391
1395
|
should_submit = True
|
|
2392
1396
|
break
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
loading, _ = self._watch_clicked_tiles(
|
|
2398
|
-
page, element, session, clicked_order)
|
|
2399
|
-
self._wait_for_any_clicked_tile_loaded(
|
|
2400
|
-
page, element, session, loading)
|
|
1397
|
+
if kind == ActionKind.WAIT:
|
|
1398
|
+
with self._phase(Phase.FADE_WAIT):
|
|
1399
|
+
loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
|
|
1400
|
+
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
2401
1401
|
continue
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
bboxes = action.get("target_bounding_boxes") or (
|
|
2405
|
-
[action["target_bounding_box"]] if action.get("target_bounding_box") else []
|
|
2406
|
-
)
|
|
2407
|
-
if not bboxes:
|
|
2408
|
-
# Malformed click: treat as a soft wait so we never submit
|
|
2409
|
-
# prematurely, and re-solve next round.
|
|
2410
|
-
_log(f"[recaptcha-grid] round {round_index}: click with no bboxes; re-solving.")
|
|
2411
|
-
_delay(500)
|
|
2412
|
-
continue
|
|
2413
|
-
|
|
2414
|
-
clicked_this_round: List[int] = []
|
|
2415
|
-
for bbox in bboxes:
|
|
2416
|
-
cell = self._bbox_to_cell(
|
|
2417
|
-
bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h
|
|
2418
|
-
)
|
|
2419
|
-
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
2420
|
-
if cell is not None:
|
|
2421
|
-
clicked_order.append(cell)
|
|
2422
|
-
clicked_this_round.append(cell)
|
|
2423
|
-
self._human.pause("between")
|
|
2424
|
-
performed_action = True
|
|
2425
|
-
_log(
|
|
2426
|
-
f"[recaptcha-grid] round {round_index}: clicked {len(bboxes)} tile(s) "
|
|
2427
|
-
f"-> cells {clicked_this_round}."
|
|
2428
|
-
)
|
|
2429
|
-
|
|
2430
|
-
with self._phase("fade-wait"):
|
|
2431
|
-
loading, chipped = self._watch_clicked_tiles(
|
|
2432
|
-
page, element, session, clicked_this_round
|
|
2433
|
-
)
|
|
2434
|
-
if chipped or not loading:
|
|
2435
|
-
# Either the widget ticked our clicks and kept the photos
|
|
2436
|
-
# (a board that does that is answered), or nothing faded
|
|
2437
|
-
# within the grace window. Submit rather than paying for
|
|
2438
|
-
# another round to be told the same thing.
|
|
2439
|
-
_log(
|
|
2440
|
-
f"[recaptcha-grid] round {round_index}: "
|
|
2441
|
-
f"{'tiles chipped' if chipped else 'nothing loading'}; submitting."
|
|
2442
|
-
)
|
|
2443
|
-
should_submit = True
|
|
2444
|
-
break
|
|
2445
|
-
with self._phase("fade-wait"):
|
|
2446
|
-
self._wait_for_any_clicked_tile_loaded(
|
|
2447
|
-
page, element, session, loading)
|
|
1402
|
+
if kind != ActionKind.CLICK:
|
|
1403
|
+
_log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
|
|
2448
1404
|
continue
|
|
2449
|
-
|
|
2450
|
-
|
|
1405
|
+
bboxes = action.get("target_bounding_boxes") or (
|
|
1406
|
+
[action["target_bounding_box"]] if action.get("target_bounding_box") else [])
|
|
1407
|
+
if not bboxes:
|
|
1408
|
+
_delay(500)
|
|
1409
|
+
continue
|
|
1410
|
+
clicked_this_round: List[int] = []
|
|
1411
|
+
for bbox in bboxes:
|
|
1412
|
+
cell = self._bbox_to_cell(bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h)
|
|
1413
|
+
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
1414
|
+
if cell is not None:
|
|
1415
|
+
clicked_order.append(cell)
|
|
1416
|
+
clicked_this_round.append(cell)
|
|
1417
|
+
self._human.pause(PauseKind.BETWEEN)
|
|
1418
|
+
performed = True
|
|
1419
|
+
_log(f"[recaptcha-grid] round {round_index}: clicked cells {clicked_this_round}.")
|
|
1420
|
+
with self._phase(Phase.FADE_WAIT):
|
|
1421
|
+
loading, chipped = self._watch_clicked_tiles(page, element, session, clicked_this_round)
|
|
1422
|
+
if chipped or not loading:
|
|
1423
|
+
_log(f"[recaptcha-grid] round {round_index}: {'tiles chipped' if chipped else 'nothing loading'}; submitting.")
|
|
1424
|
+
should_submit = True
|
|
1425
|
+
break
|
|
1426
|
+
with self._phase(Phase.FADE_WAIT):
|
|
1427
|
+
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
2451
1428
|
|
|
2452
1429
|
if should_submit:
|
|
2453
1430
|
frame = element.content_frame()
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
performed_action = True
|
|
2470
|
-
|
|
2471
|
-
return performed_action, all_usage
|
|
2472
|
-
|
|
2473
|
-
# ------------------------------------------------------------------
|
|
2474
|
-
# One pass over a rendered challenge
|
|
2475
|
-
# ------------------------------------------------------------------
|
|
2476
|
-
|
|
2477
|
-
def _solve_single(
|
|
2478
|
-
self, page: Any, element: Any, retry_mode: Optional[str]
|
|
2479
|
-
) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
2480
|
-
src = None
|
|
2481
|
-
try:
|
|
2482
|
-
src = element.get_attribute("src")
|
|
2483
|
-
except Exception:
|
|
2484
|
-
pass
|
|
2485
|
-
src = src or ""
|
|
2486
|
-
|
|
2487
|
-
# The vendor hint routes to the right pipeline. It matters: hCaptcha
|
|
2488
|
-
# click puzzles must never go through grid detection, because find_grid
|
|
2489
|
-
# false-positives on the header/footer bands.
|
|
2490
|
-
if "hcaptcha.com" in src:
|
|
2491
|
-
puzzle_source = "hcaptcha"
|
|
2492
|
-
elif "recaptcha/api2" in src:
|
|
2493
|
-
puzzle_source = "recaptcha"
|
|
2494
|
-
else:
|
|
2495
|
-
puzzle_source = "unknown"
|
|
2496
|
-
|
|
2497
|
-
# Everything the answer might have to be delivered INTO — a text box, a
|
|
2498
|
-
# slider handle — is looked up against this, never against the page.
|
|
2499
|
-
# For the iframed vendors it is the challenge document; for the ones
|
|
2500
|
-
# that render into the host page (GeeTest, Yidun, BotDetect, …) it is
|
|
2501
|
-
# the widget element, whose subtree is the same boundary.
|
|
2502
|
-
scope = element.content_frame() or element
|
|
2503
|
-
|
|
2504
|
-
# Does this puzzle want a STRING rather than a place to click? Only the
|
|
2505
|
-
# DOM can say. The picture cannot: BotDetect's warped code and
|
|
2506
|
-
# hCaptcha's "click the matching character" are the same genre of image
|
|
2507
|
-
# and want opposite answers. Restricted to `unknown` because neither
|
|
2508
|
-
# hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
|
|
2509
|
-
# inside one of their frames would be a false positive by definition.
|
|
2510
|
-
text_mode = (
|
|
2511
|
-
puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING
|
|
2512
|
-
and self._answer_box(scope, element) is not None
|
|
2513
|
-
)
|
|
1431
|
+
verify = frame and self._get_verify_button(frame)
|
|
1432
|
+
if verify:
|
|
1433
|
+
_log("[recaptcha-grid] clicking Verify to submit.")
|
|
1434
|
+
self._move_and_click(page, verify)
|
|
1435
|
+
performed = True
|
|
1436
|
+
return performed, all_usage
|
|
1437
|
+
|
|
1438
|
+
# ── one pass over a rendered challenge ───────────────────────────────
|
|
1439
|
+
|
|
1440
|
+
def _solve_single(self, page: Any, widget: Widget, retry_mode: Optional[RetryMode]) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
1441
|
+
element, puzzle_source, role = widget.element, widget.vendor, widget.role
|
|
1442
|
+
frame = element.content_frame()
|
|
1443
|
+
scope = frame or widget.at
|
|
1444
|
+
# Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
1445
|
+
text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
|
|
2514
1446
|
if text_mode:
|
|
2515
1447
|
_log("widget has a text box; solving as a distorted-text captcha")
|
|
2516
1448
|
|
|
2517
|
-
|
|
2518
|
-
# briefly shows the previous round, then a spinner, then the next one.
|
|
2519
|
-
# Screenshotting any of those transitional frames feeds the model a
|
|
2520
|
-
# blank/stale grid it correctly calls "unsupported" — which used to abort
|
|
2521
|
-
# the whole solve on round 2.
|
|
2522
|
-
is_animated = False
|
|
2523
|
-
if puzzle_source == "hcaptcha" and "frame=challenge" in src:
|
|
1449
|
+
if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
|
|
2524
1450
|
if self._last_submit_frame_hash:
|
|
2525
|
-
with self._phase(
|
|
1451
|
+
with self._phase(Phase.AWAIT_NEXT_ROUND):
|
|
2526
1452
|
self._wait_for_change_since(element, self._last_submit_frame_hash)
|
|
2527
1453
|
self._last_submit_frame_hash = None
|
|
2528
|
-
with self._phase(
|
|
2529
|
-
self.
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
# probe was never run for these, so an animated one — GeeTest's svg board
|
|
2542
|
-
# cycles its glyph set — was screenshotted mid-cycle and answered from
|
|
2543
|
-
# whatever single moment we happened to catch. reCAPTCHA is excluded on
|
|
2544
|
-
# purpose: it has its own readiness gate below, its grids are never
|
|
2545
|
-
# animated, and a second probe would only add latency to a path that
|
|
2546
|
-
# already works.
|
|
2547
|
-
is_animated = self._settle_or_animated(element)
|
|
2548
|
-
|
|
2549
|
-
# Only the image-challenge frame holds a grid. Running grid detection on
|
|
2550
|
-
# the anchor checkbox just burns an 8s timeout before the click.
|
|
2551
|
-
is_recaptcha_challenge = puzzle_source == "recaptcha" and "recaptcha/api2/bframe" in src
|
|
2552
|
-
if is_recaptcha_challenge:
|
|
2553
|
-
with self._phase("grid-load"):
|
|
1454
|
+
with self._phase(Phase.HCAPTCHA_IMAGES):
|
|
1455
|
+
self._wait_for_board_images(frame, SELECTORS[puzzle_source])
|
|
1456
|
+
|
|
1457
|
+
# A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
|
|
1458
|
+
filmable = role != FrameRole.CHECKBOX and puzzle_source != Vendor.RECAPTCHA and not text_mode
|
|
1459
|
+
is_animated = filmable and self._settle_or_animated(element)
|
|
1460
|
+
# hCaptcha keeps its challenge iframe visible ~2s after the final submit; read as a fresh puzzle it burned ~18s.
|
|
1461
|
+
if role == FrameRole.CHALLENGE and self.is_captcha_solved(page):
|
|
1462
|
+
_log("solved while waiting for the next round; skipping inference.")
|
|
1463
|
+
return False, []
|
|
1464
|
+
|
|
1465
|
+
if puzzle_source == Vendor.RECAPTCHA and role == FrameRole.CHALLENGE:
|
|
1466
|
+
with self._phase(Phase.GRID_LOAD):
|
|
2554
1467
|
self._wait_for_grid_cells_loaded(element)
|
|
2555
1468
|
grid = self._get_grid_boxes(element)
|
|
2556
1469
|
if grid and grid["size"] == 3:
|
|
2557
|
-
# Only a 3x3 ever refreshes its tiles in place, so only a 3x3
|
|
2558
|
-
# can need more than one round — and whether THIS one does is
|
|
2559
|
-
# decided inside the driver, by what the widget does with the
|
|
2560
|
-
# first click. A 4x4 never refreshes: it falls through to the
|
|
2561
|
-
# ordinary click-then-submit path below.
|
|
2562
1470
|
element_box = element.bounding_box()
|
|
2563
1471
|
if element_box:
|
|
2564
|
-
return self._solve_recaptcha_grid(
|
|
2565
|
-
|
|
2566
|
-
|
|
1472
|
+
return self._solve_recaptcha_grid(page, element, retry_mode, grid, element_box)
|
|
1473
|
+
|
|
1474
|
+
with self._phase(Phase.BOARD_PAINT):
|
|
1475
|
+
self._wait_for_board_painted(element)
|
|
2567
1476
|
|
|
2568
1477
|
shot = _tmp_png("captcha")
|
|
2569
|
-
|
|
2570
|
-
slid = False
|
|
2571
|
-
placed = False
|
|
2572
|
-
clicked = False
|
|
2573
|
-
typed = False
|
|
1478
|
+
performed = slid = answered = have_shot = False
|
|
2574
1479
|
all_usage: List[Dict[str, Any]] = []
|
|
2575
1480
|
keyframe_dir: Optional[str] = None
|
|
2576
|
-
have_shot = False
|
|
2577
1481
|
try:
|
|
2578
1482
|
if is_animated:
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
# not to move slices to a single keyframe (`mode=static`), and
|
|
2589
|
-
# that frame is exactly the still this round would have taken
|
|
2590
|
-
# anyway — so a wrong guess about "is it animated" costs the
|
|
2591
|
-
# burst and nothing else, and cannot produce a wrong answer.
|
|
2592
|
-
#
|
|
2593
|
-
# This replaced a separate probe that watched the widget FIRST
|
|
2594
|
-
# and only recorded if it saw motion. That paid for the
|
|
2595
|
-
# observation twice over: up to `animated_probe_ms` to decide,
|
|
2596
|
-
# then the burst to act, and it still had to be long enough to
|
|
2597
|
-
# outlast the longest hold (8.2s measured on
|
|
2598
|
-
# number_with_highest_value_video) or it answered "static" from
|
|
2599
|
-
# inside one — which it did, live, burning 9s of a 45s budget to
|
|
2600
|
-
# reach the wrong conclusion about a puzzle that was animating.
|
|
1483
|
+
if self._animated_plan is not None:
|
|
1484
|
+
keyframes, keyframe_dir, actions, all_usage = self._animated_plan
|
|
1485
|
+
reused = actions is not None
|
|
1486
|
+
_log("[animated] reusing the recorded answer" if reused
|
|
1487
|
+
else "[animated] re-asking on the frames already recorded")
|
|
1488
|
+
else:
|
|
1489
|
+
reused = False
|
|
1490
|
+
with self._phase(Phase.BURST):
|
|
1491
|
+
keyframes, keyframe_dir = self._record_keyframes(element)
|
|
2601
1492
|
if len(keyframes) < 2:
|
|
2602
|
-
_log("[animated] the recording shows one picture; "
|
|
2603
|
-
"solving it as a still")
|
|
1493
|
+
_log("[animated] the recording shows one picture; solving it as a still")
|
|
2604
1494
|
is_animated = False
|
|
2605
|
-
# Reuse the frame we just recorded rather than taking
|
|
2606
|
-
# another screenshot of the same motionless widget.
|
|
2607
1495
|
shutil.copyfile(keyframes[0], shot)
|
|
2608
1496
|
have_shot = True
|
|
1497
|
+
self._discard_animated_plan()
|
|
2609
1498
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
2610
1499
|
keyframe_dir = None
|
|
2611
|
-
|
|
2612
|
-
with self._phase(
|
|
1500
|
+
elif not reused:
|
|
1501
|
+
with self._phase(Phase.INFERENCE):
|
|
2613
1502
|
actions, all_usage = self._get_keyframe_solution(keyframes)
|
|
1503
|
+
self._animated_plan = (keyframes, keyframe_dir, actions, all_usage)
|
|
2614
1504
|
|
|
2615
1505
|
if not is_animated:
|
|
2616
1506
|
if not have_shot:
|
|
2617
|
-
with self._phase(
|
|
2618
|
-
self._screenshot(
|
|
1507
|
+
with self._phase(Phase.SCREENSHOT):
|
|
1508
|
+
self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1509
|
+
if self._should_speculate(puzzle_source, text_mode):
|
|
1510
|
+
actions, all_usage, keyframe_dir = self._speculate(element, shot, puzzle_source, retry_mode, text_mode)
|
|
1511
|
+
is_animated = keyframe_dir is not None
|
|
1512
|
+
else:
|
|
1513
|
+
with self._phase(Phase.INFERENCE):
|
|
1514
|
+
actions, all_usage = self._solve_frame_freshness_guarded(
|
|
2619
1515
|
element, shot,
|
|
2620
|
-
|
|
2621
|
-
with self._phase("inference"):
|
|
2622
|
-
actions, all_usage = self._solve_frame_freshness_guarded(
|
|
2623
|
-
element,
|
|
2624
|
-
shot,
|
|
2625
|
-
lambda image_path: self._get_solution(
|
|
2626
|
-
image_path, puzzle_source, retry_mode, text_mode=text_mode
|
|
2627
|
-
),
|
|
2628
|
-
)
|
|
1516
|
+
lambda p: self._get_solution(p, puzzle_source, retry_mode, text_mode=text_mode))
|
|
2629
1517
|
|
|
2630
1518
|
element_box = element.bounding_box()
|
|
2631
|
-
if not element_box:
|
|
1519
|
+
if not element_box and answer_needs_element_box(actions):
|
|
2632
1520
|
raise CaptchaSolveError("could not get bounding box of captcha element")
|
|
2633
1521
|
|
|
2634
|
-
|
|
2635
|
-
#
|
|
2636
|
-
#
|
|
2637
|
-
|
|
2638
|
-
|
|
1522
|
+
_log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
|
|
1523
|
+
# A repeated answer is not re-performed: the widget already refused it, and every extra press is
|
|
1524
|
+
# behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
|
|
1525
|
+
if self._note_answer(actions, retry_mode):
|
|
1526
|
+
return False, all_usage
|
|
2639
1527
|
_log(f"executing {len(actions)} action(s)")
|
|
2640
|
-
frame = element.content_frame()
|
|
2641
|
-
verify_button = None
|
|
2642
1528
|
|
|
2643
1529
|
for raw_action in actions:
|
|
2644
1530
|
self._check_deadline("action execution")
|
|
2645
1531
|
action = _as_dict(raw_action)
|
|
2646
1532
|
kind = action.get("action")
|
|
2647
|
-
|
|
1533
|
+
await_kf = action.get("await_keyframe")
|
|
1534
|
+
if kind == ActionKind.CLICK:
|
|
2648
1535
|
bboxes = action.get("target_bounding_boxes") or (
|
|
2649
|
-
[action["target_bounding_box"]]
|
|
2650
|
-
if action.get("target_bounding_box")
|
|
2651
|
-
else []
|
|
2652
|
-
)
|
|
1536
|
+
[action["target_bounding_box"]] if action.get("target_bounding_box") else [])
|
|
2653
1537
|
if not bboxes and not action.get("target_coordinates"):
|
|
2654
1538
|
_log("click action has no bboxes or coordinates; skipping")
|
|
2655
1539
|
continue
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
if await_kf:
|
|
2664
|
-
self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
|
|
2665
|
-
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
2666
|
-
self._human.pause("between")
|
|
2667
|
-
else:
|
|
2668
|
-
self._execute_click(page, action, element_box)
|
|
2669
|
-
performed_action = True
|
|
2670
|
-
clicked = True
|
|
2671
|
-
elif kind == "drag" and not action.get("source_bounding_box"):
|
|
2672
|
-
# No source — a puzzle-piece slider. What you grab is not
|
|
2673
|
-
# what has to arrive, so this cannot go through
|
|
2674
|
-
# _execute_drag: pressing the gap the model named and
|
|
2675
|
-
# dragging from there picks up nothing at all.
|
|
1540
|
+
for bbox in bboxes or [None]:
|
|
1541
|
+
one = {"target_bounding_box": bbox} if bbox else action
|
|
1542
|
+
self._execute_click(page, one, element_box, element, await_kf)
|
|
1543
|
+
if bbox:
|
|
1544
|
+
self._human.pause(PauseKind.BETWEEN)
|
|
1545
|
+
performed = answered = True
|
|
1546
|
+
elif kind == ActionKind.DRAG and not action.get("source_bounding_box"):
|
|
2676
1547
|
if self._execute_slide(page, element, scope, action, element_box):
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
# The destination is not gated — by the time the mouse arrives the
|
|
2682
|
-
# animation has moved on regardless, and a drop is judged by where
|
|
2683
|
-
# it lands, not by what the slot looked like on pickup.
|
|
2684
|
-
await_kf = action.get("await_keyframe")
|
|
2685
|
-
if await_kf and action.get("source_bounding_box"):
|
|
2686
|
-
self._wait_for_keyframe(
|
|
2687
|
-
element, await_kf, _bbox_center(action["source_bounding_box"])
|
|
2688
|
-
)
|
|
1548
|
+
performed = slid = True
|
|
1549
|
+
elif kind == ActionKind.DRAG:
|
|
1550
|
+
if await_kf:
|
|
1551
|
+
self._wait_for_keyframe(element, await_kf, _bbox_center(action["source_bounding_box"]))
|
|
2689
1552
|
self._execute_drag(page, action, element_box)
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
typed = True
|
|
2696
|
-
elif kind == "wait":
|
|
1553
|
+
performed = answered = True
|
|
1554
|
+
elif kind == ActionKind.TYPE:
|
|
1555
|
+
if self._execute_type(page, scope, action, widget.at):
|
|
1556
|
+
performed = answered = True
|
|
1557
|
+
elif kind == ActionKind.WAIT:
|
|
2697
1558
|
duration = int(action.get("duration_ms") or 0)
|
|
2698
1559
|
if duration > 0:
|
|
2699
1560
|
_delay(duration)
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
#
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
# reads SKIP — never enters that loop, so the lookup never ran, the
|
|
2707
|
-
# press below found `verify_button` None, and the round aborted on
|
|
2708
|
-
# 'performed no interactions'. The finder was never the problem:
|
|
2709
|
-
# 'Skip' has always been in its list. It was simply never called for
|
|
2710
|
-
# the one answer shape that performs no other action.
|
|
2711
|
-
# `scope` when there is no vendor iframe. Eight vendors render
|
|
2712
|
-
# into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
|
|
2713
|
-
# Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
|
|
2714
|
-
# for all of them and the button was never even SEARCHED FOR,
|
|
2715
|
-
# while the text box and the slider handle it sits beside were
|
|
2716
|
-
# both found through `scope` a few lines above. Two containers
|
|
2717
|
-
# for two halves of one interaction.
|
|
2718
|
-
#
|
|
2719
|
-
# This used to be gated on `typed`, for fear of turning up the
|
|
2720
|
-
# submit of the FORM the captcha guards. `scope` is the widget
|
|
2721
|
-
# container and the xpaths are RELATIVE, so that button is out
|
|
2722
|
-
# of reach by construction; what the gate actually did was make
|
|
2723
|
-
# every non-typed inline puzzle unsubmittable. Measured on the
|
|
2724
|
-
# Tier 3 fixtures: 4 pairs aborting outright and 11 more types
|
|
2725
|
-
# burning all ten solve loops on a puzzle they had answered on
|
|
2726
|
-
# the first one. The press itself is still bounded by
|
|
2727
|
-
# `should_submit` below, which is where the hazard belongs.
|
|
2728
|
-
# RESOLVED, not travelled to. `_move_and_click` below moves to the
|
|
2729
|
-
# button itself and picks its own random point inside it, so moving
|
|
2730
|
-
# here first bought a second humanised trajectory that ended a few
|
|
2731
|
-
# dozen pixels from where the first one stopped, plus a second
|
|
2732
|
-
# bounded scroll-into-view. Two hops onto one button is slower AND
|
|
2733
|
-
# is not something a hand does. A round that decides not to submit
|
|
2734
|
-
# no longer walks to the control either.
|
|
2735
|
-
lookup = frame or (scope if not slid else None)
|
|
2736
|
-
if lookup is not None:
|
|
2737
|
-
verify_button = self._get_verify_button(lookup)
|
|
2738
|
-
|
|
2739
|
-
# Submit policy: press the widget's own submit control whenever we
|
|
2740
|
-
# have put an ANSWER into it — a selection, a placed piece, a typed
|
|
2741
|
-
# code — or when we had nothing to do and want the round to advance.
|
|
2742
|
-
#
|
|
2743
|
-
# Two exclusions, and they are the whole rule:
|
|
2744
|
-
#
|
|
2745
|
-
# a completed SLIDE has already submitted. Letting go of the handle
|
|
2746
|
-
# is the gesture these puzzles grade; none of them ships a Verify
|
|
2747
|
-
# button, so anything the generic finder turns up afterwards
|
|
2748
|
-
# belongs to the host page, and pressing it would submit the form
|
|
2749
|
-
# the captcha guards while the verdict is still in flight.
|
|
2750
|
-
# a round that only WAITED has answered nothing. Submitting an
|
|
2751
|
-
# empty board spends the attempt on a puzzle we were about to
|
|
2752
|
-
# solve.
|
|
2753
|
-
#
|
|
2754
|
-
# hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
|
|
2755
|
-
# special cases; they are ordinary click rounds and this covers them.
|
|
2756
|
-
# (reCAPTCHA 3x3 never reaches here — it returned above, to the
|
|
2757
|
-
# driver that owns its fade-and-re-round rounds.)
|
|
2758
|
-
#
|
|
2759
|
-
# A click round DID used to be excluded, on the reasoning that these
|
|
2760
|
-
# boards re-round and a half-made selection spends the attempt. They
|
|
2761
|
-
# do not: the ones that grade themselves mid-selection draw no submit
|
|
2762
|
-
# control at all, so `verify_button` is None and nothing is pressed
|
|
2763
|
-
# either way. What the exclusion actually bought was a whole extra
|
|
2764
|
-
# model call per puzzle, spent asking a board we had already answered
|
|
2765
|
-
# correctly whether it was `done` — 6.2 s of prosopo_grid_3x3's 13.8 s,
|
|
2766
|
-
# on a selection that scored 1.0 on the first call.
|
|
2767
|
-
answered = clicked or placed or typed
|
|
2768
|
-
should_submit = not slid and (answered or not performed_action)
|
|
2769
|
-
if should_submit and verify_button:
|
|
1561
|
+
performed = True
|
|
1562
|
+
|
|
1563
|
+
# A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
|
|
1564
|
+
# submit the guarded form while the verdict is in flight. An empty or `done` plan still presses Verify/Skip.
|
|
1565
|
+
verify_button = self._get_verify_button(scope) if frame or not slid else None
|
|
1566
|
+
if not slid and (answered or not performed) and verify_button:
|
|
2770
1567
|
_log(f"clicking Verify to submit ({puzzle_source}).")
|
|
2771
1568
|
self._move_and_click(page, verify_button)
|
|
2772
|
-
|
|
2773
|
-
# the caller aborts a round that reports none, so submitting a
|
|
2774
|
-
# `done` answer and then returning False re-arms the very guard
|
|
2775
|
-
# this satisfies — the puzzle is sent and the solve gives up on
|
|
2776
|
-
# it one line later, which is what `prosopo_grid_3x3` did.
|
|
2777
|
-
performed_action = True
|
|
2778
|
-
# Snapshot at submit time so the NEXT attempt waits for the real
|
|
2779
|
-
# transition before treating whatever is on screen as fresh.
|
|
1569
|
+
performed = True
|
|
2780
1570
|
self._last_submit_frame_hash = self._element_frame_hash(element)
|
|
2781
1571
|
finally:
|
|
2782
1572
|
_unlink(shot)
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
if keyframe_dir:
|
|
1573
|
+
held = self._animated_plan[1] if self._animated_plan else None
|
|
1574
|
+
if keyframe_dir and keyframe_dir != held:
|
|
2786
1575
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1576
|
+
return performed, all_usage
|
|
2787
1577
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
# ------------------------------------------------------------------
|
|
2791
|
-
# Public entry point
|
|
2792
|
-
# ------------------------------------------------------------------
|
|
1578
|
+
# ── public entry point ───────────────────────────────────────────────
|
|
2793
1579
|
|
|
2794
1580
|
def watch(self, page: Any, **options: Any) -> "CaptchaWatcher":
|
|
2795
|
-
"""
|
|
2796
|
-
A watcher that solves captchas on `page` as they appear.
|
|
2797
|
-
|
|
2798
|
-
Mirrors the TS `solver.watch(page)` in intent, but NOT in whether it
|
|
2799
|
-
blocks: this returns an idle watcher and you choose how to drive it,
|
|
2800
|
-
because a sync Playwright handle cannot be driven from a worker thread.
|
|
2801
|
-
|
|
2802
|
-
solver.watch(page).run() # blocking; hold the page clean
|
|
2803
|
-
w = solver.watch(page)
|
|
2804
|
-
while my_loop():
|
|
2805
|
-
w.poll_once() # cooperative; you own the cadence
|
|
2806
|
-
|
|
2807
|
-
Options are `CaptchaWatcher`'s fields: `interval_ms`, `max_solves`,
|
|
2808
|
-
`error_backoff_ms`, `on_solved`, `on_error`. Nothing is injected into
|
|
2809
|
-
the page; see watcher.py.
|
|
2810
|
-
"""
|
|
1581
|
+
"""A watcher over `page`; `.run()` blocks, `.poll_once()` is cooperative."""
|
|
2811
1582
|
from .watcher import CaptchaWatcher
|
|
2812
1583
|
|
|
2813
1584
|
return CaptchaWatcher(solver=self, page=page, **options)
|
|
2814
1585
|
|
|
2815
1586
|
def solve(self, page: Any) -> SolveResult:
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
Returns a `SolveResult`; raises `NoCaptchaFoundError`,
|
|
2820
|
-
`UnsupportedChallengeError`, `AnimatedChallengeError`, or
|
|
2821
|
-
`CaptchaSolveError` on the failure modes named in each type.
|
|
2822
|
-
"""
|
|
2823
|
-
start = time.monotonic() * 1000.0
|
|
2824
|
-
cumulative_usage: List[Dict[str, Any]] = []
|
|
1587
|
+
start = _now()
|
|
1588
|
+
usage: List[Dict[str, Any]] = []
|
|
2825
1589
|
self._last_submit_frame_hash = None
|
|
2826
1590
|
self._deadline_ms = start + self.config.overall_solve_timeout_ms
|
|
2827
1591
|
self._human.reset(page)
|
|
2828
1592
|
self._reset_animated_state()
|
|
2829
1593
|
self._budget = PhaseBudget()
|
|
2830
|
-
|
|
2831
|
-
# Mint one session id for the WHOLE solve, exactly as the TS driver does
|
|
2832
|
-
# per `solve()`. The planner turns it into `X-CK-Session`, which is what
|
|
2833
|
-
# lets the hosted gateway group this captcha's 1..N inference rounds into
|
|
2834
|
-
# a single billable attempt. Without it a multi-round dynamic 3x3 bills
|
|
2835
|
-
# as several attempts instead of one, so this is a billing-correctness
|
|
2836
|
-
# requirement, not telemetry.
|
|
2837
|
-
#
|
|
2838
|
-
# Restored rather than left set: a stale id leaking into the NEXT solve
|
|
2839
|
-
# would merge two separate captchas into one attempt — the opposite
|
|
2840
|
-
# error, and the one that under-bills.
|
|
1594
|
+
# One session id per solve groups its inference rounds into one billable attempt.
|
|
2841
1595
|
previous_session = os.environ.get(_SESSION_ENV)
|
|
2842
|
-
session_id = str(uuid.uuid4())
|
|
2843
|
-
|
|
2844
|
-
# The verdict this driver reports back, and it starts FALSE. A solve
|
|
2845
|
-
# that raises never sets it, which is the right default: the widget did
|
|
2846
|
-
# not accept, and the boards that made it raise are exactly the ones
|
|
2847
|
-
# worth keeping. See planner.report_outcome.
|
|
2848
|
-
solved_for_report = False
|
|
1596
|
+
session_id = os.environ[_SESSION_ENV] = str(uuid.uuid4())
|
|
1597
|
+
solved = False
|
|
2849
1598
|
try:
|
|
2850
|
-
result = self._solve_impl(page, start,
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
result.phases = dict(self._budget.totals)
|
|
2854
|
-
result.phases["total"] = self._budget.elapsed_ms()
|
|
2855
|
-
solved_for_report = bool(getattr(result, "is_solved", False))
|
|
1599
|
+
result = self._solve_impl(page, start, usage)
|
|
1600
|
+
result.phases = dict(self._budget.totals, total=self._budget.elapsed_ms())
|
|
1601
|
+
solved = bool(result.is_solved)
|
|
2856
1602
|
return result
|
|
2857
1603
|
finally:
|
|
2858
|
-
|
|
2859
|
-
# solve that FAILED is exactly the one whose time you want itemised.
|
|
2860
|
-
if timings_enabled() and self._budget is not None:
|
|
1604
|
+
if timings_enabled():
|
|
2861
1605
|
print(self._budget.report(), file=sys.stderr)
|
|
2862
1606
|
self._deadline_ms = None
|
|
2863
|
-
# BEFORE the env is restored, so the id reported is this solve's.
|
|
2864
|
-
# Best-effort and never raised out of a `finally` — an exception
|
|
2865
|
-
# here would replace the caller's real error, or their real result,
|
|
2866
|
-
# with one about telemetry.
|
|
2867
1607
|
try:
|
|
2868
|
-
|
|
2869
|
-
|
|
1608
|
+
# On by default: it is the only way a hosted account's failures can be found at all.
|
|
1609
|
+
self._solver.planner.report_outcome(session_id, solved)
|
|
1610
|
+
except Exception as exc:
|
|
2870
1611
|
_log(f"[outcome] could not report: {exc}")
|
|
2871
1612
|
if previous_session is None:
|
|
2872
1613
|
os.environ.pop(_SESSION_ENV, None)
|
|
2873
1614
|
else:
|
|
2874
1615
|
os.environ[_SESSION_ENV] = previous_session
|
|
2875
1616
|
|
|
2876
|
-
def _solve_impl(
|
|
2877
|
-
self,
|
|
2878
|
-
page: Any,
|
|
2879
|
-
start: float,
|
|
2880
|
-
cumulative_usage: List[Dict[str, Any]],
|
|
2881
|
-
) -> SolveResult:
|
|
1617
|
+
def _solve_impl(self, page: Any, start: float, usage: List[Dict[str, Any]]) -> SolveResult:
|
|
2882
1618
|
cfg = self.config
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
unsupported_retries = 0
|
|
2887
|
-
stale_element_retries = 0
|
|
1619
|
+
pending_retry_mode: Optional[RetryMode] = None
|
|
1620
|
+
retried_underselect = False
|
|
1621
|
+
unsupported_retries = stale_retries = render_waits = 0
|
|
2888
1622
|
has_interacted = False
|
|
2889
|
-
|
|
2890
|
-
# Strictly FEWER than the solve loops. A render wait consumes an
|
|
2891
|
-
# attempt, so at parity the loop runs out first and the branch below
|
|
2892
|
-
# never fires — "no interactive captcha widget", which is the correct
|
|
2893
|
-
# and benign answer for a reCAPTCHA v3 / invisible page, is reported
|
|
2894
|
-
# instead as "still detected after N solve loops". That is a hard error
|
|
2895
|
-
# for a caller who catches NoCaptchaFoundError to mean "nothing here",
|
|
2896
|
-
# and it appeared the moment max_solve_loops came down from 10 to 6.
|
|
1623
|
+
# Strictly fewer than the loops, else the no-widget branch for invisible reCAPTCHA never fires.
|
|
2897
1624
|
max_render_waits = min(6, cfg.max_solve_loops - 1)
|
|
2898
1625
|
|
|
1626
|
+
def done() -> SolveResult:
|
|
1627
|
+
return SolveResult(True, self._last_mouse, _aggregate(usage))
|
|
1628
|
+
|
|
2899
1629
|
for attempt in range(1, cfg.max_solve_loops + 1):
|
|
2900
|
-
# Round 1 did not finish the challenge. Before spending another
|
|
2901
|
-
# still inference on it, let this round's settle check probe whether
|
|
2902
|
-
# the widget is animated — the settle rule alone cannot tell, and
|
|
2903
|
-
# answering an animated puzzle from one still is how a solve burns
|
|
2904
|
-
# all ten loops without ever being able to succeed.
|
|
2905
1630
|
if attempt >= 2:
|
|
2906
1631
|
self._arm_animated_probe()
|
|
2907
|
-
if (
|
|
1632
|
+
if _now() - start > cfg.overall_solve_timeout_ms:
|
|
2908
1633
|
raise CaptchaSolveError(
|
|
2909
|
-
f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms "
|
|
2910
|
-
f"(attempt {attempt}/{cfg.max_solve_loops})"
|
|
2911
|
-
)
|
|
2912
|
-
|
|
2913
|
-
# Ask the DOM whether we are DONE before asking what to solve next.
|
|
2914
|
-
# Only once we have interacted: before that a populated token is the
|
|
2915
|
-
# "already satisfied" case handled below, and this ordering would
|
|
2916
|
-
# skip the render-wait a fresh widget needs.
|
|
2917
|
-
#
|
|
2918
|
-
# `detect_captcha` is the expensive call — it settles pixels and
|
|
2919
|
-
# screenshots the element — and after the winning submit it returns
|
|
2920
|
-
# the challenge frame hCaptcha is TEARING DOWN. Solving that frame
|
|
2921
|
-
# cannot succeed; it just runs until the handle goes stale, which
|
|
2922
|
-
# measured 19-33s of dead time at the end of every run while the
|
|
2923
|
-
# answer had already been accepted. `is_captcha_solved` is a couple
|
|
2924
|
-
# of cheap DOM reads and is authoritative, so it goes first.
|
|
1634
|
+
f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms (attempt {attempt}/{cfg.max_solve_loops})")
|
|
2925
1635
|
if has_interacted and self.is_captcha_solved(page):
|
|
2926
1636
|
_log("captcha reports solved; finishing.")
|
|
2927
|
-
return
|
|
2928
|
-
|
|
2929
|
-
with self._phase(
|
|
2930
|
-
|
|
2931
|
-
if not
|
|
2932
|
-
# Two-stage. A null detection splits into two very different
|
|
2933
|
-
# cases and treating them alike is how you either hang on a
|
|
2934
|
-
# v3 page or give up on a slow-rendering widget.
|
|
1637
|
+
return done()
|
|
1638
|
+
|
|
1639
|
+
with self._phase(Phase.DETECT):
|
|
1640
|
+
widget = self.detect_captcha(page)
|
|
1641
|
+
if not widget:
|
|
2935
1642
|
if has_interacted:
|
|
2936
1643
|
_log("no supported captcha remains after interaction; considering solved.")
|
|
2937
|
-
return
|
|
2938
|
-
# Already satisfied before we touched anything — the widget is
|
|
2939
|
-
# present but the vendor has passed it (anchor checked / token
|
|
2940
|
-
# populated). Common with a good stealth browser: camoufox often
|
|
2941
|
-
# clears reCAPTCHA on the checkbox alone, with no challenge ever
|
|
2942
|
-
# shown. Without this, the render-wait branch below sits for
|
|
2943
|
-
# ~6s and then raises "no interactive captcha widget", turning
|
|
2944
|
-
# the BEST outcome into an exception the caller has to catch.
|
|
1644
|
+
return done()
|
|
2945
1645
|
if self.is_captcha_solved(page):
|
|
2946
1646
|
_log("captcha already satisfied; nothing to solve.")
|
|
2947
|
-
return
|
|
1647
|
+
return done()
|
|
2948
1648
|
if self.has_interactive_widget_in_dom(page) and render_waits < max_render_waits:
|
|
2949
1649
|
render_waits += 1
|
|
2950
|
-
_log(
|
|
2951
|
-
f"widget in DOM but not yet rendered; waiting "
|
|
2952
|
-
f"({render_waits}/{max_render_waits})."
|
|
2953
|
-
)
|
|
1650
|
+
_log(f"widget in DOM but not yet rendered; waiting ({render_waits}/{max_render_waits}).")
|
|
2954
1651
|
_delay(800 + random.random() * 300)
|
|
2955
1652
|
continue
|
|
2956
1653
|
raise NoCaptchaFoundError(self._no_widget_message(page))
|
|
2957
1654
|
|
|
2958
1655
|
_log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
|
|
2959
|
-
|
|
2960
|
-
pending_retry_mode = None
|
|
2961
|
-
|
|
1656
|
+
retry_mode, pending_retry_mode = pending_retry_mode, None
|
|
2962
1657
|
try:
|
|
2963
|
-
did_interact,
|
|
1658
|
+
did_interact, round_usage = self._solve_single(page, widget, retry_mode)
|
|
2964
1659
|
except AnimatedChallengeError:
|
|
2965
1660
|
raise
|
|
2966
1661
|
except UnsupportedCaptchaError as unsupported:
|
|
2967
|
-
#
|
|
2968
|
-
# BUT mid-solve, a transitional blank frame produces the same
|
|
2969
|
-
# verdict — that was the "solves round 1, dies on round 2" bug.
|
|
1662
|
+
# Mid-solve, a transitional blank frame reads as unsupported; settle and retry.
|
|
2970
1663
|
if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
|
|
2971
1664
|
unsupported_retries += 1
|
|
2972
|
-
|
|
2973
|
-
with self._phase(
|
|
2974
|
-
|
|
2975
|
-
if
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
# recording path. `unsupported_retries` still bounds it, so a
|
|
2979
|
-
# widget that is animated AND unsolvable cannot spin here.
|
|
2980
|
-
if not cfg.video_solve_enabled:
|
|
2981
|
-
raise AnimatedChallengeError(
|
|
2982
|
-
"the challenge never settles and video_solve_enabled is off"
|
|
2983
|
-
)
|
|
2984
|
-
_log('"unsupported" mid-solve and the next round is animated; '
|
|
2985
|
-
"retrying into the recording path.")
|
|
2986
|
-
continue
|
|
2987
|
-
_log(
|
|
2988
|
-
f'"unsupported" mid-solve; settled and retrying '
|
|
2989
|
-
f"({unsupported_retries}/{cfg.max_unsupported_resolves})."
|
|
2990
|
-
)
|
|
1665
|
+
again = self.detect_captcha(page)
|
|
1666
|
+
with self._phase(Phase.SETTLE):
|
|
1667
|
+
settled = again and self._wait_for_element_settled(again.element)
|
|
1668
|
+
if settled == SettleVerdict.ANIMATED and not cfg.video_solve_enabled:
|
|
1669
|
+
raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
|
|
1670
|
+
_log(f'"unsupported" mid-solve; retrying ({unsupported_retries}/{cfg.max_unsupported_resolves}).')
|
|
2991
1671
|
continue
|
|
2992
|
-
|
|
2993
|
-
# This used to substitute "likely an hCaptcha click/drag
|
|
2994
|
-
# puzzle" for every unsupported verdict, including the ones
|
|
2995
|
-
# that already said exactly what was wrong — "prompt
|
|
2996
|
-
# generation 1 has no distorted-text prompt", say, which names
|
|
2997
|
-
# both the cause and the fix. Reporting a wrong guess in place
|
|
2998
|
-
# of a right answer costs whoever reads the gate an
|
|
2999
|
-
# investigation, every time.
|
|
3000
|
-
raise UnsupportedChallengeError(
|
|
3001
|
-
f"cannot solve this kind of captcha — {unsupported}"
|
|
3002
|
-
) from unsupported
|
|
1672
|
+
raise UnsupportedChallengeError(f"cannot solve this kind of captcha — {unsupported}") from unsupported
|
|
3003
1673
|
except Exception as exc:
|
|
3004
|
-
# A stale/detached handle after a submit is a TRANSITION, not a
|
|
3005
|
-
# dead puzzle: hCaptcha swapped in the next round while we held
|
|
3006
|
-
# the old iframe. Only after interacting — a first-frame failure
|
|
3007
|
-
# is a genuine problem worth surfacing.
|
|
3008
1674
|
message = str(exc)
|
|
3009
1675
|
closed = bool(_CLOSED_TARGET_RE.search(message))
|
|
3010
1676
|
if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
|
|
3011
|
-
#
|
|
3012
|
-
# BECAUSE the answer was accepted: reCAPTCHA tears the
|
|
3013
|
-
# challenge iframe down the instant it takes an answer, and
|
|
3014
|
-
# hCaptcha swaps the frame out. Re-entering the pipeline
|
|
3015
|
-
# here spends a whole round — detect, screenshot, infer — on
|
|
3016
|
-
# a puzzle that no longer exists, and races the teardown.
|
|
3017
|
-
# Headless usually won that race; headed usually lost it and
|
|
3018
|
-
# surfaced `TargetClosedError` from whatever ran next.
|
|
3019
|
-
#
|
|
3020
|
-
# The top-of-loop check is not enough on its own: it runs
|
|
3021
|
-
# only after the backoff, and it does DOM reads that throw
|
|
3022
|
-
# on a target that is already gone.
|
|
1677
|
+
# The handle most often went stale because the answer was accepted.
|
|
3023
1678
|
try:
|
|
3024
1679
|
if self.is_captcha_solved(page):
|
|
3025
1680
|
_log("captcha reports solved; finishing.")
|
|
3026
|
-
return
|
|
3027
|
-
|
|
3028
|
-
)
|
|
3029
|
-
except Exception: # noqa: BLE001
|
|
3030
|
-
# Can't consult the vendor — the page is gone. Fall
|
|
3031
|
-
# through; `closed` decides between a named error and a
|
|
3032
|
-
# retry, and neither wants this exception in its place.
|
|
1681
|
+
return done()
|
|
1682
|
+
except Exception:
|
|
3033
1683
|
pass
|
|
3034
|
-
|
|
3035
1684
|
if closed:
|
|
3036
1685
|
raise PageClosedError(
|
|
3037
|
-
"the page, context or browser closed mid-solve, after the "
|
|
3038
|
-
"
|
|
3039
|
-
"
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
stale_element_retries += 1
|
|
3044
|
-
_log(
|
|
3045
|
-
f"stale challenge handle after submit; re-detecting next round "
|
|
3046
|
-
f"({stale_element_retries}/{cfg.max_stale_element_retries})."
|
|
3047
|
-
)
|
|
1686
|
+
"the page, context or browser closed mid-solve, after the answer had been "
|
|
1687
|
+
"submitted but before the vendor's verdict could be read — the solve may in "
|
|
1688
|
+
"fact have succeeded") from exc
|
|
1689
|
+
if stale_retries < cfg.max_stale_element_retries:
|
|
1690
|
+
stale_retries += 1
|
|
1691
|
+
_log(f"stale challenge handle after submit; re-detecting ({stale_retries}/{cfg.max_stale_element_retries}).")
|
|
3048
1692
|
_delay(cfg.stale_element_backoff_ms)
|
|
3049
1693
|
continue
|
|
3050
1694
|
raise
|
|
3051
1695
|
|
|
3052
1696
|
has_interacted = has_interacted or did_interact
|
|
3053
|
-
|
|
3054
|
-
# Give up on a solve that is repeating itself, rather than letting
|
|
3055
|
-
# the clock do it. The alternative is not "one more chance" — it is
|
|
3056
|
-
# the same click, again, until `overall_solve_timeout_ms`, which is
|
|
3057
|
-
# how a hopeless captcha came to cost 66s and how half of Tier 3's
|
|
3058
|
-
# wall-clock was being spent on attempts that could not succeed.
|
|
3059
1697
|
if self._no_progress_rounds >= cfg.max_no_progress_rounds:
|
|
3060
1698
|
raise CaptchaSolveError(
|
|
3061
|
-
f"no progress: the model returned the same answer "
|
|
3062
|
-
f"
|
|
3063
|
-
f"is still up (attempt {attempt}/{cfg.max_solve_loops})"
|
|
3064
|
-
)
|
|
1699
|
+
f"no progress: the model returned the same answer {self._no_progress_rounds + 1} times "
|
|
1700
|
+
f"running and the challenge is still up (attempt {attempt}/{cfg.max_solve_loops})")
|
|
3065
1701
|
render_waits = 0
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
#
|
|
3069
|
-
#
|
|
3070
|
-
# There used to be two: this poll when the round interacted, and a
|
|
3071
|
-
# flat `_delay(post_solve_delay_ms + jitter)` when it did not. The
|
|
3072
|
-
# flat sleep observed NOTHING — it ran to the end and only then
|
|
3073
|
-
# asked whether the widget was still there — so a round that had in
|
|
3074
|
-
# fact finished the captcha paid 1200-1500ms to find that out. The
|
|
3075
|
-
# poll below already asks exactly that question and answers it the
|
|
3076
|
-
# moment it becomes true.
|
|
3077
|
-
#
|
|
3078
|
-
# The two windows keep different LENGTHS, because different things
|
|
3079
|
-
# size them: `post_solve_outcome_timeout_ms` covers how late a
|
|
3080
|
-
# vendor's success signal can arrive (measured — see the constant),
|
|
3081
|
-
# while `post_solve_delay_ms` is the dwell a round that answered
|
|
3082
|
-
# nothing takes before deciding the page has settled.
|
|
1702
|
+
usage.extend(round_usage)
|
|
1703
|
+
|
|
1704
|
+
# One polled wait per round: the vendor's verdict, the widget going away, or a fresh board. The flat
|
|
1705
|
+
# sleep it replaced observed nothing and cost 1200-1500ms per finished round.
|
|
3083
1706
|
window_ms = (cfg.post_solve_outcome_timeout_ms if did_interact
|
|
3084
1707
|
else cfg.post_solve_delay_ms + random.random() * 300)
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
# seconds while verifying; without this the loop re-solves that
|
|
3088
|
-
# closing frame and burns ~18s. Only ever early-RETURNS on a
|
|
3089
|
-
# definitive signal, so it cannot loop.
|
|
3090
|
-
deadline = time.monotonic() * 1000.0 + window_ms
|
|
1708
|
+
deadline = _now() + window_ms
|
|
1709
|
+
t0 = time.perf_counter()
|
|
3091
1710
|
solved = False
|
|
3092
1711
|
widget_gone = 0
|
|
3093
|
-
|
|
3094
|
-
while time.monotonic() * 1000.0 < deadline:
|
|
1712
|
+
while _now() < deadline:
|
|
3095
1713
|
if self.is_captcha_solved(page):
|
|
3096
1714
|
solved = True
|
|
3097
1715
|
break
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
# a signal that cannot arrive. Measured on geetest_v4_slide:
|
|
3103
|
-
# 5.2s of a 12.3s solve, spent after the puzzle was already
|
|
3104
|
-
# answered, with the widget sitting there visibly solved.
|
|
3105
|
-
#
|
|
3106
|
-
# "The widget is gone" is the completion signal for those
|
|
3107
|
-
# vendors and is already the authority immediately after
|
|
3108
|
-
# this loop, so this only reaches the same verdict sooner —
|
|
3109
|
-
# confirmed over two polls so a frame caught mid-swap
|
|
3110
|
-
# between rounds cannot read as a solve.
|
|
3111
|
-
if self.detect_captcha(page) is None:
|
|
3112
|
-
widget_gone += 1
|
|
3113
|
-
if widget_gone >= 2:
|
|
3114
|
-
solved = True
|
|
3115
|
-
break
|
|
3116
|
-
else:
|
|
3117
|
-
widget_gone = 0
|
|
1716
|
+
widget_gone = widget_gone + 1 if self.detect_captcha(page) is None else 0
|
|
1717
|
+
if widget_gone >= 2:
|
|
1718
|
+
solved = True
|
|
1719
|
+
break
|
|
3118
1720
|
if self._is_challenge_freshly_rendered(page):
|
|
3119
|
-
|
|
1721
|
+
self._fresh_board()
|
|
1722
|
+
break
|
|
3120
1723
|
_delay(cfg.post_solve_outcome_poll_ms)
|
|
3121
|
-
|
|
1724
|
+
verdict_ms = (time.perf_counter() - t0) * 1000.0
|
|
3122
1725
|
if self._budget is not None:
|
|
3123
|
-
self._budget.add(
|
|
3124
|
-
"await-verdict" if did_interact else "post-submit-delay",
|
|
3125
|
-
_verdict_ms)
|
|
3126
|
-
# How long a SUCCESS actually took to show itself. This is the
|
|
3127
|
-
# only number that can size `post_solve_outcome_timeout_ms`: the
|
|
3128
|
-
# window exists to catch a late success, so it needs to cover
|
|
3129
|
-
# the slowest real one and nothing beyond it. A round that ends
|
|
3130
|
-
# any other way spends the whole window by construction — there
|
|
3131
|
-
# is no signal on a wrong answer — so its duration says nothing
|
|
3132
|
-
# about how long the window ought to be.
|
|
1726
|
+
self._budget.add(Phase.AWAIT_VERDICT if did_interact else Phase.POST_SUBMIT_DELAY, verdict_ms)
|
|
3133
1727
|
if solved:
|
|
3134
|
-
_log(f"[verdict] success signal arrived after {
|
|
3135
|
-
return
|
|
1728
|
+
_log(f"[verdict] success signal arrived after {verdict_ms:.0f}ms")
|
|
1729
|
+
return done()
|
|
3136
1730
|
|
|
3137
|
-
if self.
|
|
3138
|
-
if
|
|
1731
|
+
if self._banner_is_fatal_after_retry(self._banner_kind(page)):
|
|
1732
|
+
if retried_underselect:
|
|
3139
1733
|
raise CaptchaSolveError(
|
|
3140
|
-
"reCAPTCHA still showing the under-selection error after retry; "
|
|
3141
|
-
"
|
|
3142
|
-
)
|
|
1734
|
+
"reCAPTCHA still showing the under-selection error after retry; aborting "
|
|
1735
|
+
"(model unable to identify the missed tile)")
|
|
3143
1736
|
_log("reCAPTCHA under-selection error; retrying with missed-tiles prompt.")
|
|
3144
|
-
pending_retry_mode =
|
|
3145
|
-
already_retried_underselect = True
|
|
1737
|
+
pending_retry_mode, retried_underselect = RetryMode.MISSED_TILES, True
|
|
3146
1738
|
|
|
3147
1739
|
if not self.detect_captcha(page):
|
|
3148
|
-
return
|
|
3149
|
-
|
|
3150
|
-
if not did_interact:
|
|
1740
|
+
return done()
|
|
1741
|
+
if not did_interact and not self._no_progress_rounds:
|
|
3151
1742
|
raise CaptchaSolveError(
|
|
3152
|
-
"captcha still detected but the solver performed no interactions; "
|
|
3153
|
-
"aborting to avoid an infinite loop"
|
|
3154
|
-
)
|
|
1743
|
+
"captcha still detected but the solver performed no interactions; aborting to avoid an infinite loop")
|
|
3155
1744
|
|
|
3156
|
-
raise CaptchaSolveError(
|
|
3157
|
-
f"captcha still detected after {cfg.max_solve_loops} solve loops"
|
|
3158
|
-
)
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
# --------------------------------------------------------------------------
|
|
3162
|
-
# Helpers
|
|
3163
|
-
# --------------------------------------------------------------------------
|
|
1745
|
+
raise CaptchaSolveError(f"captcha still detected after {cfg.max_solve_loops} solve loops")
|
|
3164
1746
|
|
|
3165
1747
|
|
|
3166
1748
|
def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]:
|
|
3167
|
-
"""Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the keyframe
|
|
3168
|
-
wait gate compares around. The solver builds these boxes as a small square
|
|
3169
|
-
around the model's point, so the centre recovers that point exactly."""
|
|
3170
1749
|
x1, y1, x2, y2 = (float(v) for v in bbox)
|
|
3171
|
-
return (
|
|
1750
|
+
return (x1 + x2) / 2.0, (y1 + y2) / 2.0
|
|
3172
1751
|
|
|
3173
1752
|
|
|
3174
1753
|
def _round_pts(value: Any, places: int = 3) -> Any:
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
Rounded rather than compared exactly because the same tile chosen twice can
|
|
3178
|
-
differ in the last float digit after the normalise/clamp round-trip, and a
|
|
3179
|
-
repeat that reads as "different" is a repeat that costs a round.
|
|
3180
|
-
"""
|
|
1754
|
+
# Float last-digit drift would make a repeated answer look new and cost a round.
|
|
3181
1755
|
if isinstance(value, (int, float)):
|
|
3182
1756
|
return round(float(value), places)
|
|
3183
1757
|
if isinstance(value, (list, tuple)):
|
|
@@ -3186,7 +1760,6 @@ def _round_pts(value: Any, places: int = 3) -> Any:
|
|
|
3186
1760
|
|
|
3187
1761
|
|
|
3188
1762
|
def _as_dict(action: Any) -> Dict[str, Any]:
|
|
3189
|
-
"""Actions arrive as pydantic models in-process, or dicts from JSON."""
|
|
3190
1763
|
if isinstance(action, dict):
|
|
3191
1764
|
return action
|
|
3192
1765
|
for method in ("model_dump", "dict"):
|
|
@@ -3199,7 +1772,6 @@ def _as_dict(action: Any) -> Dict[str, Any]:
|
|
|
3199
1772
|
|
|
3200
1773
|
|
|
3201
1774
|
def _aggregate(usage: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
3202
|
-
"""Mirror of `aggregateTokenUsage`: one summed row, plus the raw rounds."""
|
|
3203
1775
|
if not usage:
|
|
3204
1776
|
return []
|
|
3205
1777
|
total = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
@@ -3210,30 +1782,17 @@ def _aggregate(usage: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
3210
1782
|
|
|
3211
1783
|
|
|
3212
1784
|
def _read_png_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
|
3213
|
-
"""
|
|
3214
|
-
Width/height from the IHDR chunk, so no image-size dependency is needed.
|
|
3215
|
-
PNG signature is 8 bytes, IHDR length+type another 8, then two big-endian
|
|
3216
|
-
uint32s at offsets 16 and 20.
|
|
3217
|
-
"""
|
|
1785
|
+
"""Width and height from the IHDR chunk, so no image-size dependency is needed."""
|
|
3218
1786
|
try:
|
|
3219
1787
|
with open(path, "rb") as handle:
|
|
3220
1788
|
header = handle.read(24)
|
|
3221
1789
|
if len(header) < 24 or header[1:4] != b"PNG":
|
|
3222
1790
|
return None
|
|
3223
|
-
width = int.from_bytes(header[16:20], "big")
|
|
3224
|
-
height = int.from_bytes(header[20:24], "big")
|
|
1791
|
+
width, height = int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big")
|
|
3225
1792
|
return (width, height) if width and height else None
|
|
3226
1793
|
except OSError:
|
|
3227
1794
|
return None
|
|
3228
1795
|
|
|
3229
1796
|
|
|
3230
1797
|
def solve_captcha_on_page(page: Any, **kwargs: Any) -> SolveResult:
|
|
3231
|
-
"""
|
|
3232
|
-
One-shot convenience wrapper mirroring the TS `new CaptchaKrakenSolver().solve(page)`.
|
|
3233
|
-
|
|
3234
|
-
Synchronous only. The async Playwright API needs a parallel implementation
|
|
3235
|
-
(`await` at every call site) rather than a wrapper — sync Playwright handles
|
|
3236
|
-
cannot be driven from inside an event loop, so `asyncio.to_thread` would not
|
|
3237
|
-
save this. Async support is not yet written.
|
|
3238
|
-
"""
|
|
3239
1798
|
return PageSolver(**kwargs).solve(page)
|