captchakraken 2.6.1 → 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 +90 -7
  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 +112 -5
  11. package/dist/solver.js +527 -174
  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 +99 -0
  17. package/package.json +1 -1
  18. package/python/README.md +43 -0
  19. package/python/pyproject.toml +1 -1
  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 +476 -270
  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
@@ -388,16 +438,93 @@ class PageSolverConfig:
388
438
  # solver.ts — keep both in the same order with the same selectors.
389
439
  VENDOR_WIDGET_LOCATORS = [
390
440
  {"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"]']},
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"]']},
392
453
  {"puzzle_source": "yidun", "selectors": [".yidun_panel", ".yidun"]},
393
- {"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"]},
394
460
  {"puzzle_source": "lemin", "selectors": ["#lemin-cropped-captcha", ".lemin-captcha-popup"]},
395
461
  {"puzzle_source": "prosopo", "selectors": [".prosopo-modalInner", ".procaptcha-checkbox"]},
396
- {"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"]},
397
468
  {"puzzle_source": "botdetect", "selectors": [".BDC_CaptchaDiv"]},
398
469
  ]
399
470
 
400
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
+
401
528
  # ── where the answer goes, when it is not a click ───────────────────────────
402
529
  #
403
530
  # Both tables are ordered VENDOR-FIRST, GENERIC-LAST, and the driver takes the
@@ -414,7 +541,13 @@ VENDOR_WIDGET_LOCATORS = [
414
541
 
415
542
  # A distorted-text captcha's answer box. The three vendor entries are the three
416
543
  # types in instructions.py::TEXT_TYPES.
417
- 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 = [
418
551
  # BotDetect — the input is application-defined, so match the id fragment its
419
552
  # own docs and samples use. These three are what the nightly collector
420
553
  # already drives (src/captchaCollection/sources.py).
@@ -429,6 +562,9 @@ TEXT_INPUT_SELECTORS = [
429
562
  ".AdvancedCaptcha-Input input",
430
563
  "input.Textinput-Control",
431
564
  'input[name="rep"]',
565
+ ]
566
+
567
+ TEXT_INPUT_GENERIC_SELECTORS = [
432
568
  # Generic — an input the page itself labels as the captcha answer.
433
569
  'input[name*="captcha" i]',
434
570
  'input[id*="captcha" i]',
@@ -443,6 +579,8 @@ TEXT_INPUT_SELECTORS = [
443
579
  "textarea",
444
580
  ]
445
581
 
582
+ TEXT_INPUT_SELECTORS = TEXT_INPUT_VENDOR_SELECTORS + TEXT_INPUT_GENERIC_SELECTORS
583
+
446
584
  # The handle you drag on a puzzle-piece slider. NOT the piece: on every one of
447
585
  # these vendors the piece is inert decoration that the handle carries, so a
448
586
  # drag starting on the piece moves nothing at all.
@@ -451,7 +589,10 @@ SLIDER_HANDLE_SELECTORS = [
451
589
  ".geetest_slider_button",
452
590
  ".geetest_btn",
453
591
  ".geetest_slider .geetest_arrow",
454
- # 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",
455
596
  "#tcaptcha_drag_thumb",
456
597
  ".tc-slider-normal",
457
598
  "[id*=slideBlock]",
@@ -508,17 +649,24 @@ class PageSolver:
508
649
  self.config = config or PageSolverConfig()
509
650
  # One CaptchaSolver for the whole driver: it owns the planner, which
510
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)
511
661
  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
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)
516
666
  self._last_submit_frame_hash: Optional[str] = None
517
667
  # Absolute deadline for the current solve, in the same clock as
518
668
  # time.monotonic() * 1000. None outside a solve.
519
669
  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
670
  # Animation detection, all reset per solve by `_reset_animated_state`.
523
671
  # `_known_animated` latches: once a widget has been SEEN to animate,
524
672
  # every later round takes the keyframe path without re-probing.
@@ -535,6 +683,16 @@ class PageSolver:
535
683
  # Per-solve phase accounting; see `_phase`.
536
684
  self._budget: Optional[PhaseBudget] = None
537
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
+
538
696
  @contextmanager
539
697
  def _phase(self, name: str):
540
698
  """Attribute the enclosed wall-clock to `name` for this solve's budget.
@@ -713,132 +871,12 @@ class PageSolver:
713
871
  # Mouse
714
872
  # ------------------------------------------------------------------
715
873
 
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
874
  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)
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."""
840
878
  with self._phase("mouse"):
841
- self._trace_path(page, points, timings)
879
+ self._human.move(page, (x, y))
842
880
 
843
881
  def _move_to_element(self, page: Any, element: Any, padding_percentage: float = 25.0) -> None:
844
882
  # BOUNDED. Playwright's default timeout is 30s, and this is called once
@@ -870,9 +908,8 @@ class PageSolver:
870
908
 
871
909
  def _move_and_click(self, page: Any, element: Any) -> None:
872
910
  self._move_to_element(page, element)
873
- page.mouse.down()
874
- _delay(random.random() * 20 + 20)
875
- page.mouse.up()
911
+ with self._phase("mouse"):
912
+ self._human.click(page, self._last_mouse)
876
913
 
877
914
  def _execute_click(
878
915
  self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
@@ -897,10 +934,8 @@ class PageSolver:
897
934
  _log("click action without coordinates or bounding box; skipping")
898
935
  return
899
936
 
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()
937
+ with self._phase("mouse"):
938
+ self._human.click(page, (element_box["x"] + rel_x, element_box["y"] + rel_y))
904
939
 
905
940
  def _execute_drag(
906
941
  self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]
@@ -913,12 +948,8 @@ class PageSolver:
913
948
 
914
949
  src = center(action["source_bounding_box"])
915
950
  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()
951
+ with self._phase("mouse"):
952
+ self._human.drag(page, src, dst)
922
953
 
923
954
  # ------------------------------------------------------------------
924
955
  # Typing and sliding
@@ -942,33 +973,67 @@ class PageSolver:
942
973
  return element
943
974
  return None
944
975
 
945
- 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:
946
1023
  """Put the model's reading of a distorted-text captcha into its box."""
947
1024
  text = str(action.get("text") or "")
948
1025
  if not text:
949
1026
  return False
950
- field = self._find_control(scope, TEXT_INPUT_SELECTORS)
1027
+ field = self._answer_box(scope, element)
951
1028
  if field is None:
952
1029
  _log("type action, but no text box in the widget; skipping")
953
1030
  return False
954
1031
 
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)
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
972
1037
  _log(f"typed {len(text)} character(s) into the captcha field")
973
1038
  return True
974
1039
 
@@ -985,6 +1050,17 @@ class PageSolver:
985
1050
  _debug(f"track_piece failed: {exc}")
986
1051
  return None
987
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
+
988
1064
  def _execute_slide(
989
1065
  self,
990
1066
  page: Any,
@@ -1043,12 +1119,12 @@ class PageSolver:
1043
1119
  + float(action["target_bounding_box"][3])) / 2
1044
1120
  ) * element_box["height"]
1045
1121
  _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()
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
+ )
1052
1128
  return True
1053
1129
 
1054
1130
  hbox = handle.bounding_box()
@@ -1063,7 +1139,7 @@ class PageSolver:
1063
1139
  # moving thing in frame, and we would track the handle instead of the
1064
1140
  # piece.
1065
1141
  pad = max(4.0, hbox["height"] * 0.35)
1066
- exclude = [
1142
+ band = [
1067
1143
  0.0,
1068
1144
  hbox["y"] - element_box["y"] - pad,
1069
1145
  element_box["width"],
@@ -1073,20 +1149,32 @@ class PageSolver:
1073
1149
  shots = [_tmp_png("slide") for _ in range(4)]
1074
1150
  try:
1075
1151
  self._move_to_element(page, handle, padding_percentage=30.0)
1076
- page.mouse.down()
1077
- _delay(random.random() * 60 + 60)
1152
+ self._human.press(page)
1153
+ self._human.pause("grab")
1078
1154
  self._screenshot(element, shots[0],
1079
1155
  timeout_ms=self.config.element_screenshot_timeout_ms)
1080
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
+
1081
1169
  probes = self.config.slide_probe_offsets_px
1082
1170
  widths: List[Tuple[float, float]] = []
1083
1171
  last_box = None
1084
1172
  for offset, shot in zip(probes, shots[1:]):
1085
1173
  self._smooth_move(page, start_x + offset, hold_y)
1086
- _delay(random.random() * 40 + 40)
1174
+ self._human.pause("probe")
1087
1175
  box = self._track_piece(element, shots[0], shot, exclude)
1088
1176
  if box is not None:
1089
- widths.append((float(offset), float(box[2] - box[0])))
1177
+ widths.append((float(offset), float(box[2] - box[0]) / scale))
1090
1178
  last_box = box
1091
1179
 
1092
1180
  piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
@@ -1107,27 +1195,28 @@ class PageSolver:
1107
1195
  # asked for.
1108
1196
  offset = float(widths[-1][0])
1109
1197
  for _ in range(self.config.slide_max_corrections):
1110
- piece_center = (last_box[2] - piece_w / 2.0)
1198
+ piece_center = (last_box[2] / scale - piece_w / 2.0)
1111
1199
  error = target_x - piece_center
1112
1200
  if abs(error) <= self.config.slide_tolerance_px:
1113
1201
  break
1114
1202
  offset += error / ratio
1115
1203
  self._smooth_move(page, start_x + offset, hold_y)
1116
- _delay(random.random() * 40 + 40)
1204
+ self._human.pause("probe")
1117
1205
  box = self._track_piece(element, shots[0], shots[3], exclude)
1118
1206
  if box is None:
1119
1207
  break # ran out of track; release where we are
1120
1208
  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}")
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}")
1123
1212
 
1124
1213
  # Settle before letting go. A release in the same tick as the last
1125
1214
  # move reads as a machine, and some vendors sample the final
1126
1215
  # milliseconds of the gesture.
1127
- _delay(random.random() * 120 + 90)
1216
+ self._human.pause("settle")
1128
1217
  finally:
1129
1218
  try:
1130
- page.mouse.up()
1219
+ self._human.release(page)
1131
1220
  except Exception:
1132
1221
  pass
1133
1222
  for shot in shots:
@@ -1288,6 +1377,54 @@ class PageSolver:
1288
1377
 
1289
1378
  return None
1290
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
+
1291
1428
  def is_captcha_solved(self, page: Any) -> bool:
1292
1429
  """
1293
1430
  The vendor's definitive DONE signal — anchor checked or token populated.
@@ -2006,6 +2143,10 @@ class PageSolver:
2006
2143
  )
2007
2144
 
2008
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
2009
2150
  first = session.grid_boxes[0]
2010
2151
  cell_w = (first[2] - first[0]) * session.scale_x
2011
2152
  cell_h = (first[3] - first[1]) * session.scale_y
@@ -2217,19 +2358,29 @@ class PageSolver:
2217
2358
  # Each round can legitimately spend ~10s waiting on fades, so eight
2218
2359
  # of them plus the model calls can outlast the whole solve budget.
2219
2360
  self._check_deadline(f"recaptcha grid round {round_index}")
2220
- with self._phase("grid-load"):
2221
- 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)
2222
2369
  shot = _tmp_png("recap")
2223
2370
  action: Optional[Dict[str, Any]] = None
2224
2371
  try:
2225
- 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)
2226
2375
  retry_for_round = pending_retry
2227
2376
  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
- )
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
+ )
2233
2384
  all_usage.extend(usage)
2234
2385
  action = _as_dict(actions[0]) if actions else None
2235
2386
  finally:
@@ -2242,8 +2393,11 @@ class PageSolver:
2242
2393
 
2243
2394
  if action.get("action") == "wait":
2244
2395
  _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)
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)
2247
2401
  continue
2248
2402
 
2249
2403
  if action.get("action") == "click":
@@ -2266,16 +2420,17 @@ class PageSolver:
2266
2420
  if cell is not None:
2267
2421
  clicked_order.append(cell)
2268
2422
  clicked_this_round.append(cell)
2269
- _delay(random.random() * 80 + 80)
2423
+ self._human.pause("between")
2270
2424
  performed_action = True
2271
2425
  _log(
2272
2426
  f"[recaptcha-grid] round {round_index}: clicked {len(bboxes)} tile(s) "
2273
2427
  f"-> cells {clicked_this_round}."
2274
2428
  )
2275
2429
 
2276
- loading, chipped = self._watch_clicked_tiles(
2277
- page, element, session, clicked_this_round
2278
- )
2430
+ with self._phase("fade-wait"):
2431
+ loading, chipped = self._watch_clicked_tiles(
2432
+ page, element, session, clicked_this_round
2433
+ )
2279
2434
  if chipped or not loading:
2280
2435
  # Either the widget ticked our clicks and kept the photos
2281
2436
  # (a board that does that is answered), or nothing faded
@@ -2287,7 +2442,9 @@ class PageSolver:
2287
2442
  )
2288
2443
  should_submit = True
2289
2444
  break
2290
- 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)
2291
2448
  continue
2292
2449
 
2293
2450
  _log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
@@ -2299,6 +2456,17 @@ class PageSolver:
2299
2456
  if verify:
2300
2457
  _log("[recaptcha-grid] clicking Verify to submit.")
2301
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
2302
2470
 
2303
2471
  return performed_action, all_usage
2304
2472
 
@@ -2339,9 +2507,10 @@ class PageSolver:
2339
2507
  # and want opposite answers. Restricted to `unknown` because neither
2340
2508
  # hCaptcha nor reCAPTCHA has ever served a typed challenge, so a match
2341
2509
  # 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
2510
+ text_mode = (
2511
+ puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING
2512
+ and self._answer_box(scope, element) is not None
2513
+ )
2345
2514
  if text_mode:
2346
2515
  _log("widget has a text box; solving as a distorted-text captcha")
2347
2516
 
@@ -2367,7 +2536,7 @@ class PageSolver:
2367
2536
  if self.is_captcha_solved(page):
2368
2537
  _log("solved while waiting for the next round; skipping inference.")
2369
2538
  return False, []
2370
- elif puzzle_source == "unknown":
2539
+ elif puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING:
2371
2540
  # Non-hCaptcha, non-reCAPTCHA widgets (GeeTest, Tencent, …). The settle
2372
2541
  # probe was never run for these, so an animated one — GeeTest's svg board
2373
2542
  # cycles its glyph set — was screenshotted mid-cycle and answered from
@@ -2445,8 +2614,10 @@ class PageSolver:
2445
2614
 
2446
2615
  if not is_animated:
2447
2616
  if not have_shot:
2448
- self._screenshot(element, shot,
2449
- 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)
2450
2621
  with self._phase("inference"):
2451
2622
  actions, all_usage = self._solve_frame_freshness_guarded(
2452
2623
  element,
@@ -2492,7 +2663,7 @@ class PageSolver:
2492
2663
  if await_kf:
2493
2664
  self._wait_for_keyframe(element, await_kf, _bbox_center(bbox))
2494
2665
  self._execute_click(page, {"target_bounding_box": bbox}, element_box)
2495
- _delay(random.random() * 80 + 80)
2666
+ self._human.pause("between")
2496
2667
  else:
2497
2668
  self._execute_click(page, action, element_box)
2498
2669
  performed_action = True
@@ -2519,7 +2690,7 @@ class PageSolver:
2519
2690
  performed_action = True
2520
2691
  placed = True
2521
2692
  elif kind == "type":
2522
- if self._execute_type(page, scope, action):
2693
+ if self._execute_type(page, scope, action, element):
2523
2694
  performed_action = True
2524
2695
  typed = True
2525
2696
  elif kind == "wait":
@@ -2554,11 +2725,16 @@ class PageSolver:
2554
2725
  # burning all ten solve loops on a puzzle they had answered on
2555
2726
  # the first one. The press itself is still bounded by
2556
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.
2557
2735
  lookup = frame or (scope if not slid else None)
2558
2736
  if lookup is not None:
2559
2737
  verify_button = self._get_verify_button(lookup)
2560
- if verify_button:
2561
- self._move_to_element(page, verify_button)
2562
2738
 
2563
2739
  # Submit policy: press the widget's own submit control whenever we
2564
2740
  # have put an ANSWER into it — a selection, a placed piece, a typed
@@ -2648,8 +2824,7 @@ class PageSolver:
2648
2824
  cumulative_usage: List[Dict[str, Any]] = []
2649
2825
  self._last_submit_frame_hash = None
2650
2826
  self._deadline_ms = start + self.config.overall_solve_timeout_ms
2651
- self._viewport_cache = None
2652
- self._cursor_seeded = False
2827
+ self._human.reset(page)
2653
2828
  self._reset_animated_state()
2654
2829
  self._budget = PhaseBudget()
2655
2830
 
@@ -2664,15 +2839,35 @@ class PageSolver:
2664
2839
  # would merge two separate captchas into one attempt — the opposite
2665
2840
  # error, and the one that under-bills.
2666
2841
  previous_session = os.environ.get(_SESSION_ENV)
2667
- 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
2668
2849
  try:
2669
- 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
2670
2857
  finally:
2671
2858
  # Printed on the way out of every solve, success or failure — a
2672
2859
  # solve that FAILED is exactly the one whose time you want itemised.
2673
2860
  if timings_enabled() and self._budget is not None:
2674
2861
  print(self._budget.report(), file=sys.stderr)
2675
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}")
2676
2871
  if previous_session is None:
2677
2872
  os.environ.pop(_SESSION_ENV, None)
2678
2873
  else:
@@ -2758,10 +2953,7 @@ class PageSolver:
2758
2953
  )
2759
2954
  _delay(800 + random.random() * 300)
2760
2955
  continue
2761
- raise NoCaptchaFoundError(
2762
- "no interactive captcha widget detected (likely reCAPTCHA v3 / "
2763
- "invisible or a click-triggered challenge)"
2764
- )
2956
+ raise NoCaptchaFoundError(self._no_widget_message(page))
2765
2957
 
2766
2958
  _log(f"--- captcha solve loop {attempt}/{cfg.max_solve_loops} ---")
2767
2959
  retry_mode_this_loop = pending_retry_mode
@@ -2873,60 +3065,74 @@ class PageSolver:
2873
3065
  render_waits = 0
2874
3066
  cumulative_usage.extend(usage)
2875
3067
 
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):
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:
2888
3114
  solved = True
2889
3115
  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)
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))
2930
3136
 
2931
3137
  if self._has_recaptcha_underselect_error(page):
2932
3138
  if already_retried_underselect: