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.
- package/README.md +90 -7
- package/dist/cli-invocation.d.ts +2 -0
- package/dist/cli-invocation.js +5 -1
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +112 -5
- package/dist/solver.js +527 -174
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +27 -0
- package/dist/trajectory.js +104 -0
- package/dist/types.d.ts +99 -0
- package/package.json +1 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +62 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +99 -0
- package/python/src/captchakraken/models.json +149 -13
- package/python/src/captchakraken/page_solver.py +476 -270
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +118 -9
- package/python/src/captchakraken/prompts.py +182 -0
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +97 -57
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
|
@@ -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 .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
self.
|
|
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
|
-
|
|
839
|
-
|
|
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.
|
|
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
|
-
|
|
874
|
-
|
|
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.
|
|
901
|
-
|
|
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.
|
|
917
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
956
|
-
#
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
except Exception:
|
|
961
|
-
pass
|
|
962
|
-
# Per character rather than one `type(text, delay=…)` call: a constant
|
|
963
|
-
# inter-key delay is itself a signal, and these are the vendors that
|
|
964
|
-
# score typing cadence.
|
|
965
|
-
for ch in text:
|
|
966
|
-
try:
|
|
967
|
-
page.keyboard.type(ch)
|
|
968
|
-
except Exception as exc:
|
|
969
|
-
_log(f"could not type into the captcha field: {exc}")
|
|
970
|
-
return False
|
|
971
|
-
_delay(random.random() * 90 + 45)
|
|
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.
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
1216
|
+
self._human.pause("settle")
|
|
1128
1217
|
finally:
|
|
1129
1218
|
try:
|
|
1130
|
-
|
|
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
|
-
|
|
2221
|
-
|
|
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.
|
|
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
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
-
|
|
2246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2277
|
-
|
|
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.
|
|
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 =
|
|
2343
|
-
|
|
2344
|
-
|
|
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
|
|
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.
|
|
2449
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
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
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
#
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
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:
|