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.
Files changed (38) hide show
  1. package/README.md +90 -7
  2. package/dist/cli-invocation.d.ts +16 -16
  3. package/dist/cli-invocation.js +33 -21
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/model-name.d.ts +1 -1
  10. package/dist/model-name.js +1 -1
  11. package/dist/playwright-types.d.ts +18 -0
  12. package/dist/solver.d.ts +314 -16
  13. package/dist/solver.js +1208 -278
  14. package/dist/timing.d.ts +48 -0
  15. package/dist/timing.js +103 -0
  16. package/dist/trajectory.d.ts +28 -1
  17. package/dist/trajectory.js +105 -1
  18. package/dist/types.d.ts +113 -2
  19. package/package.json +2 -1
  20. package/python/README.md +43 -0
  21. package/python/pyproject.toml +1 -1
  22. package/python/src/captchakraken/__init__.py +36 -1
  23. package/python/src/captchakraken/cli.py +66 -4
  24. package/python/src/captchakraken/config.py +28 -2
  25. package/python/src/captchakraken/errors.py +24 -0
  26. package/python/src/captchakraken/humanize.py +929 -0
  27. package/python/src/captchakraken/keyframes.py +184 -12
  28. package/python/src/captchakraken/models.json +180 -18
  29. package/python/src/captchakraken/page_solver.py +1132 -321
  30. package/python/src/captchakraken/pinned_model.json +3 -2
  31. package/python/src/captchakraken/planner.py +204 -16
  32. package/python/src/captchakraken/prompts.py +186 -4
  33. package/python/src/captchakraken/server_manager.py +9 -4
  34. package/python/src/captchakraken/solver.py +98 -58
  35. package/python/src/captchakraken/timing.py +8 -3
  36. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  37. package/python/src/captchakraken/trajectory.py +117 -0
  38. 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 .trajectory import generate_trajectory
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 recaptcha_grid_4x4 (fixture seed 20260730): the model answers
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 recaptcha_grid_4x4 solve.
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 finetune repo), so a
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
- keyframe_wait_timeout_ms: int = 6_000
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, Tier 3 run 32596340560: EVERY python-port failure on
333
- #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
334
- #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
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.video_burst_duration_ms + self.keyframe_wait_timeout_ms
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
- {"puzzle_source": "tencent", "selectors": ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
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
- {"puzzle_source": "yandex", "selectors": [".CheckboxCaptcha"]},
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
- {"puzzle_source": "mtcaptcha", "selectors": [".mtcap"]},
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
- TEXT_INPUT_SELECTORS = [
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
- self._last_mouse: Tuple[float, float] = (0.0, 0.0)
513
- # See _seed_cursor: the (0, 0) origin wedges camoufox's humanised
514
- # mouse, so the first move of each solve must step off it plainly.
515
- self._cursor_seeded = False
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
- # A board that reads the same every round is the signature of a
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
- self._seed_cursor(page)
839
- points, timings = generate_trajectory(self._last_mouse, (x, y), 60)
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._trace_path(page, points, timings)
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
- page.mouse.down()
874
- _delay(random.random() * 20 + 20)
875
- page.mouse.up()
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._smooth_move(page, element_box["x"] + rel_x, element_box["y"] + rel_y)
901
- page.mouse.down()
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._smooth_move(page, *src)
917
- page.mouse.down()
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 _execute_type(self, page: Any, scope: Any, action: Dict[str, Any]) -> bool:
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._find_control(scope, TEXT_INPUT_SELECTORS)
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
- self._move_and_click(page, field) # travel there, then press to focus
956
- # A retry round arrives with the previous attempt still in the box, and
957
- # typing would APPEND to it — submitting a string the model never read.
958
- try:
959
- page.keyboard.press("Control+A")
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._smooth_move(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
1047
- page.mouse.down()
1048
- _delay(random.random() * 50 + 50)
1049
- self._smooth_move(page, element_box["x"] + target_x, element_box["y"] + target_y)
1050
- _delay(random.random() * 50 + 50)
1051
- page.mouse.up()
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
- exclude = [
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
- page.mouse.down()
1077
- _delay(random.random() * 60 + 60)
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
- _delay(random.random() * 40 + 40)
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
- _delay(random.random() * 40 + 40)
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} target={target_x:.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
- _delay(random.random() * 120 + 90)
1427
+ self._human.pause("settle")
1128
1428
  finally:
1129
1429
  try:
1130
- page.mouse.up()
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/test_solved_detection.py.
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
- def _has_recaptcha_underselect_error(self, page: Any) -> bool:
1375
- """
1376
- reCAPTCHA's "select all matching images" banner, shown after Verify with
1377
- an incomplete selection.
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
- Critically, the tiles do NOT refresh on this error: without special
1380
- handling the model sees the same image, answers `done` (to it, everything
1381
- matching IS selected), we click Verify again, and the solve loops until
1382
- it times out. Detecting it switches the next call into missed-tiles mode.
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 False
1766
+ return None
1388
1767
  frame = bframe.content_frame()
1389
1768
  if not frame:
1390
- return False
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 True
1779
+ return kind
1405
1780
  except Exception:
1406
1781
  pass
1407
- return False
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
- total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
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 = (time.monotonic() * 1000.0
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 {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
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
- `cleanSamples/test/raw/**/keyframes/` are `even`; `cycle` has never once
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
- `hcaptcha_rotating_obj_video`: 6.0s of a 28.8s solve, closest region diff
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
- if self._keyframe_mode == "even":
1777
- _log("[animated] clip is mode=even (no state recurs); "
1778
- "acting on the model's frame without waiting")
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
- self._screenshot(element, probe)
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
- `hcaptcha_number_with_highest_value_video`: 24.0s of a 45.2s solve, the
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
- with self._phase("grid-load"):
2221
- self._wait_for_grid_cells_loaded(element)
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._screenshot(element, shot, timeout_ms=cfg.element_screenshot_timeout_ms)
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
- actions, usage = self._solve_frame_freshness_guarded(
2229
- element,
2230
- shot,
2231
- lambda image_path: self._get_solution(image_path, "recaptcha", retry_for_round),
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
- loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
2246
- self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
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
- _delay(random.random() * 80 + 80)
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
- loading, chipped = self._watch_clicked_tiles(
2277
- page, element, session, clicked_this_round
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._wait_for_any_clicked_tile_loaded(page, element, session, loading)
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 = puzzle_source == "unknown" and self._find_control(
2343
- scope, TEXT_INPUT_SELECTORS
2344
- ) is not None
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 == "unknown":
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
- with self._phase("burst"):
2417
- keyframes, keyframe_dir = self._record_keyframes(element)
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
- else:
3166
+ elif not reused_plan:
2443
3167
  with self._phase("inference"):
2444
3168
  actions, all_usage = self._get_keyframe_solution(keyframes)
2445
-
2446
- if not is_animated:
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._screenshot(element, shot,
2449
- timeout_ms=self.config.element_screenshot_timeout_ms)
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._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
2494
- self._execute_click(page, {"target_bounding_box": bbox}, element_box)
2495
- _delay(random.random() * 80 + 80)
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 prosopo_grid_3x3's 13.8 s,
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 `prosopo_grid_3x3` did.
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
- if keyframe_dir:
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._viewport_cache = None
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
- os.environ[_SESSION_ENV] = str(uuid.uuid4())
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
- return self._solve_impl(page, start, cumulative_usage)
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
- if did_interact:
2877
- # Poll for the vendor's SOLVED signal before re-entering the
2878
- # pipeline. hCaptcha keeps the challenge visible for a couple of
2879
- # seconds while verifying; without this the loop re-solves that
2880
- # closing frame and burns ~18s. Only ever early-RETURNS on a
2881
- # definitive signal, so it cannot loop.
2882
- deadline = time.monotonic() * 1000.0 + cfg.post_solve_outcome_timeout_ms
2883
- solved = False
2884
- widget_gone = 0
2885
- _verdict_t0 = time.perf_counter()
2886
- while time.monotonic() * 1000.0 < deadline:
2887
- if self.is_captcha_solved(page):
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
- # The eight inline vendors have no response token, so
2891
- # `is_captcha_solved` — which reads only the hCaptcha and
2892
- # reCAPTCHA anchors — can never fire for them and this loop
2893
- # ran out its whole 2.5s budget on EVERY round, waiting for
2894
- # a signal that cannot arrive. Measured on geetest_v4_slide:
2895
- # 5.2s of a 12.3s solve, spent after the puzzle was already
2896
- # answered, with the widget sitting there visibly solved.
2897
- #
2898
- # "The widget is gone" is the completion signal for those
2899
- # vendors and is already the authority immediately after
2900
- # this loop, so this only reaches the same verdict sooner —
2901
- # confirmed over two polls so a frame caught mid-swap
2902
- # between rounds cannot read as a solve.
2903
- if self.detect_captcha(page) is None:
2904
- widget_gone += 1
2905
- if widget_gone >= 2:
2906
- solved = True
2907
- break
2908
- else:
2909
- widget_gone = 0
2910
- if self._is_challenge_freshly_rendered(page):
2911
- break # next round is up; go solve it now
2912
- _delay(cfg.post_solve_outcome_poll_ms)
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
- if self._has_recaptcha_underselect_error(page):
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; "