captchakraken 2.6.1 → 2.9.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/README.md +90 -7
- package/dist/cli-invocation.d.ts +16 -16
- package/dist/cli-invocation.js +33 -21
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/model-name.d.ts +1 -1
- package/dist/model-name.js +1 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +314 -16
- package/dist/solver.js +1208 -278
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +28 -1
- package/dist/trajectory.js +105 -1
- package/dist/types.d.ts +113 -2
- package/package.json +2 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +66 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +184 -12
- package/python/src/captchakraken/models.json +180 -18
- package/python/src/captchakraken/page_solver.py +1132 -321
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +204 -16
- package/python/src/captchakraken/prompts.py +186 -4
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +98 -58
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
|
@@ -50,10 +50,11 @@ from contextlib import contextmanager
|
|
|
50
50
|
from dataclasses import dataclass, field
|
|
51
51
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
52
52
|
|
|
53
|
+
from . import planner
|
|
53
54
|
from .action_types import CaptchaAction
|
|
54
55
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
55
56
|
from .timing import PhaseBudget, timings_enabled
|
|
56
|
-
from .
|
|
57
|
+
from .humanize import Humanizer, resolve as resolve_humanizer
|
|
57
58
|
|
|
58
59
|
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
59
60
|
|
|
@@ -156,6 +157,21 @@ _CLOSED_TARGET_RE = re.compile(
|
|
|
156
157
|
)
|
|
157
158
|
|
|
158
159
|
|
|
160
|
+
def answer_needs_element_box(actions: List[Dict[str, Any]]) -> bool:
|
|
161
|
+
"""Does this answer need the widget's box to be performed?
|
|
162
|
+
|
|
163
|
+
Only a coordinate-bearing action does. ``done`` means "nothing left to
|
|
164
|
+
click", which is exactly the answer a vendor that closes on success will be
|
|
165
|
+
showing no widget for.
|
|
166
|
+
|
|
167
|
+
An ALLOW-LIST of what needs nothing, not a list of what does: a coordinate
|
|
168
|
+
action added later and forgotten here must default to needing the box,
|
|
169
|
+
because that direction raises instead of clicking at the origin. Twin of
|
|
170
|
+
`answerNeedsElementBox` in the JS port (rule 1c) — if one moves, move both.
|
|
171
|
+
"""
|
|
172
|
+
return any((a or {}).get("action") != "done" for a in actions)
|
|
173
|
+
|
|
174
|
+
|
|
159
175
|
@dataclass
|
|
160
176
|
class SolveResult:
|
|
161
177
|
"""Mirror of the TS `SolveResult`."""
|
|
@@ -163,6 +179,14 @@ class SolveResult:
|
|
|
163
179
|
is_solved: bool
|
|
164
180
|
final_mouse_position: Tuple[float, float]
|
|
165
181
|
token_usage: List[Dict[str, Any]] = field(default_factory=list)
|
|
182
|
+
#: Where this solve's wall-clock went, in milliseconds per phase — the same
|
|
183
|
+
#: partition `CAPTCHA_TIMINGS=1` prints, handed to the caller instead of to
|
|
184
|
+
#: stderr. `{}` when the result was built outside a solve.
|
|
185
|
+
#:
|
|
186
|
+
#: Returned rather than only logged because "the solve took 12s" is not
|
|
187
|
+
#: actionable and "the settle monitor spent 4s of it" is, and a caller
|
|
188
|
+
#: measuring a fleet cannot scrape stderr from inside its own process.
|
|
189
|
+
phases: Dict[str, float] = field(default_factory=dict)
|
|
166
190
|
|
|
167
191
|
|
|
168
192
|
@dataclass
|
|
@@ -202,6 +226,40 @@ def settle_verdict(samples, *, settle_frames: int, animated_after_ms: int) -> st
|
|
|
202
226
|
return "timeout"
|
|
203
227
|
|
|
204
228
|
|
|
229
|
+
def burst_hang_deadline_ms(cfg) -> float:
|
|
230
|
+
"""How long a keyframe burst may run before it is called HUNG.
|
|
231
|
+
|
|
232
|
+
A hang detector, not a budget: `overall_solve_timeout_ms` is what bounds
|
|
233
|
+
real time. This only has to notice a screenshot that never returns, so it
|
|
234
|
+
is deliberately slack.
|
|
235
|
+
|
|
236
|
+
Sized off the CEILING, because that is what sizes the loop —
|
|
237
|
+
`_record_keyframes` plans `video_burst_max_ms` worth of frames, not
|
|
238
|
+
`video_burst_duration_ms` worth. Deriving it from the floor gave a
|
|
239
|
+
120-frame burst 17s, i.e. 141ms per frame against a 100ms interval, and a
|
|
240
|
+
burst that simply ran to its ceiling killed the attempt. Must match
|
|
241
|
+
`burstHangDeadlineMs` in the JS port.
|
|
242
|
+
"""
|
|
243
|
+
return 3.0 * float(cfg.video_burst_max_ms) + 5_000.0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
#: How unlike the chosen keyframe the widget must look before the gate calls it
|
|
247
|
+
#: a different board, and over how many polls. Measured on GeeTest svg: two
|
|
248
|
+
#: SCREENS of one board differ by 0.0056, a different board by 0.77 — two orders
|
|
249
|
+
#: of magnitude, so this is a plateau rather than a knife edge. Several polls
|
|
250
|
+
#: because a frame caught mid-swap can read high for a moment. Must match
|
|
251
|
+
#: NOT_THIS_BOARD_DIFF / _POLLS in the JS port.
|
|
252
|
+
#: How much the widget must change during inference to count as MOVING. A far
|
|
253
|
+
#: tighter number than `stale_frame_diff_threshold` (0.02) because it answers a
|
|
254
|
+
#: different question: 0.02 asks "did this answer go stale", this asks "is this
|
|
255
|
+
#: the same picture", which is the noise floor. Using the stale threshold for
|
|
256
|
+
#: both made the guard blind to GeeTest svg, whose screens differ by 0.0056.
|
|
257
|
+
#: Must match MOVED_DURING_INFERENCE_DIFF in the JS port.
|
|
258
|
+
_MOVED_DURING_INFERENCE_DIFF = 0.002
|
|
259
|
+
_NOT_THIS_BOARD_DIFF = 0.5
|
|
260
|
+
_NOT_THIS_BOARD_POLLS = 3
|
|
261
|
+
|
|
262
|
+
|
|
205
263
|
@dataclass
|
|
206
264
|
class PageSolverConfig:
|
|
207
265
|
"""
|
|
@@ -209,6 +267,48 @@ class PageSolverConfig:
|
|
|
209
267
|
value tuned on one driver can be found on the other.
|
|
210
268
|
"""
|
|
211
269
|
|
|
270
|
+
# ── how the driver moves ───────────────────────────────────────────────
|
|
271
|
+
#: "mouse" (default), "mobile" or "none". See `humanize.py`.
|
|
272
|
+
#:
|
|
273
|
+
#: Not a realism dial — a choice of INPUT DEVICE. "mobile" dispatches touch
|
|
274
|
+
#: events with finger kinematics, and on a touch-only widget that is the
|
|
275
|
+
#: difference between the page's handlers firing and not. "none" is the
|
|
276
|
+
#: shortest legal path to the same DOM effect, for fixtures and for callers
|
|
277
|
+
#: who humanise somewhere else in their stack.
|
|
278
|
+
#:
|
|
279
|
+
#: None means unset: `CAPTCHA_HUMANIZATION`, else "mouse".
|
|
280
|
+
humanization: Optional[str] = None
|
|
281
|
+
#: Your own `humanize.Humanizer`. Overrides `humanization` entirely — the
|
|
282
|
+
#: driver then makes no decisions about pointer motion at all.
|
|
283
|
+
humanizer: Optional[Humanizer] = None
|
|
284
|
+
#: mobile only. The thing that is actually TOUCHED, when it is not the page
|
|
285
|
+
#: object — an Appium/Selenium WebDriver on a real handset. Left None, the
|
|
286
|
+
#: mode dispatches CDP touch events at the page it was given.
|
|
287
|
+
touch_driver: Optional[Any] = None
|
|
288
|
+
#: mobile only. CSS-pixel -> device-pixel transform for `touch_driver`,
|
|
289
|
+
#: e.g. `{"scale": 3.0, "origin": (0, 132)}`. See AppiumTouchBackend; the
|
|
290
|
+
#: default identity is right for browser emulation.
|
|
291
|
+
touch_transform: Optional[Dict[str, Any]] = None
|
|
292
|
+
#: Where the pointer starts. Setting it stops the first gesture of a solve
|
|
293
|
+
#: crossing the whole window from the origin.
|
|
294
|
+
starting_mouse_position: Optional[Tuple[float, float]] = None
|
|
295
|
+
|
|
296
|
+
# ── which model answers ────────────────────────────────────────────────
|
|
297
|
+
#: Force ONE expert of a routed model — "pixel", "grid", "video" or "text".
|
|
298
|
+
#:
|
|
299
|
+
#: A routed model (Abyss) is four adapters behind one endpoint, and the
|
|
300
|
+
#: router is the prompt family the request is about to send, so the default
|
|
301
|
+
#: (None) already reaches the right one with no help from the caller. This
|
|
302
|
+
#: is the override: serve one arm, drive only the puzzles it owns, which is
|
|
303
|
+
#: what a per-arm benchmark needs and what a licence holder pinning a single
|
|
304
|
+
#: expert wants.
|
|
305
|
+
#:
|
|
306
|
+
#: Refused at construction against a model that serves one adapter — every
|
|
307
|
+
#: model published so far — because a run that quietly measured the
|
|
308
|
+
#: generalist and reported it as the expert is a number nobody can catch.
|
|
309
|
+
#: None means unset: `CAPTCHA_EXPERT`, else route by family.
|
|
310
|
+
expert: Optional[str] = None
|
|
311
|
+
|
|
212
312
|
# 45s, and the loop count that fits inside it rather than one that needs
|
|
213
313
|
# policing by the clock. A round costs ~4-7s once the waits below are paid,
|
|
214
314
|
# so six rounds is the budget; ten never fitted and only ever expressed
|
|
@@ -225,7 +325,7 @@ class PageSolverConfig:
|
|
|
225
325
|
#: is EXECUTED, so the previous one already ran and moved nothing. Repeating
|
|
226
326
|
#: it cannot do better; it just spends a round.
|
|
227
327
|
#:
|
|
228
|
-
#: Measured on
|
|
328
|
+
#: Measured on a reCAPTCHA 4x4 grid (the fixture): the model answers
|
|
229
329
|
#: `[2,6,7,9,10]` on round 1 and then `[2,6,7,10]` on rounds 2 THROUGH 10 —
|
|
230
330
|
#: nine identical click sets, each one clicked, each one rejected, ending in
|
|
231
331
|
#: "still detected after 10 solve loops" at 66.1s. Of that, 39.0s was pure
|
|
@@ -235,6 +335,7 @@ class PageSolverConfig:
|
|
|
235
335
|
#: the one recovery worth trying (a cycling board reads as a still and
|
|
236
336
|
#: answers the same way every round). One more round buys that chance.
|
|
237
337
|
max_no_progress_rounds: int = 2
|
|
338
|
+
|
|
238
339
|
post_solve_delay_ms: int = 1_200
|
|
239
340
|
# How long to keep watching for a SUCCESS after submitting.
|
|
240
341
|
#
|
|
@@ -255,7 +356,7 @@ class PageSolverConfig:
|
|
|
255
356
|
# 1000ms is therefore the measured worst case (528ms, ~264ms once the poll
|
|
256
357
|
# below is halved) plus deliberate headroom for a vendor round-trip nobody
|
|
257
358
|
# has measured yet. Down from 2500ms, which was spent in full on every
|
|
258
|
-
# unsuccessful round — 25.5s of one 66.1s
|
|
359
|
+
# unsuccessful round — 25.5s of one 66.1s a reCAPTCHA 4x4 grid solve.
|
|
259
360
|
#
|
|
260
361
|
# To replace the headroom with a number: drive the real vendor pages via
|
|
261
362
|
# `tests/live-solve/src/demo-targets.ts` and re-read this distribution.
|
|
@@ -305,17 +406,55 @@ class PageSolverConfig:
|
|
|
305
406
|
# still than spend `video_burst_duration_ms` finding out.
|
|
306
407
|
animated_probe_enabled: bool = True
|
|
307
408
|
# Burst geometry. Deliberately identical to the collector's
|
|
308
|
-
# (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the
|
|
409
|
+
# (`_collect_common.BURST_DURATION_MS` / `BURST_FPS` in the training repo), so a
|
|
309
410
|
# challenge recorded here is the same shape of artifact the model trained on —
|
|
310
411
|
# same clip length, same frame rate, therefore the same keyframe slicing.
|
|
311
412
|
video_burst_duration_ms: int = 4_000
|
|
413
|
+
#: Hard ceiling on a burst that never repeats a screen. The burst normally
|
|
414
|
+
#: ends when the board's cycle closes; this bounds a continuous animation,
|
|
415
|
+
#: which never closes one. Must match `videoBurstMaxMs` in the JS port.
|
|
416
|
+
video_burst_max_ms: int = 12_000
|
|
417
|
+
#: Record while the still screenshot is being read, so a cycling board is
|
|
418
|
+
#: known before its answer is acted on. Must match `speculativeBurstEnabled`
|
|
419
|
+
#: in the JS port.
|
|
420
|
+
#:
|
|
421
|
+
#: OFF since 2026-09-09, because it cost the most on exactly the boards it
|
|
422
|
+
#: had nothing to say about. `_speculate` calls a board MOVING when two
|
|
423
|
+
#: frames hash differently — an exact SHA-1 of the PNG bytes — so one pixel
|
|
424
|
+
#: of render noise reads as a second screen, `cycle_closed` never fires
|
|
425
|
+
#: (noise does not repeat), and the burst runs to `video_burst_max_ms`.
|
|
426
|
+
#:
|
|
427
|
+
#: Measured over the 6 Sep full Tier 3 (260 attempts), and the numbers are
|
|
428
|
+
#: the wrong way round: the genuinely ANIMATED families closed their cycle
|
|
429
|
+
#: at the 4s floor — tile_flip 3.96s, odd_animal 3.98s, highest_jumper
|
|
430
|
+
#: 3.95s — while STILL boards ran to the 12s ceiling. tower_stack 12.03s,
|
|
431
|
+
#: click_blocked_by_lines 11.93s, missing_piece 11.89s, silhouette_match
|
|
432
|
+
#: 11.76s, tetris_fit 9.96s, connect_path 9.94s. Those are click and drag
|
|
433
|
+
#: archetypes with no animation in them, and three of them scored 0%: they
|
|
434
|
+
#: were not answered wrongly, they ran out of budget being filmed.
|
|
435
|
+
#:
|
|
436
|
+
#: Worse than the wall clock, `len(order) >= 2` also DISCARDS the still
|
|
437
|
+
#: answer and escalates to the keyframe path, so a correct reading of a
|
|
438
|
+
#: static board was being thrown away because the render jittered.
|
|
439
|
+
#:
|
|
440
|
+
#: An animated board is still solved: `animated_probe_enabled` escalates on
|
|
441
|
+
#: the repeated-answer signal and `_record_keyframes` films a bounded 4s.
|
|
442
|
+
#: That costs a genuinely animated board one extra round and saves every
|
|
443
|
+
#: still board 4-12s. Turn this back on only with the digest replaced by a
|
|
444
|
+
#: comparison that has a noise floor.
|
|
445
|
+
speculative_burst_enabled: bool = False
|
|
312
446
|
video_burst_fps: int = 10
|
|
313
447
|
# How long to wait for the widget to return to the keyframe the model chose,
|
|
314
448
|
# before clicking anyway. Bounded because the alternative is worse: these
|
|
315
449
|
# puzzles cycle, so the state DOES come back — but if the recording caught a
|
|
316
450
|
# one-off transition it never will, and a click on the model's coordinates is
|
|
317
451
|
# still a better use of the remaining budget than a timeout.
|
|
318
|
-
|
|
452
|
+
# 9000, not 6000. A GeeTest svg board dwells p50 1.5s / p75 2.0s / max 2.7s
|
|
453
|
+
# per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst case — a
|
|
454
|
+
# 6s budget could not catch the worst case however well aimed. Must match
|
|
455
|
+
# SOLVE_DEFAULTS.keyframeWaitTimeoutMs in the JS port: `video_budget_ms` is
|
|
456
|
+
# derived from it on both sides and the two must not disagree.
|
|
457
|
+
keyframe_wait_timeout_ms: int = 9_000
|
|
319
458
|
keyframe_wait_poll_ms: int = 120
|
|
320
459
|
#: Extra wall clock granted ONCE, the first time a solve escalates to a
|
|
321
460
|
#: recording. NOT a looser `overall_solve_timeout_ms`.
|
|
@@ -329,9 +468,9 @@ class PageSolverConfig:
|
|
|
329
468
|
#: reported a TIMEOUT — which reads as a slow model rather than as a budget
|
|
330
469
|
#: with no room for the thing the solver had just decided to do.
|
|
331
470
|
#:
|
|
332
|
-
#: Measured 2026-08-22,
|
|
333
|
-
#:
|
|
334
|
-
#:
|
|
471
|
+
#: Measured 2026-08-22, a full driver-gate run: EVERY python-port failure on
|
|
472
|
+
#: an hCaptcha odd-one-out animation, an hCaptcha highest-value animation and
|
|
473
|
+
#: an hCaptcha tile-flip animation was "exceeded overall_solve_timeout_ms during
|
|
335
474
|
#: recording the animated challenge", at 45.7-52.7s elapsed. The same three
|
|
336
475
|
#: fixtures solve in 11-20s on the rounds where the still path happens to
|
|
337
476
|
#: answer them, so it is the escalation that does not fit, not the puzzle.
|
|
@@ -347,7 +486,7 @@ class PageSolverConfig:
|
|
|
347
486
|
DERIVED, so a longer burst or a longer keyframe wait carries its own
|
|
348
487
|
budget instead of quietly reintroducing the timeout this exists to fix.
|
|
349
488
|
"""
|
|
350
|
-
return (self.
|
|
489
|
+
return (self.video_burst_max_ms + self.keyframe_wait_timeout_ms
|
|
351
490
|
+ self.video_extra_inference_ms)
|
|
352
491
|
|
|
353
492
|
# Grid load / dynamic-refresh timing.
|
|
@@ -388,16 +527,93 @@ class PageSolverConfig:
|
|
|
388
527
|
# solver.ts — keep both in the same order with the same selectors.
|
|
389
528
|
VENDOR_WIDGET_LOCATORS = [
|
|
390
529
|
{"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
|
|
391
|
-
|
|
530
|
+
# Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
|
|
531
|
+
# `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first,
|
|
532
|
+
# because the iframe build is still deployed on sites pinning an older
|
|
533
|
+
# widget and costs nothing to keep. Dropping the in-page selectors is what
|
|
534
|
+
# made the client blind to every live Tencent captcha for twelve days.
|
|
535
|
+
#
|
|
536
|
+
# `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
|
|
537
|
+
# MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
|
|
538
|
+
# Tencent's entry was silently the only thing detecting MTCaptcha at all,
|
|
539
|
+
# which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
|
|
540
|
+
# matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
|
|
541
|
+
{"puzzle_source": "tencent", "selectors": ['#tcaptcha_transform_dy', '#tCaptchaDyContent', '.tencent-captcha-dy__content', 'iframe#tcaptcha_iframe_dy', 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
|
|
392
542
|
{"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
|
|
393
|
-
|
|
543
|
+
# Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
|
|
544
|
+
# that frame's document — `query_selector` does not cross a frame boundary,
|
|
545
|
+
# so the host page never carried it and this entry matched nothing, ever.
|
|
546
|
+
# Challenge frame first, anchor second, exactly like reCAPTCHA above.
|
|
547
|
+
# `.CheckboxCaptcha` stays for an inline embed that renders it directly.
|
|
548
|
+
{"puzzle_source": "yandex", "selectors": ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', ".CheckboxCaptcha"]},
|
|
394
549
|
{"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
|
|
395
550
|
{"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
|
|
396
|
-
|
|
551
|
+
# MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
|
|
552
|
+
# id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
|
|
553
|
+
# is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
|
|
554
|
+
# class selector matches whole tokens, so it never matched the host page's
|
|
555
|
+
# `class="mtcaptcha"` either. Kept last for an inline embed.
|
|
556
|
+
{"puzzle_source": "mtcaptcha", "selectors": ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', ".mtcaptcha", ".mtcap"]},
|
|
397
557
|
{"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
|
|
398
558
|
]
|
|
399
559
|
|
|
400
560
|
|
|
561
|
+
# ── which vendor's code the page LOADED, as opposed to what it rendered ─────
|
|
562
|
+
#
|
|
563
|
+
# A selector is a claim about markup, and markup is the half of a captcha the
|
|
564
|
+
# vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
|
|
565
|
+
# the point. The REQUEST is far more stable: Tencent moved its widget out of an
|
|
566
|
+
# iframe and renamed every class on 2026-08-11, and went on fetching from
|
|
567
|
+
# `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
|
|
568
|
+
# substring `captcha.gtimg.com` — already in the table above, on a selector that
|
|
569
|
+
# stopped matching — never left the wire.
|
|
570
|
+
#
|
|
571
|
+
# This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
|
|
572
|
+
# page; it cannot say where the widget is, and the solver needs an ELEMENT to
|
|
573
|
+
# screenshot, to scope its controls against, and to drag. What it buys is the
|
|
574
|
+
# difference between the two things a null detection can mean:
|
|
575
|
+
#
|
|
576
|
+
# · no captcha here (reCAPTCHA v3 / invisible / not triggered yet) — fine, and
|
|
577
|
+
# what the message used to guess unconditionally;
|
|
578
|
+
# · the vendor's code is loaded and running and NOTHING WE LOOK FOR MATCHES —
|
|
579
|
+
# i.e. the DOM moved under us. That is a bug report, and it took twelve days
|
|
580
|
+
# to notice the last time because the client said the first thing while
|
|
581
|
+
# meaning the second.
|
|
582
|
+
#
|
|
583
|
+
# Hosts measured 2026-08-24 by loading each vendor's demo page and recording
|
|
584
|
+
# every third-party request (see scripts/check_vendor_selectors.py --hosts).
|
|
585
|
+
#: The two vendors with bespoke handling in this file. EVERYTHING else shares the
|
|
586
|
+
#: generic path, and the two behaviours below are gated on "not one of these" —
|
|
587
|
+
#: spelled as this set rather than as `== "unknown"`, which is what they used to
|
|
588
|
+
#: say. The two were identical only while `unknown` was the sole third value, so
|
|
589
|
+
#: the day anyone reports a vendor by name — to constrain which grid shapes it may
|
|
590
|
+
#: be solved as, say — `== "unknown"` silently turns OFF typed-challenge detection
|
|
591
|
+
#: for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
|
|
592
|
+
#: animated settle probe for GeeTest and Tencent. Neither failure raises; the text
|
|
593
|
+
#: captcha just becomes unsolvable and the animated board gets answered from one
|
|
594
|
+
#: arbitrary frame.
|
|
595
|
+
VENDORS_WITH_BESPOKE_HANDLING = frozenset({"hcaptcha", "recaptcha"})
|
|
596
|
+
|
|
597
|
+
VENDOR_URL_MARKERS = [
|
|
598
|
+
{"puzzle_source": "hcaptcha", "hosts": ["hcaptcha.com"]},
|
|
599
|
+
{"puzzle_source": "recaptcha", "hosts": ["google.com/recaptcha", "recaptcha.net"]},
|
|
600
|
+
{"puzzle_source": "turnstile", "hosts": ["challenges.cloudflare.com"]},
|
|
601
|
+
{"puzzle_source": "geetest", "hosts": ["geetest.com"]},
|
|
602
|
+
{"puzzle_source": "tencent", "hosts": ["captcha.gtimg.com", "captcha.qcloud.com"]},
|
|
603
|
+
{"puzzle_source": "yidun", "hosts": ["dun.163.com", "cstaticdun.126.net",
|
|
604
|
+
"necaptcha.nosdn.127.net"]},
|
|
605
|
+
{"puzzle_source": "yandex", "hosts": ["smartcaptcha.yandexcloud.net"]},
|
|
606
|
+
{"puzzle_source": "lemin", "hosts": ["leminnow.com"]},
|
|
607
|
+
{"puzzle_source": "prosopo", "hosts": ["prosopo.io"]},
|
|
608
|
+
{"puzzle_source": "mtcaptcha", "hosts": ["mtcaptcha.com"]},
|
|
609
|
+
# BotDetect is deliberately absent and must stay absent. It is a SELF-HOSTED
|
|
610
|
+
# library — the image comes from the application's own origin
|
|
611
|
+
# (`BotDetectCaptcha.ashx`), so there is no vendor host to see and a URL
|
|
612
|
+
# tripwire cannot cover it. Recording that is the point: an empty result for
|
|
613
|
+
# a BotDetect page means "this check does not apply", not "no captcha".
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
|
|
401
617
|
# ── where the answer goes, when it is not a click ───────────────────────────
|
|
402
618
|
#
|
|
403
619
|
# Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
|
|
@@ -414,7 +630,13 @@ VENDOR_WIDGET_LOCATORS = [
|
|
|
414
630
|
|
|
415
631
|
# A distorted-text captcha's answer box. The three vendor entries are the three
|
|
416
632
|
# types in instructions.py::TEXT_TYPES.
|
|
417
|
-
|
|
633
|
+
#
|
|
634
|
+
# Split in two because the two halves are safe in different places. The NAMED
|
|
635
|
+
# half identifies a captcha's answer box wherever it sits; the GENERIC half only
|
|
636
|
+
# means "the text box in this widget" and is trustworthy only when the scope
|
|
637
|
+
# already is the widget. `_answer_box` widens past the widget for BotDetect and
|
|
638
|
+
# may take the named half with it, never the generic one.
|
|
639
|
+
TEXT_INPUT_VENDOR_SELECTORS = [
|
|
418
640
|
# BotDetect — the input is application-defined, so match the id fragment its
|
|
419
641
|
# own docs and samples use. These three are what the nightly collector
|
|
420
642
|
# already drives (src/captchaCollection/sources.py).
|
|
@@ -429,6 +651,9 @@ TEXT_INPUT_SELECTORS = [
|
|
|
429
651
|
".AdvancedCaptcha-Input input",
|
|
430
652
|
"input.Textinput-Control",
|
|
431
653
|
'input[name="rep"]',
|
|
654
|
+
]
|
|
655
|
+
|
|
656
|
+
TEXT_INPUT_GENERIC_SELECTORS = [
|
|
432
657
|
# Generic — an input the page itself labels as the captcha answer.
|
|
433
658
|
'input[name*="captcha" i]',
|
|
434
659
|
'input[id*="captcha" i]',
|
|
@@ -443,6 +668,8 @@ TEXT_INPUT_SELECTORS = [
|
|
|
443
668
|
"textarea",
|
|
444
669
|
]
|
|
445
670
|
|
|
671
|
+
TEXT_INPUT_SELECTORS = TEXT_INPUT_VENDOR_SELECTORS + TEXT_INPUT_GENERIC_SELECTORS
|
|
672
|
+
|
|
446
673
|
# The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
|
|
447
674
|
# these vendors the piece is inert decoration that the handle carries, so a
|
|
448
675
|
# drag starting on the piece moves nothing at all.
|
|
@@ -451,7 +678,10 @@ SLIDER_HANDLE_SELECTORS = [
|
|
|
451
678
|
".geetest_slider_button",
|
|
452
679
|
".geetest_btn",
|
|
453
680
|
".geetest_slider .geetest_arrow",
|
|
454
|
-
# Tencent
|
|
681
|
+
# Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
|
|
682
|
+
# no aria-valuenow — so every generic fallback below misses it and it has to
|
|
683
|
+
# be named. The three after it are the pre-redesign widget.
|
|
684
|
+
".tencent-captcha-dy__slider-block",
|
|
455
685
|
"#tcaptcha_drag_thumb",
|
|
456
686
|
".tc-slider-normal",
|
|
457
687
|
"[id*=slideBlock]",
|
|
@@ -508,17 +738,24 @@ class PageSolver:
|
|
|
508
738
|
self.config = config or PageSolverConfig()
|
|
509
739
|
# One CaptchaSolver for the whole driver: it owns the planner, which
|
|
510
740
|
# accumulates token usage and holds the HTTP session to vLLM.
|
|
741
|
+
#
|
|
742
|
+
# `expert` reaches the solver from the config, so the knob is in the
|
|
743
|
+
# same object as every other tunable and matches the TS port's
|
|
744
|
+
# `CaptchaKrakenConfig.expert`. An explicit kwarg still wins — a caller
|
|
745
|
+
# who built the argument list themselves has said the more specific
|
|
746
|
+
# thing — and a caller who passed their OWN solver has already made
|
|
747
|
+
# this decision inside it.
|
|
748
|
+
if self.config.expert is not None:
|
|
749
|
+
solver_kwargs.setdefault("expert", self.config.expert)
|
|
511
750
|
self._solver = solver or CaptchaSolver(**solver_kwargs)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
self.
|
|
751
|
+
#: Every gesture goes through here, and it owns the pointer position.
|
|
752
|
+
#: See humanize.py — the driver names gestures, this decides what
|
|
753
|
+
#: events they are and how long they take.
|
|
754
|
+
self._human: Humanizer = resolve_humanizer(self.config)
|
|
516
755
|
self._last_submit_frame_hash: Optional[str] = None
|
|
517
756
|
# Absolute deadline for the current solve, in the same clock as
|
|
518
757
|
# time.monotonic() * 1000. None outside a solve.
|
|
519
758
|
self._deadline_ms: Optional[float] = None
|
|
520
|
-
# Window size for clamping, resolved once per solve. See _viewport.
|
|
521
|
-
self._viewport_cache: Optional[Dict[str, float]] = None
|
|
522
759
|
# Animation detection, all reset per solve by `_reset_animated_state`.
|
|
523
760
|
# `_known_animated` latches: once a widget has been SEEN to animate,
|
|
524
761
|
# every later round takes the keyframe path without re-probing.
|
|
@@ -528,13 +765,40 @@ class PageSolver:
|
|
|
528
765
|
# One-shot: the recording path buys its own budget the first time it is
|
|
529
766
|
# entered, and never again in the same solve. See video_budget_ms.
|
|
530
767
|
self._video_budget_granted = False
|
|
768
|
+
# THE ONE recording and THE ONE answer for the animated board on screen.
|
|
769
|
+
# A cycling board needs a single inference: the burst shows the model
|
|
770
|
+
# every screen, the answer names the cell and the screen it is on, and
|
|
771
|
+
# the gate holds the click until that screen is back. None of that
|
|
772
|
+
# changes between rounds, so asking again buys nothing and costs a burst
|
|
773
|
+
# plus a multi-image inference. Mirrors `animatedPlan` in the JS port.
|
|
774
|
+
self._animated_plan: Optional[Tuple[List[str], str, List[CaptchaAction],
|
|
775
|
+
List[Dict[str, Any]]]] = None
|
|
531
776
|
self._keyframe_mode: Optional[str] = None
|
|
777
|
+
# How many distinct steady screens the current clip sits on; 0 if it is
|
|
778
|
+
# not that kind of clip. THE GATE KEYS ON THIS, NOT ON `_keyframe_mode`
|
|
779
|
+
# — see the JS twin's `keyframeSteadyScreens` and `steady_screens()` in
|
|
780
|
+
# keyframes.py. `even` means the slicer could not PROVE recurrence, not
|
|
781
|
+
# that the board has nothing to come back to.
|
|
782
|
+
self._keyframe_steady_screens: int = 0
|
|
532
783
|
# Repeat detection; see `max_no_progress_rounds`.
|
|
533
784
|
self._last_answer_sig: Optional[str] = None
|
|
534
785
|
self._no_progress_rounds = 0
|
|
786
|
+
#: How many times THIS board has been re-asked. Indexes
|
|
787
|
+
#: `planner.RESAMPLE_TEMPERATURES`; reset by `_fresh_board`.
|
|
788
|
+
self._resample_level = 0
|
|
535
789
|
# Per-solve phase accounting; see `_phase`.
|
|
536
790
|
self._budget: Optional[PhaseBudget] = None
|
|
537
791
|
|
|
792
|
+
@property
|
|
793
|
+
def _last_mouse(self) -> Tuple[float, float]:
|
|
794
|
+
"""Where the pointer is. Owned by the humanizer, because a mode that
|
|
795
|
+
dispatches no motion (mobile, between taps) still has to answer this."""
|
|
796
|
+
return self._human.at
|
|
797
|
+
|
|
798
|
+
@_last_mouse.setter
|
|
799
|
+
def _last_mouse(self, at: Tuple[float, float]) -> None:
|
|
800
|
+
self._human.at = (float(at[0]), float(at[1]))
|
|
801
|
+
|
|
538
802
|
@contextmanager
|
|
539
803
|
def _phase(self, name: str):
|
|
540
804
|
"""Attribute the enclosed wall-clock to `name` for this solve's budget.
|
|
@@ -585,7 +849,19 @@ class PageSolver:
|
|
|
585
849
|
_log(f"[no-progress] the model returned the same answer again "
|
|
586
850
|
f"({self._no_progress_rounds}/{self.config.max_no_progress_rounds}) — "
|
|
587
851
|
f"the previous one already ran and changed nothing")
|
|
588
|
-
#
|
|
852
|
+
# THE SAME ANSWER IS NOT NEWS — IT IS ARITHMETIC. `planner` sends
|
|
853
|
+
# `temperature: 0`, so re-reading an unchanged board returns the
|
|
854
|
+
# identical answer by construction, and counting that to a limit
|
|
855
|
+
# measures nothing but the limit. Ask for a different SAMPLE before
|
|
856
|
+
# spending the next round on a reply already known.
|
|
857
|
+
self._resample_level += 1
|
|
858
|
+
self._apply_sampling()
|
|
859
|
+
# …and make sure there is something to re-ask. On the animated path
|
|
860
|
+
# the answer is cached for the life of the board, so without this
|
|
861
|
+
# the new sampling never reaches a request and the identical
|
|
862
|
+
# coordinates go back up the wire.
|
|
863
|
+
self._invalidate_animated_answer()
|
|
864
|
+
# A board that reads the same every round is ALSO the signature of a
|
|
589
865
|
# CYCLING challenge answered as a still. Give the recording path a
|
|
590
866
|
# chance before giving up on the solve entirely.
|
|
591
867
|
self._arm_animated_probe()
|
|
@@ -593,14 +869,49 @@ class PageSolver:
|
|
|
593
869
|
self._no_progress_rounds = 0
|
|
594
870
|
self._last_answer_sig = sig
|
|
595
871
|
|
|
872
|
+
def _apply_sampling(self) -> None:
|
|
873
|
+
"""Point the planner at this board's re-ask level.
|
|
874
|
+
|
|
875
|
+
Level 0 is greedy and is what every first look gets, so this is a no-op
|
|
876
|
+
on the path that already works. Past that it hands the model a
|
|
877
|
+
temperature and a fresh seed, because a board the vendor refused is one
|
|
878
|
+
where the greedy answer has been TESTED and rejected — re-running it is
|
|
879
|
+
the one thing guaranteed not to help.
|
|
880
|
+
"""
|
|
881
|
+
target = getattr(self._solver, "planner", None)
|
|
882
|
+
if target is None:
|
|
883
|
+
return
|
|
884
|
+
# `planner.sampling_for_level` is the ONE copy of the schedule, and the
|
|
885
|
+
# JS port reaches the same function through CAPTCHA_RESAMPLE_LEVEL.
|
|
886
|
+
# Deliberately NOT a `PageSolverConfig` field: the contract pins the two
|
|
887
|
+
# ports to the same solver options, and a knob on one side only is the
|
|
888
|
+
# parity gap CLAUDE.md says may only shrink. It is a tuning constant,
|
|
889
|
+
# not something a caller has any way to choose well.
|
|
890
|
+
sampling = planner.sampling_for_level(self._resample_level)
|
|
891
|
+
target.sampling = sampling
|
|
892
|
+
if sampling:
|
|
893
|
+
_log(f"[resample] re-asking this board at temperature "
|
|
894
|
+
f"{sampling['temperature']}, seed {sampling['seed']}")
|
|
895
|
+
|
|
896
|
+
def _fresh_board(self) -> None:
|
|
897
|
+
"""A different puzzle is up: go back to the greedy read."""
|
|
898
|
+
if self._resample_level:
|
|
899
|
+
_log("[resample] fresh board — back to greedy")
|
|
900
|
+
self._resample_level = 0
|
|
901
|
+
self._apply_sampling()
|
|
902
|
+
|
|
596
903
|
def _reset_animated_state(self) -> None:
|
|
597
904
|
self._known_animated = False
|
|
598
905
|
self._animated_probe_armed = False
|
|
599
906
|
self._animated_probe_done = False
|
|
600
907
|
self._video_budget_granted = False
|
|
908
|
+
self._discard_animated_plan()
|
|
601
909
|
self._keyframe_mode = None
|
|
910
|
+
self._keyframe_steady_screens = 0
|
|
602
911
|
self._last_answer_sig: Optional[str] = None
|
|
603
912
|
self._no_progress_rounds = 0
|
|
913
|
+
self._resample_level = 0
|
|
914
|
+
self._apply_sampling()
|
|
604
915
|
|
|
605
916
|
def _check_deadline(self, where: str) -> None:
|
|
606
917
|
"""
|
|
@@ -713,132 +1024,12 @@ class PageSolver:
|
|
|
713
1024
|
# Mouse
|
|
714
1025
|
# ------------------------------------------------------------------
|
|
715
1026
|
|
|
716
|
-
def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
|
|
717
|
-
"""
|
|
718
|
-
The window we must keep the cursor inside, cached per solve.
|
|
719
|
-
|
|
720
|
-
MUST NOT be skipped and MUST NOT be guessed, because a mouse move to a
|
|
721
|
-
coordinate outside the window WEDGES camoufox. Its juggler humanises
|
|
722
|
-
each move into a trajectory, guards the intermediate points against the
|
|
723
|
-
bounds, and then dispatches the requested destination unguarded
|
|
724
|
-
("Always finish exactly on the requested destination"). An out-of-window
|
|
725
|
-
destination fires as an exit event instead of eMouseMove, so no
|
|
726
|
-
hit-renderer ack comes back; dispatch is serialised on a process-global
|
|
727
|
-
activation chain, so that one missing ack hangs every later input event
|
|
728
|
-
forever. Symptom: `page.mouse.move()` never returns, 0% CPU, solve
|
|
729
|
-
appears dead. Same failure family as camoufox #225.
|
|
730
|
-
|
|
731
|
-
`page.viewport_size` is None under camoufox (it uses the real window
|
|
732
|
-
rather than a spoofed viewport), which is why this falls back to asking
|
|
733
|
-
the page itself instead of assuming a size.
|
|
734
|
-
"""
|
|
735
|
-
if self._viewport_cache is not None:
|
|
736
|
-
return self._viewport_cache
|
|
737
|
-
try:
|
|
738
|
-
vp = page.viewport_size
|
|
739
|
-
if callable(vp): # some adapters expose it as a method
|
|
740
|
-
vp = vp()
|
|
741
|
-
if vp and vp.get("width") and vp.get("height"):
|
|
742
|
-
self._viewport_cache = {"width": float(vp["width"]), "height": float(vp["height"])}
|
|
743
|
-
return self._viewport_cache
|
|
744
|
-
except Exception:
|
|
745
|
-
pass
|
|
746
|
-
try:
|
|
747
|
-
inner = page.evaluate("() => ({width: window.innerWidth, height: window.innerHeight})")
|
|
748
|
-
if inner and inner.get("width") and inner.get("height"):
|
|
749
|
-
self._viewport_cache = {
|
|
750
|
-
"width": float(inner["width"]),
|
|
751
|
-
"height": float(inner["height"]),
|
|
752
|
-
}
|
|
753
|
-
return self._viewport_cache
|
|
754
|
-
except Exception:
|
|
755
|
-
pass
|
|
756
|
-
return None
|
|
757
|
-
|
|
758
|
-
def _trace_path(self, page: Any, points: Sequence[Tuple[float, float]], timings: Sequence[float]) -> None:
|
|
759
|
-
# Clamp ONLY when the viewport is actually known.
|
|
760
|
-
#
|
|
761
|
-
# camoufox reports `viewport_size is None` (it uses the real window
|
|
762
|
-
# rather than a spoofed viewport). The obvious fallback — assume
|
|
763
|
-
# 1920x1080 — is actively harmful: it clamps coordinates to the edge of
|
|
764
|
-
# a viewport that is not the real one, and a coordinate sitting EXACTLY
|
|
765
|
-
# on the boundary is the input that deadlocks camoufox's humanised-mouse
|
|
766
|
-
# juggler patch (upstream #225, "humanize edge deadlock"). The symptom is
|
|
767
|
-
# a `page.mouse.move()` that never returns: the process sits at 0% CPU
|
|
768
|
-
# with no in-flight work and the solve appears hung, which is precisely
|
|
769
|
-
# what this driver did against camoufox until this was found.
|
|
770
|
-
#
|
|
771
|
-
# A guessed clamp buys nothing anyway — the points come from a
|
|
772
|
-
# trajectory between two on-screen elements, so they are already in
|
|
773
|
-
# range. When we do clamp, we inset by a pixel so a legitimately
|
|
774
|
-
# off-screen point lands just inside the edge instead of exactly on it.
|
|
775
|
-
viewport = self._viewport(page)
|
|
776
|
-
|
|
777
|
-
start = time.monotonic() * 1000.0
|
|
778
|
-
for i, (x, y) in enumerate(points):
|
|
779
|
-
try:
|
|
780
|
-
if viewport is None:
|
|
781
|
-
cx, cy = float(x), float(y)
|
|
782
|
-
else:
|
|
783
|
-
cx = max(1.0, min(float(x), float(viewport["width"]) - 1.0))
|
|
784
|
-
cy = max(1.0, min(float(y), float(viewport["height"]) - 1.0))
|
|
785
|
-
page.mouse.move(cx, cy)
|
|
786
|
-
self._last_mouse = (cx, cy)
|
|
787
|
-
if i < len(timings):
|
|
788
|
-
target = start + timings[i]
|
|
789
|
-
_delay(target - time.monotonic() * 1000.0)
|
|
790
|
-
except Exception as exc:
|
|
791
|
-
msg = str(exc)
|
|
792
|
-
if "Target closed" in msg or "Session closed" in msg:
|
|
793
|
-
_log("could not move mouse; page or session closed")
|
|
794
|
-
return
|
|
795
|
-
# Any other per-sample failure is skipped rather than fatal —
|
|
796
|
-
# losing one mousemove must not lose the solve.
|
|
797
|
-
|
|
798
|
-
def _seed_cursor(self, page: Any) -> None:
|
|
799
|
-
"""Move the cursor off its (0, 0) origin once per solve, in ONE plain
|
|
800
|
-
move, before any humanised trajectory runs.
|
|
801
|
-
|
|
802
|
-
Without this, the first trajectory of a solve begins at (0, 0) — the
|
|
803
|
-
exact window corner, because that is where the pointer starts and
|
|
804
|
-
nothing has moved it. Under camoufox's `humanize` juggler a short move
|
|
805
|
-
from that origin never returns: `page.mouse.move()` blocks forever at 0%
|
|
806
|
-
CPU, and since dispatch is serialised on a process-global activation
|
|
807
|
-
chain, every later input event blocks behind it too. The solve looks
|
|
808
|
-
hung with no error anywhere and the Tier 3 fixture run times out on all
|
|
809
|
-
three attempts.
|
|
810
|
-
|
|
811
|
-
Reduced to four lines against camoufox directly:
|
|
812
|
-
|
|
813
|
-
with Camoufox(headless=True, humanize=True) as b:
|
|
814
|
-
p = b.new_page(); p.goto("about:blank")
|
|
815
|
-
p.mouse.move(1.0, 1.0) # never returns
|
|
816
|
-
|
|
817
|
-
The same move after ANY interior move completes in ~1.1s, which is what
|
|
818
|
-
makes this the fix rather than a workaround: it is the ORIGIN that is
|
|
819
|
-
poisoned, not the destination. Same failure family as camoufox #225.
|
|
820
|
-
|
|
821
|
-
Deliberately not routed through `_smooth_move`: that would generate a
|
|
822
|
-
trajectory from (0, 0) and reintroduce exactly the move being avoided.
|
|
823
|
-
"""
|
|
824
|
-
if self._cursor_seeded:
|
|
825
|
-
return
|
|
826
|
-
self._cursor_seeded = True
|
|
827
|
-
vp = self._viewport(page)
|
|
828
|
-
# Centre when the window is known, else a modest interior point — any
|
|
829
|
-
# coordinate comfortably off the corner will do.
|
|
830
|
-
cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
|
|
831
|
-
try:
|
|
832
|
-
page.mouse.move(cx, cy)
|
|
833
|
-
self._last_mouse = (cx, cy)
|
|
834
|
-
except Exception: # noqa: BLE001 — an adapter without a mouse must not fail the solve
|
|
835
|
-
pass
|
|
836
|
-
|
|
837
1027
|
def _smooth_move(self, page: Any, x: float, y: float) -> None:
|
|
838
|
-
|
|
839
|
-
|
|
1028
|
+
"""Travel to a point. What that MEANS is the humanizer's business — on
|
|
1029
|
+
a touchscreen with no finger down it is a bookkeeping update and emits
|
|
1030
|
+
nothing at all."""
|
|
840
1031
|
with self._phase("mouse"):
|
|
841
|
-
self.
|
|
1032
|
+
self._human.move(page, (x, y))
|
|
842
1033
|
|
|
843
1034
|
def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
|
|
844
1035
|
# BOUNDED. Playwright's default timeout is 30s, and this is called once
|
|
@@ -870,9 +1061,66 @@ class PageSolver:
|
|
|
870
1061
|
|
|
871
1062
|
def _move_and_click(self, page: Any, element: Any) -> None:
|
|
872
1063
|
self._move_to_element(page, element)
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1064
|
+
with self._phase("mouse"):
|
|
1065
|
+
self._human.click(page, self._last_mouse)
|
|
1066
|
+
|
|
1067
|
+
def _click_point_for(
|
|
1068
|
+
self, action: Dict[str, Any], element_box: Dict[str, float]
|
|
1069
|
+
) -> Optional[Tuple[float, float]]:
|
|
1070
|
+
"""Where in the element this click lands, in element-relative pixels.
|
|
1071
|
+
|
|
1072
|
+
Split out of `_execute_click` because on an animated board the point has
|
|
1073
|
+
to be chosen ONCE and used three times — to park the pointer, to ask
|
|
1074
|
+
about the right neighbourhood, and to press. Choosing it inside the
|
|
1075
|
+
click (it is randomised within the target) meant the gate watched the
|
|
1076
|
+
bbox centre while the press landed somewhere else.
|
|
1077
|
+
"""
|
|
1078
|
+
bbox = action.get("target_bounding_box")
|
|
1079
|
+
coords = action.get("target_coordinates")
|
|
1080
|
+
if bbox:
|
|
1081
|
+
min_x, min_y, max_x, max_y = (float(v) for v in bbox)
|
|
1082
|
+
px_min_x = min_x * element_box["width"]
|
|
1083
|
+
px_max_x = max_x * element_box["width"]
|
|
1084
|
+
px_min_y = min_y * element_box["height"]
|
|
1085
|
+
px_max_y = max_y * element_box["height"]
|
|
1086
|
+
pad_x = (px_max_x - px_min_x) * 0.1
|
|
1087
|
+
pad_y = (px_max_y - px_min_y) * 0.1
|
|
1088
|
+
return (
|
|
1089
|
+
(px_min_x + pad_x) + random.random() * ((px_max_x - pad_x) - (px_min_x + pad_x)),
|
|
1090
|
+
(px_min_y + pad_y) + random.random() * ((px_max_y - pad_y) - (px_min_y + pad_y)),
|
|
1091
|
+
)
|
|
1092
|
+
if coords:
|
|
1093
|
+
return (float(coords[0]) * element_box["width"],
|
|
1094
|
+
float(coords[1]) * element_box["height"])
|
|
1095
|
+
return None
|
|
1096
|
+
|
|
1097
|
+
def _click_when_frame_matches(
|
|
1098
|
+
self, page: Any, element: Any, action: Dict[str, Any],
|
|
1099
|
+
element_box: Dict[str, float], await_keyframe: str,
|
|
1100
|
+
) -> None:
|
|
1101
|
+
"""Click one target on an ANIMATED board, at the moment its screen is up.
|
|
1102
|
+
|
|
1103
|
+
The ORDER is the point of it: park on the target, open the gate, press
|
|
1104
|
+
in place — so the only thing between "the right screen is showing" and
|
|
1105
|
+
the click is a mouse-down. It used to wait and then click, and the click
|
|
1106
|
+
begins with a humanised move of 274-647ms against a 1500ms median dwell,
|
|
1107
|
+
so the gate could succeed and the press still land on the next screen.
|
|
1108
|
+
Moving to a point the pointer already occupies is a no-op, so parking
|
|
1109
|
+
early costs nothing.
|
|
1110
|
+
"""
|
|
1111
|
+
rel = self._click_point_for(action, element_box)
|
|
1112
|
+
if rel is None:
|
|
1113
|
+
_log("click action without coordinates or bounding box; skipping")
|
|
1114
|
+
return
|
|
1115
|
+
at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
|
|
1116
|
+
with self._phase("mouse"):
|
|
1117
|
+
self._human.move(page, at)
|
|
1118
|
+
self._wait_for_keyframe(
|
|
1119
|
+
element, await_keyframe,
|
|
1120
|
+
(rel[0] / element_box["width"], rel[1] / element_box["height"]),
|
|
1121
|
+
)
|
|
1122
|
+
with self._phase("mouse"):
|
|
1123
|
+
self._human.click(page, at)
|
|
876
1124
|
|
|
877
1125
|
def _execute_click(
|
|
878
1126
|
self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
|
|
@@ -897,10 +1145,8 @@ class PageSolver:
|
|
|
897
1145
|
_log("click action without coordinates or bounding box; skipping")
|
|
898
1146
|
return
|
|
899
1147
|
|
|
900
|
-
self.
|
|
901
|
-
|
|
902
|
-
_delay(random.random() * 30 + 20)
|
|
903
|
-
page.mouse.up()
|
|
1148
|
+
with self._phase("mouse"):
|
|
1149
|
+
self._human.click(page, (element_box["x"] + rel_x, element_box["y"] + rel_y))
|
|
904
1150
|
|
|
905
1151
|
def _execute_drag(
|
|
906
1152
|
self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
|
|
@@ -913,12 +1159,8 @@ class PageSolver:
|
|
|
913
1159
|
|
|
914
1160
|
src = center(action["source_bounding_box"])
|
|
915
1161
|
dst = center(action["target_bounding_box"])
|
|
916
|
-
self.
|
|
917
|
-
|
|
918
|
-
_delay(random.random() * 50 + 50)
|
|
919
|
-
self._smooth_move(page, *dst)
|
|
920
|
-
_delay(random.random() * 50 + 50)
|
|
921
|
-
page.mouse.up()
|
|
1162
|
+
with self._phase("mouse"):
|
|
1163
|
+
self._human.drag(page, src, dst)
|
|
922
1164
|
|
|
923
1165
|
# ------------------------------------------------------------------
|
|
924
1166
|
# Typing and sliding
|
|
@@ -942,33 +1184,67 @@ class PageSolver:
|
|
|
942
1184
|
return element
|
|
943
1185
|
return None
|
|
944
1186
|
|
|
945
|
-
def
|
|
1187
|
+
def _answer_box(self, scope: Any, element: Any = None) -> Optional[Any]:
|
|
1188
|
+
"""Where a distorted-text captcha's answer goes.
|
|
1189
|
+
|
|
1190
|
+
Inside the widget first. If the widget holds no text box at all, widen
|
|
1191
|
+
ONCE to its enclosing <fieldset>/<form> and look again for a
|
|
1192
|
+
VENDOR-NAMED box only.
|
|
1193
|
+
|
|
1194
|
+
BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
|
|
1195
|
+
LIBRARY, so the host application owns the layout: measured on
|
|
1196
|
+
captcha.com 2026-08-24, `.BDC_CaptchaDiv` — the element `detect_captcha`
|
|
1197
|
+
returns — is 280x50 and holds the image alone, while `#captchaCode` sits
|
|
1198
|
+
in a sibling `<div class="validationDiv">` under the enclosing
|
|
1199
|
+
<fieldset>. Scoped to the widget the solver found no box, `text_mode`
|
|
1200
|
+
stayed False, and it read the code correctly and never typed it. A clean
|
|
1201
|
+
solve thrown away, and indistinguishable in any report from a model that
|
|
1202
|
+
cannot read warped text.
|
|
1203
|
+
|
|
1204
|
+
The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on
|
|
1205
|
+
purpose. The generic tail (`input[type=text]`, `textarea`,
|
|
1206
|
+
`input:not([type])`) is what makes the in-widget lookup work for every
|
|
1207
|
+
other vendor, and OUTSIDE the widget it is exactly how a captcha's
|
|
1208
|
+
answer ends up in a login form's username box — the failure
|
|
1209
|
+
`_find_control`'s docstring already warns about. One level, named
|
|
1210
|
+
selectors, and never the page.
|
|
1211
|
+
"""
|
|
1212
|
+
inside = self._find_control(scope, TEXT_INPUT_SELECTORS)
|
|
1213
|
+
if inside is not None or element is None:
|
|
1214
|
+
return inside
|
|
1215
|
+
# Nearest first: a <fieldset> inside a <form> is the tighter box, and
|
|
1216
|
+
# `query_selector` on a union would hand back whichever comes first in
|
|
1217
|
+
# document order — the outer one.
|
|
1218
|
+
for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"):
|
|
1219
|
+
try:
|
|
1220
|
+
host = element.query_selector(f"xpath={axis}")
|
|
1221
|
+
except Exception:
|
|
1222
|
+
continue
|
|
1223
|
+
if host is None:
|
|
1224
|
+
continue
|
|
1225
|
+
found = self._find_control(host, TEXT_INPUT_VENDOR_SELECTORS)
|
|
1226
|
+
if found is not None:
|
|
1227
|
+
_log("widget holds no answer box; using the vendor-named one "
|
|
1228
|
+
"in its enclosing form")
|
|
1229
|
+
return found
|
|
1230
|
+
return None
|
|
1231
|
+
|
|
1232
|
+
def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any],
|
|
1233
|
+
element: Any = None) -> bool:
|
|
946
1234
|
"""Put the model's reading of a distorted-text captcha into its box."""
|
|
947
1235
|
text = str(action.get("text") or "")
|
|
948
1236
|
if not text:
|
|
949
1237
|
return False
|
|
950
|
-
field = self.
|
|
1238
|
+
field = self._answer_box(scope, element)
|
|
951
1239
|
if field is None:
|
|
952
1240
|
_log("type action, but no text box in the widget; skipping")
|
|
953
1241
|
return False
|
|
954
1242
|
|
|
955
|
-
|
|
956
|
-
#
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
except Exception:
|
|
961
|
-
pass
|
|
962
|
-
# Per character rather than one `type(text, delay=…)` call: a constant
|
|
963
|
-
# inter-key delay is itself a signal, and these are the vendors that
|
|
964
|
-
# score typing cadence.
|
|
965
|
-
for ch in text:
|
|
966
|
-
try:
|
|
967
|
-
page.keyboard.type(ch)
|
|
968
|
-
except Exception as exc:
|
|
969
|
-
_log(f"could not type into the captcha field: {exc}")
|
|
970
|
-
return False
|
|
971
|
-
_delay(random.random() * 90 + 45)
|
|
1243
|
+
# Tapping the box is what focuses it — and on a phone it is also what
|
|
1244
|
+
# raises the keyboard, so this is not decoration on either device.
|
|
1245
|
+
self._move_and_click(page, field)
|
|
1246
|
+
if not self._human.type_text(page, field, text):
|
|
1247
|
+
return False
|
|
972
1248
|
_log(f"typed {len(text)} character(s) into the captcha field")
|
|
973
1249
|
return True
|
|
974
1250
|
|
|
@@ -985,6 +1261,17 @@ class PageSolver:
|
|
|
985
1261
|
_debug(f"track_piece failed: {exc}")
|
|
986
1262
|
return None
|
|
987
1263
|
|
|
1264
|
+
@staticmethod
|
|
1265
|
+
def _shot_scale(shot: str, css_width: float) -> float:
|
|
1266
|
+
"""Device pixels per CSS pixel, read off the shot we are about to
|
|
1267
|
+
measure. 1.0 when the image cannot be read — an unreadable shot is
|
|
1268
|
+
already the `_track_piece` returns-None path, and guessing a ratio
|
|
1269
|
+
would steer the handle by it."""
|
|
1270
|
+
dims = _read_png_dimensions(shot)
|
|
1271
|
+
if not dims or css_width <= 0:
|
|
1272
|
+
return 1.0
|
|
1273
|
+
return dims[0] / css_width
|
|
1274
|
+
|
|
988
1275
|
def _execute_slide(
|
|
989
1276
|
self,
|
|
990
1277
|
page: Any,
|
|
@@ -1043,12 +1330,12 @@ class PageSolver:
|
|
|
1043
1330
|
+ float(action["target_bounding_box"][3])) / 2
|
|
1044
1331
|
) * element_box["height"]
|
|
1045
1332
|
_log("no slider track; dragging the piece to the slot directly")
|
|
1046
|
-
self.
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1333
|
+
with self._phase("mouse"):
|
|
1334
|
+
self._human.drag(
|
|
1335
|
+
page,
|
|
1336
|
+
(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
|
|
1337
|
+
(element_box["x"] + target_x, element_box["y"] + target_y),
|
|
1338
|
+
)
|
|
1052
1339
|
return True
|
|
1053
1340
|
|
|
1054
1341
|
hbox = handle.bounding_box()
|
|
@@ -1063,7 +1350,7 @@ class PageSolver:
|
|
|
1063
1350
|
# moving thing in frame, and we would track the handle instead of the
|
|
1064
1351
|
# piece.
|
|
1065
1352
|
pad = max(4.0, hbox["height"] * 0.35)
|
|
1066
|
-
|
|
1353
|
+
band = [
|
|
1067
1354
|
0.0,
|
|
1068
1355
|
hbox["y"] - element_box["y"] - pad,
|
|
1069
1356
|
element_box["width"],
|
|
@@ -1073,20 +1360,32 @@ class PageSolver:
|
|
|
1073
1360
|
shots = [_tmp_png("slide") for _ in range(4)]
|
|
1074
1361
|
try:
|
|
1075
1362
|
self._move_to_element(page, handle, padding_percentage=30.0)
|
|
1076
|
-
|
|
1077
|
-
|
|
1363
|
+
self._human.press(page)
|
|
1364
|
+
self._human.pause("grab")
|
|
1078
1365
|
self._screenshot(element, shots[0],
|
|
1079
1366
|
timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
1080
1367
|
|
|
1368
|
+
# CSS pixels out here, DEVICE pixels inside the shots. The same
|
|
1369
|
+
# number on a 1x desktop and 2.625x apart on a phone, which is why
|
|
1370
|
+
# this loop measured a slider correctly for a year and then missed
|
|
1371
|
+
# every attempt the moment Tier 3 grew a mobile arm: the mask landed
|
|
1372
|
+
# above the handle, the widths came back 2.6x too wide, and
|
|
1373
|
+
# `_solve_slide_geometry` threw them out as wider than the widget.
|
|
1374
|
+
# Measured from the shot rather than asked of the page, for the same
|
|
1375
|
+
# reason the grid path does it (`_GridSession.scale_x`): what the CV
|
|
1376
|
+
# reads is the image, whatever the window thinks its ratio is.
|
|
1377
|
+
scale = self._shot_scale(shots[0], element_box["width"])
|
|
1378
|
+
exclude = [v * scale for v in band]
|
|
1379
|
+
|
|
1081
1380
|
probes = self.config.slide_probe_offsets_px
|
|
1082
1381
|
widths: List[Tuple[float, float]] = []
|
|
1083
1382
|
last_box = None
|
|
1084
1383
|
for offset, shot in zip(probes, shots[1:]):
|
|
1085
1384
|
self._smooth_move(page, start_x + offset, hold_y)
|
|
1086
|
-
|
|
1385
|
+
self._human.pause("probe")
|
|
1087
1386
|
box = self._track_piece(element, shots[0], shot, exclude)
|
|
1088
1387
|
if box is not None:
|
|
1089
|
-
widths.append((float(offset), float(box[2] - box[0])))
|
|
1388
|
+
widths.append((float(offset), float(box[2] - box[0]) / scale))
|
|
1090
1389
|
last_box = box
|
|
1091
1390
|
|
|
1092
1391
|
piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
|
|
@@ -1107,27 +1406,28 @@ class PageSolver:
|
|
|
1107
1406
|
# asked for.
|
|
1108
1407
|
offset = float(widths[-1][0])
|
|
1109
1408
|
for _ in range(self.config.slide_max_corrections):
|
|
1110
|
-
piece_center = (last_box[2] - piece_w / 2.0)
|
|
1409
|
+
piece_center = (last_box[2] / scale - piece_w / 2.0)
|
|
1111
1410
|
error = target_x - piece_center
|
|
1112
1411
|
if abs(error) <= self.config.slide_tolerance_px:
|
|
1113
1412
|
break
|
|
1114
1413
|
offset += error / ratio
|
|
1115
1414
|
self._smooth_move(page, start_x + offset, hold_y)
|
|
1116
|
-
|
|
1415
|
+
self._human.pause("probe")
|
|
1117
1416
|
box = self._track_piece(element, shots[0], shots[3], exclude)
|
|
1118
1417
|
if box is None:
|
|
1119
1418
|
break # ran out of track; release where we are
|
|
1120
1419
|
last_box = box
|
|
1121
|
-
_debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} "
|
|
1122
|
-
f"final_center={last_box[2] - piece_w / 2.0:.1f}
|
|
1420
|
+
_debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} scale={scale:.3f} "
|
|
1421
|
+
f"final_center={last_box[2] / scale - piece_w / 2.0:.1f} "
|
|
1422
|
+
f"target={target_x:.1f}")
|
|
1123
1423
|
|
|
1124
1424
|
# Settle before letting go. A release in the same tick as the last
|
|
1125
1425
|
# move reads as a machine, and some vendors sample the final
|
|
1126
1426
|
# milliseconds of the gesture.
|
|
1127
|
-
|
|
1427
|
+
self._human.pause("settle")
|
|
1128
1428
|
finally:
|
|
1129
1429
|
try:
|
|
1130
|
-
|
|
1430
|
+
self._human.release(page)
|
|
1131
1431
|
except Exception:
|
|
1132
1432
|
pass
|
|
1133
1433
|
for shot in shots:
|
|
@@ -1288,6 +1588,54 @@ class PageSolver:
|
|
|
1288
1588
|
|
|
1289
1589
|
return None
|
|
1290
1590
|
|
|
1591
|
+
def vendors_on_the_wire(self, page: Any) -> List[str]:
|
|
1592
|
+
"""Which vendors' code this page actually loaded.
|
|
1593
|
+
|
|
1594
|
+
Read out of the page's own resource timing plus the `src`/`href` of
|
|
1595
|
+
everything it linked, so it works whenever it is called — a request
|
|
1596
|
+
listener would have to have been attached before navigation, and by the
|
|
1597
|
+
time a solve fails that ship has sailed.
|
|
1598
|
+
|
|
1599
|
+
Best-effort by construction: the resource-timing buffer is finite and a
|
|
1600
|
+
page may have cleared it. An empty answer is "nothing seen", never "no
|
|
1601
|
+
captcha here".
|
|
1602
|
+
"""
|
|
1603
|
+
try:
|
|
1604
|
+
names = page.evaluate(
|
|
1605
|
+
"""() => {
|
|
1606
|
+
const out = [];
|
|
1607
|
+
try {
|
|
1608
|
+
for (const e of performance.getEntriesByType('resource')) out.push(e.name);
|
|
1609
|
+
} catch (err) {}
|
|
1610
|
+
for (const el of document.querySelectorAll('script[src],iframe[src],link[href],img[src]')) {
|
|
1611
|
+
out.push(el.getAttribute('src') || el.getAttribute('href') || '');
|
|
1612
|
+
}
|
|
1613
|
+
return out;
|
|
1614
|
+
}"""
|
|
1615
|
+
)
|
|
1616
|
+
except Exception:
|
|
1617
|
+
return []
|
|
1618
|
+
blob = " ".join(str(n) for n in (names or []))
|
|
1619
|
+
return [entry["puzzle_source"] for entry in VENDOR_URL_MARKERS
|
|
1620
|
+
if any(host in blob for host in entry["hosts"])]
|
|
1621
|
+
|
|
1622
|
+
def _no_widget_message(self, page: Any) -> str:
|
|
1623
|
+
"""Why nothing was found — the two cases told apart.
|
|
1624
|
+
|
|
1625
|
+
The old text guessed "reCAPTCHA v3 / invisible" every time, including
|
|
1626
|
+
the times a vendor had simply moved its DOM. See VENDOR_URL_MARKERS.
|
|
1627
|
+
"""
|
|
1628
|
+
base = "no interactive captcha widget detected"
|
|
1629
|
+
loaded = self.vendors_on_the_wire(page)
|
|
1630
|
+
if not loaded:
|
|
1631
|
+
return (f"{base} (no vendor captcha code loaded on this page — "
|
|
1632
|
+
"likely reCAPTCHA v3 / invisible, or a click-triggered "
|
|
1633
|
+
"challenge that has not been triggered)")
|
|
1634
|
+
return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on "
|
|
1635
|
+
"this page. The vendor's markup no longer matches anything in "
|
|
1636
|
+
"VENDOR_WIDGET_LOCATORS — re-measure with "
|
|
1637
|
+
"scripts/check_vendor_selectors.py and update BOTH solver ports")
|
|
1638
|
+
|
|
1291
1639
|
def is_captcha_solved(self, page: Any) -> bool:
|
|
1292
1640
|
"""
|
|
1293
1641
|
The vendor's definitive DONE signal — anchor checked or token populated.
|
|
@@ -1304,7 +1652,7 @@ class PageSolver:
|
|
|
1304
1652
|
# overlay up for a couple of seconds after the winning submit.
|
|
1305
1653
|
# Gating this on `_visible(anchor)` meant the one signal that was
|
|
1306
1654
|
# already true went unread, and the loop ground on against a frame
|
|
1307
|
-
# being torn down. See tests
|
|
1655
|
+
# being torn down. See the training repo's solved-detection tests.
|
|
1308
1656
|
if self._has_non_empty_field_value(page, '[name="h-captcha-response"]'):
|
|
1309
1657
|
return True
|
|
1310
1658
|
if self._has_non_empty_field_value(page, '[name="g-recaptcha-response"]'):
|
|
@@ -1371,28 +1719,55 @@ class PageSolver:
|
|
|
1371
1719
|
pass
|
|
1372
1720
|
return False
|
|
1373
1721
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1722
|
+
#: Which of reCAPTCHA's bframe banners is showing, by selector.
|
|
1723
|
+
#:
|
|
1724
|
+
#: ONE ENTRY PER MEANING. They share a corner and a look and they are three
|
|
1725
|
+
#: different facts; see `_recaptcha_banner_kind`.
|
|
1726
|
+
_RECAPTCHA_BANNERS = (
|
|
1727
|
+
(".rc-imageselect-error-select-more", "select-more"),
|
|
1728
|
+
(".rc-imageselect-error-dynamic-more", "dynamic-more"),
|
|
1729
|
+
(".rc-imageselect-incorrect-response", "rejected"),
|
|
1730
|
+
)
|
|
1378
1731
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1732
|
+
@staticmethod
|
|
1733
|
+
def _banner_is_fatal_after_retry(kind: Optional[str]) -> bool:
|
|
1734
|
+
"""May a REPEAT of this banner end the solve?
|
|
1735
|
+
|
|
1736
|
+
Only where repeating means stuck. `dynamic-more` repeating is what a
|
|
1737
|
+
dynamic board's normal progress looks like, and treating it as fatal is
|
|
1738
|
+
the bug this split was made to fix.
|
|
1739
|
+
"""
|
|
1740
|
+
return kind in ("select-more", "rejected")
|
|
1741
|
+
|
|
1742
|
+
def _recaptcha_banner_kind(self, page: Any) -> Optional[str]:
|
|
1743
|
+
"""WHICH of reCAPTCHA's three bframe banners is showing, if any.
|
|
1744
|
+
|
|
1745
|
+
`rejected` "Please try again." The answer was wrong; a fresh board
|
|
1746
|
+
follows, so the useful response is to solve that one.
|
|
1747
|
+
`select-more` "Please select all matching images." Under-selected, and
|
|
1748
|
+
the tiles do NOT refresh — so a driver that re-submits
|
|
1749
|
+
the same answer loops until the session times out. The
|
|
1750
|
+
missed-tiles retry and the abort exist for this case.
|
|
1751
|
+
`dynamic-more` "Please also check the new images." NOT AN ERROR. It is
|
|
1752
|
+
the dynamic 3x3's normal flow: cleared tiles fade out,
|
|
1753
|
+
replacements fade in, and the widget says so — on
|
|
1754
|
+
essentially every round of that variant.
|
|
1755
|
+
|
|
1756
|
+
This used to be a boolean over all three, which made the third
|
|
1757
|
+
indistinguishable from the two genuine errors and armed the one-retry
|
|
1758
|
+
abort with the sentence that means "you are doing fine". Every dynamic
|
|
1759
|
+
board therefore died at round two. Measured 2026-09-06: three of three
|
|
1760
|
+
`recaptcha_3x3_fade` attempts stopped at exactly boards=2, while
|
|
1761
|
+
`recaptcha_4x4` on the same run passed at 2, 3 and 5 boards.
|
|
1383
1762
|
"""
|
|
1384
1763
|
try:
|
|
1385
1764
|
bframe = page.query_selector('iframe[src*="recaptcha/api2/bframe"]')
|
|
1386
1765
|
if not bframe:
|
|
1387
|
-
return
|
|
1766
|
+
return None
|
|
1388
1767
|
frame = bframe.content_frame()
|
|
1389
1768
|
if not frame:
|
|
1390
|
-
return
|
|
1391
|
-
for selector in
|
|
1392
|
-
".rc-imageselect-error-select-more",
|
|
1393
|
-
".rc-imageselect-error-dynamic-more",
|
|
1394
|
-
".rc-imageselect-incorrect-response",
|
|
1395
|
-
):
|
|
1769
|
+
return None
|
|
1770
|
+
for selector, kind in self._RECAPTCHA_BANNERS:
|
|
1396
1771
|
element = frame.query_selector(selector)
|
|
1397
1772
|
if not element:
|
|
1398
1773
|
continue
|
|
@@ -1401,10 +1776,10 @@ class PageSolver:
|
|
|
1401
1776
|
if self._visible(element):
|
|
1402
1777
|
text = element.text_content() or ""
|
|
1403
1778
|
if text.strip():
|
|
1404
|
-
return
|
|
1779
|
+
return kind
|
|
1405
1780
|
except Exception:
|
|
1406
1781
|
pass
|
|
1407
|
-
return
|
|
1782
|
+
return None
|
|
1408
1783
|
|
|
1409
1784
|
def _get_verify_button(self, frame: Any) -> Optional[Any]:
|
|
1410
1785
|
# `.//` — RELATIVE. `scope` is an ElementHandle whenever the widget is
|
|
@@ -1612,6 +1987,160 @@ class PageSolver:
|
|
|
1612
1987
|
if self.config.animated_probe_enabled and not self._animated_probe_done:
|
|
1613
1988
|
self._animated_probe_armed = True
|
|
1614
1989
|
|
|
1990
|
+
def _should_speculate(self, puzzle_source: str, text_mode: bool) -> bool:
|
|
1991
|
+
"""Should this round record while it asks?
|
|
1992
|
+
|
|
1993
|
+
reCAPTCHA is excluded for the same reason it is excluded from the
|
|
1994
|
+
animated path at all: its dynamic 3x3 REPLACES tiles in place and has
|
|
1995
|
+
its own fade gates, so a burst there would film a fade and call it a
|
|
1996
|
+
cycle. A distorted-text round is excluded because the answer is a
|
|
1997
|
+
string, not a place, and no recording helps read one.
|
|
1998
|
+
"""
|
|
1999
|
+
cfg = self.config
|
|
2000
|
+
if not cfg.video_solve_enabled:
|
|
2001
|
+
return False
|
|
2002
|
+
if not cfg.speculative_burst_enabled:
|
|
2003
|
+
return False
|
|
2004
|
+
if puzzle_source == "recaptcha":
|
|
2005
|
+
return False
|
|
2006
|
+
return not text_mode
|
|
2007
|
+
|
|
2008
|
+
def _speculate(
|
|
2009
|
+
self, element: Any, shot: str, puzzle_source: str,
|
|
2010
|
+
retry_mode: Optional[str], text_mode: bool,
|
|
2011
|
+
) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
|
|
2012
|
+
"""Read the still and film the widget at once.
|
|
2013
|
+
|
|
2014
|
+
Returns `(actions, usage, keyframe_dir)`. `keyframe_dir` is None when
|
|
2015
|
+
the board turned out to be still — the caller then behaves exactly as
|
|
2016
|
+
it always has.
|
|
2017
|
+
"""
|
|
2018
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2019
|
+
|
|
2020
|
+
import cv2
|
|
2021
|
+
|
|
2022
|
+
from .keyframes import extract_keyframes, write_keyframes
|
|
2023
|
+
|
|
2024
|
+
cfg = self.config
|
|
2025
|
+
fps = max(1, int(cfg.video_burst_fps))
|
|
2026
|
+
floor_frames = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
|
|
2027
|
+
total = max(floor_frames, round(cfg.video_burst_max_ms / (1000.0 / fps)))
|
|
2028
|
+
interval = 1.0 / fps
|
|
2029
|
+
|
|
2030
|
+
frames: List[Any] = []
|
|
2031
|
+
order: List[str] = []
|
|
2032
|
+
last_digest: Optional[str] = None
|
|
2033
|
+
cycle_closed = False
|
|
2034
|
+
probe = _tmp_png("spec")
|
|
2035
|
+
|
|
2036
|
+
pool = ThreadPoolExecutor(max_workers=1)
|
|
2037
|
+
try:
|
|
2038
|
+
# `_get_solution` DIRECTLY, not through the freshness guard.
|
|
2039
|
+
#
|
|
2040
|
+
# Two reasons, and the first is a hard constraint: the guard
|
|
2041
|
+
# screenshots the element to decide whether the answer went stale,
|
|
2042
|
+
# and a Playwright call from this worker would violate the sync
|
|
2043
|
+
# API's thread affinity. Only the planner's HTTP request may cross.
|
|
2044
|
+
#
|
|
2045
|
+
# The second is that the guard is redundant here. It exists to ask
|
|
2046
|
+
# "did the widget change while the model was reading it" — which is
|
|
2047
|
+
# exactly what the burst on the main thread is answering, at the
|
|
2048
|
+
# noise floor, from every frame rather than from one diff at the
|
|
2049
|
+
# end.
|
|
2050
|
+
fut = pool.submit(
|
|
2051
|
+
self._get_solution, shot, puzzle_source, retry_mode, text_mode)
|
|
2052
|
+
with self._phase("burst"):
|
|
2053
|
+
for i in range(total):
|
|
2054
|
+
start = time.monotonic()
|
|
2055
|
+
try:
|
|
2056
|
+
self._screenshot(element, probe, animations="allow")
|
|
2057
|
+
img = cv2.imread(probe)
|
|
2058
|
+
except Exception as exc: # noqa: BLE001 — a dropped frame is not fatal
|
|
2059
|
+
_debug(f"speculative frame {i} failed: {exc}")
|
|
2060
|
+
img = None
|
|
2061
|
+
if img is not None:
|
|
2062
|
+
frames.append(img)
|
|
2063
|
+
try:
|
|
2064
|
+
with open(probe, "rb") as fh:
|
|
2065
|
+
d = hashlib.sha1(fh.read()).hexdigest()
|
|
2066
|
+
except Exception: # noqa: BLE001
|
|
2067
|
+
d = None
|
|
2068
|
+
if d is not None and d != last_digest:
|
|
2069
|
+
if d in order and len(order) >= 2:
|
|
2070
|
+
cycle_closed = True
|
|
2071
|
+
elif d not in order:
|
|
2072
|
+
order.append(d)
|
|
2073
|
+
last_digest = d
|
|
2074
|
+
moved = len(order) >= 2
|
|
2075
|
+
# Still, and the answer has landed: nothing more to film.
|
|
2076
|
+
if not moved and fut.done():
|
|
2077
|
+
break
|
|
2078
|
+
# Moving, and the cycle has closed: every screen is in hand.
|
|
2079
|
+
if moved and cycle_closed and len(frames) >= floor_frames:
|
|
2080
|
+
_log(f"[animated] cycle closed after {len(frames) * interval:.1f}s "
|
|
2081
|
+
f"({len(order)} screens); stopping the burst")
|
|
2082
|
+
break
|
|
2083
|
+
wait = interval - (time.monotonic() - start)
|
|
2084
|
+
if wait > 0:
|
|
2085
|
+
time.sleep(wait)
|
|
2086
|
+
|
|
2087
|
+
if len(order) < 2:
|
|
2088
|
+
actions, usage = fut.result()
|
|
2089
|
+
return actions, usage, None
|
|
2090
|
+
|
|
2091
|
+
_log("[animated] the widget moved while the model was reading it — "
|
|
2092
|
+
"dropping the still answer and finishing the recording.")
|
|
2093
|
+
fut.result() # drained, not used: it describes a screen that has gone
|
|
2094
|
+
# THE ESCALATION BUYS ITS OWN BUDGET, once per solve — and only now,
|
|
2095
|
+
# because a speculative burst that turned out to be unnecessary must
|
|
2096
|
+
# not extend the deadline of a still solve.
|
|
2097
|
+
if cfg.video_solve_enabled and not self._video_budget_granted:
|
|
2098
|
+
self._video_budget_granted = True
|
|
2099
|
+
if self._deadline_ms is not None:
|
|
2100
|
+
self._deadline_ms += cfg.video_budget_ms()
|
|
2101
|
+
_log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
|
|
2102
|
+
kfset = extract_keyframes(frames, fps=float(fps))
|
|
2103
|
+
self._keyframe_mode = kfset.mode
|
|
2104
|
+
self._keyframe_steady_screens = kfset.steady_screens
|
|
2105
|
+
keyframe_dir = tempfile.mkdtemp(prefix="ckkf_")
|
|
2106
|
+
paths = write_keyframes(kfset, keyframe_dir, stem="challenge")
|
|
2107
|
+
with self._phase("inference"):
|
|
2108
|
+
actions, usage = self._get_keyframe_solution(paths)
|
|
2109
|
+
self._animated_plan = (paths, keyframe_dir, actions, usage)
|
|
2110
|
+
return actions, usage, keyframe_dir
|
|
2111
|
+
finally:
|
|
2112
|
+
pool.shutdown(wait=True)
|
|
2113
|
+
_unlink(probe)
|
|
2114
|
+
|
|
2115
|
+
def _invalidate_animated_answer(self) -> None:
|
|
2116
|
+
"""The recording is still good; the ANSWER it produced is not.
|
|
2117
|
+
|
|
2118
|
+
`_animated_plan` caches "one burst, one inference, for as long as this
|
|
2119
|
+
board is up". For a cycling board whose answer merely landed on the
|
|
2120
|
+
wrong screen that is right — the frames do not change, so re-recording
|
|
2121
|
+
buys nothing. It is WRONG the moment the widget has refused the answer:
|
|
2122
|
+
re-submitting the identical coordinates cannot succeed, and the driver
|
|
2123
|
+
did exactly that until it ran out of rounds. Measured 2026-09-09 on
|
|
2124
|
+
an hCaptcha line-pieces board, where every round logged "reusing the recorded
|
|
2125
|
+
answer" and then "the model returned the same answer again" — the model
|
|
2126
|
+
was never asked a second time.
|
|
2127
|
+
|
|
2128
|
+
Drops the ANSWER and keeps the FRAMES, so the retry costs one inference
|
|
2129
|
+
rather than another `video_burst_max_ms` of filming.
|
|
2130
|
+
"""
|
|
2131
|
+
plan = self._animated_plan
|
|
2132
|
+
if plan is None or plan[2] is None:
|
|
2133
|
+
return
|
|
2134
|
+
keyframes, keyframe_dir, _actions, _usage = plan
|
|
2135
|
+
self._animated_plan = (keyframes, keyframe_dir, None, None)
|
|
2136
|
+
|
|
2137
|
+
def _discard_animated_plan(self) -> None:
|
|
2138
|
+
"""Forget the recorded answer, and delete the frames it was holding."""
|
|
2139
|
+
plan = self._animated_plan
|
|
2140
|
+
self._animated_plan = None
|
|
2141
|
+
if plan and plan[1]:
|
|
2142
|
+
shutil.rmtree(plan[1], ignore_errors=True)
|
|
2143
|
+
|
|
1615
2144
|
def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
|
|
1616
2145
|
"""Record the widget and return `(keyframe_paths, temp_dir)`.
|
|
1617
2146
|
|
|
@@ -1636,7 +2165,24 @@ class PageSolver:
|
|
|
1636
2165
|
|
|
1637
2166
|
cfg = self.config
|
|
1638
2167
|
fps = max(1, int(cfg.video_burst_fps))
|
|
1639
|
-
|
|
2168
|
+
# A BURST MUST OUTLAST ONE FULL CYCLE OR IT OMITS A SCREEN — and may
|
|
2169
|
+
# omit the one the answer is on. CLAUDE.md states that rule; the
|
|
2170
|
+
# COLLECTOR was fixed for it and the driver was not.
|
|
2171
|
+
#
|
|
2172
|
+
# Measured on the live GeeTest svg board, 2026-09-07: three screens
|
|
2173
|
+
# holding 1.5-1.9s each, FULL PERIOD 5.3s, against a 4000ms window. So
|
|
2174
|
+
# the model was shown two screens of three on about half of attempts,
|
|
2175
|
+
# and when the target sat on the third it was being asked a question
|
|
2176
|
+
# with the answer removed.
|
|
2177
|
+
#
|
|
2178
|
+
# Fixed by watching rather than by a bigger constant: the frames of one
|
|
2179
|
+
# screen are byte-identical, so a digest per frame says exactly when the
|
|
2180
|
+
# board returns to a screen already recorded — the cycle is closed and
|
|
2181
|
+
# there is nothing left to capture. A continuous animation never
|
|
2182
|
+
# repeats and stops at the floor, keeping today's behaviour.
|
|
2183
|
+
floor_frames = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
|
|
2184
|
+
total = max(floor_frames,
|
|
2185
|
+
round(cfg.video_burst_max_ms / (1000.0 / fps)))
|
|
1640
2186
|
interval = 1.0 / fps
|
|
1641
2187
|
|
|
1642
2188
|
# THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
|
|
@@ -1677,19 +2223,25 @@ class PageSolver:
|
|
|
1677
2223
|
)
|
|
1678
2224
|
|
|
1679
2225
|
frames: List[Any] = []
|
|
2226
|
+
order: List[str] = [] # distinct screens, in first-seen order
|
|
2227
|
+
last_digest: Optional[str] = None
|
|
2228
|
+
cycle_closed = False
|
|
2229
|
+
#: Frame index at which the most recent NEW screen appeared. A board
|
|
2230
|
+
#: that has shown nothing new for a whole floor-length window has
|
|
2231
|
+
#: settled — see the exit below.
|
|
2232
|
+
last_new_at = 0
|
|
1680
2233
|
|
|
1681
2234
|
remaining = max(0, total - len(frames))
|
|
1682
2235
|
shot = _tmp_png("burst")
|
|
1683
2236
|
# A burst that runs far past its own length is a hung screenshot, not a
|
|
1684
2237
|
# tight budget — bounded separately so the two cannot be confused.
|
|
1685
|
-
burst_deadline =
|
|
1686
|
-
+ 3 * cfg.video_burst_duration_ms + 5_000)
|
|
2238
|
+
burst_deadline = time.monotonic() * 1000.0 + burst_hang_deadline_ms(cfg)
|
|
1687
2239
|
try:
|
|
1688
2240
|
for i in range(remaining):
|
|
1689
2241
|
if time.monotonic() * 1000.0 > burst_deadline:
|
|
1690
2242
|
raise CaptchaSolveError(
|
|
1691
2243
|
f"the animated recording stalled: {i} of {remaining} frames "
|
|
1692
|
-
f"in {
|
|
2244
|
+
f"in {burst_hang_deadline_ms(cfg):.0f}ms. The widget "
|
|
1693
2245
|
f"is not screenshotting."
|
|
1694
2246
|
)
|
|
1695
2247
|
start = time.monotonic()
|
|
@@ -1707,6 +2259,63 @@ class PageSolver:
|
|
|
1707
2259
|
img = cv2.imread(shot)
|
|
1708
2260
|
if img is not None:
|
|
1709
2261
|
frames.append(img)
|
|
2262
|
+
try:
|
|
2263
|
+
with open(shot, "rb") as fh:
|
|
2264
|
+
d = hashlib.sha1(fh.read()).hexdigest()
|
|
2265
|
+
except Exception: # noqa: BLE001 — no digest, no early stop
|
|
2266
|
+
d = None
|
|
2267
|
+
if d is not None and d != last_digest:
|
|
2268
|
+
# A screen already recorded, coming back after
|
|
2269
|
+
# another one: the loop has closed and every screen
|
|
2270
|
+
# is now in the clip.
|
|
2271
|
+
if d in order and len(order) >= 2:
|
|
2272
|
+
cycle_closed = True
|
|
2273
|
+
elif d not in order:
|
|
2274
|
+
order.append(d)
|
|
2275
|
+
last_new_at = len(frames)
|
|
2276
|
+
last_digest = d
|
|
2277
|
+
# Past the floor with the cycle closed — more frames are the
|
|
2278
|
+
# same screens again, paid for in budget the solve needs.
|
|
2279
|
+
if cycle_closed and len(frames) >= floor_frames:
|
|
2280
|
+
_log(f"[animated] cycle closed after {len(frames) * interval:.1f}s "
|
|
2281
|
+
f"({len(order)} screens); stopping the burst")
|
|
2282
|
+
break
|
|
2283
|
+
# NOTHING NEW FOR A WHOLE FLOOR-LENGTH WINDOW: the board has
|
|
2284
|
+
# SETTLED, and a settled board can never close a cycle —
|
|
2285
|
+
# `cycle_closed` needs a digest to come back AFTER a different
|
|
2286
|
+
# one. Without this exit the only way out is `remaining`, so
|
|
2287
|
+
# every escalation onto a widget that is not cycling filmed the
|
|
2288
|
+
# full `video_burst_max_ms`.
|
|
2289
|
+
#
|
|
2290
|
+
# `_speculate` has always had this half (`not moved and
|
|
2291
|
+
# fut.done()`); it could, because it has an inference to wait
|
|
2292
|
+
# on. This path had nothing equivalent and so could not stop
|
|
2293
|
+
# early at all.
|
|
2294
|
+
#
|
|
2295
|
+
# "Settled", not "one screen", because there are THREE shapes
|
|
2296
|
+
# and only one of them is a cycle. Measured on the real fixtures
|
|
2297
|
+
# through camoufox, one element screenshotted 25x at 10fps:
|
|
2298
|
+
#
|
|
2299
|
+
# click_blocked_by_lines 1 distinct sha1 MAD 0.00 never moves
|
|
2300
|
+
# connect_path 1 distinct sha1 MAD 0.00 never moves
|
|
2301
|
+
# tower_stack 2 distinct sha1 MAD 32.51 moves ONCE
|
|
2302
|
+
# geetest_v4_svg 3 distinct sha1 MAD 8.29 really cycles
|
|
2303
|
+
#
|
|
2304
|
+
# `tower_stack` is why a one-screen test is not enough: it
|
|
2305
|
+
# transitions once and then holds, so it has two screens, never
|
|
2306
|
+
# repeats either, and ran the full 12s under a one-screen rule.
|
|
2307
|
+
#
|
|
2308
|
+
# The window is the burst FLOOR (4s), which is safely longer
|
|
2309
|
+
# than the worst dwell a cycling board holds a screen for —
|
|
2310
|
+
# measured p50 1.5s / p75 2.0s / max 2.7s on the geetest svg
|
|
2311
|
+
# board — so a real cycle always produces a new screen inside
|
|
2312
|
+
# the window and is never cut short here.
|
|
2313
|
+
if (len(frames) >= floor_frames
|
|
2314
|
+
and len(frames) - last_new_at >= floor_frames):
|
|
2315
|
+
_log(f"[animated] no new screen for {floor_frames * interval:.1f}s "
|
|
2316
|
+
f"({len(order)} seen) — the board has settled; "
|
|
2317
|
+
f"stopping the burst")
|
|
2318
|
+
break
|
|
1710
2319
|
# Drift-corrected: a slow screenshot must not stretch the clip, or
|
|
1711
2320
|
# the recording covers more wall-clock than the model trained on and
|
|
1712
2321
|
# a cycle's period lands differently across the frames.
|
|
@@ -1729,6 +2338,7 @@ class PageSolver:
|
|
|
1729
2338
|
# The wait gate consults this: `even` means the extractor found no state
|
|
1730
2339
|
# that RECURS, so there is nothing for the page to come back to.
|
|
1731
2340
|
self._keyframe_mode = kfset.mode
|
|
2341
|
+
self._keyframe_steady_screens = kfset.steady_screens
|
|
1732
2342
|
return [str(p) for p in paths], temp_dir
|
|
1733
2343
|
|
|
1734
2344
|
def _wait_for_keyframe(self, element: Any, keyframe_path: str,
|
|
@@ -1757,12 +2367,12 @@ class PageSolver:
|
|
|
1757
2367
|
cheap failure: the gate polls the full `keyframe_wait_timeout_ms` (6s)
|
|
1758
2368
|
PER CLICK before giving up and clicking anyway.
|
|
1759
2369
|
And `even` is not the rare case. All 116 real clips under
|
|
1760
|
-
`
|
|
2370
|
+
`the sample corpus/**/keyframes/` are `even`; `cycle` has never once
|
|
1761
2371
|
fired on real footage, which keyframes.py itself records ("real state
|
|
1762
2372
|
separations top out around 0.007, well under this"). So on real traffic
|
|
1763
2373
|
this gate was 6s of dead time on every animated click, always followed by
|
|
1764
2374
|
the same click it would have made immediately. Measured on
|
|
1765
|
-
|
|
2375
|
+
an hCaptcha rotating-object animation: 6.0s of a 28.8s solve, closest region diff
|
|
1766
2376
|
0.0721 against a 0.05 tolerance, then it clicked and solved.
|
|
1767
2377
|
|
|
1768
2378
|
The gate stays for `cycle`/`static`, where a state genuinely does recur
|
|
@@ -1773,9 +2383,17 @@ class PageSolver:
|
|
|
1773
2383
|
|
|
1774
2384
|
from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
|
|
1775
2385
|
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
2386
|
+
# NOT `mode == "even"`. A board that holds a few screens is worth
|
|
2387
|
+
# waiting for whether or not one 4s burst was long enough to catch it
|
|
2388
|
+
# repeating — and it never is, which is why every real animated capture
|
|
2389
|
+
# slices `even` and this gate was off on all of them. What is genuinely
|
|
2390
|
+
# not worth waiting for is a clip with NO steady screens: a rotation, a
|
|
2391
|
+
# one-way fade, a sprite crossing. Measured: 0 steady screens for all
|
|
2392
|
+
# five continuous hCaptcha video types, 2-3 for every real GeeTest svg.
|
|
2393
|
+
if self._keyframe_steady_screens < 2:
|
|
2394
|
+
_log(f"[animated] clip sits on {self._keyframe_steady_screens} steady "
|
|
2395
|
+
f"screen(s); nothing to come back to, acting on the model's "
|
|
2396
|
+
f"frame without waiting")
|
|
1779
2397
|
return False
|
|
1780
2398
|
|
|
1781
2399
|
ref = cv2.imread(keyframe_path)
|
|
@@ -1788,11 +2406,17 @@ class PageSolver:
|
|
|
1788
2406
|
deadline = (time.monotonic() * 1000.0) + cfg.keyframe_wait_timeout_ms
|
|
1789
2407
|
probe = _tmp_png("kfwait")
|
|
1790
2408
|
best = 1.0
|
|
2409
|
+
polls = 0
|
|
1791
2410
|
try:
|
|
1792
2411
|
while (time.monotonic() * 1000.0) < deadline:
|
|
1793
2412
|
self._check_deadline("waiting for the challenge keyframe")
|
|
1794
2413
|
try:
|
|
1795
|
-
|
|
2414
|
+
# animations="allow": the motion is the thing being watched.
|
|
2415
|
+
# "disabled" FREEZES an infinite CSS animation, so the gate
|
|
2416
|
+
# would photograph one frozen screen on every poll and could
|
|
2417
|
+
# never see the board come round. The JS twin carries the
|
|
2418
|
+
# full note.
|
|
2419
|
+
self._screenshot(element, probe, animations="allow")
|
|
1796
2420
|
live = cv2.imread(probe)
|
|
1797
2421
|
except Exception as exc: # noqa: BLE001
|
|
1798
2422
|
_debug(f"keyframe probe failed: {exc}")
|
|
@@ -1803,12 +2427,31 @@ class PageSolver:
|
|
|
1803
2427
|
if d <= MATCH_REGION_TOLERANCE:
|
|
1804
2428
|
_log(f"[animated] widget matched the chosen keyframe (diff={d:.4f})")
|
|
1805
2429
|
return True
|
|
2430
|
+
# NOT THIS BOARD AT ALL — stop waiting for a screen of it.
|
|
2431
|
+
# Two SCREENS of one board differ by 0.0056 (measured on
|
|
2432
|
+
# GeeTest svg); a different board reads 0.77. So a diff this
|
|
2433
|
+
# large means the puzzle we recorded is gone — solved,
|
|
2434
|
+
# refreshed or replaced — and the rest of the budget buys
|
|
2435
|
+
# nothing. See the JS twin for the full note.
|
|
2436
|
+
polls += 1
|
|
2437
|
+
if polls >= _NOT_THIS_BOARD_POLLS and best > _NOT_THIS_BOARD_DIFF:
|
|
2438
|
+
_log(f"[animated] the widget no longer resembles the recorded "
|
|
2439
|
+
f"board (best diff={best:.4f} over {polls} polls); not "
|
|
2440
|
+
f"waiting out the budget")
|
|
2441
|
+
self._discard_animated_plan()
|
|
2442
|
+
return False
|
|
1806
2443
|
_delay(cfg.keyframe_wait_poll_ms)
|
|
1807
2444
|
finally:
|
|
1808
2445
|
_unlink(probe)
|
|
2446
|
+
# NEVER SAW IT. Either the board moved on to a different challenge or
|
|
2447
|
+
# the answer was for a screen this widget does not show. Both mean the
|
|
2448
|
+
# plan is spent, so the next round records afresh rather than
|
|
2449
|
+
# re-clicking a cell chosen from pictures that are gone.
|
|
2450
|
+
self._discard_animated_plan()
|
|
1809
2451
|
_log(f"[animated] widget never matched the chosen keyframe within "
|
|
1810
2452
|
f"{cfg.keyframe_wait_timeout_ms}ms (closest diff={best:.4f}); "
|
|
1811
|
-
f"clicking on the model's coordinates anyway"
|
|
2453
|
+
f"clicking on the model's coordinates anyway, and recording "
|
|
2454
|
+
f"afresh next round")
|
|
1812
2455
|
return False
|
|
1813
2456
|
|
|
1814
2457
|
def _wait_for_change_since(self, element: Any, since_hash: str) -> bool:
|
|
@@ -1835,7 +2478,7 @@ class PageSolver:
|
|
|
1835
2478
|
`return !!(example && ...)`, which is false when there is no example
|
|
1836
2479
|
image — so a challenge with no tile grid, no canvas and no example
|
|
1837
2480
|
polled until the timeout and then carried on regardless. Measured on
|
|
1838
|
-
|
|
2481
|
+
an hCaptcha highest-value animation: 24.0s of a 45.2s solve, the
|
|
1839
2482
|
full 8s three times over, more than half the budget spent asking a
|
|
1840
2483
|
question about elements that were not on the page. A readiness gate can
|
|
1841
2484
|
only report on what it can see; with nothing to check it has no opinion,
|
|
@@ -1972,9 +2615,42 @@ class PageSolver:
|
|
|
1972
2615
|
if not cfg.stale_frame_resolve_enabled:
|
|
1973
2616
|
return actions, merged_usage
|
|
1974
2617
|
|
|
2618
|
+
# DID THE PICTURE MOVE WHILE THE MODEL READ IT? Asked once per
|
|
2619
|
+
# round, at the noise floor rather than the staleness threshold. A
|
|
2620
|
+
# still puzzle answers no for the price of one frame diff; a
|
|
2621
|
+
# cycling one answers yes and stops being treated as a still a
|
|
2622
|
+
# whole round earlier than "the same answer came back twice"
|
|
2623
|
+
# allows. Acting on one observation is safe because the recording
|
|
2624
|
+
# is self-checking — see `_settle_or_animated`.
|
|
2625
|
+
if self._frame_changed_since(element, current,
|
|
2626
|
+
_MOVED_DURING_INFERENCE_DIFF):
|
|
2627
|
+
self._arm_animated_probe()
|
|
2628
|
+
_log("[freshness] the widget moved while the model was reading "
|
|
2629
|
+
"it, with nothing clicked — recording it rather than "
|
|
2630
|
+
"answering another still.")
|
|
2631
|
+
|
|
2632
|
+
changed_during_inference = 0
|
|
1975
2633
|
for attempt in range(cfg.max_stale_frame_resolves):
|
|
1976
2634
|
if not self._frame_changed_since(element, current, cfg.stale_frame_diff_threshold):
|
|
1977
2635
|
break # frame held still through inference — answer is valid
|
|
2636
|
+
# CHANGED AGAIN, having already re-solved once and touched
|
|
2637
|
+
# nothing. One change is a board still developing — tiles
|
|
2638
|
+
# fading in — which is what the re-solve below is for. A SECOND
|
|
2639
|
+
# change in the same round is a board that does not stop, and
|
|
2640
|
+
# re-solving it is futile: each answer is for a screen that will
|
|
2641
|
+
# be gone before the click.
|
|
2642
|
+
#
|
|
2643
|
+
# This is the earliest the driver can know. Both ports otherwise
|
|
2644
|
+
# wait for the same answer to come back twice, which costs a
|
|
2645
|
+
# whole extra round and its inference — ~10s of a 40s solve
|
|
2646
|
+
# spent rediscovering what the guard has just watched happen.
|
|
2647
|
+
changed_during_inference += 1
|
|
2648
|
+
if changed_during_inference >= 2:
|
|
2649
|
+
self._arm_animated_probe()
|
|
2650
|
+
_log("[freshness] the frame changed twice during inference with "
|
|
2651
|
+
"nothing clicked — this board cycles; recording it rather "
|
|
2652
|
+
"than re-solving a screen that has gone.")
|
|
2653
|
+
return actions, merged_usage
|
|
1978
2654
|
fresh = _tmp_png("freshsolve")
|
|
1979
2655
|
try:
|
|
1980
2656
|
self._screenshot(element, fresh)
|
|
@@ -2006,6 +2682,10 @@ class PageSolver:
|
|
|
2006
2682
|
)
|
|
2007
2683
|
|
|
2008
2684
|
def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
|
|
2685
|
+
# A hover is mimicry of a resting CURSOR, so on a device that has none
|
|
2686
|
+
# it is not weaker mimicry — it is a mousemove at a touch-only widget.
|
|
2687
|
+
if not self._human.hovers:
|
|
2688
|
+
return
|
|
2009
2689
|
first = session.grid_boxes[0]
|
|
2010
2690
|
cell_w = (first[2] - first[0]) * session.scale_x
|
|
2011
2691
|
cell_h = (first[3] - first[1]) * session.scale_y
|
|
@@ -2217,19 +2897,29 @@ class PageSolver:
|
|
|
2217
2897
|
# Each round can legitimately spend ~10s waiting on fades, so eight
|
|
2218
2898
|
# of them plus the model calls can outlast the whole solve budget.
|
|
2219
2899
|
self._check_deadline(f"recaptcha grid round {round_index}")
|
|
2220
|
-
|
|
2221
|
-
|
|
2900
|
+
# Round 1 skips it: `_solve_single` has just waited for these cells,
|
|
2901
|
+
# read the grid boxes off the loaded board and handed over, and
|
|
2902
|
+
# nothing has touched the widget in between. Rounds 2..N still wait,
|
|
2903
|
+
# because by then THIS driver has clicked and the tiles really are
|
|
2904
|
+
# reloading. Measured 2148ms x2 on one solve.
|
|
2905
|
+
if round_index > 1:
|
|
2906
|
+
with self._phase("grid-load"):
|
|
2907
|
+
self._wait_for_grid_cells_loaded(element)
|
|
2222
2908
|
shot = _tmp_png("recap")
|
|
2223
2909
|
action: Optional[Dict[str, Any]] = None
|
|
2224
2910
|
try:
|
|
2225
|
-
self.
|
|
2911
|
+
with self._phase("screenshot"):
|
|
2912
|
+
self._screenshot(element, shot,
|
|
2913
|
+
timeout_ms=cfg.element_screenshot_timeout_ms)
|
|
2226
2914
|
retry_for_round = pending_retry
|
|
2227
2915
|
pending_retry = None # only round 1 carries the inbound hint
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2916
|
+
with self._phase("inference"):
|
|
2917
|
+
actions, usage = self._solve_frame_freshness_guarded(
|
|
2918
|
+
element,
|
|
2919
|
+
shot,
|
|
2920
|
+
lambda image_path: self._get_solution(
|
|
2921
|
+
image_path, "recaptcha", retry_for_round),
|
|
2922
|
+
)
|
|
2233
2923
|
all_usage.extend(usage)
|
|
2234
2924
|
action = _as_dict(actions[0]) if actions else None
|
|
2235
2925
|
finally:
|
|
@@ -2242,8 +2932,11 @@ class PageSolver:
|
|
|
2242
2932
|
|
|
2243
2933
|
if action.get("action") == "wait":
|
|
2244
2934
|
_log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
|
|
2245
|
-
|
|
2246
|
-
|
|
2935
|
+
with self._phase("fade-wait"):
|
|
2936
|
+
loading, _ = self._watch_clicked_tiles(
|
|
2937
|
+
page, element, session, clicked_order)
|
|
2938
|
+
self._wait_for_any_clicked_tile_loaded(
|
|
2939
|
+
page, element, session, loading)
|
|
2247
2940
|
continue
|
|
2248
2941
|
|
|
2249
2942
|
if action.get("action") == "click":
|
|
@@ -2266,16 +2959,17 @@ class PageSolver:
|
|
|
2266
2959
|
if cell is not None:
|
|
2267
2960
|
clicked_order.append(cell)
|
|
2268
2961
|
clicked_this_round.append(cell)
|
|
2269
|
-
|
|
2962
|
+
self._human.pause("between")
|
|
2270
2963
|
performed_action = True
|
|
2271
2964
|
_log(
|
|
2272
2965
|
f"[recaptcha-grid] round {round_index}: clicked {len(bboxes)} tile(s) "
|
|
2273
2966
|
f"-> cells {clicked_this_round}."
|
|
2274
2967
|
)
|
|
2275
2968
|
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2969
|
+
with self._phase("fade-wait"):
|
|
2970
|
+
loading, chipped = self._watch_clicked_tiles(
|
|
2971
|
+
page, element, session, clicked_this_round
|
|
2972
|
+
)
|
|
2279
2973
|
if chipped or not loading:
|
|
2280
2974
|
# Either the widget ticked our clicks and kept the photos
|
|
2281
2975
|
# (a board that does that is answered), or nothing faded
|
|
@@ -2287,7 +2981,9 @@ class PageSolver:
|
|
|
2287
2981
|
)
|
|
2288
2982
|
should_submit = True
|
|
2289
2983
|
break
|
|
2290
|
-
self.
|
|
2984
|
+
with self._phase("fade-wait"):
|
|
2985
|
+
self._wait_for_any_clicked_tile_loaded(
|
|
2986
|
+
page, element, session, loading)
|
|
2291
2987
|
continue
|
|
2292
2988
|
|
|
2293
2989
|
_log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
|
|
@@ -2299,6 +2995,17 @@ class PageSolver:
|
|
|
2299
2995
|
if verify:
|
|
2300
2996
|
_log("[recaptcha-grid] clicking Verify to submit.")
|
|
2301
2997
|
self._move_and_click(page, verify)
|
|
2998
|
+
# The press IS an interaction, and saying so is load-bearing
|
|
2999
|
+
# — the same rule `_solve_single` learned on
|
|
3000
|
+
# a Prosopo 3x3 grid, in the other driver, on another day. A
|
|
3001
|
+
# `done` round clicks no tile, so without this the one
|
|
3002
|
+
# answer shape that submits and does nothing else reports
|
|
3003
|
+
# having done nothing: the caller then takes the
|
|
3004
|
+
# no-interaction wait instead of polling for the verdict
|
|
3005
|
+
# (~1s of dead time on every static reCAPTCHA), and raises
|
|
3006
|
+
# "performed no interactions" if the widget has not
|
|
3007
|
+
# finished tearing down — on an answer correctly sent.
|
|
3008
|
+
performed_action = True
|
|
2302
3009
|
|
|
2303
3010
|
return performed_action, all_usage
|
|
2304
3011
|
|
|
@@ -2339,9 +3046,10 @@ class PageSolver:
|
|
|
2339
3046
|
# and want opposite answers. Restricted to `unknown` because neither
|
|
2340
3047
|
# hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
|
|
2341
3048
|
# inside one of their frames would be a false positive by definition.
|
|
2342
|
-
text_mode =
|
|
2343
|
-
|
|
2344
|
-
|
|
3049
|
+
text_mode = (
|
|
3050
|
+
puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING
|
|
3051
|
+
and self._answer_box(scope, element) is not None
|
|
3052
|
+
)
|
|
2345
3053
|
if text_mode:
|
|
2346
3054
|
_log("widget has a text box; solving as a distorted-text captcha")
|
|
2347
3055
|
|
|
@@ -2367,7 +3075,7 @@ class PageSolver:
|
|
|
2367
3075
|
if self.is_captcha_solved(page):
|
|
2368
3076
|
_log("solved while waiting for the next round; skipping inference.")
|
|
2369
3077
|
return False, []
|
|
2370
|
-
elif puzzle_source
|
|
3078
|
+
elif puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING:
|
|
2371
3079
|
# Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
|
|
2372
3080
|
# probe was never run for these, so an animated one — GeeTest's svg board
|
|
2373
3081
|
# cycles its glyph set — was screenshotted mid-cycle and answered from
|
|
@@ -2413,8 +3121,22 @@ class PageSolver:
|
|
|
2413
3121
|
# whole re-solve budget would burn without ever acting. The frame
|
|
2414
3122
|
# number in the answer is the real guard: it names the state to act
|
|
2415
3123
|
# in, and `_execute_click` waits for it.
|
|
2416
|
-
|
|
2417
|
-
|
|
3124
|
+
if self._animated_plan is not None:
|
|
3125
|
+
# ONE burst for as long as this board is up — and, until
|
|
3126
|
+
# 2026-09-09, one INFERENCE too, which is what made a
|
|
3127
|
+
# refused answer immortal. `actions` is None once the
|
|
3128
|
+
# widget has rejected them (`_invalidate_animated_answer`),
|
|
3129
|
+
# so the frames are reused and the QUESTION is asked again.
|
|
3130
|
+
keyframes, keyframe_dir, actions, all_usage = self._animated_plan
|
|
3131
|
+
reused_plan = actions is not None
|
|
3132
|
+
_log("[animated] reusing the recorded answer — "
|
|
3133
|
+
"same board, same screens" if reused_plan else
|
|
3134
|
+
"[animated] same screens, but the last answer was "
|
|
3135
|
+
"refused — re-asking on the frames already recorded")
|
|
3136
|
+
else:
|
|
3137
|
+
reused_plan = False
|
|
3138
|
+
with self._phase("burst"):
|
|
3139
|
+
keyframes, keyframe_dir = self._record_keyframes(element)
|
|
2418
3140
|
# THE RECORDING IS THE TEST. A burst of a widget that turns out
|
|
2419
3141
|
# not to move slices to a single keyframe (`mode=static`), and
|
|
2420
3142
|
# that frame is exactly the still this round would have taken
|
|
@@ -2437,16 +3159,50 @@ class PageSolver:
|
|
|
2437
3159
|
# another screenshot of the same motionless widget.
|
|
2438
3160
|
shutil.copyfile(keyframes[0], shot)
|
|
2439
3161
|
have_shot = True
|
|
3162
|
+
# Not a cycling board after all, so there is no plan to keep.
|
|
3163
|
+
self._discard_animated_plan()
|
|
2440
3164
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
2441
3165
|
keyframe_dir = None
|
|
2442
|
-
|
|
3166
|
+
elif not reused_plan:
|
|
2443
3167
|
with self._phase("inference"):
|
|
2444
3168
|
actions, all_usage = self._get_keyframe_solution(keyframes)
|
|
2445
|
-
|
|
2446
|
-
|
|
3169
|
+
self._animated_plan = (keyframes, keyframe_dir,
|
|
3170
|
+
actions, all_usage)
|
|
3171
|
+
|
|
3172
|
+
if not is_animated and self._should_speculate(puzzle_source, text_mode):
|
|
3173
|
+
# ASK AND WATCH AT THE SAME TIME. The JS twin carries the full
|
|
3174
|
+
# note; in short, the burst is the only thing that can tell a
|
|
3175
|
+
# cycling board from a still one AND is also the recording that
|
|
3176
|
+
# answers it, so it runs while the screenshot is being read.
|
|
3177
|
+
#
|
|
3178
|
+
# A still board sees one picture, keeps its answer and drops the
|
|
3179
|
+
# frames — identical behaviour and identical cost, because the
|
|
3180
|
+
# recording happened inside a wait we were already making. A
|
|
3181
|
+
# moving board's still answer is discarded UNREAD, because it
|
|
3182
|
+
# describes a screen that has gone.
|
|
3183
|
+
#
|
|
3184
|
+
# THE MODEL CALL GOES TO A WORKER THREAD AND THE SCREENSHOTS DO
|
|
3185
|
+
# NOT. Playwright's sync API is thread-affine, so every browser
|
|
3186
|
+
# call stays on this thread; the planner's HTTP request touches
|
|
3187
|
+
# no browser and is the only thing handed off. Exactly one model
|
|
3188
|
+
# call is in flight at a time, so the shared planner sees no
|
|
3189
|
+
# concurrency either.
|
|
2447
3190
|
if not have_shot:
|
|
2448
|
-
self.
|
|
2449
|
-
|
|
3191
|
+
with self._phase("screenshot"):
|
|
3192
|
+
self._screenshot(
|
|
3193
|
+
element, shot,
|
|
3194
|
+
timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
3195
|
+
actions, all_usage, keyframe_dir = self._speculate(
|
|
3196
|
+
element, shot, puzzle_source, retry_mode, text_mode)
|
|
3197
|
+
if keyframe_dir is not None:
|
|
3198
|
+
is_animated = True
|
|
3199
|
+
|
|
3200
|
+
elif not is_animated:
|
|
3201
|
+
if not have_shot:
|
|
3202
|
+
with self._phase("screenshot"):
|
|
3203
|
+
self._screenshot(
|
|
3204
|
+
element, shot,
|
|
3205
|
+
timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
2450
3206
|
with self._phase("inference"):
|
|
2451
3207
|
actions, all_usage = self._solve_frame_freshness_guarded(
|
|
2452
3208
|
element,
|
|
@@ -2456,8 +3212,17 @@ class PageSolver:
|
|
|
2456
3212
|
),
|
|
2457
3213
|
)
|
|
2458
3214
|
|
|
3215
|
+
# The box turns NORMALISED coordinates into page ones, so only an
|
|
3216
|
+
# action that carries coordinates needs it. A "done" answer carries
|
|
3217
|
+
# none — it means "nothing left to click" — and several vendors
|
|
3218
|
+
# CLOSE the challenge the moment they accept it, so the widget is
|
|
3219
|
+
# legitimately gone by the time that answer arrives. Demanding a box
|
|
3220
|
+
# for it failed the solve at the moment it succeeded: measured
|
|
3221
|
+
# 2026-09-07 on gt4.geetest.com, 22 of 22 live GeeTest attempts, each
|
|
3222
|
+
# banked as the model being wrong. The JS port carries the identical
|
|
3223
|
+
# rule (rule 1c) and `answer_needs_element_box` is its twin.
|
|
2459
3224
|
element_box = element.bounding_box()
|
|
2460
|
-
if not element_box:
|
|
3225
|
+
if not element_box and answer_needs_element_box(actions):
|
|
2461
3226
|
raise CaptchaSolveError("could not get bounding box of captcha element")
|
|
2462
3227
|
|
|
2463
3228
|
# Recorded at the moment of EXECUTION, which is what makes a repeat
|
|
@@ -2489,10 +3254,13 @@ class PageSolver:
|
|
|
2489
3254
|
await_kf = action.get("await_keyframe")
|
|
2490
3255
|
if bboxes:
|
|
2491
3256
|
for bbox in bboxes:
|
|
3257
|
+
one = {"target_bounding_box": bbox}
|
|
2492
3258
|
if await_kf:
|
|
2493
|
-
self.
|
|
2494
|
-
|
|
2495
|
-
|
|
3259
|
+
self._click_when_frame_matches(
|
|
3260
|
+
page, element, one, element_box, await_kf)
|
|
3261
|
+
else:
|
|
3262
|
+
self._execute_click(page, one, element_box)
|
|
3263
|
+
self._human.pause("between")
|
|
2496
3264
|
else:
|
|
2497
3265
|
self._execute_click(page, action, element_box)
|
|
2498
3266
|
performed_action = True
|
|
@@ -2519,7 +3287,7 @@ class PageSolver:
|
|
|
2519
3287
|
performed_action = True
|
|
2520
3288
|
placed = True
|
|
2521
3289
|
elif kind == "type":
|
|
2522
|
-
if self._execute_type(page, scope, action):
|
|
3290
|
+
if self._execute_type(page, scope, action, element):
|
|
2523
3291
|
performed_action = True
|
|
2524
3292
|
typed = True
|
|
2525
3293
|
elif kind == "wait":
|
|
@@ -2554,11 +3322,16 @@ class PageSolver:
|
|
|
2554
3322
|
# burning all ten solve loops on a puzzle they had answered on
|
|
2555
3323
|
# the first one. The press itself is still bounded by
|
|
2556
3324
|
# `should_submit` below, which is where the hazard belongs.
|
|
3325
|
+
# RESOLVED, not travelled to. `_move_and_click` below moves to the
|
|
3326
|
+
# button itself and picks its own random point inside it, so moving
|
|
3327
|
+
# here first bought a second humanised trajectory that ended a few
|
|
3328
|
+
# dozen pixels from where the first one stopped, plus a second
|
|
3329
|
+
# bounded scroll-into-view. Two hops onto one button is slower AND
|
|
3330
|
+
# is not something a hand does. A round that decides not to submit
|
|
3331
|
+
# no longer walks to the control either.
|
|
2557
3332
|
lookup = frame or (scope if not slid else None)
|
|
2558
3333
|
if lookup is not None:
|
|
2559
3334
|
verify_button = self._get_verify_button(lookup)
|
|
2560
|
-
if verify_button:
|
|
2561
|
-
self._move_to_element(page, verify_button)
|
|
2562
3335
|
|
|
2563
3336
|
# Submit policy: press the widget's own submit control whenever we
|
|
2564
3337
|
# have put an ANSWER into it — a selection, a placed piece, a typed
|
|
@@ -2586,7 +3359,7 @@ class PageSolver:
|
|
|
2586
3359
|
# control at all, so `verify_button` is None and nothing is pressed
|
|
2587
3360
|
# either way. What the exclusion actually bought was a whole extra
|
|
2588
3361
|
# model call per puzzle, spent asking a board we had already answered
|
|
2589
|
-
# correctly whether it was `done` — 6.2 s of
|
|
3362
|
+
# correctly whether it was `done` — 6.2 s of a Prosopo 3x3 grid's 13.8 s,
|
|
2590
3363
|
# on a selection that scored 1.0 on the first call.
|
|
2591
3364
|
answered = clicked or placed or typed
|
|
2592
3365
|
should_submit = not slid and (answered or not performed_action)
|
|
@@ -2597,7 +3370,7 @@ class PageSolver:
|
|
|
2597
3370
|
# the caller aborts a round that reports none, so submitting a
|
|
2598
3371
|
# `done` answer and then returning False re-arms the very guard
|
|
2599
3372
|
# this satisfies — the puzzle is sent and the solve gives up on
|
|
2600
|
-
# it one line later, which is what
|
|
3373
|
+
# it one line later, which is what a Prosopo 3x3 grid did.
|
|
2601
3374
|
performed_action = True
|
|
2602
3375
|
# Snapshot at submit time so the NEXT attempt waits for the real
|
|
2603
3376
|
# transition before treating whatever is on screen as fresh.
|
|
@@ -2606,7 +3379,11 @@ class PageSolver:
|
|
|
2606
3379
|
_unlink(shot)
|
|
2607
3380
|
# Only now: the wait gate re-reads the keyframe PNG on every poll, so
|
|
2608
3381
|
# removing the directory any earlier would break the click it is gating.
|
|
2609
|
-
|
|
3382
|
+
# ...and not while the PLAN still holds them: the next round
|
|
3383
|
+
# re-executes this same answer, and the gate reads these PNGs on
|
|
3384
|
+
# every poll.
|
|
3385
|
+
held = self._animated_plan[1] if self._animated_plan else None
|
|
3386
|
+
if keyframe_dir and keyframe_dir != held:
|
|
2610
3387
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
2611
3388
|
|
|
2612
3389
|
return performed_action, all_usage
|
|
@@ -2648,8 +3425,7 @@ class PageSolver:
|
|
|
2648
3425
|
cumulative_usage: List[Dict[str, Any]] = []
|
|
2649
3426
|
self._last_submit_frame_hash = None
|
|
2650
3427
|
self._deadline_ms = start + self.config.overall_solve_timeout_ms
|
|
2651
|
-
self.
|
|
2652
|
-
self._cursor_seeded = False
|
|
3428
|
+
self._human.reset(page)
|
|
2653
3429
|
self._reset_animated_state()
|
|
2654
3430
|
self._budget = PhaseBudget()
|
|
2655
3431
|
|
|
@@ -2664,15 +3440,35 @@ class PageSolver:
|
|
|
2664
3440
|
# would merge two separate captchas into one attempt — the opposite
|
|
2665
3441
|
# error, and the one that under-bills.
|
|
2666
3442
|
previous_session = os.environ.get(_SESSION_ENV)
|
|
2667
|
-
|
|
3443
|
+
session_id = str(uuid.uuid4())
|
|
3444
|
+
os.environ[_SESSION_ENV] = session_id
|
|
3445
|
+
# The verdict this driver reports back, and it starts FALSE. A solve
|
|
3446
|
+
# that raises never sets it, which is the right default: the widget did
|
|
3447
|
+
# not accept, and the boards that made it raise are exactly the ones
|
|
3448
|
+
# worth keeping. See planner.report_outcome.
|
|
3449
|
+
solved_for_report = False
|
|
2668
3450
|
try:
|
|
2669
|
-
|
|
3451
|
+
result = self._solve_impl(page, start, cumulative_usage)
|
|
3452
|
+
# Attached HERE rather than at each of the seven `return
|
|
3453
|
+
# SolveResult(...)` sites, so a new early exit cannot forget it.
|
|
3454
|
+
result.phases = dict(self._budget.totals)
|
|
3455
|
+
result.phases["total"] = self._budget.elapsed_ms()
|
|
3456
|
+
solved_for_report = bool(getattr(result, "is_solved", False))
|
|
3457
|
+
return result
|
|
2670
3458
|
finally:
|
|
2671
3459
|
# Printed on the way out of every solve, success or failure — a
|
|
2672
3460
|
# solve that FAILED is exactly the one whose time you want itemised.
|
|
2673
3461
|
if timings_enabled() and self._budget is not None:
|
|
2674
3462
|
print(self._budget.report(), file=sys.stderr)
|
|
2675
3463
|
self._deadline_ms = None
|
|
3464
|
+
# BEFORE the env is restored, so the id reported is this solve's.
|
|
3465
|
+
# Best-effort and never raised out of a `finally` — an exception
|
|
3466
|
+
# here would replace the caller's real error, or their real result,
|
|
3467
|
+
# with one about telemetry.
|
|
3468
|
+
try:
|
|
3469
|
+
self._solver.planner.report_outcome(session_id, solved_for_report)
|
|
3470
|
+
except Exception as exc: # noqa: BLE001
|
|
3471
|
+
_log(f"[outcome] could not report: {exc}")
|
|
2676
3472
|
if previous_session is None:
|
|
2677
3473
|
os.environ.pop(_SESSION_ENV, None)
|
|
2678
3474
|
else:
|
|
@@ -2758,10 +3554,7 @@ class PageSolver:
|
|
|
2758
3554
|
)
|
|
2759
3555
|
_delay(800 + random.random() * 300)
|
|
2760
3556
|
continue
|
|
2761
|
-
raise NoCaptchaFoundError(
|
|
2762
|
-
"no interactive captcha widget detected (likely reCAPTCHA v3 / "
|
|
2763
|
-
"invisible or a click-triggered challenge)"
|
|
2764
|
-
)
|
|
3557
|
+
raise NoCaptchaFoundError(self._no_widget_message(page))
|
|
2765
3558
|
|
|
2766
3559
|
_log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
|
|
2767
3560
|
retry_mode_this_loop = pending_retry_mode
|
|
@@ -2873,62 +3666,80 @@ class PageSolver:
|
|
|
2873
3666
|
render_waits = 0
|
|
2874
3667
|
cumulative_usage.extend(usage)
|
|
2875
3668
|
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
3669
|
+
# ONE wait after a round, polled, whichever kind of round it was.
|
|
3670
|
+
#
|
|
3671
|
+
# There used to be two: this poll when the round interacted, and a
|
|
3672
|
+
# flat `_delay(post_solve_delay_ms + jitter)` when it did not. The
|
|
3673
|
+
# flat sleep observed NOTHING — it ran to the end and only then
|
|
3674
|
+
# asked whether the widget was still there — so a round that had in
|
|
3675
|
+
# fact finished the captcha paid 1200-1500ms to find that out. The
|
|
3676
|
+
# poll below already asks exactly that question and answers it the
|
|
3677
|
+
# moment it becomes true.
|
|
3678
|
+
#
|
|
3679
|
+
# The two windows keep different LENGTHS, because different things
|
|
3680
|
+
# size them: `post_solve_outcome_timeout_ms` covers how late a
|
|
3681
|
+
# vendor's success signal can arrive (measured — see the constant),
|
|
3682
|
+
# while `post_solve_delay_ms` is the dwell a round that answered
|
|
3683
|
+
# nothing takes before deciding the page has settled.
|
|
3684
|
+
window_ms = (cfg.post_solve_outcome_timeout_ms if did_interact
|
|
3685
|
+
else cfg.post_solve_delay_ms + random.random() * 300)
|
|
3686
|
+
# Poll for the vendor's SOLVED signal before re-entering the
|
|
3687
|
+
# pipeline. hCaptcha keeps the challenge visible for a couple of
|
|
3688
|
+
# seconds while verifying; without this the loop re-solves that
|
|
3689
|
+
# closing frame and burns ~18s. Only ever early-RETURNS on a
|
|
3690
|
+
# definitive signal, so it cannot loop.
|
|
3691
|
+
deadline = time.monotonic() * 1000.0 + window_ms
|
|
3692
|
+
solved = False
|
|
3693
|
+
widget_gone = 0
|
|
3694
|
+
_verdict_t0 = time.perf_counter()
|
|
3695
|
+
while time.monotonic() * 1000.0 < deadline:
|
|
3696
|
+
if self.is_captcha_solved(page):
|
|
3697
|
+
solved = True
|
|
3698
|
+
break
|
|
3699
|
+
# The eight inline vendors have no response token, so
|
|
3700
|
+
# `is_captcha_solved` — which reads only the hCaptcha and
|
|
3701
|
+
# reCAPTCHA anchors — can never fire for them and this loop
|
|
3702
|
+
# ran out its whole 2.5s budget on EVERY round, waiting for
|
|
3703
|
+
# a signal that cannot arrive. Measured on a GeeTest v4 slider:
|
|
3704
|
+
# 5.2s of a 12.3s solve, spent after the puzzle was already
|
|
3705
|
+
# answered, with the widget sitting there visibly solved.
|
|
3706
|
+
#
|
|
3707
|
+
# "The widget is gone" is the completion signal for those
|
|
3708
|
+
# vendors and is already the authority immediately after
|
|
3709
|
+
# this loop, so this only reaches the same verdict sooner —
|
|
3710
|
+
# confirmed over two polls so a frame caught mid-swap
|
|
3711
|
+
# between rounds cannot read as a solve.
|
|
3712
|
+
if self.detect_captcha(page) is None:
|
|
3713
|
+
widget_gone += 1
|
|
3714
|
+
if widget_gone >= 2:
|
|
2888
3715
|
solved = True
|
|
2889
3716
|
break
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
#
|
|
2894
|
-
#
|
|
2895
|
-
|
|
2896
|
-
#
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
_verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
|
|
2914
|
-
if self._budget is not None:
|
|
2915
|
-
self._budget.add("await-verdict", _verdict_ms)
|
|
2916
|
-
# How long a SUCCESS actually took to show itself. This is the
|
|
2917
|
-
# only number that can size `post_solve_outcome_timeout_ms`: the
|
|
2918
|
-
# window exists to catch a late success, so it needs to cover
|
|
2919
|
-
# the slowest real one and nothing beyond it. A round that ends
|
|
2920
|
-
# any other way spends the whole window by construction — there
|
|
2921
|
-
# is no signal on a wrong answer — so its duration says nothing
|
|
2922
|
-
# about how long the window ought to be.
|
|
2923
|
-
if solved:
|
|
2924
|
-
_log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
|
|
2925
|
-
if solved:
|
|
2926
|
-
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
2927
|
-
else:
|
|
2928
|
-
with self._phase("post-submit-delay"):
|
|
2929
|
-
_delay(cfg.post_solve_delay_ms + random.random() * 300)
|
|
3717
|
+
else:
|
|
3718
|
+
widget_gone = 0
|
|
3719
|
+
if self._is_challenge_freshly_rendered(page):
|
|
3720
|
+
# A DIFFERENT puzzle, so the greedy answer has not been
|
|
3721
|
+
# tested against it and the escalation must not carry over.
|
|
3722
|
+
self._fresh_board()
|
|
3723
|
+
break # next round is up; go solve it now
|
|
3724
|
+
_delay(cfg.post_solve_outcome_poll_ms)
|
|
3725
|
+
_verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
|
|
3726
|
+
if self._budget is not None:
|
|
3727
|
+
self._budget.add(
|
|
3728
|
+
"await-verdict" if did_interact else "post-submit-delay",
|
|
3729
|
+
_verdict_ms)
|
|
3730
|
+
# How long a SUCCESS actually took to show itself. This is the
|
|
3731
|
+
# only number that can size `post_solve_outcome_timeout_ms`: the
|
|
3732
|
+
# window exists to catch a late success, so it needs to cover
|
|
3733
|
+
# the slowest real one and nothing beyond it. A round that ends
|
|
3734
|
+
# any other way spends the whole window by construction — there
|
|
3735
|
+
# is no signal on a wrong answer — so its duration says nothing
|
|
3736
|
+
# about how long the window ought to be.
|
|
3737
|
+
if solved:
|
|
3738
|
+
_log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
|
|
3739
|
+
return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
|
|
2930
3740
|
|
|
2931
|
-
|
|
3741
|
+
_banner = self._recaptcha_banner_kind(page)
|
|
3742
|
+
if self._banner_is_fatal_after_retry(_banner):
|
|
2932
3743
|
if already_retried_underselect:
|
|
2933
3744
|
raise CaptchaSolveError(
|
|
2934
3745
|
"reCAPTCHA still showing the under-selection error after retry; "
|