captchakraken 2.6.0 → 2.8.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 (36) hide show
  1. package/README.md +126 -16
  2. package/dist/cli-invocation.d.ts +2 -0
  3. package/dist/cli-invocation.js +5 -1
  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/playwright-types.d.ts +18 -0
  10. package/dist/solver.d.ts +115 -5
  11. package/dist/solver.js +583 -190
  12. package/dist/timing.d.ts +48 -0
  13. package/dist/timing.js +103 -0
  14. package/dist/trajectory.d.ts +27 -0
  15. package/dist/trajectory.js +104 -0
  16. package/dist/types.d.ts +129 -0
  17. package/package.json +11 -3
  18. package/python/README.md +76 -5
  19. package/python/pyproject.toml +21 -4
  20. package/python/src/captchakraken/__init__.py +36 -1
  21. package/python/src/captchakraken/cli.py +62 -4
  22. package/python/src/captchakraken/config.py +28 -2
  23. package/python/src/captchakraken/errors.py +24 -0
  24. package/python/src/captchakraken/humanize.py +929 -0
  25. package/python/src/captchakraken/keyframes.py +99 -0
  26. package/python/src/captchakraken/models.json +149 -13
  27. package/python/src/captchakraken/page_solver.py +588 -291
  28. package/python/src/captchakraken/pinned_model.json +3 -2
  29. package/python/src/captchakraken/planner.py +118 -9
  30. package/python/src/captchakraken/prompts.py +182 -0
  31. package/python/src/captchakraken/server_manager.py +9 -4
  32. package/python/src/captchakraken/solver.py +97 -57
  33. package/python/src/captchakraken/timing.py +8 -3
  34. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  35. package/python/src/captchakraken/trajectory.py +117 -0
  36. package/python/src/captchakraken/updater.py +62 -1
@@ -53,7 +53,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
53
53
  from .action_types import CaptchaAction
54
54
  from .solver import CaptchaSolver, UnsupportedCaptchaError
55
55
  from .timing import PhaseBudget, timings_enabled
56
- from .trajectory import generate_trajectory
56
+ from .humanize import Humanizer, resolve as resolve_humanizer
57
57
 
58
58
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
59
59
 
@@ -163,6 +163,14 @@ class SolveResult:
163
163
  is_solved: bool
164
164
  final_mouse_position: Tuple[float, float]
165
165
  token_usage: List[Dict[str, Any]] = field(default_factory=list)
166
+ #: Where this solve's wall-clock went, in milliseconds per phase — the same
167
+ #: partition `CAPTCHA_TIMINGS=1` prints, handed to the caller instead of to
168
+ #: stderr. `{}` when the result was built outside a solve.
169
+ #:
170
+ #: Returned rather than only logged because "the solve took 12s" is not
171
+ #: actionable and "the settle monitor spent 4s of it" is, and a caller
172
+ #: measuring a fleet cannot scrape stderr from inside its own process.
173
+ phases: Dict[str, float] = field(default_factory=dict)
166
174
 
167
175
 
168
176
  @dataclass
@@ -209,6 +217,48 @@ class PageSolverConfig:
209
217
  value tuned on one driver can be found on the other.
210
218
  """
211
219
 
220
+ # ── how the driver moves ───────────────────────────────────────────────
221
+ #: "mouse" (default), "mobile" or "none". See `humanize.py`.
222
+ #:
223
+ #: Not a realism dial — a choice of INPUT DEVICE. "mobile" dispatches touch
224
+ #: events with finger kinematics, and on a touch-only widget that is the
225
+ #: difference between the page's handlers firing and not. "none" is the
226
+ #: shortest legal path to the same DOM effect, for fixtures and for callers
227
+ #: who humanise somewhere else in their stack.
228
+ #:
229
+ #: None means unset: `CAPTCHA_HUMANIZATION`, else "mouse".
230
+ humanization: Optional[str] = None
231
+ #: Your own `humanize.Humanizer`. Overrides `humanization` entirely — the
232
+ #: driver then makes no decisions about pointer motion at all.
233
+ humanizer: Optional[Humanizer] = None
234
+ #: mobile only. The thing that is actually TOUCHED, when it is not the page
235
+ #: object — an Appium/Selenium WebDriver on a real handset. Left None, the
236
+ #: mode dispatches CDP touch events at the page it was given.
237
+ touch_driver: Optional[Any] = None
238
+ #: mobile only. CSS-pixel -> device-pixel transform for `touch_driver`,
239
+ #: e.g. `{"scale": 3.0, "origin": (0, 132)}`. See AppiumTouchBackend; the
240
+ #: default identity is right for browser emulation.
241
+ touch_transform: Optional[Dict[str, Any]] = None
242
+ #: Where the pointer starts. Setting it stops the first gesture of a solve
243
+ #: crossing the whole window from the origin.
244
+ starting_mouse_position: Optional[Tuple[float, float]] = None
245
+
246
+ # ── which model answers ────────────────────────────────────────────────
247
+ #: Force ONE expert of a routed model — "pixel", "grid", "video" or "text".
248
+ #:
249
+ #: A routed model (Abyss) is four adapters behind one endpoint, and the
250
+ #: router is the prompt family the request is about to send, so the default
251
+ #: (None) already reaches the right one with no help from the caller. This
252
+ #: is the override: serve one arm, drive only the puzzles it owns, which is
253
+ #: what a per-arm benchmark needs and what a licence holder pinning a single
254
+ #: expert wants.
255
+ #:
256
+ #: Refused at construction against a model that serves one adapter — every
257
+ #: model published so far — because a run that quietly measured the
258
+ #: generalist and reported it as the expert is a number nobody can catch.
259
+ #: None means unset: `CAPTCHA_EXPERT`, else route by family.
260
+ expert: Optional[str] = None
261
+
212
262
  # 45s, and the loop count that fits inside it rather than one that needs
213
263
  # policing by the clock. A round costs ~4-7s once the waits below are paid,
214
264
  # so six rounds is the budget; ten never fitted and only ever expressed
@@ -317,6 +367,38 @@ class PageSolverConfig:
317
367
  # still a better use of the remaining budget than a timeout.
318
368
  keyframe_wait_timeout_ms: int = 6_000
319
369
  keyframe_wait_poll_ms: int = 120
370
+ #: Extra wall clock granted ONCE, the first time a solve escalates to a
371
+ #: recording. NOT a looser `overall_solve_timeout_ms`.
372
+ #:
373
+ #: That budget is sized for ROUNDS — "a round costs ~4-7s, so six is the
374
+ #: budget". The recording path is not a round. It is a fixed extra stage
375
+ #: costing the burst, the slice, one MULTI-IMAGE inference (six keyframes,
376
+ #: several times a still's) and the wait for the widget to come back round
377
+ #: to the frame the model chose. Nothing in the 45s was ever set aside for
378
+ #: it, so an escalation started at ~35s ran the clock out mid-burst and
379
+ #: reported a TIMEOUT — which reads as a slow model rather than as a budget
380
+ #: with no room for the thing the solver had just decided to do.
381
+ #:
382
+ #: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on
383
+ #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
384
+ #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
385
+ #: recording the animated challenge", at 45.7-52.7s elapsed. The same three
386
+ #: fixtures solve in 11-20s on the rounds where the still path happens to
387
+ #: answer them, so it is the escalation that does not fit, not the puzzle.
388
+ #:
389
+ #: Granted only when `video_solve_enabled` — a caller who wants a hard
390
+ #: deadline turns recording off, which is the switch that already means
391
+ #: "fail fast rather than spend the recording time".
392
+ video_extra_inference_ms: int = 8_000
393
+
394
+ def video_budget_ms(self) -> int:
395
+ """What one escalation to recording is allowed to cost, on top.
396
+
397
+ DERIVED, so a longer burst or a longer keyframe wait carries its own
398
+ budget instead of quietly reintroducing the timeout this exists to fix.
399
+ """
400
+ return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms
401
+ + self.video_extra_inference_ms)
320
402
 
321
403
  # Grid load / dynamic-refresh timing.
322
404
  # How long to wait for hCaptcha's task images to paint before screenshotting
@@ -356,16 +438,93 @@ class PageSolverConfig:
356
438
  # solver.ts — keep both in the same order with the same selectors.
357
439
  VENDOR_WIDGET_LOCATORS = [
358
440
  {"puzzle_source": "geetest", "selectors": [".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"]},
359
- {"puzzle_source": "tencent", "selectors": ['iframe#tcaptcha_iframe_dy', 'iframe[id*="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
441
+ # Tencent renders IN THE HOST DOCUMENT since 2026-08-11; before that it was
442
+ # `iframe#tcaptcha_iframe_dy`. Both shapes are listed, in-page first,
443
+ # because the iframe build is still deployed on sites pinning an older
444
+ # widget and costs nothing to keep. Dropping the in-page selectors is what
445
+ # made the client blind to every live Tencent captcha for twelve days.
446
+ #
447
+ # `iframe[id^="tcaptcha"]`, NOT `[id*=...]`. The substring form also matched
448
+ # MTCaptcha's `mtcaptcha-iframe-1` — "mtcaptcha" contains "tcaptcha" — so
449
+ # Tencent's entry was silently the only thing detecting MTCaptcha at all,
450
+ # which is why `.mtcap` matching nothing went unnoticed. Anchored, it still
451
+ # matches the real `tcaptcha_iframe_dy` and claims nobody else's widget.
452
+ {"puzzle_source": "tencent", "selectors": ['#tcaptcha_transform_dy', '#tCaptchaDyContent', '.tencent-captcha-dy__content', 'iframe#tcaptcha_iframe_dy', 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]']},
360
453
  {"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
361
- {"puzzle_source": "yandex", "selectors": [".CheckboxCaptcha"]},
454
+ # Yandex SmartCaptcha is IFRAMED, and `.CheckboxCaptcha` is a class inside
455
+ # that frame's document — `query_selector` does not cross a frame boundary,
456
+ # so the host page never carried it and this entry matched nothing, ever.
457
+ # Challenge frame first, anchor second, exactly like reCAPTCHA above.
458
+ # `.CheckboxCaptcha` stays for an inline embed that renders it directly.
459
+ {"puzzle_source": "yandex", "selectors": ['iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]', ".CheckboxCaptcha"]},
362
460
  {"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
363
461
  {"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
364
- {"puzzle_source": "mtcaptcha", "selectors": [".mtcap"]},
462
+ # MTCaptcha is IFRAMED too: `<div class="mtcaptcha"><iframe
463
+ # id="mtcaptcha-iframe-1" src="https://service.mtcaptcha.com/...">`. `.mtcap`
464
+ # is the class prefix used INSIDE that frame (`mtcap-inputtext`), and a CSS
465
+ # class selector matches whole tokens, so it never matched the host page's
466
+ # `class="mtcaptcha"` either. Kept last for an inline embed.
467
+ {"puzzle_source": "mtcaptcha", "selectors": ['iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', ".mtcaptcha", ".mtcap"]},
365
468
  {"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
366
469
  ]
367
470
 
368
471
 
472
+ # ── which vendor's code the page LOADED, as opposed to what it rendered ─────
473
+ #
474
+ # A selector is a claim about markup, and markup is the half of a captcha the
475
+ # vendor is free to rewrite overnight — it is an anti-bot surface, so churn is
476
+ # the point. The REQUEST is far more stable: Tencent moved its widget out of an
477
+ # iframe and renamed every class on 2026-08-11, and went on fetching from
478
+ # `turing.captcha.gtimg.com` and `turing.captcha.qcloud.com` throughout. The
479
+ # substring `captcha.gtimg.com` — already in the table above, on a selector that
480
+ # stopped matching — never left the wire.
481
+ #
482
+ # This is a TRIPWIRE, not a detection path. A URL says which vendor is on the
483
+ # page; it cannot say where the widget is, and the solver needs an ELEMENT to
484
+ # screenshot, to scope its controls against, and to drag. What it buys is the
485
+ # difference between the two things a null detection can mean:
486
+ #
487
+ # · no captcha here (reCAPTCHA v3 / invisible / not triggered yet) — fine, and
488
+ # what the message used to guess unconditionally;
489
+ # · the vendor's code is loaded and running and NOTHING WE LOOK FOR MATCHES —
490
+ # i.e. the DOM moved under us. That is a bug report, and it took twelve days
491
+ # to notice the last time because the client said the first thing while
492
+ # meaning the second.
493
+ #
494
+ # Hosts measured 2026-08-24 by loading each vendor's demo page and recording
495
+ # every third-party request (see scripts/check_vendor_selectors.py --hosts).
496
+ #: The two vendors with bespoke handling in this file. EVERYTHING else shares the
497
+ #: generic path, and the two behaviours below are gated on "not one of these" —
498
+ #: spelled as this set rather than as `== "unknown"`, which is what they used to
499
+ #: say. The two were identical only while `unknown` was the sole third value, so
500
+ #: the day anyone reports a vendor by name — to constrain which grid shapes it may
501
+ #: be solved as, say — `== "unknown"` silently turns OFF typed-challenge detection
502
+ #: for MTCaptcha, Yandex and BotDetect (all three ARE typed captchas) and the
503
+ #: animated settle probe for GeeTest and Tencent. Neither failure raises; the text
504
+ #: captcha just becomes unsolvable and the animated board gets answered from one
505
+ #: arbitrary frame.
506
+ VENDORS_WITH_BESPOKE_HANDLING = frozenset({"hcaptcha", "recaptcha"})
507
+
508
+ VENDOR_URL_MARKERS = [
509
+ {"puzzle_source": "hcaptcha", "hosts": ["hcaptcha.com"]},
510
+ {"puzzle_source": "recaptcha", "hosts": ["google.com/recaptcha", "recaptcha.net"]},
511
+ {"puzzle_source": "turnstile", "hosts": ["challenges.cloudflare.com"]},
512
+ {"puzzle_source": "geetest", "hosts": ["geetest.com"]},
513
+ {"puzzle_source": "tencent", "hosts": ["captcha.gtimg.com", "captcha.qcloud.com"]},
514
+ {"puzzle_source": "yidun", "hosts": ["dun.163.com", "cstaticdun.126.net",
515
+ "necaptcha.nosdn.127.net"]},
516
+ {"puzzle_source": "yandex", "hosts": ["smartcaptcha.yandexcloud.net"]},
517
+ {"puzzle_source": "lemin", "hosts": ["leminnow.com"]},
518
+ {"puzzle_source": "prosopo", "hosts": ["prosopo.io"]},
519
+ {"puzzle_source": "mtcaptcha", "hosts": ["mtcaptcha.com"]},
520
+ # BotDetect is deliberately absent and must stay absent. It is a SELF-HOSTED
521
+ # library — the image comes from the application's own origin
522
+ # (`BotDetectCaptcha.ashx`), so there is no vendor host to see and a URL
523
+ # tripwire cannot cover it. Recording that is the point: an empty result for
524
+ # a BotDetect page means "this check does not apply", not "no captcha".
525
+ ]
526
+
527
+
369
528
  # ── where the answer goes, when it is not a click ───────────────────────────
370
529
  #
371
530
  # Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
@@ -382,7 +541,13 @@ VENDOR_WIDGET_LOCATORS = [
382
541
 
383
542
  # A distorted-text captcha's answer box. The three vendor entries are the three
384
543
  # types in instructions.py::TEXT_TYPES.
385
- TEXT_INPUT_SELECTORS = [
544
+ #
545
+ # Split in two because the two halves are safe in different places. The NAMED
546
+ # half identifies a captcha's answer box wherever it sits; the GENERIC half only
547
+ # means "the text box in this widget" and is trustworthy only when the scope
548
+ # already is the widget. `_answer_box` widens past the widget for BotDetect and
549
+ # may take the named half with it, never the generic one.
550
+ TEXT_INPUT_VENDOR_SELECTORS = [
386
551
  # BotDetect — the input is application-defined, so match the id fragment its
387
552
  # own docs and samples use. These three are what the nightly collector
388
553
  # already drives (src/captchaCollection/sources.py).
@@ -397,6 +562,9 @@ TEXT_INPUT_SELECTORS = [
397
562
  ".AdvancedCaptcha-Input input",
398
563
  "input.Textinput-Control",
399
564
  'input[name="rep"]',
565
+ ]
566
+
567
+ TEXT_INPUT_GENERIC_SELECTORS = [
400
568
  # Generic — an input the page itself labels as the captcha answer.
401
569
  'input[name*="captcha" i]',
402
570
  'input[id*="captcha" i]',
@@ -411,6 +579,8 @@ TEXT_INPUT_SELECTORS = [
411
579
  "textarea",
412
580
  ]
413
581
 
582
+ TEXT_INPUT_SELECTORS = TEXT_INPUT_VENDOR_SELECTORS + TEXT_INPUT_GENERIC_SELECTORS
583
+
414
584
  # The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
415
585
  # these vendors the piece is inert decoration that the handle carries, so a
416
586
  # drag starting on the piece moves nothing at all.
@@ -419,7 +589,10 @@ SLIDER_HANDLE_SELECTORS = [
419
589
  ".geetest_slider_button",
420
590
  ".geetest_btn",
421
591
  ".geetest_slider .geetest_arrow",
422
- # Tencent
592
+ # Tencent. The post-2026-08-11 knob is a bare div — no id, no role=slider,
593
+ # no aria-valuenow — so every generic fallback below misses it and it has to
594
+ # be named. The three after it are the pre-redesign widget.
595
+ ".tencent-captcha-dy__slider-block",
423
596
  "#tcaptcha_drag_thumb",
424
597
  ".tc-slider-normal",
425
598
  "[id*=slideBlock]",
@@ -476,23 +649,33 @@ class PageSolver:
476
649
  self.config = config or PageSolverConfig()
477
650
  # One CaptchaSolver for the whole driver: it owns the planner, which
478
651
  # accumulates token usage and holds the HTTP session to vLLM.
652
+ #
653
+ # `expert` reaches the solver from the config, so the knob is in the
654
+ # same object as every other tunable and matches the TS port's
655
+ # `CaptchaKrakenConfig.expert`. An explicit kwarg still wins — a caller
656
+ # who built the argument list themselves has said the more specific
657
+ # thing — and a caller who passed their OWN solver has already made
658
+ # this decision inside it.
659
+ if self.config.expert is not None:
660
+ solver_kwargs.setdefault("expert", self.config.expert)
479
661
  self._solver = solver or CaptchaSolver(**solver_kwargs)
480
- self._last_mouse: Tuple[float, float] = (0.0, 0.0)
481
- # See _seed_cursor: the (0, 0) origin wedges camoufox's humanised
482
- # mouse, so the first move of each solve must step off it plainly.
483
- self._cursor_seeded = False
662
+ #: Every gesture goes through here, and it owns the pointer position.
663
+ #: See humanize.py — the driver names gestures, this decides what
664
+ #: events they are and how long they take.
665
+ self._human: Humanizer = resolve_humanizer(self.config)
484
666
  self._last_submit_frame_hash: Optional[str] = None
485
667
  # Absolute deadline for the current solve, in the same clock as
486
668
  # time.monotonic() * 1000. None outside a solve.
487
669
  self._deadline_ms: Optional[float] = None
488
- # Window size for clamping, resolved once per solve. See _viewport.
489
- self._viewport_cache: Optional[Dict[str, float]] = None
490
670
  # Animation detection, all reset per solve by `_reset_animated_state`.
491
671
  # `_known_animated` latches: once a widget has been SEEN to animate,
492
672
  # every later round takes the keyframe path without re-probing.
493
673
  self._known_animated = False
494
674
  self._animated_probe_armed = False
495
675
  self._animated_probe_done = False
676
+ # One-shot: the recording path buys its own budget the first time it is
677
+ # entered, and never again in the same solve. See video_budget_ms.
678
+ self._video_budget_granted = False
496
679
  self._keyframe_mode: Optional[str] = None
497
680
  # Repeat detection; see `max_no_progress_rounds`.
498
681
  self._last_answer_sig: Optional[str] = None
@@ -500,6 +683,16 @@ class PageSolver:
500
683
  # Per-solve phase accounting; see `_phase`.
501
684
  self._budget: Optional[PhaseBudget] = None
502
685
 
686
+ @property
687
+ def _last_mouse(self) -> Tuple[float, float]:
688
+ """Where the pointer is. Owned by the humanizer, because a mode that
689
+ dispatches no motion (mobile, between taps) still has to answer this."""
690
+ return self._human.at
691
+
692
+ @_last_mouse.setter
693
+ def _last_mouse(self, at: Tuple[float, float]) -> None:
694
+ self._human.at = (float(at[0]), float(at[1]))
695
+
503
696
  @contextmanager
504
697
  def _phase(self, name: str):
505
698
  """Attribute the enclosed wall-clock to `name` for this solve's budget.
@@ -562,6 +755,7 @@ class PageSolver:
562
755
  self._known_animated = False
563
756
  self._animated_probe_armed = False
564
757
  self._animated_probe_done = False
758
+ self._video_budget_granted = False
565
759
  self._keyframe_mode = None
566
760
  self._last_answer_sig: Optional[str] = None
567
761
  self._no_progress_rounds = 0
@@ -677,132 +871,12 @@ class PageSolver:
677
871
  # Mouse
678
872
  # ------------------------------------------------------------------
679
873
 
680
- def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
681
- """
682
- The window we must keep the cursor inside, cached per solve.
683
-
684
- MUST NOT be skipped and MUST NOT be guessed, because a mouse move to a
685
- coordinate outside the window WEDGES camoufox. Its juggler humanises
686
- each move into a trajectory, guards the intermediate points against the
687
- bounds, and then dispatches the requested destination unguarded
688
- ("Always finish exactly on the requested destination"). An out-of-window
689
- destination fires as an exit event instead of eMouseMove, so no
690
- hit-renderer ack comes back; dispatch is serialised on a process-global
691
- activation chain, so that one missing ack hangs every later input event
692
- forever. Symptom: `page.mouse.move()` never returns, 0% CPU, solve
693
- appears dead. Same failure family as camoufox #225.
694
-
695
- `page.viewport_size` is None under camoufox (it uses the real window
696
- rather than a spoofed viewport), which is why this falls back to asking
697
- the page itself instead of assuming a size.
698
- """
699
- if self._viewport_cache is not None:
700
- return self._viewport_cache
701
- try:
702
- vp = page.viewport_size
703
- if callable(vp): # some adapters expose it as a method
704
- vp = vp()
705
- if vp and vp.get("width") and vp.get("height"):
706
- self._viewport_cache = {"width": float(vp["width"]), "height": float(vp["height"])}
707
- return self._viewport_cache
708
- except Exception:
709
- pass
710
- try:
711
- inner = page.evaluate("() => ({width: window.innerWidth, height: window.innerHeight})")
712
- if inner and inner.get("width") and inner.get("height"):
713
- self._viewport_cache = {
714
- "width": float(inner["width"]),
715
- "height": float(inner["height"]),
716
- }
717
- return self._viewport_cache
718
- except Exception:
719
- pass
720
- return None
721
-
722
- def _trace_path(self, page: Any, points: Sequence[Tuple[float, float]], timings: Sequence[float]) -> None:
723
- # Clamp ONLY when the viewport is actually known.
724
- #
725
- # camoufox reports `viewport_size is None` (it uses the real window
726
- # rather than a spoofed viewport). The obvious fallback — assume
727
- # 1920x1080 — is actively harmful: it clamps coordinates to the edge of
728
- # a viewport that is not the real one, and a coordinate sitting EXACTLY
729
- # on the boundary is the input that deadlocks camoufox's humanised-mouse
730
- # juggler patch (upstream #225, "humanize edge deadlock"). The symptom is
731
- # a `page.mouse.move()` that never returns: the process sits at 0% CPU
732
- # with no in-flight work and the solve appears hung, which is precisely
733
- # what this driver did against camoufox until this was found.
734
- #
735
- # A guessed clamp buys nothing anyway — the points come from a
736
- # trajectory between two on-screen elements, so they are already in
737
- # range. When we do clamp, we inset by a pixel so a legitimately
738
- # off-screen point lands just inside the edge instead of exactly on it.
739
- viewport = self._viewport(page)
740
-
741
- start = time.monotonic() * 1000.0
742
- for i, (x, y) in enumerate(points):
743
- try:
744
- if viewport is None:
745
- cx, cy = float(x), float(y)
746
- else:
747
- cx = max(1.0, min(float(x), float(viewport["width"]) - 1.0))
748
- cy = max(1.0, min(float(y), float(viewport["height"]) - 1.0))
749
- page.mouse.move(cx, cy)
750
- self._last_mouse = (cx, cy)
751
- if i < len(timings):
752
- target = start + timings[i]
753
- _delay(target - time.monotonic() * 1000.0)
754
- except Exception as exc:
755
- msg = str(exc)
756
- if "Target closed" in msg or "Session closed" in msg:
757
- _log("could not move mouse; page or session closed")
758
- return
759
- # Any other per-sample failure is skipped rather than fatal —
760
- # losing one mousemove must not lose the solve.
761
-
762
- def _seed_cursor(self, page: Any) -> None:
763
- """Move the cursor off its (0, 0) origin once per solve, in ONE plain
764
- move, before any humanised trajectory runs.
765
-
766
- Without this, the first trajectory of a solve begins at (0, 0) — the
767
- exact window corner, because that is where the pointer starts and
768
- nothing has moved it. Under camoufox's `humanize` juggler a short move
769
- from that origin never returns: `page.mouse.move()` blocks forever at 0%
770
- CPU, and since dispatch is serialised on a process-global activation
771
- chain, every later input event blocks behind it too. The solve looks
772
- hung with no error anywhere and the Tier 3 fixture run times out on all
773
- three attempts.
774
-
775
- Reduced to four lines against camoufox directly:
776
-
777
- with Camoufox(headless=True, humanize=True) as b:
778
- p = b.new_page(); p.goto("about:blank")
779
- p.mouse.move(1.0, 1.0) # never returns
780
-
781
- The same move after ANY interior move completes in ~1.1s, which is what
782
- makes this the fix rather than a workaround: it is the ORIGIN that is
783
- poisoned, not the destination. Same failure family as camoufox #225.
784
-
785
- Deliberately not routed through `_smooth_move`: that would generate a
786
- trajectory from (0, 0) and reintroduce exactly the move being avoided.
787
- """
788
- if self._cursor_seeded:
789
- return
790
- self._cursor_seeded = True
791
- vp = self._viewport(page)
792
- # Centre when the window is known, else a modest interior point — any
793
- # coordinate comfortably off the corner will do.
794
- cx, cy = (vp["width"] / 2, vp["height"] / 2) if vp else (200.0, 200.0)
795
- try:
796
- page.mouse.move(cx, cy)
797
- self._last_mouse = (cx, cy)
798
- except Exception: # noqa: BLE001 — an adapter without a mouse must not fail the solve
799
- pass
800
-
801
874
  def _smooth_move(self, page: Any, x: float, y: float) -> None:
802
- self._seed_cursor(page)
803
- points, timings = generate_trajectory(self._last_mouse, (x, y), 60)
875
+ """Travel to a point. What that MEANS is the humanizer's business — on
876
+ a touchscreen with no finger down it is a bookkeeping update and emits
877
+ nothing at all."""
804
878
  with self._phase("mouse"):
805
- self._trace_path(page, points, timings)
879
+ self._human.move(page, (x, y))
806
880
 
807
881
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
808
882
  # BOUNDED. Playwright's default timeout is 30s, and this is called once
@@ -834,9 +908,8 @@ class PageSolver:
834
908
 
835
909
  def _move_and_click(self, page: Any, element: Any) -> None:
836
910
  self._move_to_element(page, element)
837
- page.mouse.down()
838
- _delay(random.random() * 20 + 20)
839
- page.mouse.up()
911
+ with self._phase("mouse"):
912
+ self._human.click(page, self._last_mouse)
840
913
 
841
914
  def _execute_click(
842
915
  self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
@@ -861,10 +934,8 @@ class PageSolver:
861
934
  _log("click action without coordinates or bounding box; skipping")
862
935
  return
863
936
 
864
- self._smooth_move(page, element_box["x"] + rel_x, element_box["y"] + rel_y)
865
- page.mouse.down()
866
- _delay(random.random() * 30 + 20)
867
- page.mouse.up()
937
+ with self._phase("mouse"):
938
+ self._human.click(page, (element_box["x"] + rel_x, element_box["y"] + rel_y))
868
939
 
869
940
  def _execute_drag(
870
941
  self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
@@ -877,12 +948,8 @@ class PageSolver:
877
948
 
878
949
  src = center(action["source_bounding_box"])
879
950
  dst = center(action["target_bounding_box"])
880
- self._smooth_move(page, *src)
881
- page.mouse.down()
882
- _delay(random.random() * 50 + 50)
883
- self._smooth_move(page, *dst)
884
- _delay(random.random() * 50 + 50)
885
- page.mouse.up()
951
+ with self._phase("mouse"):
952
+ self._human.drag(page, src, dst)
886
953
 
887
954
  # ------------------------------------------------------------------
888
955
  # Typing and sliding
@@ -906,33 +973,67 @@ class PageSolver:
906
973
  return element
907
974
  return None
908
975
 
909
- def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any]) -> bool:
976
+ def _answer_box(self, scope: Any, element: Any = None) -> Optional[Any]:
977
+ """Where a distorted-text captcha's answer goes.
978
+
979
+ Inside the widget first. If the widget holds no text box at all, widen
980
+ ONCE to its enclosing <fieldset>/<form> and look again for a
981
+ VENDOR-NAMED box only.
982
+
983
+ BotDetect is why, and nothing else needs it. BotDetect is a self-hosted
984
+ LIBRARY, so the host application owns the layout: measured on
985
+ captcha.com 2026-08-24, `.BDC_CaptchaDiv` — the element `detect_captcha`
986
+ returns — is 280x50 and holds the image alone, while `#captchaCode` sits
987
+ in a sibling `<div class="validationDiv">` under the enclosing
988
+ <fieldset>. Scoped to the widget the solver found no box, `text_mode`
989
+ stayed False, and it read the code correctly and never typed it. A clean
990
+ solve thrown away, and indistinguishable in any report from a model that
991
+ cannot read warped text.
992
+
993
+ The widened pass is restricted to TEXT_INPUT_VENDOR_SELECTORS on
994
+ purpose. The generic tail (`input[type=text]`, `textarea`,
995
+ `input:not([type])`) is what makes the in-widget lookup work for every
996
+ other vendor, and OUTSIDE the widget it is exactly how a captcha's
997
+ answer ends up in a login form's username box — the failure
998
+ `_find_control`'s docstring already warns about. One level, named
999
+ selectors, and never the page.
1000
+ """
1001
+ inside = self._find_control(scope, TEXT_INPUT_SELECTORS)
1002
+ if inside is not None or element is None:
1003
+ return inside
1004
+ # Nearest first: a <fieldset> inside a <form> is the tighter box, and
1005
+ # `query_selector` on a union would hand back whichever comes first in
1006
+ # document order — the outer one.
1007
+ for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"):
1008
+ try:
1009
+ host = element.query_selector(f"xpath={axis}")
1010
+ except Exception:
1011
+ continue
1012
+ if host is None:
1013
+ continue
1014
+ found = self._find_control(host, TEXT_INPUT_VENDOR_SELECTORS)
1015
+ if found is not None:
1016
+ _log("widget holds no answer box; using the vendor-named one "
1017
+ "in its enclosing form")
1018
+ return found
1019
+ return None
1020
+
1021
+ def _execute_type(self, page: Any, scope: Any, action: Dict[str, Any],
1022
+ element: Any = None) -> bool:
910
1023
  """Put the model's reading of a distorted-text captcha into its box."""
911
1024
  text = str(action.get("text") or "")
912
1025
  if not text:
913
1026
  return False
914
- field = self._find_control(scope, TEXT_INPUT_SELECTORS)
1027
+ field = self._answer_box(scope, element)
915
1028
  if field is None:
916
1029
  _log("type action, but no text box in the widget; skipping")
917
1030
  return False
918
1031
 
919
- self._move_and_click(page, field) # travel there, then press to focus
920
- # A retry round arrives with the previous attempt still in the box, and
921
- # typing would APPEND to it — submitting a string the model never read.
922
- try:
923
- page.keyboard.press("Control+A")
924
- except Exception:
925
- pass
926
- # Per character rather than one `type(text, delay=…)` call: a constant
927
- # inter-key delay is itself a signal, and these are the vendors that
928
- # score typing cadence.
929
- for ch in text:
930
- try:
931
- page.keyboard.type(ch)
932
- except Exception as exc:
933
- _log(f"could not type into the captcha field: {exc}")
934
- return False
935
- _delay(random.random() * 90 + 45)
1032
+ # Tapping the box is what focuses it — and on a phone it is also what
1033
+ # raises the keyboard, so this is not decoration on either device.
1034
+ self._move_and_click(page, field)
1035
+ if not self._human.type_text(page, field, text):
1036
+ return False
936
1037
  _log(f"typed {len(text)} character(s) into the captcha field")
937
1038
  return True
938
1039
 
@@ -949,6 +1050,17 @@ class PageSolver:
949
1050
  _debug(f"track_piece failed: {exc}")
950
1051
  return None
951
1052
 
1053
+ @staticmethod
1054
+ def _shot_scale(shot: str, css_width: float) -> float:
1055
+ """Device pixels per CSS pixel, read off the shot we are about to
1056
+ measure. 1.0 when the image cannot be read — an unreadable shot is
1057
+ already the `_track_piece` returns-None path, and guessing a ratio
1058
+ would steer the handle by it."""
1059
+ dims = _read_png_dimensions(shot)
1060
+ if not dims or css_width <= 0:
1061
+ return 1.0
1062
+ return dims[0] / css_width
1063
+
952
1064
  def _execute_slide(
953
1065
  self,
954
1066
  page: Any,
@@ -1007,12 +1119,12 @@ class PageSolver:
1007
1119
  + float(action["target_bounding_box"][3])) / 2
1008
1120
  ) * element_box["height"]
1009
1121
  _log("no slider track; dragging the piece to the slot directly")
1010
- self._smooth_move(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
1011
- page.mouse.down()
1012
- _delay(random.random() * 50 + 50)
1013
- self._smooth_move(page, element_box["x"] + target_x, element_box["y"] + target_y)
1014
- _delay(random.random() * 50 + 50)
1015
- page.mouse.up()
1122
+ with self._phase("mouse"):
1123
+ self._human.drag(
1124
+ page,
1125
+ (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
1126
+ (element_box["x"] + target_x, element_box["y"] + target_y),
1127
+ )
1016
1128
  return True
1017
1129
 
1018
1130
  hbox = handle.bounding_box()
@@ -1027,7 +1139,7 @@ class PageSolver:
1027
1139
  # moving thing in frame, and we would track the handle instead of the
1028
1140
  # piece.
1029
1141
  pad = max(4.0, hbox["height"] * 0.35)
1030
- exclude = [
1142
+ band = [
1031
1143
  0.0,
1032
1144
  hbox["y"] - element_box["y"] - pad,
1033
1145
  element_box["width"],
@@ -1037,20 +1149,32 @@ class PageSolver:
1037
1149
  shots = [_tmp_png("slide") for _ in range(4)]
1038
1150
  try:
1039
1151
  self._move_to_element(page, handle, padding_percentage=30.0)
1040
- page.mouse.down()
1041
- _delay(random.random() * 60 + 60)
1152
+ self._human.press(page)
1153
+ self._human.pause("grab")
1042
1154
  self._screenshot(element, shots[0],
1043
1155
  timeout_ms=self.config.element_screenshot_timeout_ms)
1044
1156
 
1157
+ # CSS pixels out here, DEVICE pixels inside the shots. The same
1158
+ # number on a 1x desktop and 2.625x apart on a phone, which is why
1159
+ # this loop measured a slider correctly for a year and then missed
1160
+ # every attempt the moment Tier 3 grew a mobile arm: the mask landed
1161
+ # above the handle, the widths came back 2.6x too wide, and
1162
+ # `_solve_slide_geometry` threw them out as wider than the widget.
1163
+ # Measured from the shot rather than asked of the page, for the same
1164
+ # reason the grid path does it (`_GridSession.scale_x`): what the CV
1165
+ # reads is the image, whatever the window thinks its ratio is.
1166
+ scale = self._shot_scale(shots[0], element_box["width"])
1167
+ exclude = [v * scale for v in band]
1168
+
1045
1169
  probes = self.config.slide_probe_offsets_px
1046
1170
  widths: List[Tuple[float, float]] = []
1047
1171
  last_box = None
1048
1172
  for offset, shot in zip(probes, shots[1:]):
1049
1173
  self._smooth_move(page, start_x + offset, hold_y)
1050
- _delay(random.random() * 40 + 40)
1174
+ self._human.pause("probe")
1051
1175
  box = self._track_piece(element, shots[0], shot, exclude)
1052
1176
  if box is not None:
1053
- widths.append((float(offset), float(box[2] - box[0])))
1177
+ widths.append((float(offset), float(box[2] - box[0]) / scale))
1054
1178
  last_box = box
1055
1179
 
1056
1180
  piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
@@ -1071,27 +1195,28 @@ class PageSolver:
1071
1195
  # asked for.
1072
1196
  offset = float(widths[-1][0])
1073
1197
  for _ in range(self.config.slide_max_corrections):
1074
- piece_center = (last_box[2] - piece_w / 2.0)
1198
+ piece_center = (last_box[2] / scale - piece_w / 2.0)
1075
1199
  error = target_x - piece_center
1076
1200
  if abs(error) <= self.config.slide_tolerance_px:
1077
1201
  break
1078
1202
  offset += error / ratio
1079
1203
  self._smooth_move(page, start_x + offset, hold_y)
1080
- _delay(random.random() * 40 + 40)
1204
+ self._human.pause("probe")
1081
1205
  box = self._track_piece(element, shots[0], shots[3], exclude)
1082
1206
  if box is None:
1083
1207
  break # ran out of track; release where we are
1084
1208
  last_box = box
1085
- _debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} "
1086
- f"final_center={last_box[2] - piece_w / 2.0:.1f} target={target_x:.1f}")
1209
+ _debug(f"slider: piece_w={piece_w:.1f} ratio={ratio:.3f} scale={scale:.3f} "
1210
+ f"final_center={last_box[2] / scale - piece_w / 2.0:.1f} "
1211
+ f"target={target_x:.1f}")
1087
1212
 
1088
1213
  # Settle before letting go. A release in the same tick as the last
1089
1214
  # move reads as a machine, and some vendors sample the final
1090
1215
  # milliseconds of the gesture.
1091
- _delay(random.random() * 120 + 90)
1216
+ self._human.pause("settle")
1092
1217
  finally:
1093
1218
  try:
1094
- page.mouse.up()
1219
+ self._human.release(page)
1095
1220
  except Exception:
1096
1221
  pass
1097
1222
  for shot in shots:
@@ -1252,6 +1377,54 @@ class PageSolver:
1252
1377
 
1253
1378
  return None
1254
1379
 
1380
+ def vendors_on_the_wire(self, page: Any) -> List[str]:
1381
+ """Which vendors' code this page actually loaded.
1382
+
1383
+ Read out of the page's own resource timing plus the `src`/`href` of
1384
+ everything it linked, so it works whenever it is called — a request
1385
+ listener would have to have been attached before navigation, and by the
1386
+ time a solve fails that ship has sailed.
1387
+
1388
+ Best-effort by construction: the resource-timing buffer is finite and a
1389
+ page may have cleared it. An empty answer is "nothing seen", never "no
1390
+ captcha here".
1391
+ """
1392
+ try:
1393
+ names = page.evaluate(
1394
+ """() => {
1395
+ const out = [];
1396
+ try {
1397
+ for (const e of performance.getEntriesByType('resource')) out.push(e.name);
1398
+ } catch (err) {}
1399
+ for (const el of document.querySelectorAll('script[src],iframe[src],link[href],img[src]')) {
1400
+ out.push(el.getAttribute('src') || el.getAttribute('href') || '');
1401
+ }
1402
+ return out;
1403
+ }"""
1404
+ )
1405
+ except Exception:
1406
+ return []
1407
+ blob = " ".join(str(n) for n in (names or []))
1408
+ return [entry["puzzle_source"] for entry in VENDOR_URL_MARKERS
1409
+ if any(host in blob for host in entry["hosts"])]
1410
+
1411
+ def _no_widget_message(self, page: Any) -> str:
1412
+ """Why nothing was found — the two cases told apart.
1413
+
1414
+ The old text guessed "reCAPTCHA v3 / invisible" every time, including
1415
+ the times a vendor had simply moved its DOM. See VENDOR_URL_MARKERS.
1416
+ """
1417
+ base = "no interactive captcha widget detected"
1418
+ loaded = self.vendors_on_the_wire(page)
1419
+ if not loaded:
1420
+ return (f"{base} (no vendor captcha code loaded on this page — "
1421
+ "likely reCAPTCHA v3 / invisible, or a click-triggered "
1422
+ "challenge that has not been triggered)")
1423
+ return (f"{base}, BUT {'/'.join(loaded)} code IS loaded and running on "
1424
+ "this page. The vendor's markup no longer matches anything in "
1425
+ "VENDOR_WIDGET_LOCATORS — re-measure with "
1426
+ "scripts/check_vendor_selectors.py and update BOTH solver ports")
1427
+
1255
1428
  def is_captcha_solved(self, page: Any) -> bool:
1256
1429
  """
1257
1430
  The vendor's definitive DONE signal — anchor checked or token populated.
@@ -1603,13 +1776,59 @@ class PageSolver:
1603
1776
  total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
1604
1777
  interval = 1.0 / fps
1605
1778
 
1779
+ # THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
1780
+ #
1781
+ # `overall_solve_timeout_ms` counts rounds, and this is not a round —
1782
+ # see `video_budget_ms` for the arithmetic and for what it cost not to
1783
+ # have it. Granted here rather than where the probe arms because this is
1784
+ # the one place every path into a recording goes through.
1785
+ if cfg.video_solve_enabled and not self._video_budget_granted:
1786
+ self._video_budget_granted = True
1787
+ if self._deadline_ms is not None:
1788
+ self._deadline_ms += cfg.video_budget_ms()
1789
+ _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
1790
+
1791
+ # Checked ONCE, before the first frame — never per frame.
1792
+ #
1793
+ # A HALF-RECORDED BURST IS WORTHLESS: the slicer reads a clip's temporal
1794
+ # structure, so stopping at frame 27 of 40 does not produce a shorter
1795
+ # answer, it produces a recording that may not contain the screen the
1796
+ # answer is on. Aborting mid-way therefore threw away the whole burst
1797
+ # AND the ~3s already spent making it, to report a timeout. The burst is
1798
+ # also fixed-length and short, so it is not one of the places that "can
1799
+ # legitimately spin for a long time" that `_check_deadline` is for.
1800
+ #
1801
+ # If the budget cannot fit one, say THAT — a caller who has tightened
1802
+ # `overall_solve_timeout_ms` below what a recording costs has asked for
1803
+ # something impossible, and should hear it rather than watch a burst die
1804
+ # partway through every time.
1805
+ if self._deadline_ms is not None:
1806
+ left = self._deadline_ms - time.monotonic() * 1000.0
1807
+ if left < cfg.video_burst_duration_ms:
1808
+ raise CaptchaSolveError(
1809
+ f"only {left:.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
1810
+ f"budget is left and an animated recording needs "
1811
+ f"{cfg.video_burst_duration_ms}ms — not starting one that would "
1812
+ f"be cut off mid-way. Raise overall_solve_timeout_ms or "
1813
+ f"video_extra_inference_ms, or set video_solve_enabled=False."
1814
+ )
1815
+
1606
1816
  frames: List[Any] = []
1607
1817
 
1608
1818
  remaining = max(0, total - len(frames))
1609
1819
  shot = _tmp_png("burst")
1820
+ # A burst that runs far past its own length is a hung screenshot, not a
1821
+ # tight budget — bounded separately so the two cannot be confused.
1822
+ burst_deadline = (time.monotonic() * 1000.0
1823
+ + 3 * cfg.video_burst_duration_ms + 5_000)
1610
1824
  try:
1611
1825
  for i in range(remaining):
1612
- self._check_deadline("recording the animated challenge")
1826
+ if time.monotonic() * 1000.0 > burst_deadline:
1827
+ raise CaptchaSolveError(
1828
+ f"the animated recording stalled: {i} of {remaining} frames "
1829
+ f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
1830
+ f"is not screenshotting."
1831
+ )
1613
1832
  start = time.monotonic()
1614
1833
  try:
1615
1834
  # animations ALLOWED — see `_screenshot`. The JS port has
@@ -1924,6 +2143,10 @@ class PageSolver:
1924
2143
  )
1925
2144
 
1926
2145
  def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
2146
+ # A hover is mimicry of a resting CURSOR, so on a device that has none
2147
+ # it is not weaker mimicry — it is a mousemove at a touch-only widget.
2148
+ if not self._human.hovers:
2149
+ return
1927
2150
  first = session.grid_boxes[0]
1928
2151
  cell_w = (first[2] - first[0]) * session.scale_x
1929
2152
  cell_h = (first[3] - first[1]) * session.scale_y
@@ -2135,19 +2358,29 @@ class PageSolver:
2135
2358
  # Each round can legitimately spend ~10s waiting on fades, so eight
2136
2359
  # of them plus the model calls can outlast the whole solve budget.
2137
2360
  self._check_deadline(f"recaptcha grid round {round_index}")
2138
- with self._phase("grid-load"):
2139
- self._wait_for_grid_cells_loaded(element)
2361
+ # Round 1 skips it: `_solve_single` has just waited for these cells,
2362
+ # read the grid boxes off the loaded board and handed over, and
2363
+ # nothing has touched the widget in between. Rounds 2..N still wait,
2364
+ # because by then THIS driver has clicked and the tiles really are
2365
+ # reloading. Measured 2148ms x2 on one solve.
2366
+ if round_index > 1:
2367
+ with self._phase("grid-load"):
2368
+ self._wait_for_grid_cells_loaded(element)
2140
2369
  shot = _tmp_png("recap")
2141
2370
  action: Optional[Dict[str, Any]] = None
2142
2371
  try:
2143
- self._screenshot(element, shot, timeout_ms=cfg.element_screenshot_timeout_ms)
2372
+ with self._phase("screenshot"):
2373
+ self._screenshot(element, shot,
2374
+ timeout_ms=cfg.element_screenshot_timeout_ms)
2144
2375
  retry_for_round = pending_retry
2145
2376
  pending_retry = None # only round 1 carries the inbound hint
2146
- actions, usage = self._solve_frame_freshness_guarded(
2147
- element,
2148
- shot,
2149
- lambda image_path: self._get_solution(image_path, "recaptcha", retry_for_round),
2150
- )
2377
+ with self._phase("inference"):
2378
+ actions, usage = self._solve_frame_freshness_guarded(
2379
+ element,
2380
+ shot,
2381
+ lambda image_path: self._get_solution(
2382
+ image_path, "recaptcha", retry_for_round),
2383
+ )
2151
2384
  all_usage.extend(usage)
2152
2385
  action = _as_dict(actions[0]) if actions else None
2153
2386
  finally:
@@ -2160,8 +2393,11 @@ class PageSolver:
2160
2393
 
2161
2394
  if action.get("action") == "wait":
2162
2395
  _log(f"[recaptcha-grid] round {round_index}: waiting for tiles.")
2163
- loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
2164
- self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
2396
+ with self._phase("fade-wait"):
2397
+ loading, _ = self._watch_clicked_tiles(
2398
+ page, element, session, clicked_order)
2399
+ self._wait_for_any_clicked_tile_loaded(
2400
+ page, element, session, loading)
2165
2401
  continue
2166
2402
 
2167
2403
  if action.get("action") == "click":
@@ -2184,16 +2420,17 @@ class PageSolver:
2184
2420
  if cell is not None:
2185
2421
  clicked_order.append(cell)
2186
2422
  clicked_this_round.append(cell)
2187
- _delay(random.random() * 80 + 80)
2423
+ self._human.pause("between")
2188
2424
  performed_action = True
2189
2425
  _log(
2190
2426
  f"[recaptcha-grid] round {round_index}: clicked {len(bboxes)} tile(s) "
2191
2427
  f"-> cells {clicked_this_round}."
2192
2428
  )
2193
2429
 
2194
- loading, chipped = self._watch_clicked_tiles(
2195
- page, element, session, clicked_this_round
2196
- )
2430
+ with self._phase("fade-wait"):
2431
+ loading, chipped = self._watch_clicked_tiles(
2432
+ page, element, session, clicked_this_round
2433
+ )
2197
2434
  if chipped or not loading:
2198
2435
  # Either the widget ticked our clicks and kept the photos
2199
2436
  # (a board that does that is answered), or nothing faded
@@ -2205,7 +2442,9 @@ class PageSolver:
2205
2442
  )
2206
2443
  should_submit = True
2207
2444
  break
2208
- self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
2445
+ with self._phase("fade-wait"):
2446
+ self._wait_for_any_clicked_tile_loaded(
2447
+ page, element, session, loading)
2209
2448
  continue
2210
2449
 
2211
2450
  _log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
@@ -2217,6 +2456,17 @@ class PageSolver:
2217
2456
  if verify:
2218
2457
  _log("[recaptcha-grid] clicking Verify to submit.")
2219
2458
  self._move_and_click(page, verify)
2459
+ # The press IS an interaction, and saying so is load-bearing
2460
+ # — the same rule `_solve_single` learned on
2461
+ # prosopo_grid_3x3, in the other driver, on another day. A
2462
+ # `done` round clicks no tile, so without this the one
2463
+ # answer shape that submits and does nothing else reports
2464
+ # having done nothing: the caller then takes the
2465
+ # no-interaction wait instead of polling for the verdict
2466
+ # (~1s of dead time on every static reCAPTCHA), and raises
2467
+ # "performed no interactions" if the widget has not
2468
+ # finished tearing down — on an answer correctly sent.
2469
+ performed_action = True
2220
2470
 
2221
2471
  return performed_action, all_usage
2222
2472
 
@@ -2257,9 +2507,10 @@ class PageSolver:
2257
2507
  # and want opposite answers. Restricted to `unknown` because neither
2258
2508
  # hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
2259
2509
  # inside one of their frames would be a false positive by definition.
2260
- text_mode = puzzle_source == "unknown" and self._find_control(
2261
- scope, TEXT_INPUT_SELECTORS
2262
- ) is not None
2510
+ text_mode = (
2511
+ puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING
2512
+ and self._answer_box(scope, element) is not None
2513
+ )
2263
2514
  if text_mode:
2264
2515
  _log("widget has a text box; solving as a distorted-text captcha")
2265
2516
 
@@ -2285,7 +2536,7 @@ class PageSolver:
2285
2536
  if self.is_captcha_solved(page):
2286
2537
  _log("solved while waiting for the next round; skipping inference.")
2287
2538
  return False, []
2288
- elif puzzle_source == "unknown":
2539
+ elif puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING:
2289
2540
  # Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
2290
2541
  # probe was never run for these, so an animated one — GeeTest's svg board
2291
2542
  # cycles its glyph set — was screenshotted mid-cycle and answered from
@@ -2363,8 +2614,10 @@ class PageSolver:
2363
2614
 
2364
2615
  if not is_animated:
2365
2616
  if not have_shot:
2366
- self._screenshot(element, shot,
2367
- timeout_ms=self.config.element_screenshot_timeout_ms)
2617
+ with self._phase("screenshot"):
2618
+ self._screenshot(
2619
+ element, shot,
2620
+ timeout_ms=self.config.element_screenshot_timeout_ms)
2368
2621
  with self._phase("inference"):
2369
2622
  actions, all_usage = self._solve_frame_freshness_guarded(
2370
2623
  element,
@@ -2410,7 +2663,7 @@ class PageSolver:
2410
2663
  if await_kf:
2411
2664
  self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
2412
2665
  self._execute_click(page, {"target_bounding_box": bbox}, element_box)
2413
- _delay(random.random() * 80 + 80)
2666
+ self._human.pause("between")
2414
2667
  else:
2415
2668
  self._execute_click(page, action, element_box)
2416
2669
  performed_action = True
@@ -2437,7 +2690,7 @@ class PageSolver:
2437
2690
  performed_action = True
2438
2691
  placed = True
2439
2692
  elif kind == "type":
2440
- if self._execute_type(page, scope, action):
2693
+ if self._execute_type(page, scope, action, element):
2441
2694
  performed_action = True
2442
2695
  typed = True
2443
2696
  elif kind == "wait":
@@ -2446,28 +2699,42 @@ class PageSolver:
2446
2699
  _delay(duration)
2447
2700
  performed_action = True
2448
2701
 
2449
- # `scope` when there is no vendor iframe. Eight vendors render
2450
- # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2451
- # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2452
- # for all of them and the button was never even SEARCHED FOR,
2453
- # while the text box and the slider handle it sits beside were
2454
- # both found through `scope` a few lines above. Two containers
2455
- # for two halves of one interaction.
2456
- #
2457
- # This used to be gated on `typed`, for fear of turning up the
2458
- # submit of the FORM the captcha guards. `scope` is the widget
2459
- # container and the xpaths are RELATIVE, so that button is out
2460
- # of reach by construction; what the gate actually did was make
2461
- # every non-typed inline puzzle unsubmittable. Measured on the
2462
- # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2463
- # burning all ten solve loops on a puzzle they had answered on
2464
- # the first one. The press itself is still bounded by
2465
- # `should_submit` below, which is where the hazard belongs.
2466
- lookup = frame or (scope if not slid else None)
2467
- if lookup is not None:
2468
- verify_button = self._get_verify_button(lookup)
2469
- if verify_button:
2470
- self._move_to_element(page, verify_button)
2702
+
2703
+ # Resolve the widget's submit control AFTER the action loop, not
2704
+ # inside it. An empty plan — the correct answer to reCAPTCHA 3x3's
2705
+ # `none_present` variation, where nothing matches and the control
2706
+ # reads SKIP — never enters that loop, so the lookup never ran, the
2707
+ # press below found `verify_button` None, and the round aborted on
2708
+ # 'performed no interactions'. The finder was never the problem:
2709
+ # 'Skip' has always been in its list. It was simply never called for
2710
+ # the one answer shape that performs no other action.
2711
+ # `scope` when there is no vendor iframe. Eight vendors render
2712
+ # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2713
+ # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2714
+ # for all of them and the button was never even SEARCHED FOR,
2715
+ # while the text box and the slider handle it sits beside were
2716
+ # both found through `scope` a few lines above. Two containers
2717
+ # for two halves of one interaction.
2718
+ #
2719
+ # This used to be gated on `typed`, for fear of turning up the
2720
+ # submit of the FORM the captcha guards. `scope` is the widget
2721
+ # container and the xpaths are RELATIVE, so that button is out
2722
+ # of reach by construction; what the gate actually did was make
2723
+ # every non-typed inline puzzle unsubmittable. Measured on the
2724
+ # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2725
+ # burning all ten solve loops on a puzzle they had answered on
2726
+ # the first one. The press itself is still bounded by
2727
+ # `should_submit` below, which is where the hazard belongs.
2728
+ # RESOLVED, not travelled to. `_move_and_click` below moves to the
2729
+ # button itself and picks its own random point inside it, so moving
2730
+ # here first bought a second humanised trajectory that ended a few
2731
+ # dozen pixels from where the first one stopped, plus a second
2732
+ # bounded scroll-into-view. Two hops onto one button is slower AND
2733
+ # is not something a hand does. A round that decides not to submit
2734
+ # no longer walks to the control either.
2735
+ lookup = frame or (scope if not slid else None)
2736
+ if lookup is not None:
2737
+ verify_button = self._get_verify_button(lookup)
2471
2738
 
2472
2739
  # Submit policy: press the widget's own submit control whenever we
2473
2740
  # have put an ANSWER into it — a selection, a placed piece, a typed
@@ -2557,8 +2824,7 @@ class PageSolver:
2557
2824
  cumulative_usage: List[Dict[str, Any]] = []
2558
2825
  self._last_submit_frame_hash = None
2559
2826
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
2560
- self._viewport_cache = None
2561
- self._cursor_seeded = False
2827
+ self._human.reset(page)
2562
2828
  self._reset_animated_state()
2563
2829
  self._budget = PhaseBudget()
2564
2830
 
@@ -2573,15 +2839,35 @@ class PageSolver:
2573
2839
  # would merge two separate captchas into one attempt — the opposite
2574
2840
  # error, and the one that under-bills.
2575
2841
  previous_session = os.environ.get(_SESSION_ENV)
2576
- os.environ[_SESSION_ENV] = str(uuid.uuid4())
2842
+ session_id = str(uuid.uuid4())
2843
+ os.environ[_SESSION_ENV] = session_id
2844
+ # The verdict this driver reports back, and it starts FALSE. A solve
2845
+ # that raises never sets it, which is the right default: the widget did
2846
+ # not accept, and the boards that made it raise are exactly the ones
2847
+ # worth keeping. See planner.report_outcome.
2848
+ solved_for_report = False
2577
2849
  try:
2578
- return self._solve_impl(page, start, cumulative_usage)
2850
+ result = self._solve_impl(page, start, cumulative_usage)
2851
+ # Attached HERE rather than at each of the seven `return
2852
+ # SolveResult(...)` sites, so a new early exit cannot forget it.
2853
+ result.phases = dict(self._budget.totals)
2854
+ result.phases["total"] = self._budget.elapsed_ms()
2855
+ solved_for_report = bool(getattr(result, "is_solved", False))
2856
+ return result
2579
2857
  finally:
2580
2858
  # Printed on the way out of every solve, success or failure — a
2581
2859
  # solve that FAILED is exactly the one whose time you want itemised.
2582
2860
  if timings_enabled() and self._budget is not None:
2583
2861
  print(self._budget.report(), file=sys.stderr)
2584
2862
  self._deadline_ms = None
2863
+ # BEFORE the env is restored, so the id reported is this solve's.
2864
+ # Best-effort and never raised out of a `finally` — an exception
2865
+ # here would replace the caller's real error, or their real result,
2866
+ # with one about telemetry.
2867
+ try:
2868
+ self._solver.planner.report_outcome(session_id, solved_for_report)
2869
+ except Exception as exc: # noqa: BLE001
2870
+ _log(f"[outcome] could not report: {exc}")
2585
2871
  if previous_session is None:
2586
2872
  os.environ.pop(_SESSION_ENV, None)
2587
2873
  else:
@@ -2667,10 +2953,7 @@ class PageSolver:
2667
2953
  )
2668
2954
  _delay(800 + random.random() * 300)
2669
2955
  continue
2670
- raise NoCaptchaFoundError(
2671
- "no interactive captcha widget detected (likely reCAPTCHA v3 / "
2672
- "invisible or a click-triggered challenge)"
2673
- )
2956
+ raise NoCaptchaFoundError(self._no_widget_message(page))
2674
2957
 
2675
2958
  _log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
2676
2959
  retry_mode_this_loop = pending_retry_mode
@@ -2782,60 +3065,74 @@ class PageSolver:
2782
3065
  render_waits = 0
2783
3066
  cumulative_usage.extend(usage)
2784
3067
 
2785
- if did_interact:
2786
- # Poll for the vendor's SOLVED signal before re-entering the
2787
- # pipeline. hCaptcha keeps the challenge visible for a couple of
2788
- # seconds while verifying; without this the loop re-solves that
2789
- # closing frame and burns ~18s. Only ever early-RETURNS on a
2790
- # definitive signal, so it cannot loop.
2791
- deadline = time.monotonic() * 1000.0 + cfg.post_solve_outcome_timeout_ms
2792
- solved = False
2793
- widget_gone = 0
2794
- _verdict_t0 = time.perf_counter()
2795
- while time.monotonic() * 1000.0 < deadline:
2796
- if self.is_captcha_solved(page):
3068
+ # ONE wait after a round, polled, whichever kind of round it was.
3069
+ #
3070
+ # There used to be two: this poll when the round interacted, and a
3071
+ # flat `_delay(post_solve_delay_ms + jitter)` when it did not. The
3072
+ # flat sleep observed NOTHING — it ran to the end and only then
3073
+ # asked whether the widget was still there — so a round that had in
3074
+ # fact finished the captcha paid 1200-1500ms to find that out. The
3075
+ # poll below already asks exactly that question and answers it the
3076
+ # moment it becomes true.
3077
+ #
3078
+ # The two windows keep different LENGTHS, because different things
3079
+ # size them: `post_solve_outcome_timeout_ms` covers how late a
3080
+ # vendor's success signal can arrive (measured — see the constant),
3081
+ # while `post_solve_delay_ms` is the dwell a round that answered
3082
+ # nothing takes before deciding the page has settled.
3083
+ window_ms = (cfg.post_solve_outcome_timeout_ms if did_interact
3084
+ else cfg.post_solve_delay_ms + random.random() * 300)
3085
+ # Poll for the vendor's SOLVED signal before re-entering the
3086
+ # pipeline. hCaptcha keeps the challenge visible for a couple of
3087
+ # seconds while verifying; without this the loop re-solves that
3088
+ # closing frame and burns ~18s. Only ever early-RETURNS on a
3089
+ # definitive signal, so it cannot loop.
3090
+ deadline = time.monotonic() * 1000.0 + window_ms
3091
+ solved = False
3092
+ widget_gone = 0
3093
+ _verdict_t0 = time.perf_counter()
3094
+ while time.monotonic() * 1000.0 < deadline:
3095
+ if self.is_captcha_solved(page):
3096
+ solved = True
3097
+ break
3098
+ # The eight inline vendors have no response token, so
3099
+ # `is_captcha_solved` — which reads only the hCaptcha and
3100
+ # reCAPTCHA anchors — can never fire for them and this loop
3101
+ # ran out its whole 2.5s budget on EVERY round, waiting for
3102
+ # a signal that cannot arrive. Measured on geetest_v4_slide:
3103
+ # 5.2s of a 12.3s solve, spent after the puzzle was already
3104
+ # answered, with the widget sitting there visibly solved.
3105
+ #
3106
+ # "The widget is gone" is the completion signal for those
3107
+ # vendors and is already the authority immediately after
3108
+ # this loop, so this only reaches the same verdict sooner —
3109
+ # confirmed over two polls so a frame caught mid-swap
3110
+ # between rounds cannot read as a solve.
3111
+ if self.detect_captcha(page) is None:
3112
+ widget_gone += 1
3113
+ if widget_gone >= 2:
2797
3114
  solved = True
2798
3115
  break
2799
- # The eight inline vendors have no response token, so
2800
- # `is_captcha_solved` — which reads only the hCaptcha and
2801
- # reCAPTCHA anchors — can never fire for them and this loop
2802
- # ran out its whole 2.5s budget on EVERY round, waiting for
2803
- # a signal that cannot arrive. Measured on geetest_v4_slide:
2804
- # 5.2s of a 12.3s solve, spent after the puzzle was already
2805
- # answered, with the widget sitting there visibly solved.
2806
- #
2807
- # "The widget is gone" is the completion signal for those
2808
- # vendors and is already the authority immediately after
2809
- # this loop, so this only reaches the same verdict sooner —
2810
- # confirmed over two polls so a frame caught mid-swap
2811
- # between rounds cannot read as a solve.
2812
- if self.detect_captcha(page) is None:
2813
- widget_gone += 1
2814
- if widget_gone >= 2:
2815
- solved = True
2816
- break
2817
- else:
2818
- widget_gone = 0
2819
- if self._is_challenge_freshly_rendered(page):
2820
- break # next round is up; go solve it now
2821
- _delay(cfg.post_solve_outcome_poll_ms)
2822
- _verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
2823
- if self._budget is not None:
2824
- self._budget.add("await-verdict", _verdict_ms)
2825
- # How long a SUCCESS actually took to show itself. This is the
2826
- # only number that can size `post_solve_outcome_timeout_ms`: the
2827
- # window exists to catch a late success, so it needs to cover
2828
- # the slowest real one and nothing beyond it. A round that ends
2829
- # any other way spends the whole window by construction — there
2830
- # is no signal on a wrong answer — so its duration says nothing
2831
- # about how long the window ought to be.
2832
- if solved:
2833
- _log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
2834
- if solved:
2835
- return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
2836
- else:
2837
- with self._phase("post-submit-delay"):
2838
- _delay(cfg.post_solve_delay_ms + random.random() * 300)
3116
+ else:
3117
+ widget_gone = 0
3118
+ if self._is_challenge_freshly_rendered(page):
3119
+ break # next round is up; go solve it now
3120
+ _delay(cfg.post_solve_outcome_poll_ms)
3121
+ _verdict_ms = (time.perf_counter() - _verdict_t0) * 1000.0
3122
+ if self._budget is not None:
3123
+ self._budget.add(
3124
+ "await-verdict" if did_interact else "post-submit-delay",
3125
+ _verdict_ms)
3126
+ # How long a SUCCESS actually took to show itself. This is the
3127
+ # only number that can size `post_solve_outcome_timeout_ms`: the
3128
+ # window exists to catch a late success, so it needs to cover
3129
+ # the slowest real one and nothing beyond it. A round that ends
3130
+ # any other way spends the whole window by construction — there
3131
+ # is no signal on a wrong answer — so its duration says nothing
3132
+ # about how long the window ought to be.
3133
+ if solved:
3134
+ _log(f"[verdict] success signal arrived after {_verdict_ms:.0f}ms")
3135
+ return SolveResult(True, self._last_mouse, _aggregate(cumulative_usage))
2839
3136
 
2840
3137
  if self._has_recaptcha_underselect_error(page):
2841
3138
  if already_retried_underselect: