captchakraken 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1285 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +212 -2
- package/python/src/captchakraken/config.py +39 -5
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +253 -59
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -41,14 +41,18 @@ import hashlib
|
|
|
41
41
|
import os
|
|
42
42
|
import random
|
|
43
43
|
import re
|
|
44
|
+
import shutil
|
|
45
|
+
import sys
|
|
44
46
|
import tempfile
|
|
45
47
|
import time
|
|
46
48
|
import uuid
|
|
49
|
+
from contextlib import contextmanager
|
|
47
50
|
from dataclasses import dataclass, field
|
|
48
51
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
49
52
|
|
|
50
53
|
from .action_types import CaptchaAction
|
|
51
54
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
55
|
+
from .timing import PhaseBudget, timings_enabled
|
|
52
56
|
from .trajectory import generate_trajectory
|
|
53
57
|
|
|
54
58
|
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
@@ -104,7 +108,14 @@ class CaptchaSolveError(Exception):
|
|
|
104
108
|
|
|
105
109
|
|
|
106
110
|
class AnimatedChallengeError(CaptchaSolveError):
|
|
107
|
-
"""
|
|
111
|
+
"""An animated challenge we could not RECORD.
|
|
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
|
+
"""
|
|
108
119
|
|
|
109
120
|
|
|
110
121
|
class UnsupportedChallengeError(CaptchaSolveError):
|
|
@@ -115,10 +126,32 @@ class NoCaptchaFoundError(CaptchaSolveError):
|
|
|
115
126
|
"""No interactive widget — reCAPTCHA v3/invisible, or a click-triggered challenge."""
|
|
116
127
|
|
|
117
128
|
|
|
129
|
+
class PageClosedError(CaptchaSolveError):
|
|
130
|
+
"""The page, context or browser went away mid-solve.
|
|
131
|
+
|
|
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
|
+
|
|
139
|
+
|
|
118
140
|
# Playwright surfaces these as messages, not types, so both drivers match on
|
|
119
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.
|
|
120
146
|
_STALE_HANDLE_RE = re.compile(
|
|
121
|
-
r"Timeout .*exceeded|not visible|not attached|detached
|
|
147
|
+
r"Timeout .*exceeded|not visible|not attached|detached",
|
|
148
|
+
re.IGNORECASE,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# A target that no longer exists. Nothing can be re-detected on it, so this is
|
|
152
|
+
# terminal rather than retryable.
|
|
153
|
+
_CLOSED_TARGET_RE = re.compile(
|
|
154
|
+
r"Target (?:page, context or browser )?(?:has been )?closed|Session closed",
|
|
122
155
|
re.IGNORECASE,
|
|
123
156
|
)
|
|
124
157
|
|
|
@@ -144,6 +177,31 @@ class _GridSession:
|
|
|
144
177
|
screenshot_h: int
|
|
145
178
|
|
|
146
179
|
|
|
180
|
+
def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int) -> str:
|
|
181
|
+
"""The pixel-settle rule, over `(elapsed_ms, moved)` polls.
|
|
182
|
+
|
|
183
|
+
Split out of the polling loop so the RULE can be tested against recorded
|
|
184
|
+
timelines without a browser — see `tests/test_animated_is_detected.py`,
|
|
185
|
+
which pins what it does to each shipped animated puzzle.
|
|
186
|
+
|
|
187
|
+
Note what it cannot do: 'animated' needs a poll that MOVED at or after
|
|
188
|
+
`animated_after_ms`, so any `settle_frames` run of stillness before then
|
|
189
|
+
ends the wait as 'settled' first. Every animated puzzle we ship rests
|
|
190
|
+
between screens for longer than that, which is why the caller also probes.
|
|
191
|
+
"""
|
|
192
|
+
still_streak = 0
|
|
193
|
+
for elapsed_ms, moved in samples:
|
|
194
|
+
if moved:
|
|
195
|
+
still_streak = 0
|
|
196
|
+
if elapsed_ms >= animated_after_ms:
|
|
197
|
+
return "animated"
|
|
198
|
+
else:
|
|
199
|
+
still_streak += 1
|
|
200
|
+
if still_streak >= settle_frames:
|
|
201
|
+
return "settled"
|
|
202
|
+
return "timeout"
|
|
203
|
+
|
|
204
|
+
|
|
147
205
|
@dataclass
|
|
148
206
|
class PageSolverConfig:
|
|
149
207
|
"""
|
|
@@ -151,10 +209,65 @@ class PageSolverConfig:
|
|
|
151
209
|
value tuned on one driver can be found on the other.
|
|
152
210
|
"""
|
|
153
211
|
|
|
154
|
-
|
|
155
|
-
|
|
212
|
+
# 45s, and the loop count that fits inside it rather than one that needs
|
|
213
|
+
# policing by the clock. A round costs ~4-7s once the waits below are paid,
|
|
214
|
+
# so six rounds is the budget; ten never fitted and only ever expressed
|
|
215
|
+
# itself as a timeout. The cap is a BACKSTOP — `max_no_progress_rounds` is
|
|
216
|
+
# what is supposed to end a hopeless solve, and a solve that reaches this
|
|
217
|
+
# number is a bug report, not a normal outcome.
|
|
218
|
+
max_solve_loops: int = 6
|
|
219
|
+
overall_solve_timeout_ms: int = 45_000
|
|
220
|
+
#: Consecutive rounds producing the SAME answer before the solve is
|
|
221
|
+
#: abandoned.
|
|
222
|
+
#:
|
|
223
|
+
#: At temperature 0 the model is a function of the picture, so an identical
|
|
224
|
+
#: answer means an identical picture — and every answer this driver produces
|
|
225
|
+
#: is EXECUTED, so the previous one already ran and moved nothing. Repeating
|
|
226
|
+
#: it cannot do better; it just spends a round.
|
|
227
|
+
#:
|
|
228
|
+
#: Measured on recaptcha_grid_4x4 (fixture seed 20260730): the model answers
|
|
229
|
+
#: `[2,6,7,9,10]` on round 1 and then `[2,6,7,10]` on rounds 2 THROUGH 10 —
|
|
230
|
+
#: nine identical click sets, each one clicked, each one rejected, ending in
|
|
231
|
+
#: "still detected after 10 solve loops" at 66.1s. Of that, 39.0s was pure
|
|
232
|
+
#: waiting. Stopping on the second repeat ends it at round 4.
|
|
233
|
+
#:
|
|
234
|
+
#: 2 rather than 1: the first repeat also ARMS the animated probe, which is
|
|
235
|
+
#: the one recovery worth trying (a cycling board reads as a still and
|
|
236
|
+
#: answers the same way every round). One more round buys that chance.
|
|
237
|
+
max_no_progress_rounds: int = 2
|
|
156
238
|
post_solve_delay_ms: int = 1_200
|
|
157
|
-
|
|
239
|
+
# How long to keep watching for a SUCCESS after submitting.
|
|
240
|
+
#
|
|
241
|
+
# That is the window's whole job, and it is why a wrong answer spends all of
|
|
242
|
+
# it: the vendors emit nothing on a wrong answer, they just re-deal. Sizing
|
|
243
|
+
# it therefore means asking how late a real success can arrive.
|
|
244
|
+
#
|
|
245
|
+
# MEASURED over 34 successful rounds across 20 puzzle types (fixture server,
|
|
246
|
+
# two seeds each): min 3ms, p50 360ms, p90 378ms, max 528ms.
|
|
247
|
+
#
|
|
248
|
+
# Read that with care — those values are quantized by `..._poll_ms`, not by
|
|
249
|
+
# any vendor. The clusters sit at one poll (~200ms), two polls (~370ms, the
|
|
250
|
+
# `widget_gone >= 2` confirmation) and three (~520ms). So the measurement
|
|
251
|
+
# mostly describes THIS loop, and the fixture's verdict is a local fetch,
|
|
252
|
+
# which means real hCaptcha/reCAPTCHA round-trips are absent from it
|
|
253
|
+
# entirely.
|
|
254
|
+
#
|
|
255
|
+
# 1000ms is therefore the measured worst case (528ms, ~264ms once the poll
|
|
256
|
+
# below is halved) plus deliberate headroom for a vendor round-trip nobody
|
|
257
|
+
# has measured yet. Down from 2500ms, which was spent in full on every
|
|
258
|
+
# unsuccessful round — 25.5s of one 66.1s recaptcha_grid_4x4 solve.
|
|
259
|
+
#
|
|
260
|
+
# To replace the headroom with a number: drive the real vendor pages via
|
|
261
|
+
# `tests/live-solve/src/demo-targets.ts` and re-read this distribution.
|
|
262
|
+
post_solve_outcome_timeout_ms: int = 1_000
|
|
263
|
+
#: Poll interval inside that window.
|
|
264
|
+
#:
|
|
265
|
+
#: The success signals are polled, so detection latency is a MULTIPLE of
|
|
266
|
+
#: this — and the one that matters most, "the widget is gone", needs two
|
|
267
|
+
#: consecutive polls to confirm, so it cannot be seen in less than 2x. At
|
|
268
|
+
#: 150ms that put a 300ms floor under every inline vendor's success. 75ms
|
|
269
|
+
#: halves it, and the poll is cheap: `detect_captcha` measures ~3ms.
|
|
270
|
+
post_solve_outcome_poll_ms: int = 75
|
|
158
271
|
element_screenshot_timeout_ms: int = 8_000
|
|
159
272
|
max_unsupported_resolves: int = 3
|
|
160
273
|
max_stale_element_retries: int = 3
|
|
@@ -173,7 +286,45 @@ class PageSolverConfig:
|
|
|
173
286
|
settle_diff_threshold: float = 0.01
|
|
174
287
|
post_submit_change_timeout_ms: int = 4_000
|
|
175
288
|
|
|
289
|
+
# ── Animated challenges ────────────────────────────────────────────────
|
|
290
|
+
# A challenge that never settles is RECORDED and solved from keyframes rather
|
|
291
|
+
# than abandoned. Off switch for callers who would rather fail fast than spend
|
|
292
|
+
# the recording time.
|
|
293
|
+
video_solve_enabled: bool = True
|
|
294
|
+
# Whether a repeated answer may escalate to a RECORDING of the widget.
|
|
295
|
+
#
|
|
296
|
+
# A board that cycles reads as static to `_wait_for_element_settled` (it
|
|
297
|
+
# rests between screens for far longer than the 440ms that rule stops at),
|
|
298
|
+
# gets answered from whichever screen the screenshot caught, and then
|
|
299
|
+
# answers identically every round. That repeat is the signal, and the
|
|
300
|
+
# response is to record a burst and let the SLICER decide: a widget that
|
|
301
|
+
# really is static slices to one keyframe and is solved as the still it is,
|
|
302
|
+
# so the escalation costs one burst and can never produce a worse answer.
|
|
303
|
+
#
|
|
304
|
+
# Off restores the old behaviour for a caller who would rather re-read a
|
|
305
|
+
# still than spend `video_burst_duration_ms` finding out.
|
|
306
|
+
animated_probe_enabled: bool = True
|
|
307
|
+
# Burst geometry. Deliberately identical to the collector's
|
|
308
|
+
# (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the finetune repo), so a
|
|
309
|
+
# challenge recorded here is the same shape of artifact the model trained on —
|
|
310
|
+
# same clip length, same frame rate, therefore the same keyframe slicing.
|
|
311
|
+
video_burst_duration_ms: int = 4_000
|
|
312
|
+
video_burst_fps: int = 10
|
|
313
|
+
# How long to wait for the widget to return to the keyframe the model chose,
|
|
314
|
+
# before clicking anyway. Bounded because the alternative is worse: these
|
|
315
|
+
# puzzles cycle, so the state DOES come back — but if the recording caught a
|
|
316
|
+
# one-off transition it never will, and a click on the model's coordinates is
|
|
317
|
+
# still a better use of the remaining budget than a timeout.
|
|
318
|
+
keyframe_wait_timeout_ms: int = 6_000
|
|
319
|
+
keyframe_wait_poll_ms: int = 120
|
|
320
|
+
|
|
176
321
|
# Grid load / dynamic-refresh timing.
|
|
322
|
+
# How long to wait for hCaptcha's task images to paint before screenshotting
|
|
323
|
+
# anyway. Best-effort by design — the screenshot happens either way — so it
|
|
324
|
+
# is bounded by what a loading tile plausibly costs, not by a generous
|
|
325
|
+
# default. 8s was two-thirds of what a whole solve is now allowed to take.
|
|
326
|
+
hcaptcha_images_timeout_ms: int = 3_000
|
|
327
|
+
|
|
177
328
|
grid_load_poll_interval_ms: int = 250
|
|
178
329
|
grid_load_timeout_ms: int = 8_000
|
|
179
330
|
recaptcha_max_dynamic_rounds: int = 8
|
|
@@ -182,6 +333,125 @@ class PageSolverConfig:
|
|
|
182
333
|
recaptcha_dynamic_fade_wait_ms: int = 6_000
|
|
183
334
|
recaptcha_tile_hover_enabled: bool = True
|
|
184
335
|
|
|
336
|
+
# ── puzzle-piece sliders (see _execute_slide) ──────────────────────────
|
|
337
|
+
# How far to nudge the handle, in px, to learn the piece's width and how
|
|
338
|
+
# fast it follows. Two probes because two unknowns; far enough apart that
|
|
339
|
+
# the difference between the two widths is signal rather than rounding, and
|
|
340
|
+
# both small enough to stay on the shortest track observed (~250px).
|
|
341
|
+
slide_probe_offsets_px: Tuple[float, ...] = (24.0, 64.0)
|
|
342
|
+
# Stop steering once the piece is this close. Tighter than any vendor's
|
|
343
|
+
# accept window, so the limit on solving is the model's slot estimate.
|
|
344
|
+
slide_tolerance_px: float = 2.0
|
|
345
|
+
# Corrections after the probes. Each costs a screenshot with the mouse held
|
|
346
|
+
# down; two is enough for a linear system, and the third would only be
|
|
347
|
+
# chasing a measurement that is not going to converge.
|
|
348
|
+
slide_max_corrections: int = 2
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# Vendors with no checkbox/challenge split — one container, one interactive
|
|
352
|
+
# surface. Checked in detect_captcha() after the five hard-coded reCAPTCHA /
|
|
353
|
+
# hCaptcha / Turnstile checks above, so those keep first refusal. Selectors
|
|
354
|
+
# lifted from src/captchaCollection/sources.py, which already drives these 8
|
|
355
|
+
# vendors nightly in the collector. Mirror of VENDOR_WIDGET_LOCATORS in
|
|
356
|
+
# solver.ts — keep both in the same order with the same selectors.
|
|
357
|
+
VENDOR_WIDGET_LOCATORS = [
|
|
358
|
+
{"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
|
|
359
|
+
{"puzzle_source": "tencent", "selectors": ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
|
|
360
|
+
{"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
|
|
361
|
+
{"puzzle_source": "yandex", "selectors": [".CheckboxCaptcha"]},
|
|
362
|
+
{"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
|
|
363
|
+
{"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
|
|
364
|
+
{"puzzle_source": "mtcaptcha", "selectors": [".mtcap"]},
|
|
365
|
+
{"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── where the answer goes, when it is not a click ───────────────────────────
|
|
370
|
+
#
|
|
371
|
+
# Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
|
|
372
|
+
# first visible match. That order is the whole design: a named vendor selector
|
|
373
|
+
# is unambiguous, while the generic patterns are guesses that happen to be right
|
|
374
|
+
# most of the time. Trying the guess first would, on a page that hosts a captcha
|
|
375
|
+
# *and* a login form, type the captcha's answer into the username box.
|
|
376
|
+
#
|
|
377
|
+
# The generic tail is not a nicety either — it is what actually fires on most
|
|
378
|
+
# pages. Vendors rename these classes without notice (they are anti-bot
|
|
379
|
+
# surfaces, so churn is the point), and our own Tier 3 fixtures render neither
|
|
380
|
+
# vendor's DOM. Anything that only worked via the vendor list would be a feature
|
|
381
|
+
# that passes review and fails in the field.
|
|
382
|
+
|
|
383
|
+
# A distorted-text captcha's answer box. The three vendor entries are the three
|
|
384
|
+
# types in instructions.py::TEXT_TYPES.
|
|
385
|
+
TEXT_INPUT_SELECTORS = [
|
|
386
|
+
# BotDetect — the input is application-defined, so match the id fragment its
|
|
387
|
+
# own docs and samples use. These three are what the nightly collector
|
|
388
|
+
# already drives (src/captchaCollection/sources.py).
|
|
389
|
+
"input[id*=captchaCode]",
|
|
390
|
+
"input#captchaCode",
|
|
391
|
+
"input[id*=validateCaptcha]",
|
|
392
|
+
".BDC_CaptchaDiv input[type=text]",
|
|
393
|
+
# MTCaptcha
|
|
394
|
+
"input.mtcap-inputtext",
|
|
395
|
+
".mtcap input[type=text]",
|
|
396
|
+
# Yandex SmartCaptcha
|
|
397
|
+
".AdvancedCaptcha-Input input",
|
|
398
|
+
"input.Textinput-Control",
|
|
399
|
+
'input[name="rep"]',
|
|
400
|
+
# Generic — an input the page itself labels as the captcha answer.
|
|
401
|
+
'input[name*="captcha" i]',
|
|
402
|
+
'input[id*="captcha" i]',
|
|
403
|
+
'input[aria-label*="captcha" i]',
|
|
404
|
+
'input[placeholder*="code" i]',
|
|
405
|
+
'input[autocomplete="off"][type=text]',
|
|
406
|
+
# Last resort: the only text box in the widget. Scoped to the challenge
|
|
407
|
+
# frame/container by the caller, never to the whole page — see _find_control.
|
|
408
|
+
"input[type=text]",
|
|
409
|
+
"input:not([type])",
|
|
410
|
+
"input[type=tel]",
|
|
411
|
+
"textarea",
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
# The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
|
|
415
|
+
# these vendors the piece is inert decoration that the handle carries, so a
|
|
416
|
+
# drag starting on the piece moves nothing at all.
|
|
417
|
+
SLIDER_HANDLE_SELECTORS = [
|
|
418
|
+
# GeeTest v3 / v4
|
|
419
|
+
".geetest_slider_button",
|
|
420
|
+
".geetest_btn",
|
|
421
|
+
".geetest_slider .geetest_arrow",
|
|
422
|
+
# Tencent
|
|
423
|
+
"#tcaptcha_drag_thumb",
|
|
424
|
+
".tc-slider-normal",
|
|
425
|
+
"[id*=slideBlock]",
|
|
426
|
+
# Yidun (NetEase)
|
|
427
|
+
".yidun_slider",
|
|
428
|
+
".yidun_jigsaw",
|
|
429
|
+
# Lemin
|
|
430
|
+
".lemin-slider-handle",
|
|
431
|
+
"#lemin-cropped-captcha .slider",
|
|
432
|
+
# Generic — an ARIA slider, or a class that says handle/thumb/button on a
|
|
433
|
+
# track. `[draggable=true]` is deliberately absent: it is the HTML5
|
|
434
|
+
# drag-and-drop opt-in, which fires dragstart rather than pointermove, and
|
|
435
|
+
# no slider captcha uses it.
|
|
436
|
+
'[role="slider"]',
|
|
437
|
+
"[aria-valuenow]",
|
|
438
|
+
'[class*="slider"][class*="btn"]',
|
|
439
|
+
'[class*="slider"][class*="button"]',
|
|
440
|
+
'[class*="slide"][class*="handle"]',
|
|
441
|
+
'[class*="drag"][class*="thumb"]',
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
# Fallback for the sliderless members of the family. Lemin's "cropped" puzzle
|
|
445
|
+
# has no track at all — you drag the piece itself onto the gap — and the model
|
|
446
|
+
# answers it with the same sourceless drag, because from the picture the two are
|
|
447
|
+
# indistinguishable. Tried only after SLIDER_HANDLE_SELECTORS finds nothing.
|
|
448
|
+
DRAGGABLE_PIECE_SELECTORS = [
|
|
449
|
+
".lemin-cropped-puzzle-piece",
|
|
450
|
+
"#lemin-cropped-captcha canvas + canvas",
|
|
451
|
+
'[class*="puzzle"][class*="piece"]',
|
|
452
|
+
'[class*="jigsaw"]',
|
|
453
|
+
]
|
|
454
|
+
|
|
185
455
|
|
|
186
456
|
class PageSolver:
|
|
187
457
|
"""
|
|
@@ -208,12 +478,93 @@ class PageSolver:
|
|
|
208
478
|
# accumulates token usage and holds the HTTP session to vLLM.
|
|
209
479
|
self._solver = solver or CaptchaSolver(**solver_kwargs)
|
|
210
480
|
self._last_mouse: Tuple[float, float] = (0.0, 0.0)
|
|
481
|
+
# See _seed_cursor: the (0, 0) origin wedges camoufox's humanised
|
|
482
|
+
# mouse, so the first move of each solve must step off it plainly.
|
|
483
|
+
self._cursor_seeded = False
|
|
211
484
|
self._last_submit_frame_hash: Optional[str] = None
|
|
212
485
|
# Absolute deadline for the current solve, in the same clock as
|
|
213
486
|
# time.monotonic() * 1000. None outside a solve.
|
|
214
487
|
self._deadline_ms: Optional[float] = None
|
|
215
488
|
# Window size for clamping, resolved once per solve. See _viewport.
|
|
216
489
|
self._viewport_cache: Optional[Dict[str, float]] = None
|
|
490
|
+
# Animation detection, all reset per solve by `_reset_animated_state`.
|
|
491
|
+
# `_known_animated` latches: once a widget has been SEEN to animate,
|
|
492
|
+
# every later round takes the keyframe path without re-probing.
|
|
493
|
+
self._known_animated = False
|
|
494
|
+
self._animated_probe_armed = False
|
|
495
|
+
self._animated_probe_done = False
|
|
496
|
+
self._keyframe_mode: Optional[str] = None
|
|
497
|
+
# Repeat detection; see `max_no_progress_rounds`.
|
|
498
|
+
self._last_answer_sig: Optional[str] = None
|
|
499
|
+
self._no_progress_rounds = 0
|
|
500
|
+
# Per-solve phase accounting; see `_phase`.
|
|
501
|
+
self._budget: Optional[PhaseBudget] = None
|
|
502
|
+
|
|
503
|
+
@contextmanager
|
|
504
|
+
def _phase(self, name: str):
|
|
505
|
+
"""Attribute the enclosed wall-clock to `name` for this solve's budget.
|
|
506
|
+
|
|
507
|
+
A no-op outside a solve, so helpers stay callable from tests and from
|
|
508
|
+
the CLI without a budget having been opened.
|
|
509
|
+
"""
|
|
510
|
+
if self._budget is None:
|
|
511
|
+
yield
|
|
512
|
+
return
|
|
513
|
+
with self._budget.phase(name):
|
|
514
|
+
yield
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _answer_signature(actions: Sequence[Any], retry_mode: Optional[str]) -> Optional[str]:
|
|
518
|
+
"""A stable identity for "the answer this round is about to execute".
|
|
519
|
+
|
|
520
|
+
Keyed on the retry mode too: the missed-tiles retry deliberately re-asks
|
|
521
|
+
about the same board and its answer legitimately overlaps the previous
|
|
522
|
+
one, so counting that as a repeat would abandon the one path built to
|
|
523
|
+
recover from an under-selection.
|
|
524
|
+
|
|
525
|
+
Returns None when the actions cannot be summarised, which is treated as
|
|
526
|
+
"not a repeat" — an unreadable answer must never be the reason a solve
|
|
527
|
+
is abandoned.
|
|
528
|
+
"""
|
|
529
|
+
try:
|
|
530
|
+
parts = []
|
|
531
|
+
for raw in actions:
|
|
532
|
+
a = _as_dict(raw)
|
|
533
|
+
parts.append((
|
|
534
|
+
a.get("action"),
|
|
535
|
+
_round_pts(a.get("target_bounding_boxes")),
|
|
536
|
+
_round_pts(a.get("target_bounding_box")),
|
|
537
|
+
_round_pts(a.get("target_coordinates")),
|
|
538
|
+
_round_pts(a.get("source_bounding_box")),
|
|
539
|
+
a.get("text"),
|
|
540
|
+
))
|
|
541
|
+
return repr((retry_mode, parts))
|
|
542
|
+
except Exception: # noqa: BLE001 — a signature is an optimisation, not a contract
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[str]) -> None:
|
|
546
|
+
"""Count consecutive identical answers; see `max_no_progress_rounds`."""
|
|
547
|
+
sig = self._answer_signature(actions, retry_mode)
|
|
548
|
+
if sig is not None and sig == self._last_answer_sig:
|
|
549
|
+
self._no_progress_rounds += 1
|
|
550
|
+
_log(f"[no-progress] the model returned the same answer again "
|
|
551
|
+
f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds}) — "
|
|
552
|
+
f"the previous one already ran and changed nothing")
|
|
553
|
+
# A board that reads the same every round is the signature of a
|
|
554
|
+
# CYCLING challenge answered as a still. Give the recording path a
|
|
555
|
+
# chance before giving up on the solve entirely.
|
|
556
|
+
self._arm_animated_probe()
|
|
557
|
+
else:
|
|
558
|
+
self._no_progress_rounds = 0
|
|
559
|
+
self._last_answer_sig = sig
|
|
560
|
+
|
|
561
|
+
def _reset_animated_state(self) -> None:
|
|
562
|
+
self._known_animated = False
|
|
563
|
+
self._animated_probe_armed = False
|
|
564
|
+
self._animated_probe_done = False
|
|
565
|
+
self._keyframe_mode = None
|
|
566
|
+
self._last_answer_sig: Optional[str] = None
|
|
567
|
+
self._no_progress_rounds = 0
|
|
217
568
|
|
|
218
569
|
def _check_deadline(self, where: str) -> None:
|
|
219
570
|
"""
|
|
@@ -283,7 +634,8 @@ class PageSolver:
|
|
|
283
634
|
return None
|
|
284
635
|
|
|
285
636
|
def _get_solution(
|
|
286
|
-
self, image_path: str, puzzle_source: str, retry_mode: Optional[str]
|
|
637
|
+
self, image_path: str, puzzle_source: str, retry_mode: Optional[str],
|
|
638
|
+
text_mode: bool = False,
|
|
287
639
|
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
288
640
|
"""
|
|
289
641
|
The model query. In-process where TS spawns the CLI.
|
|
@@ -295,13 +647,32 @@ class PageSolver:
|
|
|
295
647
|
planner = self._solver.planner
|
|
296
648
|
before = len(planner.token_usage)
|
|
297
649
|
actions = self._solver.solve(
|
|
298
|
-
image_path, puzzle_source=puzzle_source, retry_mode=retry_mode
|
|
650
|
+
image_path, puzzle_source=puzzle_source, retry_mode=retry_mode,
|
|
651
|
+
text_mode=text_mode,
|
|
299
652
|
)
|
|
300
653
|
usage = [dict(u) for u in planner.token_usage[before:]]
|
|
301
654
|
if not isinstance(actions, list):
|
|
302
655
|
actions = [actions]
|
|
303
656
|
return actions, usage
|
|
304
657
|
|
|
658
|
+
def _get_keyframe_solution(
|
|
659
|
+
self, keyframe_paths: Sequence[str]
|
|
660
|
+
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]]]:
|
|
661
|
+
"""The model query for an animated challenge. Usage read by DELTA, as above.
|
|
662
|
+
|
|
663
|
+
One request for the whole keyframe set, not one per frame: the model has to
|
|
664
|
+
compare the frames to find what differs between them, which it can only do
|
|
665
|
+
with all of them in a single context. Per-frame queries would also cost N
|
|
666
|
+
billable rounds for one puzzle.
|
|
667
|
+
"""
|
|
668
|
+
planner = self._solver.planner
|
|
669
|
+
before = len(planner.token_usage)
|
|
670
|
+
actions = self._solver.solve_keyframes(keyframe_paths)
|
|
671
|
+
usage = [dict(u) for u in planner.token_usage[before:]]
|
|
672
|
+
if not isinstance(actions, list):
|
|
673
|
+
actions = [actions]
|
|
674
|
+
return actions, usage
|
|
675
|
+
|
|
305
676
|
# ------------------------------------------------------------------
|
|
306
677
|
# Mouse
|
|
307
678
|
# ------------------------------------------------------------------
|
|
@@ -388,9 +759,50 @@ class PageSolver:
|
|
|
388
759
|
# Any other per-sample failure is skipped rather than fatal —
|
|
389
760
|
# losing one mousemove must not lose the solve.
|
|
390
761
|
|
|
762
|
+
def _seed_cursor(self, page: Any) -> None:
|
|
763
|
+
"""Move the cursor off its (0, 0) origin once per solve, in ONE plain
|
|
764
|
+
move, before any humanised trajectory runs.
|
|
765
|
+
|
|
766
|
+
Without this, the first trajectory of a solve begins at (0, 0) — the
|
|
767
|
+
exact window corner, because that is where the pointer starts and
|
|
768
|
+
nothing has moved it. Under camoufox's `humanize` juggler a short move
|
|
769
|
+
from that origin never returns: `page.mouse.move()` blocks forever at 0%
|
|
770
|
+
CPU, and since dispatch is serialised on a process-global activation
|
|
771
|
+
chain, every later input event blocks behind it too. The solve looks
|
|
772
|
+
hung with no error anywhere and the Tier 3 fixture run times out on all
|
|
773
|
+
three attempts.
|
|
774
|
+
|
|
775
|
+
Reduced to four lines against camoufox directly:
|
|
776
|
+
|
|
777
|
+
with Camoufox(headless=True, humanize=True) as b:
|
|
778
|
+
p = b.new_page(); p.goto("about:blank")
|
|
779
|
+
p.mouse.move(1.0, 1.0) # never returns
|
|
780
|
+
|
|
781
|
+
The same move after ANY interior move completes in ~1.1s, which is what
|
|
782
|
+
makes this the fix rather than a workaround: it is the ORIGIN that is
|
|
783
|
+
poisoned, not the destination. Same failure family as camoufox #225.
|
|
784
|
+
|
|
785
|
+
Deliberately not routed through `_smooth_move`: that would generate a
|
|
786
|
+
trajectory from (0, 0) and reintroduce exactly the move being avoided.
|
|
787
|
+
"""
|
|
788
|
+
if self._cursor_seeded:
|
|
789
|
+
return
|
|
790
|
+
self._cursor_seeded = True
|
|
791
|
+
vp = self._viewport(page)
|
|
792
|
+
# Centre when the window is known, else a modest interior point — any
|
|
793
|
+
# coordinate comfortably off the corner will do.
|
|
794
|
+
cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
|
|
795
|
+
try:
|
|
796
|
+
page.mouse.move(cx, cy)
|
|
797
|
+
self._last_mouse = (cx, cy)
|
|
798
|
+
except Exception: # noqa: BLE001 — an adapter without a mouse must not fail the solve
|
|
799
|
+
pass
|
|
800
|
+
|
|
391
801
|
def _smooth_move(self, page: Any, x: float, y: float) -> None:
|
|
802
|
+
self._seed_cursor(page)
|
|
392
803
|
points, timings = generate_trajectory(self._last_mouse, (x, y), 60)
|
|
393
|
-
self.
|
|
804
|
+
with self._phase("mouse"):
|
|
805
|
+
self._trace_path(page, points, timings)
|
|
394
806
|
|
|
395
807
|
def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
|
|
396
808
|
# BOUNDED. Playwright's default timeout is 30s, and this is called once
|
|
@@ -472,6 +884,256 @@ class PageSolver:
|
|
|
472
884
|
_delay(random.random() * 50 + 50)
|
|
473
885
|
page.mouse.up()
|
|
474
886
|
|
|
887
|
+
# ------------------------------------------------------------------
|
|
888
|
+
# Typing and sliding
|
|
889
|
+
# ------------------------------------------------------------------
|
|
890
|
+
|
|
891
|
+
def _find_control(self, scope: Any, selectors: Sequence[str]) -> Optional[Any]:
|
|
892
|
+
"""First VISIBLE match for `selectors`, tried in order.
|
|
893
|
+
|
|
894
|
+
`scope` is the challenge frame, or — for the vendors that render into
|
|
895
|
+
the host page rather than an iframe — the widget element itself. Never
|
|
896
|
+
the page: the generic tail of both selector tables would otherwise
|
|
897
|
+
happily match a login form's text box or a carousel's drag handle
|
|
898
|
+
somewhere else on the document, and the answer would go there.
|
|
899
|
+
"""
|
|
900
|
+
for selector in selectors:
|
|
901
|
+
try:
|
|
902
|
+
element = scope.query_selector(selector)
|
|
903
|
+
except Exception:
|
|
904
|
+
continue # a selector this adapter can't parse must not end the search
|
|
905
|
+
if self._visible(element):
|
|
906
|
+
return element
|
|
907
|
+
return None
|
|
908
|
+
|
|
909
|
+
def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any]) -> bool:
|
|
910
|
+
"""Put the model's reading of a distorted-text captcha into its box."""
|
|
911
|
+
text = str(action.get("text") or "")
|
|
912
|
+
if not text:
|
|
913
|
+
return False
|
|
914
|
+
field = self._find_control(scope, TEXT_INPUT_SELECTORS)
|
|
915
|
+
if field is None:
|
|
916
|
+
_log("type action, but no text box in the widget; skipping")
|
|
917
|
+
return False
|
|
918
|
+
|
|
919
|
+
self._move_and_click(page, field) # travel there, then press to focus
|
|
920
|
+
# A retry round arrives with the previous attempt still in the box, and
|
|
921
|
+
# typing would APPEND to it — submitting a string the model never read.
|
|
922
|
+
try:
|
|
923
|
+
page.keyboard.press("Control+A")
|
|
924
|
+
except Exception:
|
|
925
|
+
pass
|
|
926
|
+
# Per character rather than one `type(text, delay=…)` call: a constant
|
|
927
|
+
# inter-key delay is itself a signal, and these are the vendors that
|
|
928
|
+
# score typing cadence.
|
|
929
|
+
for ch in text:
|
|
930
|
+
try:
|
|
931
|
+
page.keyboard.type(ch)
|
|
932
|
+
except Exception as exc:
|
|
933
|
+
_log(f"could not type into the captcha field: {exc}")
|
|
934
|
+
return False
|
|
935
|
+
_delay(random.random() * 90 + 45)
|
|
936
|
+
_log(f"typed {len(text)} character(s) into the captcha field")
|
|
937
|
+
return True
|
|
938
|
+
|
|
939
|
+
def _track_piece(
|
|
940
|
+
self, element: Any, before: str, after: str, exclude: Sequence[float]
|
|
941
|
+
) -> Optional[Sequence[int]]:
|
|
942
|
+
"""`captchakraken track-piece` — box of what moved, handle masked out."""
|
|
943
|
+
from .tool_calls.track_piece import changed_bbox
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
947
|
+
return changed_bbox(before, after, exclude)
|
|
948
|
+
except Exception as exc:
|
|
949
|
+
_debug(f"track_piece failed: {exc}")
|
|
950
|
+
return None
|
|
951
|
+
|
|
952
|
+
def _execute_slide(
|
|
953
|
+
self,
|
|
954
|
+
page: Any,
|
|
955
|
+
element: Any,
|
|
956
|
+
scope: Any,
|
|
957
|
+
action: Dict[str, Any],
|
|
958
|
+
element_box: Dict[str, float],
|
|
959
|
+
) -> bool:
|
|
960
|
+
"""Drive a puzzle-piece slider until the PIECE reaches the model's slot.
|
|
961
|
+
|
|
962
|
+
The model is asked for one thing here — the centre of the gap — because
|
|
963
|
+
it is the only thing the picture can tell it. What it cannot know is how
|
|
964
|
+
far the handle must travel to put the piece there: the handle is
|
|
965
|
+
elsewhere on the widget, and the ratio between the two is a vendor
|
|
966
|
+
implementation detail that several of them deliberately vary.
|
|
967
|
+
|
|
968
|
+
So this is closed-loop, not a calculation. Press the handle, nudge it
|
|
969
|
+
twice by known amounts, and watch the screen:
|
|
970
|
+
|
|
971
|
+
union(before, after) spans the piece's ORIGINAL left edge to its
|
|
972
|
+
CURRENT right edge, so its width is piece_width + ratio × nudge.
|
|
973
|
+
|
|
974
|
+
Two nudges, two widths, two unknowns — solve for both, then steer the
|
|
975
|
+
remaining distance and re-measure. The mouse is not released until the
|
|
976
|
+
piece is home, because on every one of these puzzles releasing IS the
|
|
977
|
+
submit; there is no Verify button to reconsider at.
|
|
978
|
+
|
|
979
|
+
Returns False if there is nothing here to drag, leaving the caller's
|
|
980
|
+
normal no-op handling to deal with it.
|
|
981
|
+
"""
|
|
982
|
+
target_x = (
|
|
983
|
+
(float(action["target_bounding_box"][0]) + float(action["target_bounding_box"][2])) / 2
|
|
984
|
+
) * element_box["width"]
|
|
985
|
+
|
|
986
|
+
handle = self._find_control(scope, SLIDER_HANDLE_SELECTORS)
|
|
987
|
+
if handle is None:
|
|
988
|
+
# No track — the sliderless members of the family (Lemin's
|
|
989
|
+
# "cropped") want the piece dragged directly. Same answer from the
|
|
990
|
+
# model, because the two look identical; different gesture. Nothing
|
|
991
|
+
# to close a loop on, since the piece is under the cursor and moves
|
|
992
|
+
# with it one for one.
|
|
993
|
+
piece = self._find_control(scope, DRAGGABLE_PIECE_SELECTORS)
|
|
994
|
+
if piece is None:
|
|
995
|
+
_log("slide action, but the widget has neither a slider nor a draggable piece")
|
|
996
|
+
return False
|
|
997
|
+
box = piece.bounding_box()
|
|
998
|
+
if not box:
|
|
999
|
+
return False
|
|
1000
|
+
# BOTH axes. The rail members travel horizontally and nothing else,
|
|
1001
|
+
# so the handle's own y is the only y there is — but a free drag
|
|
1002
|
+
# carries the piece across the card, and holding the piece's row
|
|
1003
|
+
# here slid it along the TRAY and released it there, 250 px below
|
|
1004
|
+
# the slot, on every attempt.
|
|
1005
|
+
target_y = (
|
|
1006
|
+
(float(action["target_bounding_box"][1])
|
|
1007
|
+
+ float(action["target_bounding_box"][3])) / 2
|
|
1008
|
+
) * element_box["height"]
|
|
1009
|
+
_log("no slider track; dragging the piece to the slot directly")
|
|
1010
|
+
self._smooth_move(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
|
|
1011
|
+
page.mouse.down()
|
|
1012
|
+
_delay(random.random() * 50 + 50)
|
|
1013
|
+
self._smooth_move(page, element_box["x"] + target_x, element_box["y"] + target_y)
|
|
1014
|
+
_delay(random.random() * 50 + 50)
|
|
1015
|
+
page.mouse.up()
|
|
1016
|
+
return True
|
|
1017
|
+
|
|
1018
|
+
hbox = handle.bounding_box()
|
|
1019
|
+
if not hbox:
|
|
1020
|
+
return False
|
|
1021
|
+
start_x = hbox["x"] + hbox["width"] / 2
|
|
1022
|
+
hold_y = hbox["y"] + hbox["height"] / 2
|
|
1023
|
+
|
|
1024
|
+
# Mask the whole horizontal BAND the handle runs in, not just where it
|
|
1025
|
+
# is now: it is about to move across that band, and most vendors fill
|
|
1026
|
+
# the track behind it as it goes. Either would otherwise be the largest
|
|
1027
|
+
# moving thing in frame, and we would track the handle instead of the
|
|
1028
|
+
# piece.
|
|
1029
|
+
pad = max(4.0, hbox["height"] * 0.35)
|
|
1030
|
+
exclude = [
|
|
1031
|
+
0.0,
|
|
1032
|
+
hbox["y"] - element_box["y"] - pad,
|
|
1033
|
+
element_box["width"],
|
|
1034
|
+
hbox["y"] + hbox["height"] - element_box["y"] + pad,
|
|
1035
|
+
]
|
|
1036
|
+
|
|
1037
|
+
shots = [_tmp_png("slide") for _ in range(4)]
|
|
1038
|
+
try:
|
|
1039
|
+
self._move_to_element(page, handle, padding_percentage=30.0)
|
|
1040
|
+
page.mouse.down()
|
|
1041
|
+
_delay(random.random() * 60 + 60)
|
|
1042
|
+
self._screenshot(element, shots[0],
|
|
1043
|
+
timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1044
|
+
|
|
1045
|
+
probes = self.config.slide_probe_offsets_px
|
|
1046
|
+
widths: List[Tuple[float, float]] = []
|
|
1047
|
+
last_box = None
|
|
1048
|
+
for offset, shot in zip(probes, shots[1:]):
|
|
1049
|
+
self._smooth_move(page, start_x + offset, hold_y)
|
|
1050
|
+
_delay(random.random() * 40 + 40)
|
|
1051
|
+
box = self._track_piece(element, shots[0], shot, exclude)
|
|
1052
|
+
if box is not None:
|
|
1053
|
+
widths.append((float(offset), float(box[2] - box[0])))
|
|
1054
|
+
last_box = box
|
|
1055
|
+
|
|
1056
|
+
piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
|
|
1057
|
+
if last_box is None or piece_w is None:
|
|
1058
|
+
# Never saw the piece — a canvas the screenshot cannot separate,
|
|
1059
|
+
# a widget that redraws wholesale, or a press the handle refused.
|
|
1060
|
+
# Fall back on the geometry every one of these puzzles shares:
|
|
1061
|
+
# piece and handle both start flush left, so the handle's travel
|
|
1062
|
+
# is the piece's travel.
|
|
1063
|
+
_log("slider: piece never resolved on screen; steering by handle travel alone")
|
|
1064
|
+
self._smooth_move(page, start_x + (target_x - (start_x - element_box["x"])), hold_y)
|
|
1065
|
+
else:
|
|
1066
|
+
# The offset `last_box` was MEASURED at — not `probes[-1]`, and
|
|
1067
|
+
# not indexed by how many measurements succeeded. If the first
|
|
1068
|
+
# probe failed to resolve and the second worked, those two
|
|
1069
|
+
# disagree, and steering from a base the reading does not belong
|
|
1070
|
+
# to sends the piece somewhere neither the model nor the screen
|
|
1071
|
+
# asked for.
|
|
1072
|
+
offset = float(widths[-1][0])
|
|
1073
|
+
for _ in range(self.config.slide_max_corrections):
|
|
1074
|
+
piece_center = (last_box[2] - piece_w / 2.0)
|
|
1075
|
+
error = target_x - piece_center
|
|
1076
|
+
if abs(error) <= self.config.slide_tolerance_px:
|
|
1077
|
+
break
|
|
1078
|
+
offset += error / ratio
|
|
1079
|
+
self._smooth_move(page, start_x + offset, hold_y)
|
|
1080
|
+
_delay(random.random() * 40 + 40)
|
|
1081
|
+
box = self._track_piece(element, shots[0], shots[3], exclude)
|
|
1082
|
+
if box is None:
|
|
1083
|
+
break # ran out of track; release where we are
|
|
1084
|
+
last_box = box
|
|
1085
|
+
_debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} "
|
|
1086
|
+
f"final_center={last_box[2] - piece_w / 2.0:.1f} target={target_x:.1f}")
|
|
1087
|
+
|
|
1088
|
+
# Settle before letting go. A release in the same tick as the last
|
|
1089
|
+
# move reads as a machine, and some vendors sample the final
|
|
1090
|
+
# milliseconds of the gesture.
|
|
1091
|
+
_delay(random.random() * 120 + 90)
|
|
1092
|
+
finally:
|
|
1093
|
+
try:
|
|
1094
|
+
page.mouse.up()
|
|
1095
|
+
except Exception:
|
|
1096
|
+
pass
|
|
1097
|
+
for shot in shots:
|
|
1098
|
+
_unlink(shot)
|
|
1099
|
+
return True
|
|
1100
|
+
|
|
1101
|
+
@staticmethod
|
|
1102
|
+
def _solve_slide_geometry(
|
|
1103
|
+
widths: Sequence[Tuple[float, float]], widget_width: float
|
|
1104
|
+
) -> Tuple[Optional[float], float]:
|
|
1105
|
+
"""Piece width and handle-to-piece travel ratio, from probe measurements.
|
|
1106
|
+
|
|
1107
|
+
Each measurement is (handle offset, width of what changed), and
|
|
1108
|
+
width = piece_width + ratio × offset. Two of them determine both.
|
|
1109
|
+
|
|
1110
|
+
With only one usable measurement the system is underdetermined, so ratio
|
|
1111
|
+
is ASSUMED to be 1 — true of every vendor observed, and the assumption
|
|
1112
|
+
is stated here rather than buried as a default. A ratio solved from
|
|
1113
|
+
implausible measurements (a redraw, a piece that hit the wall between
|
|
1114
|
+
probes) is rejected the same way: better a 1:1 guess that overshoots and
|
|
1115
|
+
gets corrected than a ratio of 0.02 that sends the handle off the track.
|
|
1116
|
+
"""
|
|
1117
|
+
if not widths:
|
|
1118
|
+
return None, 1.0
|
|
1119
|
+
piece_w: Optional[float] = None
|
|
1120
|
+
ratio = 1.0
|
|
1121
|
+
if len(widths) >= 2:
|
|
1122
|
+
(o1, w1), (o2, w2) = widths[0], widths[-1]
|
|
1123
|
+
if o2 != o1:
|
|
1124
|
+
candidate = (w2 - w1) / (o2 - o1)
|
|
1125
|
+
if 0.2 <= candidate <= 3.0:
|
|
1126
|
+
ratio = candidate
|
|
1127
|
+
piece_w = w1 - ratio * o1
|
|
1128
|
+
if piece_w is None:
|
|
1129
|
+
o, w = widths[-1]
|
|
1130
|
+
piece_w = w - ratio * o
|
|
1131
|
+
# A piece narrower than a few pixels, or wider than half the widget, is
|
|
1132
|
+
# a measurement of something else.
|
|
1133
|
+
if not 3.0 <= piece_w <= widget_width * 0.6:
|
|
1134
|
+
return None, ratio
|
|
1135
|
+
return piece_w, ratio
|
|
1136
|
+
|
|
475
1137
|
# ------------------------------------------------------------------
|
|
476
1138
|
# Detection
|
|
477
1139
|
# ------------------------------------------------------------------
|
|
@@ -580,6 +1242,14 @@ class PageSolver:
|
|
|
580
1242
|
):
|
|
581
1243
|
return turnstile_container
|
|
582
1244
|
|
|
1245
|
+
# Vendors with one interactive surface (no checkbox/challenge split) —
|
|
1246
|
+
# GeeTest, Tencent, Yidun, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect.
|
|
1247
|
+
for entry in VENDOR_WIDGET_LOCATORS:
|
|
1248
|
+
for selector in entry["selectors"]:
|
|
1249
|
+
el = page.query_selector(selector)
|
|
1250
|
+
if self._visible(el):
|
|
1251
|
+
return el
|
|
1252
|
+
|
|
583
1253
|
return None
|
|
584
1254
|
|
|
585
1255
|
def is_captcha_solved(self, page: Any) -> bool:
|
|
@@ -591,18 +1261,35 @@ class PageSolver:
|
|
|
591
1261
|
a fresh puzzle burns ~18s re-running the pipeline on a closing frame.
|
|
592
1262
|
"""
|
|
593
1263
|
try:
|
|
1264
|
+
# The token FIRST, and unconditionally. It is a hidden field on the
|
|
1265
|
+
# PAGE, not inside the widget, so it never needed the anchor iframe
|
|
1266
|
+
# to be on screen — and the moment it matters most is precisely when
|
|
1267
|
+
# the anchor is NOT on screen, because hCaptcha keeps its challenge
|
|
1268
|
+
# overlay up for a couple of seconds after the winning submit.
|
|
1269
|
+
# Gating this on `_visible(anchor)` meant the one signal that was
|
|
1270
|
+
# already true went unread, and the loop ground on against a frame
|
|
1271
|
+
# being torn down. See tests/test_solved_detection.py.
|
|
1272
|
+
if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
|
|
1273
|
+
return True
|
|
1274
|
+
if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
|
|
1275
|
+
return True
|
|
1276
|
+
# Turnstile. `detect_captcha` already reads this exact field to
|
|
1277
|
+
# decide a Turnstile widget is UNSOLVED, so the signal was known to
|
|
1278
|
+
# one half of the driver and ignored by the other: a solved
|
|
1279
|
+
# Turnstile reported unsolved here and the loop ground through the
|
|
1280
|
+
# readiness waits and a wasted inference until the widget happened
|
|
1281
|
+
# to disappear. Same defect the hCaptcha token had.
|
|
1282
|
+
if self._has_non_empty_field_value(page, '[name="cf-turnstile-response"]'):
|
|
1283
|
+
return True
|
|
1284
|
+
|
|
1285
|
+
# Anchor state is the fallback, and this one DOES need the iframe:
|
|
1286
|
+
# it is read out of the anchor's own document.
|
|
594
1287
|
hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=checkbox"]')
|
|
595
|
-
if self._visible(hc):
|
|
596
|
-
|
|
597
|
-
return True
|
|
598
|
-
if self._is_hcaptcha_anchor_checked(hc):
|
|
599
|
-
return True
|
|
1288
|
+
if self._visible(hc) and self._is_hcaptcha_anchor_checked(hc):
|
|
1289
|
+
return True
|
|
600
1290
|
rc = page.query_selector('iframe[src*="recaptcha/api2/anchor"]')
|
|
601
|
-
if self._visible(rc):
|
|
602
|
-
|
|
603
|
-
return True
|
|
604
|
-
if self._is_recaptcha_anchor_checked(rc):
|
|
605
|
-
return True
|
|
1291
|
+
if self._visible(rc) and self._is_recaptcha_anchor_checked(rc):
|
|
1292
|
+
return True
|
|
606
1293
|
except Exception:
|
|
607
1294
|
pass
|
|
608
1295
|
return False
|
|
@@ -616,6 +1303,18 @@ class PageSolver:
|
|
|
616
1303
|
try:
|
|
617
1304
|
hc = page.query_selector('iframe[src*="hcaptcha"][src*="frame=challenge"]')
|
|
618
1305
|
if self._visible(hc):
|
|
1306
|
+
# "Prompt painted" alone does NOT mean a next round. hCaptcha
|
|
1307
|
+
# leaves the round you just answered on screen while it
|
|
1308
|
+
# verifies, so this fired on the CLOSING frame and broke the
|
|
1309
|
+
# post-submit poll out of its solved-check after ~0ms — which
|
|
1310
|
+
# then committed the solver to ~21s of readiness waits and a
|
|
1311
|
+
# full inference against a frame that was about to be destroyed.
|
|
1312
|
+
# The frame must have actually CHANGED since the submit; we
|
|
1313
|
+
# already snapshot exactly that at submit time.
|
|
1314
|
+
if self._last_submit_frame_hash:
|
|
1315
|
+
current = self._element_frame_hash(hc)
|
|
1316
|
+
if current and current == self._last_submit_frame_hash:
|
|
1317
|
+
return False
|
|
619
1318
|
frame = hc.content_frame()
|
|
620
1319
|
prompt = frame.query_selector(".prompt-text") if frame else None
|
|
621
1320
|
if self._visible(prompt):
|
|
@@ -672,13 +1371,20 @@ class PageSolver:
|
|
|
672
1371
|
return False
|
|
673
1372
|
|
|
674
1373
|
def _get_verify_button(self, frame: Any) -> Optional[Any]:
|
|
1374
|
+
# `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
|
|
1375
|
+
# markup on the host page rather than a vendor iframe (every
|
|
1376
|
+
# distorted-text captcha), and a document-rooted `//button` does not
|
|
1377
|
+
# resolve against an element handle: the query returned None even with
|
|
1378
|
+
# the button sitting inside that very element, so a typed code was never
|
|
1379
|
+
# submitted. On a Frame the context node is the document, where `.//` and
|
|
1380
|
+
# `//` mean the same thing, so the vendor paths are unaffected.
|
|
675
1381
|
for text in ("Verify", "Next", "Submit", "Skip"):
|
|
676
1382
|
lowered = text.lower()
|
|
677
1383
|
try:
|
|
678
1384
|
button = frame.query_selector(
|
|
679
|
-
f"xpath
|
|
1385
|
+
f"xpath=.//button[contains(translate(., "
|
|
680
1386
|
f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
|
|
681
|
-
f" |
|
|
1387
|
+
f" | .//div[@role='button' and contains(translate(., "
|
|
682
1388
|
f"'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{lowered}')]"
|
|
683
1389
|
)
|
|
684
1390
|
if self._visible(button):
|
|
@@ -692,6 +1398,26 @@ class PageSolver:
|
|
|
692
1398
|
hcaptcha_verify = frame.query_selector(".button-submit")
|
|
693
1399
|
if self._visible(hcaptcha_verify):
|
|
694
1400
|
return hcaptcha_verify
|
|
1401
|
+
# GeeTest: `<div class="geetest_submit geetest_disable">OK</div>`.
|
|
1402
|
+
#
|
|
1403
|
+
# Invisible to BOTH shapes above — a bare div carries no
|
|
1404
|
+
# role="button", and "OK" is on none of the four word lists. So
|
|
1405
|
+
# nothing was ever pressed on the puzzles that need pressing, and a
|
|
1406
|
+
# GeeTest board does not grade until you do: the solve loop re-read
|
|
1407
|
+
# the same unchanged panel and re-answered it identically until the
|
|
1408
|
+
# round cap. Ordered icon-click scored 0/31 and 0/13 that way while
|
|
1409
|
+
# the model was answering CORRECTLY (measured 2026-08-19). A driver
|
|
1410
|
+
# that discards a right answer is indistinguishable from a model
|
|
1411
|
+
# that cannot solve the puzzle, which is how this hid.
|
|
1412
|
+
#
|
|
1413
|
+
# Matched by CLASS, not by the word: `geetest_submit_tips` sits
|
|
1414
|
+
# beside it and also reads "OK", and pressing the tooltip does
|
|
1415
|
+
# nothing. `.geetest_submit` is a distinct token, so it cannot
|
|
1416
|
+
# match it. Mirrors getVerifyButton in js/src/solver.ts — CLAUDE.md
|
|
1417
|
+
# 1c, the two ports must behave the same.
|
|
1418
|
+
geetest_submit = frame.query_selector(".geetest_submit")
|
|
1419
|
+
if self._visible(geetest_submit):
|
|
1420
|
+
return geetest_submit
|
|
695
1421
|
except Exception:
|
|
696
1422
|
pass
|
|
697
1423
|
return None
|
|
@@ -700,18 +1426,30 @@ class PageSolver:
|
|
|
700
1426
|
# Frame readiness
|
|
701
1427
|
# ------------------------------------------------------------------
|
|
702
1428
|
|
|
703
|
-
def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None
|
|
1429
|
+
def _screenshot(self, element: Any, path: str, timeout_ms: Optional[int] = None,
|
|
1430
|
+
animations: str = "disabled") -> None:
|
|
704
1431
|
"""
|
|
705
|
-
Short timeout
|
|
1432
|
+
Short timeout, and animations disabled unless the caller needs the motion.
|
|
706
1433
|
|
|
707
1434
|
Playwright's default 30s stability wait hangs per-screenshot on a
|
|
708
1435
|
closing/animating challenge element; that alone made a multi-round solve
|
|
709
1436
|
take ~115s. Failing fast and skipping a frame is strictly better.
|
|
1437
|
+
|
|
1438
|
+
`animations` is 'disabled' everywhere the goal is a STABLE STILL, which
|
|
1439
|
+
is almost everywhere. It is not a formality: the flag fast-forwards
|
|
1440
|
+
finite animations and FREEZES infinite ones, which is exactly what a
|
|
1441
|
+
model-facing screenshot wants and exactly what a recording must not do.
|
|
1442
|
+
The burst and the animation probe pass 'allow' — see `solver.ts`
|
|
1443
|
+
`recordKeyframeBurst`, which learned this first: GeeTest's svg board
|
|
1444
|
+
cycles in CSS, so a burst taken with animations disabled captured the
|
|
1445
|
+
same picture forty times, the slicer honestly reported `mode=static`,
|
|
1446
|
+
and the solve went back to answering a single still. hCaptcha hid it,
|
|
1447
|
+
because it animates in canvas and the flag does not touch canvas.
|
|
710
1448
|
"""
|
|
711
1449
|
element.screenshot(
|
|
712
1450
|
path=path,
|
|
713
1451
|
timeout=timeout_ms if timeout_ms is not None else 2_500,
|
|
714
|
-
animations=
|
|
1452
|
+
animations=animations,
|
|
715
1453
|
)
|
|
716
1454
|
|
|
717
1455
|
def _element_frame_hash(self, element: Any) -> Optional[str]:
|
|
@@ -737,8 +1475,11 @@ class PageSolver:
|
|
|
737
1475
|
cfg = self.config
|
|
738
1476
|
start = time.monotonic() * 1000.0
|
|
739
1477
|
previous: Optional[str] = None
|
|
740
|
-
still_streak = 0
|
|
741
1478
|
frames: List[str] = []
|
|
1479
|
+
# This loop only COLLECTS polls; the verdict is `settle_verdict`'s, so
|
|
1480
|
+
# the recorded timelines in tests/test_animated_is_detected.py exercise
|
|
1481
|
+
# the same rule that runs here rather than a copy of it that can drift.
|
|
1482
|
+
samples: List[Tuple[float, bool]] = []
|
|
742
1483
|
try:
|
|
743
1484
|
while (time.monotonic() * 1000.0) - start < cfg.settle_timeout_ms:
|
|
744
1485
|
self._check_deadline("waiting for the challenge to settle")
|
|
@@ -752,14 +1493,16 @@ class PageSolver:
|
|
|
752
1493
|
frames.append(path)
|
|
753
1494
|
if previous:
|
|
754
1495
|
moved = self._has_movement(previous, path, cfg.settle_diff_threshold)
|
|
755
|
-
still_streak = 0 if moved else still_streak + 1
|
|
756
1496
|
if len(frames) > 1:
|
|
757
1497
|
_unlink(frames.pop(0))
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1498
|
+
samples.append(((time.monotonic() * 1000.0) - start, moved))
|
|
1499
|
+
verdict = settle_verdict(
|
|
1500
|
+
samples,
|
|
1501
|
+
settle_frames=cfg.settle_frames,
|
|
1502
|
+
animated_after_ms=cfg.animated_challenge_after_ms,
|
|
1503
|
+
)
|
|
1504
|
+
if verdict != "timeout":
|
|
1505
|
+
return verdict
|
|
763
1506
|
previous = frames[-1]
|
|
764
1507
|
_delay(cfg.settle_poll_ms)
|
|
765
1508
|
return "timeout"
|
|
@@ -767,6 +1510,225 @@ class PageSolver:
|
|
|
767
1510
|
for path in frames:
|
|
768
1511
|
_unlink(path)
|
|
769
1512
|
|
|
1513
|
+
# ------------------------------------------------------------------
|
|
1514
|
+
# Animated challenges
|
|
1515
|
+
# ------------------------------------------------------------------
|
|
1516
|
+
|
|
1517
|
+
def _settle_or_animated(self, element: Any) -> bool:
|
|
1518
|
+
"""Wait for the widget to settle; return whether it is animated instead.
|
|
1519
|
+
|
|
1520
|
+
"Never settles" stopped being a failure. hCaptcha's "select the odd animal"
|
|
1521
|
+
fades its sprites on independent cycles and its "unique motion pattern"
|
|
1522
|
+
puzzle spins identical meshes — those challenges are animated BY DESIGN, and
|
|
1523
|
+
the answer only exists across frames. True here routes the caller to the
|
|
1524
|
+
recording path.
|
|
1525
|
+
|
|
1526
|
+
`video_solve_enabled=False` restores the old behaviour for callers who would
|
|
1527
|
+
rather fail fast than spend the recording time.
|
|
1528
|
+
"""
|
|
1529
|
+
# Already established for this widget — don't re-probe every round.
|
|
1530
|
+
if self._known_animated:
|
|
1531
|
+
return True
|
|
1532
|
+
|
|
1533
|
+
with self._phase("settle"):
|
|
1534
|
+
verdict = self._wait_for_element_settled(element)
|
|
1535
|
+
if verdict != "animated":
|
|
1536
|
+
# 'settled' is not proof of static. The settle rule stops at the
|
|
1537
|
+
# first `settle_frames` of stillness, and every animated puzzle we
|
|
1538
|
+
# ship rests between screens for longer than that, so it calls all
|
|
1539
|
+
# of them settled (tests/test_animated_is_detected.py has the
|
|
1540
|
+
# measured timelines).
|
|
1541
|
+
#
|
|
1542
|
+
# What decides it instead is a RECORDING, taken once a round has
|
|
1543
|
+
# already answered the same thing twice — the signature of a
|
|
1544
|
+
# cycling board read as a still. The recording is self-checking: if
|
|
1545
|
+
# the widget really is static it slices to one keyframe and the
|
|
1546
|
+
# caller solves that frame as the still it is. So there is nothing
|
|
1547
|
+
# to be careful about here, and nothing to observe first.
|
|
1548
|
+
if not self._animated_probe_armed or self._animated_probe_done:
|
|
1549
|
+
return False
|
|
1550
|
+
self._animated_probe_done = True
|
|
1551
|
+
self._animated_probe_armed = False
|
|
1552
|
+
_log("[animated] the same answer came back twice — recording the "
|
|
1553
|
+
"challenge to see whether it is cycling")
|
|
1554
|
+
|
|
1555
|
+
# Latch either route. Re-deciding every round would re-record a widget
|
|
1556
|
+
# we have already SEEN animate, and on a puzzle that rests between
|
|
1557
|
+
# screens a later recording can land inside a hold, slice to one frame,
|
|
1558
|
+
# and flip the solve back onto the still path halfway through.
|
|
1559
|
+
self._known_animated = True
|
|
1560
|
+
|
|
1561
|
+
if not self.config.video_solve_enabled:
|
|
1562
|
+
raise AnimatedChallengeError(
|
|
1563
|
+
"the challenge never settles and video_solve_enabled is off"
|
|
1564
|
+
)
|
|
1565
|
+
_log("[animated] challenge is animated — solving it from keyframes")
|
|
1566
|
+
return True
|
|
1567
|
+
|
|
1568
|
+
def _arm_animated_probe(self) -> None:
|
|
1569
|
+
"""Round N finished without finishing the CHALLENGE — probe next round.
|
|
1570
|
+
|
|
1571
|
+
Deliberately not armed for round 1. A static puzzle solves there and
|
|
1572
|
+
never pays anything, which is what keeps the common case at the ~530ms
|
|
1573
|
+
settle it has today. Only a widget that survived a round is worth
|
|
1574
|
+
spending a cycle of observation on.
|
|
1575
|
+
"""
|
|
1576
|
+
if self.config.animated_probe_enabled and not self._animated_probe_done:
|
|
1577
|
+
self._animated_probe_armed = True
|
|
1578
|
+
|
|
1579
|
+
def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
|
|
1580
|
+
"""Record the widget and return `(keyframe_paths, temp_dir)`.
|
|
1581
|
+
|
|
1582
|
+
Screenshots the element at the collector's burst geometry (4 s @ 10 fps),
|
|
1583
|
+
then hands the frames to the SAME slicer the training data was cut with
|
|
1584
|
+
(`keyframes.extract_keyframes`). That shared code path is the whole point:
|
|
1585
|
+
the model answers with a frame NUMBER, and a number only means something if
|
|
1586
|
+
the live set was sliced the way the trained set was.
|
|
1587
|
+
|
|
1588
|
+
Frames are kept in memory and sliced from there — no intermediate mp4. The
|
|
1589
|
+
old pipeline encoded one, and every clip it produced was mp4v, a codec the
|
|
1590
|
+
serving side may or may not decode. Skipping the encode removes that whole
|
|
1591
|
+
class of silent failure along with the disk round-trip.
|
|
1592
|
+
|
|
1593
|
+
The caller owns `temp_dir` and must remove it once the actions are done
|
|
1594
|
+
with: the returned paths are read back by the wait gate on every poll, so
|
|
1595
|
+
cleaning up any earlier would break the click.
|
|
1596
|
+
|
|
1597
|
+
Raises AnimatedChallengeError if nothing could be captured.
|
|
1598
|
+
"""
|
|
1599
|
+
from .keyframes import extract_keyframes, write_keyframes
|
|
1600
|
+
|
|
1601
|
+
cfg = self.config
|
|
1602
|
+
fps = max(1, int(cfg.video_burst_fps))
|
|
1603
|
+
total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
|
|
1604
|
+
interval = 1.0 / fps
|
|
1605
|
+
|
|
1606
|
+
frames: List[Any] = []
|
|
1607
|
+
|
|
1608
|
+
remaining = max(0, total - len(frames))
|
|
1609
|
+
shot = _tmp_png("burst")
|
|
1610
|
+
try:
|
|
1611
|
+
for i in range(remaining):
|
|
1612
|
+
self._check_deadline("recording the animated challenge")
|
|
1613
|
+
start = time.monotonic()
|
|
1614
|
+
try:
|
|
1615
|
+
# animations ALLOWED — see `_screenshot`. The JS port has
|
|
1616
|
+
# done this since the GeeTest svg burst came back as 40
|
|
1617
|
+
# copies of one picture; this port never did, so every
|
|
1618
|
+
# CSS-animated vendor was recorded frozen.
|
|
1619
|
+
self._screenshot(element, shot, animations="allow")
|
|
1620
|
+
except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
|
|
1621
|
+
_debug(f"burst frame {i} failed: {exc}")
|
|
1622
|
+
else:
|
|
1623
|
+
import cv2
|
|
1624
|
+
|
|
1625
|
+
img = cv2.imread(shot)
|
|
1626
|
+
if img is not None:
|
|
1627
|
+
frames.append(img)
|
|
1628
|
+
# Drift-corrected: a slow screenshot must not stretch the clip, or
|
|
1629
|
+
# the recording covers more wall-clock than the model trained on and
|
|
1630
|
+
# a cycle's period lands differently across the frames.
|
|
1631
|
+
wait = interval - (time.monotonic() - start)
|
|
1632
|
+
if wait > 0 and i < remaining - 1:
|
|
1633
|
+
time.sleep(wait)
|
|
1634
|
+
finally:
|
|
1635
|
+
_unlink(shot)
|
|
1636
|
+
|
|
1637
|
+
if not frames:
|
|
1638
|
+
raise AnimatedChallengeError(
|
|
1639
|
+
"could not record the animated challenge (no frame screenshotted)"
|
|
1640
|
+
)
|
|
1641
|
+
_log(f"[animated] recorded {len(frames)} frames at {fps}fps")
|
|
1642
|
+
|
|
1643
|
+
kfset = extract_keyframes(frames, fps=float(fps))
|
|
1644
|
+
temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
|
|
1645
|
+
paths = write_keyframes(kfset, temp_dir, stem="challenge")
|
|
1646
|
+
_log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
|
|
1647
|
+
# The wait gate consults this: `even` means the extractor found no state
|
|
1648
|
+
# that RECURS, so there is nothing for the page to come back to.
|
|
1649
|
+
self._keyframe_mode = kfset.mode
|
|
1650
|
+
return [str(p) for p in paths], temp_dir
|
|
1651
|
+
|
|
1652
|
+
def _wait_for_keyframe(self, element: Any, keyframe_path: str,
|
|
1653
|
+
point_norm: Tuple[float, float]) -> bool:
|
|
1654
|
+
"""Hold until the widget looks like `keyframe_path` around `point_norm`.
|
|
1655
|
+
|
|
1656
|
+
This is the reason an animated answer names a frame. The model picked the
|
|
1657
|
+
moment its target was visible; the coordinates are only correct at that
|
|
1658
|
+
moment. Clicking as soon as the answer arrives lands on whatever the sprite
|
|
1659
|
+
happens to be doing — for a cross-fade, usually background.
|
|
1660
|
+
|
|
1661
|
+
Compares only the neighbourhood of the action point, with the same box and
|
|
1662
|
+
the same metric the training label was chosen with
|
|
1663
|
+
(`keyframes.region_box` / `region_diff_ratio`). Local rather than
|
|
1664
|
+
whole-frame because everything ELSE in these puzzles is also moving: a
|
|
1665
|
+
whole-frame match would need every unrelated sprite to align too, and would
|
|
1666
|
+
essentially never open.
|
|
1667
|
+
|
|
1668
|
+
Returns whether the state was reached. On timeout the caller clicks anyway
|
|
1669
|
+
— see `keyframe_wait_timeout_ms`.
|
|
1670
|
+
|
|
1671
|
+
NOT ATTEMPTED on an `even` clip. `keyframes.py` picks that mode precisely
|
|
1672
|
+
when the clip never revisits a picture it has already shown — a slow
|
|
1673
|
+
one-way change, a rotation, a cross-fade — so there is no state for the
|
|
1674
|
+
page to come BACK to and the wait can only ever run out. It is not a
|
|
1675
|
+
cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
|
|
1676
|
+
PER CLICK before giving up and clicking anyway.
|
|
1677
|
+
And `even` is not the rare case. All 116 real clips under
|
|
1678
|
+
`cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
|
|
1679
|
+
fired on real footage, which keyframes.py itself records ("real state
|
|
1680
|
+
separations top out around 0.007, well under this"). So on real traffic
|
|
1681
|
+
this gate was 6s of dead time on every animated click, always followed by
|
|
1682
|
+
the same click it would have made immediately. Measured on
|
|
1683
|
+
`hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
|
|
1684
|
+
0.0721 against a 0.05 tolerance, then it clicked and solved.
|
|
1685
|
+
|
|
1686
|
+
The gate stays for `cycle`/`static`, where a state genuinely does recur
|
|
1687
|
+
and waiting is the difference between clicking the sprite and clicking
|
|
1688
|
+
the background.
|
|
1689
|
+
"""
|
|
1690
|
+
import cv2
|
|
1691
|
+
|
|
1692
|
+
from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
|
|
1693
|
+
|
|
1694
|
+
if self._keyframe_mode == "even":
|
|
1695
|
+
_log("[animated] clip is mode=even (no state recurs); "
|
|
1696
|
+
"acting on the model's frame without waiting")
|
|
1697
|
+
return False
|
|
1698
|
+
|
|
1699
|
+
ref = cv2.imread(keyframe_path)
|
|
1700
|
+
if ref is None:
|
|
1701
|
+
_debug(f"keyframe {keyframe_path} unreadable; not waiting")
|
|
1702
|
+
return False
|
|
1703
|
+
box = region_box(ref.shape[1::-1], point_norm)
|
|
1704
|
+
|
|
1705
|
+
cfg = self.config
|
|
1706
|
+
deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
|
|
1707
|
+
probe = _tmp_png("kfwait")
|
|
1708
|
+
best = 1.0
|
|
1709
|
+
try:
|
|
1710
|
+
while (time.monotonic() * 1000.0) < deadline:
|
|
1711
|
+
self._check_deadline("waiting for the challenge keyframe")
|
|
1712
|
+
try:
|
|
1713
|
+
self._screenshot(element, probe)
|
|
1714
|
+
live = cv2.imread(probe)
|
|
1715
|
+
except Exception as exc: # noqa: BLE001
|
|
1716
|
+
_debug(f"keyframe probe failed: {exc}")
|
|
1717
|
+
live = None
|
|
1718
|
+
if live is not None:
|
|
1719
|
+
d = region_diff_ratio(ref, live, box)
|
|
1720
|
+
best = min(best, d)
|
|
1721
|
+
if d <= MATCH_REGION_TOLERANCE:
|
|
1722
|
+
_log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
|
|
1723
|
+
return True
|
|
1724
|
+
_delay(cfg.keyframe_wait_poll_ms)
|
|
1725
|
+
finally:
|
|
1726
|
+
_unlink(probe)
|
|
1727
|
+
_log(f"[animated] widget never matched the chosen keyframe within "
|
|
1728
|
+
f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
|
|
1729
|
+
f"clicking on the model's coordinates anyway")
|
|
1730
|
+
return False
|
|
1731
|
+
|
|
770
1732
|
def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
|
|
771
1733
|
"""After a submit the frame MUST change (next round, or closing)."""
|
|
772
1734
|
cfg = self.config
|
|
@@ -786,12 +1748,24 @@ class PageSolver:
|
|
|
786
1748
|
ships an empty `url("")` placeholder before it arrives. Best-effort: a
|
|
787
1749
|
timeout falls through to the screenshot, where the existing fail-fast
|
|
788
1750
|
path still covers a genuinely unsupported puzzle.
|
|
1751
|
+
|
|
1752
|
+
"NOTHING TO WAIT FOR" IS READY. The last clause used to be
|
|
1753
|
+
`return !!(example && ...)`, which is false when there is no example
|
|
1754
|
+
image — so a challenge with no tile grid, no canvas and no example
|
|
1755
|
+
polled until the timeout and then carried on regardless. Measured on
|
|
1756
|
+
`hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
|
|
1757
|
+
full 8s three times over, more than half the budget spent asking a
|
|
1758
|
+
question about elements that were not on the page. A readiness gate can
|
|
1759
|
+
only report on what it can see; with nothing to check it has no opinion,
|
|
1760
|
+
and "no opinion" must not read as "not ready".
|
|
789
1761
|
"""
|
|
1762
|
+
cfg = self.config
|
|
790
1763
|
try:
|
|
791
1764
|
frame = challenge_iframe.content_frame()
|
|
792
1765
|
if not frame:
|
|
793
1766
|
return
|
|
794
|
-
frame.wait_for_selector(".prompt-text", state="visible",
|
|
1767
|
+
frame.wait_for_selector(".prompt-text", state="visible",
|
|
1768
|
+
timeout=cfg.hcaptcha_images_timeout_ms)
|
|
795
1769
|
frame.wait_for_function(
|
|
796
1770
|
"""() => {
|
|
797
1771
|
const tiles = Array.from(
|
|
@@ -806,9 +1780,10 @@ class PageSolver:
|
|
|
806
1780
|
if (canvas && canvas.width > 0 && canvas.height > 0) return true;
|
|
807
1781
|
const example = document.querySelector(
|
|
808
1782
|
'.challenge-example img, .image-wrapper img');
|
|
809
|
-
|
|
1783
|
+
if (example) return example.complete && example.naturalWidth > 0;
|
|
1784
|
+
return true; // nothing here to be waited for
|
|
810
1785
|
}""",
|
|
811
|
-
timeout=
|
|
1786
|
+
timeout=cfg.hcaptcha_images_timeout_ms,
|
|
812
1787
|
)
|
|
813
1788
|
except Exception:
|
|
814
1789
|
pass # timed out or detached mid-load; screenshot anyway
|
|
@@ -981,11 +1956,35 @@ class PageSolver:
|
|
|
981
1956
|
ordered.extend(sorted(remaining))
|
|
982
1957
|
return ordered
|
|
983
1958
|
|
|
984
|
-
def
|
|
1959
|
+
def _watch_clicked_tiles(
|
|
985
1960
|
self, page: Any, element: Any, session: _GridSession, priority: Sequence[int] = ()
|
|
986
|
-
) -> List[int]:
|
|
1961
|
+
) -> Tuple[List[int], bool]:
|
|
987
1962
|
"""
|
|
988
|
-
Watch
|
|
1963
|
+
Watch the just-clicked tiles until the widget says what it did with them.
|
|
1964
|
+
|
|
1965
|
+
Returns `(loading, chipped)`. Two answers because reCAPTCHA gives a click
|
|
1966
|
+
one of exactly two replies, and they are the two kinds of board:
|
|
1967
|
+
|
|
1968
|
+
* the small blue CHIP in the tile's top-left corner — the photo was
|
|
1969
|
+
KEPT. Nothing is on its way in, the selection is the answer, and the
|
|
1970
|
+
caller should press Verify (`chipped`).
|
|
1971
|
+
* the photo blanking or dissolving under a large centred check — the
|
|
1972
|
+
tile is being SWAPPED, and what lands may match too, so the board has
|
|
1973
|
+
to be read again (`loading`).
|
|
1974
|
+
|
|
1975
|
+
A widget that swaps one clicked cell swaps them all, so the two never
|
|
1976
|
+
share a board and one look at the tiles we just clicked settles it. The
|
|
1977
|
+
chip is what `detect_selected_cells` reports as `selected` — it looks for
|
|
1978
|
+
it in the top-left corner only, behind a circularity and a centroid test
|
|
1979
|
+
a centred check fails — and we already read it every poll.
|
|
1980
|
+
|
|
1981
|
+
`chipped` needs EVERY watched tile, because a partial reading is a
|
|
1982
|
+
misread and the two mistakes do not cost the same: calling a swapping
|
|
1983
|
+
board finished submits half an answer and burns the attempt, while
|
|
1984
|
+
calling a chipped board unfinished costs one inference. It is also only
|
|
1985
|
+
ever OUR click that a chip can be reporting: tiles already wearing one
|
|
1986
|
+
are filtered out of the model's answer before we click, so a chip on a
|
|
1987
|
+
watched tile arrived in response to this round.
|
|
989
1988
|
|
|
990
1989
|
The blank/fade transition LAGS the click by a beat: reCAPTCHA holds a
|
|
991
1990
|
clicked tile selected (old image visible) for ~1-3s and only then blanks
|
|
@@ -1024,17 +2023,23 @@ class PageSolver:
|
|
|
1024
2023
|
frames.append(path)
|
|
1025
2024
|
|
|
1026
2025
|
states = self._grid_cell_states(frames[-2], frames[-1], session.grid_boxes)
|
|
2026
|
+
# Chip first: a chip landing on a tile ZOOMS its photo out, which
|
|
2027
|
+
# reads as `changing` on the same frame that shows the chip. Test
|
|
2028
|
+
# the swap first and every chipped board looks like a swapping
|
|
2029
|
+
# one for as long as the animation runs.
|
|
2030
|
+
if priority and set((states or {}).get("selected", [])).issuperset(priority):
|
|
2031
|
+
return [], True
|
|
1027
2032
|
in_scope = lambda c: watch is None or c in watch # noqa: E731
|
|
1028
2033
|
empty = [c for c in (states or {}).get("empty", []) if in_scope(c)]
|
|
1029
2034
|
changing = [c for c in (states or {}).get("changing", []) if in_scope(c)]
|
|
1030
2035
|
loading = sorted(set(empty) | set(changing))
|
|
1031
2036
|
if loading:
|
|
1032
|
-
return self._order_by_priority(loading, priority)
|
|
2037
|
+
return self._order_by_priority(loading, priority), False
|
|
1033
2038
|
_unlink(frames.pop(0))
|
|
1034
|
-
return []
|
|
2039
|
+
return [], False
|
|
1035
2040
|
except Exception as exc:
|
|
1036
2041
|
_debug(f"fade-onset error: {exc}")
|
|
1037
|
-
return []
|
|
2042
|
+
return [], False
|
|
1038
2043
|
finally:
|
|
1039
2044
|
for path in frames:
|
|
1040
2045
|
_unlink(path)
|
|
@@ -1101,8 +2106,14 @@ class PageSolver:
|
|
|
1101
2106
|
tiles and waits for at least one to finish reloading before re-solving,
|
|
1102
2107
|
so we never burn a model call on a grid that is still mid-fade.
|
|
1103
2108
|
|
|
1104
|
-
|
|
1105
|
-
|
|
2109
|
+
Only a board that SWAPS a clicked tile out is worth those extra rounds.
|
|
2110
|
+
One that ticks the tile and keeps the photo has been fully answered by
|
|
2111
|
+
the round that clicked it — same as the 4x4 — so `_watch_clicked_tiles`
|
|
2112
|
+
reports the chip and this submits there and then. Rounds 2..N exist for
|
|
2113
|
+
the fading board and nothing else.
|
|
2114
|
+
|
|
2115
|
+
Submits on `done`, and on a board that ticked our clicks — never on a
|
|
2116
|
+
round-cap exit, which is left to the outer loop to re-detect and decide.
|
|
1106
2117
|
"""
|
|
1107
2118
|
cfg = self.config
|
|
1108
2119
|
session = _GridSession(
|
|
@@ -1124,7 +2135,8 @@ class PageSolver:
|
|
|
1124
2135
|
# Each round can legitimately spend ~10s waiting on fades, so eight
|
|
1125
2136
|
# of them plus the model calls can outlast the whole solve budget.
|
|
1126
2137
|
self._check_deadline(f"recaptcha grid round {round_index}")
|
|
1127
|
-
self.
|
|
2138
|
+
with self._phase("grid-load"):
|
|
2139
|
+
self._wait_for_grid_cells_loaded(element)
|
|
1128
2140
|
shot = _tmp_png("recap")
|
|
1129
2141
|
action: Optional[Dict[str, Any]] = None
|
|
1130
2142
|
try:
|
|
@@ -1148,7 +2160,7 @@ class PageSolver:
|
|
|
1148
2160
|
|
|
1149
2161
|
if action.get("action") == "wait":
|
|
1150
2162
|
_log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
|
|
1151
|
-
loading = self.
|
|
2163
|
+
loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
|
|
1152
2164
|
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
1153
2165
|
continue
|
|
1154
2166
|
|
|
@@ -1179,11 +2191,18 @@ class PageSolver:
|
|
|
1179
2191
|
f"-> cells {clicked_this_round}."
|
|
1180
2192
|
)
|
|
1181
2193
|
|
|
1182
|
-
loading = self.
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
2194
|
+
loading, chipped = self._watch_clicked_tiles(
|
|
2195
|
+
page, element, session, clicked_this_round
|
|
2196
|
+
)
|
|
2197
|
+
if chipped or not loading:
|
|
2198
|
+
# Either the widget ticked our clicks and kept the photos
|
|
2199
|
+
# (a board that does that is answered), or nothing faded
|
|
2200
|
+
# within the grace window. Submit rather than paying for
|
|
2201
|
+
# another round to be told the same thing.
|
|
2202
|
+
_log(
|
|
2203
|
+
f"[recaptcha-grid] round {round_index}: "
|
|
2204
|
+
f"{'tiles chipped' if chipped else 'nothing loading'}; submitting."
|
|
2205
|
+
)
|
|
1187
2206
|
should_submit = True
|
|
1188
2207
|
break
|
|
1189
2208
|
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
@@ -1225,55 +2244,145 @@ class PageSolver:
|
|
|
1225
2244
|
else:
|
|
1226
2245
|
puzzle_source = "unknown"
|
|
1227
2246
|
|
|
2247
|
+
# Everything the answer might have to be delivered INTO — a text box, a
|
|
2248
|
+
# slider handle — is looked up against this, never against the page.
|
|
2249
|
+
# For the iframed vendors it is the challenge document; for the ones
|
|
2250
|
+
# that render into the host page (GeeTest, Yidun, BotDetect, …) it is
|
|
2251
|
+
# the widget element, whose subtree is the same boundary.
|
|
2252
|
+
scope = element.content_frame() or element
|
|
2253
|
+
|
|
2254
|
+
# Does this puzzle want a STRING rather than a place to click? Only the
|
|
2255
|
+
# DOM can say. The picture cannot: BotDetect's warped code and
|
|
2256
|
+
# hCaptcha's "click the matching character" are the same genre of image
|
|
2257
|
+
# and want opposite answers. Restricted to `unknown` because neither
|
|
2258
|
+
# hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
|
|
2259
|
+
# inside one of their frames would be a false positive by definition.
|
|
2260
|
+
text_mode = puzzle_source == "unknown" and self._find_control(
|
|
2261
|
+
scope, TEXT_INPUT_SELECTORS
|
|
2262
|
+
) is not None
|
|
2263
|
+
if text_mode:
|
|
2264
|
+
_log("widget has a text box; solving as a distorted-text captcha")
|
|
2265
|
+
|
|
1228
2266
|
# hCaptcha REUSES the challenge iframe across rounds: after a submit it
|
|
1229
2267
|
# briefly shows the previous round, then a spinner, then the next one.
|
|
1230
2268
|
# Screenshotting any of those transitional frames feeds the model a
|
|
1231
2269
|
# blank/stale grid it correctly calls "unsupported" — which used to abort
|
|
1232
2270
|
# the whole solve on round 2.
|
|
2271
|
+
is_animated = False
|
|
1233
2272
|
if puzzle_source == "hcaptcha" and "frame=challenge" in src:
|
|
1234
2273
|
if self._last_submit_frame_hash:
|
|
1235
|
-
self.
|
|
2274
|
+
with self._phase("await-next-round"):
|
|
2275
|
+
self._wait_for_change_since(element, self._last_submit_frame_hash)
|
|
1236
2276
|
self._last_submit_frame_hash = None
|
|
1237
|
-
self.
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
2277
|
+
with self._phase("hcaptcha-images"):
|
|
2278
|
+
self._wait_for_hcaptcha_challenge_images(element)
|
|
2279
|
+
is_animated = self._settle_or_animated(element)
|
|
2280
|
+
# Those three waits total up to ~21s and none of them watches for
|
|
2281
|
+
# success, so the vendor's token routinely lands DURING them. Ask
|
|
2282
|
+
# once more before spending an inference: this is the last free
|
|
2283
|
+
# moment to notice the captcha is already accepted, and the
|
|
2284
|
+
# inference is the single most expensive thing in the loop.
|
|
2285
|
+
if self.is_captcha_solved(page):
|
|
2286
|
+
_log("solved while waiting for the next round; skipping inference.")
|
|
2287
|
+
return False, []
|
|
2288
|
+
elif puzzle_source == "unknown":
|
|
2289
|
+
# Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
|
|
2290
|
+
# probe was never run for these, so an animated one — GeeTest's svg board
|
|
2291
|
+
# cycles its glyph set — was screenshotted mid-cycle and answered from
|
|
2292
|
+
# whatever single moment we happened to catch. reCAPTCHA is excluded on
|
|
2293
|
+
# purpose: it has its own readiness gate below, its grids are never
|
|
2294
|
+
# animated, and a second probe would only add latency to a path that
|
|
2295
|
+
# already works.
|
|
2296
|
+
is_animated = self._settle_or_animated(element)
|
|
1242
2297
|
|
|
1243
2298
|
# Only the image-challenge frame holds a grid. Running grid detection on
|
|
1244
2299
|
# the anchor checkbox just burns an 8s timeout before the click.
|
|
1245
2300
|
is_recaptcha_challenge = puzzle_source == "recaptcha" and "recaptcha/api2/bframe" in src
|
|
1246
|
-
is_recaptcha_one_shot = False
|
|
1247
2301
|
if is_recaptcha_challenge:
|
|
1248
|
-
self.
|
|
2302
|
+
with self._phase("grid-load"):
|
|
2303
|
+
self._wait_for_grid_cells_loaded(element)
|
|
1249
2304
|
grid = self._get_grid_boxes(element)
|
|
1250
2305
|
if grid and grid["size"] == 3:
|
|
1251
|
-
# 3x3 refreshes tiles in place, so
|
|
1252
|
-
#
|
|
2306
|
+
# Only a 3x3 ever refreshes its tiles in place, so only a 3x3
|
|
2307
|
+
# can need more than one round — and whether THIS one does is
|
|
2308
|
+
# decided inside the driver, by what the widget does with the
|
|
2309
|
+
# first click. A 4x4 never refreshes: it falls through to the
|
|
2310
|
+
# ordinary click-then-submit path below.
|
|
1253
2311
|
element_box = element.bounding_box()
|
|
1254
2312
|
if element_box:
|
|
1255
2313
|
return self._solve_recaptcha_grid(
|
|
1256
2314
|
page, element, retry_mode, grid, element_box
|
|
1257
2315
|
)
|
|
1258
|
-
elif grid and grid["size"] == 4:
|
|
1259
|
-
is_recaptcha_one_shot = True
|
|
1260
2316
|
|
|
1261
2317
|
shot = _tmp_png("captcha")
|
|
1262
2318
|
performed_action = False
|
|
2319
|
+
slid = False
|
|
2320
|
+
placed = False
|
|
2321
|
+
clicked = False
|
|
2322
|
+
typed = False
|
|
1263
2323
|
all_usage: List[Dict[str, Any]] = []
|
|
2324
|
+
keyframe_dir: Optional[str] = None
|
|
2325
|
+
have_shot = False
|
|
1264
2326
|
try:
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
2327
|
+
if is_animated:
|
|
2328
|
+
# The freshness guard is deliberately SKIPPED here. It re-solves when
|
|
2329
|
+
# the frame changes during inference, and an animated challenge
|
|
2330
|
+
# changes by definition — every attempt would be judged stale and the
|
|
2331
|
+
# whole re-solve budget would burn without ever acting. The frame
|
|
2332
|
+
# number in the answer is the real guard: it names the state to act
|
|
2333
|
+
# in, and `_execute_click` waits for it.
|
|
2334
|
+
with self._phase("burst"):
|
|
2335
|
+
keyframes, keyframe_dir = self._record_keyframes(element)
|
|
2336
|
+
# THE RECORDING IS THE TEST. A burst of a widget that turns out
|
|
2337
|
+
# not to move slices to a single keyframe (`mode=static`), and
|
|
2338
|
+
# that frame is exactly the still this round would have taken
|
|
2339
|
+
# anyway — so a wrong guess about "is it animated" costs the
|
|
2340
|
+
# burst and nothing else, and cannot produce a wrong answer.
|
|
2341
|
+
#
|
|
2342
|
+
# This replaced a separate probe that watched the widget FIRST
|
|
2343
|
+
# and only recorded if it saw motion. That paid for the
|
|
2344
|
+
# observation twice over: up to `animated_probe_ms` to decide,
|
|
2345
|
+
# then the burst to act, and it still had to be long enough to
|
|
2346
|
+
# outlast the longest hold (8.2s measured on
|
|
2347
|
+
# number_with_highest_value_video) or it answered "static" from
|
|
2348
|
+
# inside one — which it did, live, burning 9s of a 45s budget to
|
|
2349
|
+
# reach the wrong conclusion about a puzzle that was animating.
|
|
2350
|
+
if len(keyframes) < 2:
|
|
2351
|
+
_log("[animated] the recording shows one picture; "
|
|
2352
|
+
"solving it as a still")
|
|
2353
|
+
is_animated = False
|
|
2354
|
+
# Reuse the frame we just recorded rather than taking
|
|
2355
|
+
# another screenshot of the same motionless widget.
|
|
2356
|
+
shutil.copyfile(keyframes[0], shot)
|
|
2357
|
+
have_shot = True
|
|
2358
|
+
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
2359
|
+
keyframe_dir = None
|
|
2360
|
+
else:
|
|
2361
|
+
with self._phase("inference"):
|
|
2362
|
+
actions, all_usage = self._get_keyframe_solution(keyframes)
|
|
2363
|
+
|
|
2364
|
+
if not is_animated:
|
|
2365
|
+
if not have_shot:
|
|
2366
|
+
self._screenshot(element, shot,
|
|
2367
|
+
timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
2368
|
+
with self._phase("inference"):
|
|
2369
|
+
actions, all_usage = self._solve_frame_freshness_guarded(
|
|
2370
|
+
element,
|
|
2371
|
+
shot,
|
|
2372
|
+
lambda image_path: self._get_solution(
|
|
2373
|
+
image_path, puzzle_source, retry_mode, text_mode=text_mode
|
|
2374
|
+
),
|
|
2375
|
+
)
|
|
1272
2376
|
|
|
1273
2377
|
element_box = element.bounding_box()
|
|
1274
2378
|
if not element_box:
|
|
1275
2379
|
raise CaptchaSolveError("could not get bounding box of captcha element")
|
|
1276
2380
|
|
|
2381
|
+
# Recorded at the moment of EXECUTION, which is what makes a repeat
|
|
2382
|
+
# mean something: this exact answer is about to be performed, so if
|
|
2383
|
+
# it matches the last one, the last one already ran and the page is
|
|
2384
|
+
# still asking the same question.
|
|
2385
|
+
self._note_answer(actions, retry_mode)
|
|
1277
2386
|
_log(f"executing {len(actions)} action(s)")
|
|
1278
2387
|
frame = element.content_frame()
|
|
1279
2388
|
verify_button = None
|
|
@@ -1291,43 +2400,123 @@ class PageSolver:
|
|
|
1291
2400
|
if not bboxes and not action.get("target_coordinates"):
|
|
1292
2401
|
_log("click action has no bboxes or coordinates; skipping")
|
|
1293
2402
|
continue
|
|
2403
|
+
# On an animated challenge, hold each click until the widget is
|
|
2404
|
+
# back in the state the model answered about. Per-click, not once
|
|
2405
|
+
# per action: these puzzles keep cycling, so by the time click 2
|
|
2406
|
+
# comes round the state has moved on again.
|
|
2407
|
+
await_kf = action.get("await_keyframe")
|
|
1294
2408
|
if bboxes:
|
|
1295
2409
|
for bbox in bboxes:
|
|
2410
|
+
if await_kf:
|
|
2411
|
+
self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
|
|
1296
2412
|
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
1297
2413
|
_delay(random.random() * 80 + 80)
|
|
1298
2414
|
else:
|
|
1299
2415
|
self._execute_click(page, action, element_box)
|
|
1300
2416
|
performed_action = True
|
|
2417
|
+
clicked = True
|
|
2418
|
+
elif kind == "drag" and not action.get("source_bounding_box"):
|
|
2419
|
+
# No source — a puzzle-piece slider. What you grab is not
|
|
2420
|
+
# what has to arrive, so this cannot go through
|
|
2421
|
+
# _execute_drag: pressing the gap the model named and
|
|
2422
|
+
# dragging from there picks up nothing at all.
|
|
2423
|
+
if self._execute_slide(page, element, scope, action, element_box):
|
|
2424
|
+
performed_action = True
|
|
2425
|
+
slid = True
|
|
1301
2426
|
elif kind == "drag":
|
|
2427
|
+
# Wait on the SOURCE: the piece has to be there to be picked up.
|
|
2428
|
+
# The destination is not gated — by the time the mouse arrives the
|
|
2429
|
+
# animation has moved on regardless, and a drop is judged by where
|
|
2430
|
+
# it lands, not by what the slot looked like on pickup.
|
|
2431
|
+
await_kf = action.get("await_keyframe")
|
|
2432
|
+
if await_kf and action.get("source_bounding_box"):
|
|
2433
|
+
self._wait_for_keyframe(
|
|
2434
|
+
element, await_kf, _bbox_center(action["source_bounding_box"])
|
|
2435
|
+
)
|
|
1302
2436
|
self._execute_drag(page, action, element_box)
|
|
1303
2437
|
performed_action = True
|
|
2438
|
+
placed = True
|
|
2439
|
+
elif kind == "type":
|
|
2440
|
+
if self._execute_type(page, scope, action):
|
|
2441
|
+
performed_action = True
|
|
2442
|
+
typed = True
|
|
1304
2443
|
elif kind == "wait":
|
|
1305
2444
|
duration = int(action.get("duration_ms") or 0)
|
|
1306
2445
|
if duration > 0:
|
|
1307
2446
|
_delay(duration)
|
|
1308
2447
|
performed_action = True
|
|
1309
2448
|
|
|
1310
|
-
|
|
1311
|
-
|
|
2449
|
+
# `scope` when there is no vendor iframe. Eight vendors render
|
|
2450
|
+
# into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
|
|
2451
|
+
# Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
|
|
2452
|
+
# for all of them and the button was never even SEARCHED FOR,
|
|
2453
|
+
# while the text box and the slider handle it sits beside were
|
|
2454
|
+
# both found through `scope` a few lines above. Two containers
|
|
2455
|
+
# for two halves of one interaction.
|
|
2456
|
+
#
|
|
2457
|
+
# This used to be gated on `typed`, for fear of turning up the
|
|
2458
|
+
# submit of the FORM the captcha guards. `scope` is the widget
|
|
2459
|
+
# container and the xpaths are RELATIVE, so that button is out
|
|
2460
|
+
# of reach by construction; what the gate actually did was make
|
|
2461
|
+
# every non-typed inline puzzle unsubmittable. Measured on the
|
|
2462
|
+
# Tier 3 fixtures: 4 pairs aborting outright and 11 more types
|
|
2463
|
+
# burning all ten solve loops on a puzzle they had answered on
|
|
2464
|
+
# the first one. The press itself is still bounded by
|
|
2465
|
+
# `should_submit` below, which is where the hazard belongs.
|
|
2466
|
+
lookup = frame or (scope if not slid else None)
|
|
2467
|
+
if lookup is not None:
|
|
2468
|
+
verify_button = self._get_verify_button(lookup)
|
|
1312
2469
|
if verify_button:
|
|
1313
2470
|
self._move_to_element(page, verify_button)
|
|
1314
2471
|
|
|
1315
|
-
# Submit policy:
|
|
1316
|
-
#
|
|
1317
|
-
#
|
|
1318
|
-
#
|
|
1319
|
-
#
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
2472
|
+
# Submit policy: press the widget's own submit control whenever we
|
|
2473
|
+
# have put an ANSWER into it — a selection, a placed piece, a typed
|
|
2474
|
+
# code — or when we had nothing to do and want the round to advance.
|
|
2475
|
+
#
|
|
2476
|
+
# Two exclusions, and they are the whole rule:
|
|
2477
|
+
#
|
|
2478
|
+
# a completed SLIDE has already submitted. Letting go of the handle
|
|
2479
|
+
# is the gesture these puzzles grade; none of them ships a Verify
|
|
2480
|
+
# button, so anything the generic finder turns up afterwards
|
|
2481
|
+
# belongs to the host page, and pressing it would submit the form
|
|
2482
|
+
# the captcha guards while the verdict is still in flight.
|
|
2483
|
+
# a round that only WAITED has answered nothing. Submitting an
|
|
2484
|
+
# empty board spends the attempt on a puzzle we were about to
|
|
2485
|
+
# solve.
|
|
2486
|
+
#
|
|
2487
|
+
# hCaptcha and the reCAPTCHA 4x4 used to be named here as one-shot
|
|
2488
|
+
# special cases; they are ordinary click rounds and this covers them.
|
|
2489
|
+
# (reCAPTCHA 3x3 never reaches here — it returned above, to the
|
|
2490
|
+
# driver that owns its fade-and-re-round rounds.)
|
|
2491
|
+
#
|
|
2492
|
+
# A click round DID used to be excluded, on the reasoning that these
|
|
2493
|
+
# boards re-round and a half-made selection spends the attempt. They
|
|
2494
|
+
# do not: the ones that grade themselves mid-selection draw no submit
|
|
2495
|
+
# control at all, so `verify_button` is None and nothing is pressed
|
|
2496
|
+
# either way. What the exclusion actually bought was a whole extra
|
|
2497
|
+
# model call per puzzle, spent asking a board we had already answered
|
|
2498
|
+
# correctly whether it was `done` — 6.2 s of prosopo_grid_3x3's 13.8 s,
|
|
2499
|
+
# on a selection that scored 1.0 on the first call.
|
|
2500
|
+
answered = clicked or placed or typed
|
|
2501
|
+
should_submit = not slid and (answered or not performed_action)
|
|
2502
|
+
if should_submit and verify_button:
|
|
1324
2503
|
_log(f"clicking Verify to submit ({puzzle_source}).")
|
|
1325
2504
|
self._move_and_click(page, verify_button)
|
|
2505
|
+
# The press IS an interaction, and saying so is load-bearing:
|
|
2506
|
+
# the caller aborts a round that reports none, so submitting a
|
|
2507
|
+
# `done` answer and then returning False re-arms the very guard
|
|
2508
|
+
# this satisfies — the puzzle is sent and the solve gives up on
|
|
2509
|
+
# it one line later, which is what `prosopo_grid_3x3` did.
|
|
2510
|
+
performed_action = True
|
|
1326
2511
|
# Snapshot at submit time so the NEXT attempt waits for the real
|
|
1327
2512
|
# transition before treating whatever is on screen as fresh.
|
|
1328
2513
|
self._last_submit_frame_hash = self._element_frame_hash(element)
|
|
1329
2514
|
finally:
|
|
1330
2515
|
_unlink(shot)
|
|
2516
|
+
# Only now: the wait gate re-reads the keyframe PNG on every poll, so
|
|
2517
|
+
# removing the directory any earlier would break the click it is gating.
|
|
2518
|
+
if keyframe_dir:
|
|
2519
|
+
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1331
2520
|
|
|
1332
2521
|
return performed_action, all_usage
|
|
1333
2522
|
|
|
@@ -1335,6 +2524,27 @@ class PageSolver:
|
|
|
1335
2524
|
# Public entry point
|
|
1336
2525
|
# ------------------------------------------------------------------
|
|
1337
2526
|
|
|
2527
|
+
def watch(self, page: Any, **options: Any) -> "CaptchaWatcher":
|
|
2528
|
+
"""
|
|
2529
|
+
A watcher that solves captchas on `page` as they appear.
|
|
2530
|
+
|
|
2531
|
+
Mirrors the TS `solver.watch(page)` in intent, but NOT in whether it
|
|
2532
|
+
blocks: this returns an idle watcher and you choose how to drive it,
|
|
2533
|
+
because a sync Playwright handle cannot be driven from a worker thread.
|
|
2534
|
+
|
|
2535
|
+
solver.watch(page).run() # blocking; hold the page clean
|
|
2536
|
+
w = solver.watch(page)
|
|
2537
|
+
while my_loop():
|
|
2538
|
+
w.poll_once() # cooperative; you own the cadence
|
|
2539
|
+
|
|
2540
|
+
Options are `CaptchaWatcher`'s fields: `interval_ms`, `max_solves`,
|
|
2541
|
+
`error_backoff_ms`, `on_solved`, `on_error`. Nothing is injected into
|
|
2542
|
+
the page; see watcher.py.
|
|
2543
|
+
"""
|
|
2544
|
+
from .watcher import CaptchaWatcher
|
|
2545
|
+
|
|
2546
|
+
return CaptchaWatcher(solver=self, page=page, **options)
|
|
2547
|
+
|
|
1338
2548
|
def solve(self, page: Any) -> SolveResult:
|
|
1339
2549
|
"""
|
|
1340
2550
|
Solve whatever captcha is on `page`.
|
|
@@ -1348,6 +2558,9 @@ class PageSolver:
|
|
|
1348
2558
|
self._last_submit_frame_hash = None
|
|
1349
2559
|
self._deadline_ms = start + self.config.overall_solve_timeout_ms
|
|
1350
2560
|
self._viewport_cache = None
|
|
2561
|
+
self._cursor_seeded = False
|
|
2562
|
+
self._reset_animated_state()
|
|
2563
|
+
self._budget = PhaseBudget()
|
|
1351
2564
|
|
|
1352
2565
|
# Mint one session id for the WHOLE solve, exactly as the TS driver does
|
|
1353
2566
|
# per `solve()`. The planner turns it into `X-CK-Session`, which is what
|
|
@@ -1364,6 +2577,10 @@ class PageSolver:
|
|
|
1364
2577
|
try:
|
|
1365
2578
|
return self._solve_impl(page, start, cumulative_usage)
|
|
1366
2579
|
finally:
|
|
2580
|
+
# Printed on the way out of every solve, success or failure — a
|
|
2581
|
+
# solve that FAILED is exactly the one whose time you want itemised.
|
|
2582
|
+
if timings_enabled() and self._budget is not None:
|
|
2583
|
+
print(self._budget.report(), file=sys.stderr)
|
|
1367
2584
|
self._deadline_ms = None
|
|
1368
2585
|
if previous_session is None:
|
|
1369
2586
|
os.environ.pop(_SESSION_ENV, None)
|
|
@@ -1384,16 +2601,47 @@ class PageSolver:
|
|
|
1384
2601
|
stale_element_retries = 0
|
|
1385
2602
|
has_interacted = False
|
|
1386
2603
|
render_waits = 0
|
|
1387
|
-
|
|
2604
|
+
# Strictly FEWER than the solve loops. A render wait consumes an
|
|
2605
|
+
# attempt, so at parity the loop runs out first and the branch below
|
|
2606
|
+
# never fires — "no interactive captcha widget", which is the correct
|
|
2607
|
+
# and benign answer for a reCAPTCHA v3 / invisible page, is reported
|
|
2608
|
+
# instead as "still detected after N solve loops". That is a hard error
|
|
2609
|
+
# for a caller who catches NoCaptchaFoundError to mean "nothing here",
|
|
2610
|
+
# and it appeared the moment max_solve_loops came down from 10 to 6.
|
|
2611
|
+
max_render_waits = min(6, cfg.max_solve_loops - 1)
|
|
1388
2612
|
|
|
1389
2613
|
for attempt in range(1, cfg.max_solve_loops + 1):
|
|
2614
|
+
# Round 1 did not finish the challenge. Before spending another
|
|
2615
|
+
# still inference on it, let this round's settle check probe whether
|
|
2616
|
+
# the widget is animated — the settle rule alone cannot tell, and
|
|
2617
|
+
# answering an animated puzzle from one still is how a solve burns
|
|
2618
|
+
# all ten loops without ever being able to succeed.
|
|
2619
|
+
if attempt >= 2:
|
|
2620
|
+
self._arm_animated_probe()
|
|
1390
2621
|
if (time.monotonic() * 1000.0) - start > cfg.overall_solve_timeout_ms:
|
|
1391
2622
|
raise CaptchaSolveError(
|
|
1392
2623
|
f"captcha solve timed out after {cfg.overall_solve_timeout_ms}ms "
|
|
1393
2624
|
f"(attempt {attempt}/{cfg.max_solve_loops})"
|
|
1394
2625
|
)
|
|
1395
2626
|
|
|
1396
|
-
|
|
2627
|
+
# Ask the DOM whether we are DONE before asking what to solve next.
|
|
2628
|
+
# Only once we have interacted: before that a populated token is the
|
|
2629
|
+
# "already satisfied" case handled below, and this ordering would
|
|
2630
|
+
# skip the render-wait a fresh widget needs.
|
|
2631
|
+
#
|
|
2632
|
+
# `detect_captcha` is the expensive call — it settles pixels and
|
|
2633
|
+
# screenshots the element — and after the winning submit it returns
|
|
2634
|
+
# the challenge frame hCaptcha is TEARING DOWN. Solving that frame
|
|
2635
|
+
# cannot succeed; it just runs until the handle goes stale, which
|
|
2636
|
+
# measured 19-33s of dead time at the end of every run while the
|
|
2637
|
+
# answer had already been accepted. `is_captcha_solved` is a couple
|
|
2638
|
+
# of cheap DOM reads and is authoritative, so it goes first.
|
|
2639
|
+
if has_interacted and self.is_captcha_solved(page):
|
|
2640
|
+
_log("captcha reports solved; finishing.")
|
|
2641
|
+
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
2642
|
+
|
|
2643
|
+
with self._phase("detect"):
|
|
2644
|
+
element = self.detect_captcha(page)
|
|
1397
2645
|
if not element:
|
|
1398
2646
|
# Two-stage. A null detection splits into two very different
|
|
1399
2647
|
# cases and treating them alike is how you either hang on a
|
|
@@ -1432,47 +2680,105 @@ class PageSolver:
|
|
|
1432
2680
|
did_interact, usage = self._solve_single(page, element, retry_mode_this_loop)
|
|
1433
2681
|
except AnimatedChallengeError:
|
|
1434
2682
|
raise
|
|
1435
|
-
except UnsupportedCaptchaError:
|
|
2683
|
+
except UnsupportedCaptchaError as unsupported:
|
|
1436
2684
|
# A settled frame the model cannot solve is normally definitive.
|
|
1437
2685
|
# BUT mid-solve, a transitional blank frame produces the same
|
|
1438
2686
|
# verdict — that was the "solves round 1, dies on round 2" bug.
|
|
1439
2687
|
if has_interacted and unsupported_retries < cfg.max_unsupported_resolves:
|
|
1440
2688
|
unsupported_retries += 1
|
|
1441
2689
|
current = self.detect_captcha(page)
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
2690
|
+
with self._phase("settle"):
|
|
2691
|
+
_settled = self._wait_for_element_settled(current)
|
|
2692
|
+
if current and _settled == "animated":
|
|
2693
|
+
# Used to be terminal. Now it just means the next round is an
|
|
2694
|
+
# animated puzzle: retry the loop and `_solve_single` takes the
|
|
2695
|
+
# recording path. `unsupported_retries` still bounds it, so a
|
|
2696
|
+
# widget that is animated AND unsolvable cannot spin here.
|
|
2697
|
+
if not cfg.video_solve_enabled:
|
|
2698
|
+
raise AnimatedChallengeError(
|
|
2699
|
+
"the challenge never settles and video_solve_enabled is off"
|
|
2700
|
+
)
|
|
2701
|
+
_log('"unsupported" mid-solve and the next round is animated; '
|
|
2702
|
+
"retrying into the recording path.")
|
|
2703
|
+
continue
|
|
1446
2704
|
_log(
|
|
1447
2705
|
f'"unsupported" mid-solve; settled and retrying '
|
|
1448
2706
|
f"({unsupported_retries}/{cfg.max_unsupported_resolves})."
|
|
1449
2707
|
)
|
|
1450
2708
|
continue
|
|
2709
|
+
# The solver's OWN message, not a guess about what it saw.
|
|
2710
|
+
# This used to substitute "likely an hCaptcha click/drag
|
|
2711
|
+
# puzzle" for every unsupported verdict, including the ones
|
|
2712
|
+
# that already said exactly what was wrong — "prompt
|
|
2713
|
+
# generation 1 has no distorted-text prompt", say, which names
|
|
2714
|
+
# both the cause and the fix. Reporting a wrong guess in place
|
|
2715
|
+
# of a right answer costs whoever reads the gate an
|
|
2716
|
+
# investigation, every time.
|
|
1451
2717
|
raise UnsupportedChallengeError(
|
|
1452
|
-
"cannot solve this kind of captcha —
|
|
1453
|
-
|
|
1454
|
-
)
|
|
2718
|
+
f"cannot solve this kind of captcha — {unsupported}"
|
|
2719
|
+
) from unsupported
|
|
1455
2720
|
except Exception as exc:
|
|
1456
2721
|
# A stale/detached handle after a submit is a TRANSITION, not a
|
|
1457
2722
|
# dead puzzle: hCaptcha swapped in the next round while we held
|
|
1458
2723
|
# the old iframe. Only after interacting — a first-frame failure
|
|
1459
2724
|
# is a genuine problem worth surfacing.
|
|
1460
2725
|
message = str(exc)
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
2726
|
+
closed = bool(_CLOSED_TARGET_RE.search(message))
|
|
2727
|
+
if has_interacted and (closed or _STALE_HANDLE_RE.search(message)):
|
|
2728
|
+
# ASK THE VENDOR FIRST. The handle most often went stale
|
|
2729
|
+
# BECAUSE the answer was accepted: reCAPTCHA tears the
|
|
2730
|
+
# challenge iframe down the instant it takes an answer, and
|
|
2731
|
+
# hCaptcha swaps the frame out. Re-entering the pipeline
|
|
2732
|
+
# here spends a whole round — detect, screenshot, infer — on
|
|
2733
|
+
# a puzzle that no longer exists, and races the teardown.
|
|
2734
|
+
# Headless usually won that race; headed usually lost it and
|
|
2735
|
+
# surfaced `TargetClosedError` from whatever ran next.
|
|
2736
|
+
#
|
|
2737
|
+
# The top-of-loop check is not enough on its own: it runs
|
|
2738
|
+
# only after the backoff, and it does DOM reads that throw
|
|
2739
|
+
# on a target that is already gone.
|
|
2740
|
+
try:
|
|
2741
|
+
if self.is_captcha_solved(page):
|
|
2742
|
+
_log("captcha reports solved; finishing.")
|
|
2743
|
+
return SolveResult(
|
|
2744
|
+
True, self._last_mouse, _aggregate(cumulative_usage)
|
|
2745
|
+
)
|
|
2746
|
+
except Exception: # noqa: BLE001
|
|
2747
|
+
# Can't consult the vendor — the page is gone. Fall
|
|
2748
|
+
# through; `closed` decides between a named error and a
|
|
2749
|
+
# retry, and neither wants this exception in its place.
|
|
2750
|
+
pass
|
|
2751
|
+
|
|
2752
|
+
if closed:
|
|
2753
|
+
raise PageClosedError(
|
|
2754
|
+
"the page, context or browser closed mid-solve, after the "
|
|
2755
|
+
"answer had been submitted but before the vendor's verdict "
|
|
2756
|
+
"could be read — the solve may in fact have succeeded"
|
|
2757
|
+
) from exc
|
|
2758
|
+
|
|
2759
|
+
if stale_element_retries < cfg.max_stale_element_retries:
|
|
2760
|
+
stale_element_retries += 1
|
|
2761
|
+
_log(
|
|
2762
|
+
f"stale challenge handle after submit; re-detecting next round "
|
|
2763
|
+
f"({stale_element_retries}/{cfg.max_stale_element_retries})."
|
|
2764
|
+
)
|
|
2765
|
+
_delay(cfg.stale_element_backoff_ms)
|
|
2766
|
+
continue
|
|
1473
2767
|
raise
|
|
1474
2768
|
|
|
1475
2769
|
has_interacted = has_interacted or did_interact
|
|
2770
|
+
|
|
2771
|
+
# Give up on a solve that is repeating itself, rather than letting
|
|
2772
|
+
# the clock do it. The alternative is not "one more chance" — it is
|
|
2773
|
+
# the same click, again, until `overall_solve_timeout_ms`, which is
|
|
2774
|
+
# how a hopeless captcha came to cost 66s and how half of Tier 3's
|
|
2775
|
+
# wall-clock was being spent on attempts that could not succeed.
|
|
2776
|
+
if self._no_progress_rounds >= cfg.max_no_progress_rounds:
|
|
2777
|
+
raise CaptchaSolveError(
|
|
2778
|
+
f"no progress: the model returned the same answer "
|
|
2779
|
+
f"{self._no_progress_rounds + 1} times running and the challenge "
|
|
2780
|
+
f"is still up (attempt {attempt}/{cfg.max_solve_loops})"
|
|
2781
|
+
)
|
|
1476
2782
|
render_waits = 0
|
|
1477
2783
|
cumulative_usage.extend(usage)
|
|
1478
2784
|
|
|
@@ -1484,17 +2790,52 @@ class PageSolver:
|
|
|
1484
2790
|
# definitive signal, so it cannot loop.
|
|
1485
2791
|
deadline = time.monotonic() * 1000.0 + cfg.post_solve_outcome_timeout_ms
|
|
1486
2792
|
solved = False
|
|
2793
|
+
widget_gone = 0
|
|
2794
|
+
_verdict_t0 = time.perf_counter()
|
|
1487
2795
|
while time.monotonic() * 1000.0 < deadline:
|
|
1488
2796
|
if self.is_captcha_solved(page):
|
|
1489
2797
|
solved = True
|
|
1490
2798
|
break
|
|
2799
|
+
# The eight inline vendors have no response token, so
|
|
2800
|
+
# `is_captcha_solved` — which reads only the hCaptcha and
|
|
2801
|
+
# reCAPTCHA anchors — can never fire for them and this loop
|
|
2802
|
+
# ran out its whole 2.5s budget on EVERY round, waiting for
|
|
2803
|
+
# a signal that cannot arrive. Measured on geetest_v4_slide:
|
|
2804
|
+
# 5.2s of a 12.3s solve, spent after the puzzle was already
|
|
2805
|
+
# answered, with the widget sitting there visibly solved.
|
|
2806
|
+
#
|
|
2807
|
+
# "The widget is gone" is the completion signal for those
|
|
2808
|
+
# vendors and is already the authority immediately after
|
|
2809
|
+
# this loop, so this only reaches the same verdict sooner —
|
|
2810
|
+
# confirmed over two polls so a frame caught mid-swap
|
|
2811
|
+
# between rounds cannot read as a solve.
|
|
2812
|
+
if self.detect_captcha(page) is None:
|
|
2813
|
+
widget_gone += 1
|
|
2814
|
+
if widget_gone >= 2:
|
|
2815
|
+
solved = True
|
|
2816
|
+
break
|
|
2817
|
+
else:
|
|
2818
|
+
widget_gone = 0
|
|
1491
2819
|
if self._is_challenge_freshly_rendered(page):
|
|
1492
2820
|
break # next round is up; go solve it now
|
|
1493
|
-
_delay(
|
|
2821
|
+
_delay(cfg.post_solve_outcome_poll_ms)
|
|
2822
|
+
_verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
|
|
2823
|
+
if self._budget is not None:
|
|
2824
|
+
self._budget.add("await-verdict", _verdict_ms)
|
|
2825
|
+
# How long a SUCCESS actually took to show itself. This is the
|
|
2826
|
+
# only number that can size `post_solve_outcome_timeout_ms`: the
|
|
2827
|
+
# window exists to catch a late success, so it needs to cover
|
|
2828
|
+
# the slowest real one and nothing beyond it. A round that ends
|
|
2829
|
+
# any other way spends the whole window by construction — there
|
|
2830
|
+
# is no signal on a wrong answer — so its duration says nothing
|
|
2831
|
+
# about how long the window ought to be.
|
|
2832
|
+
if solved:
|
|
2833
|
+
_log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
|
|
1494
2834
|
if solved:
|
|
1495
2835
|
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
1496
2836
|
else:
|
|
1497
|
-
|
|
2837
|
+
with self._phase("post-submit-delay"):
|
|
2838
|
+
_delay(cfg.post_solve_delay_ms + random.random() * 300)
|
|
1498
2839
|
|
|
1499
2840
|
if self._has_recaptcha_underselect_error(page):
|
|
1500
2841
|
if already_retried_underselect:
|
|
@@ -1525,6 +2866,28 @@ class PageSolver:
|
|
|
1525
2866
|
# --------------------------------------------------------------------------
|
|
1526
2867
|
|
|
1527
2868
|
|
|
2869
|
+
def _bbox_center(bbox: Sequence[float]) -> Tuple[float, float]:
|
|
2870
|
+
"""Centre of an [x1, y1, x2, y2] 0–1 box, as the (x, y) 0–1 point the keyframe
|
|
2871
|
+
wait gate compares around. The solver builds these boxes as a small square
|
|
2872
|
+
around the model's point, so the centre recovers that point exactly."""
|
|
2873
|
+
x1, y1, x2, y2 = (float(v) for v in bbox)
|
|
2874
|
+
return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
|
|
2875
|
+
|
|
2876
|
+
|
|
2877
|
+
def _round_pts(value: Any, places: int = 3) -> Any:
|
|
2878
|
+
"""Coordinates rounded, for comparing two answers for sameness.
|
|
2879
|
+
|
|
2880
|
+
Rounded rather than compared exactly because the same tile chosen twice can
|
|
2881
|
+
differ in the last float digit after the normalise/clamp round-trip, and a
|
|
2882
|
+
repeat that reads as "different" is a repeat that costs a round.
|
|
2883
|
+
"""
|
|
2884
|
+
if isinstance(value, (int, float)):
|
|
2885
|
+
return round(float(value), places)
|
|
2886
|
+
if isinstance(value, (list, tuple)):
|
|
2887
|
+
return [_round_pts(v, places) for v in value]
|
|
2888
|
+
return value
|
|
2889
|
+
|
|
2890
|
+
|
|
1528
2891
|
def _as_dict(action: Any) -> Dict[str, Any]:
|
|
1529
2892
|
"""Actions arrive as pydantic models in-process, or dicts from JSON."""
|
|
1530
2893
|
if isinstance(action, dict):
|