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