captchakraken 3.2.0 → 4.0.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/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import io
|
|
7
|
-
import itertools
|
|
8
7
|
import json
|
|
9
8
|
import math
|
|
10
9
|
import os
|
|
@@ -17,18 +16,16 @@ import time
|
|
|
17
16
|
import uuid
|
|
18
17
|
from concurrent.futures import ThreadPoolExecutor
|
|
19
18
|
from contextlib import contextmanager
|
|
20
|
-
from dataclasses import dataclass, field
|
|
21
|
-
from typing import AbstractSet, Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple
|
|
19
|
+
from dataclasses import InitVar, dataclass, field
|
|
20
|
+
from typing import AbstractSet, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Set, Tuple, Union
|
|
22
21
|
|
|
23
22
|
from . import planner
|
|
24
23
|
from .action_types import CaptchaAction
|
|
25
|
-
from .humanize import Humanizer, resolve as resolve_humanizer
|
|
24
|
+
from .humanize import Humanizer, hold, resolve as resolve_humanizer
|
|
26
25
|
from .kinds import (ActionKind, FrameRole, HumanizationMode, KeyframeMode, PauseKind, Phase, PromptFamily,
|
|
27
26
|
RecaptchaBanner, RetryMode, SettleVerdict, Vendor, Verdict)
|
|
28
|
-
from .selectors import
|
|
29
|
-
|
|
30
|
-
VendorSelectors, WidgetProbe)
|
|
31
|
-
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
27
|
+
from .selectors import DEFAULT_CONFIG_PATH, DEFAULT_TABLE, SelectorTable, VendorSelectors, WidgetProbe
|
|
28
|
+
from .solver import AnimatedRecording, CaptchaSolver, UnsupportedCaptchaError, cut_recording
|
|
32
29
|
from .timing import PhaseBudget, timings_enabled
|
|
33
30
|
from .verdicts import RoundVerdict, VerdictLog
|
|
34
31
|
|
|
@@ -197,6 +194,40 @@ class SolveResult:
|
|
|
197
194
|
verdicts: List[RoundVerdict] = field(default_factory=list)
|
|
198
195
|
|
|
199
196
|
|
|
197
|
+
#: A typed captcha's text box widens the shot only when it sits by the image: the union may be at most this many
|
|
198
|
+
#: times the image's area. A vendor-named box found in the enclosing form can be a whole login form away.
|
|
199
|
+
ANSWER_BOX_MAX_GROWTH = 4.0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _WithAnswerBox:
|
|
203
|
+
"""A typed captcha's image and its text box, photographed and pointed into as one element.
|
|
204
|
+
|
|
205
|
+
BotDetect's widget element is a 280x50 strip holding only the image; its text box is a sibling. Every
|
|
206
|
+
training board and every real capture of a typed captcha shows the box under the image, and on the bare
|
|
207
|
+
strip the pointer read the image as a click puzzle (p(type) 0.46) or doubled a glyph, where the same frame
|
|
208
|
+
with its box read R9VK at p >= 0.89 per character. The answer's points are taken in this union's frame, so
|
|
209
|
+
`bounding_box` is the union everywhere the round reads it; everything else is the element's own.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, element: Any, box: Any):
|
|
213
|
+
self._element, self._box = element, box
|
|
214
|
+
|
|
215
|
+
def bounding_box(self) -> Optional[Dict[str, float]]:
|
|
216
|
+
a = self._element.bounding_box()
|
|
217
|
+
b = self._box.bounding_box() if a else None
|
|
218
|
+
if not a or not b:
|
|
219
|
+
return a
|
|
220
|
+
x0, y0 = min(a["x"], b["x"]), min(a["y"], b["y"])
|
|
221
|
+
x1 = max(a["x"] + a["width"], b["x"] + b["width"])
|
|
222
|
+
y1 = max(a["y"] + a["height"], b["y"] + b["height"])
|
|
223
|
+
if (x1 - x0) * (y1 - y0) > ANSWER_BOX_MAX_GROWTH * max(a["width"] * a["height"], 1.0):
|
|
224
|
+
return a
|
|
225
|
+
return {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
|
|
226
|
+
|
|
227
|
+
def __getattr__(self, name: str) -> Any:
|
|
228
|
+
return getattr(self._element, name)
|
|
229
|
+
|
|
230
|
+
|
|
200
231
|
@dataclass
|
|
201
232
|
class _GridSession:
|
|
202
233
|
grid_boxes: List[Sequence[int]]
|
|
@@ -250,12 +281,29 @@ _NOT_THIS_BOARD_POLLS = 3
|
|
|
250
281
|
# How long a control we hovered, pressed or typed into takes to stop repainting in reaction. A board read
|
|
251
282
|
# inside this window is wearing our own feedback, which is not a screen it ever showed by itself.
|
|
252
283
|
INPUT_SETTLE_MS = 400
|
|
284
|
+
# While a reCAPTCHA tile reloads the hand rests on it, drifting a few pixels every second or two. Reaching for a
|
|
285
|
+
# different reloading tile on every 250 ms poll read as a cursor darting about the board.
|
|
286
|
+
IDLE_DRIFT_EVERY_MS = (1200.0, 2800.0)
|
|
287
|
+
IDLE_DRIFT_PX = 3.0
|
|
288
|
+
# How much longer than `grid_load_timeout_ms` a reCAPTCHA tile that is still painting is waited on.
|
|
289
|
+
GRID_LOAD_CAP_FACTOR = 3
|
|
290
|
+
# A press-and-hold is released once the widget's picture has stopped changing for this long after its fill
|
|
291
|
+
# moved, and at HOLD_MAX_MS whatever it shows. Press & Hold widgets ask for seconds, not tens of seconds.
|
|
292
|
+
HOLD_SETTLE_MS = 900
|
|
293
|
+
HOLD_MAX_MS = 12000
|
|
253
294
|
|
|
254
295
|
|
|
255
296
|
@dataclass
|
|
256
297
|
class PageSolverConfig:
|
|
257
298
|
humanization: Optional[HumanizationMode] = None
|
|
258
299
|
humanizer: Optional[Humanizer] = None
|
|
300
|
+
# Every URL pattern and selector the solver detects and drives with: a path to a config file of the shape of the
|
|
301
|
+
# repository's `captchakraken.config.json`, or the parsed object. What it gives is laid over the shipped default,
|
|
302
|
+
# so a file holding only a `custom` entry adds that entry and keeps every built-in vendor.
|
|
303
|
+
selectors: Union[str, "os.PathLike[str]", Mapping[str, Any]] = DEFAULT_CONFIG_PATH
|
|
304
|
+
# Deprecated, kept working: your own vendor entry in code. Prefer `custom` in the `selectors` file;
|
|
305
|
+
# when both are given this one wins.
|
|
306
|
+
custom_selectors: Optional[VendorSelectors] = None
|
|
259
307
|
touch_driver: Optional[Any] = None
|
|
260
308
|
touch_transform: Optional[Dict[str, Any]] = None
|
|
261
309
|
starting_mouse_position: Optional[Tuple[float, float]] = None
|
|
@@ -265,16 +313,11 @@ class PageSolverConfig:
|
|
|
265
313
|
# A page just navigated has often not drawn its widget; this long is spent looking before "no captcha" is
|
|
266
314
|
# believed. It is not charged to overall_solve_timeout_ms, which starts when there is something to solve.
|
|
267
315
|
detection_timeout_ms: int = 15_000
|
|
268
|
-
# Repeats of one answer on one board before the solve ends; the first repeats are re-asked
|
|
316
|
+
# Repeats of one answer on one board before the solve ends; the first repeats are re-asked naming the refused answer.
|
|
269
317
|
max_no_progress_rounds: int = 2
|
|
270
|
-
# Deprecated and ignored since 3.2.0: a round that performed nothing goes straight on to the next.
|
|
271
|
-
post_solve_delay_ms: int = 1_200
|
|
272
318
|
post_solve_outcome_timeout_ms: int = 1_000
|
|
273
319
|
post_solve_outcome_poll_ms: int = 75
|
|
274
320
|
element_screenshot_timeout_ms: int = 8_000
|
|
275
|
-
# Deprecated and ignored since 3.2.0: each of these failures now counts against max_solve_loops.
|
|
276
|
-
max_unsupported_resolves: int = 3
|
|
277
|
-
max_stale_element_retries: int = 3
|
|
278
321
|
# The pause before a failed round is tried again.
|
|
279
322
|
stale_element_backoff_ms: int = 900
|
|
280
323
|
stale_frame_resolve_enabled: bool = True
|
|
@@ -300,7 +343,7 @@ class PageSolverConfig:
|
|
|
300
343
|
keyframe_wait_timeout_ms: int = 9_000
|
|
301
344
|
keyframe_wait_poll_ms: int = 120
|
|
302
345
|
video_extra_inference_ms: int = 8_000
|
|
303
|
-
|
|
346
|
+
board_images_timeout_ms: int = 3_000
|
|
304
347
|
grid_load_poll_interval_ms: int = 250
|
|
305
348
|
grid_load_timeout_ms: int = 8_000
|
|
306
349
|
board_paint_poll_ms: int = 180
|
|
@@ -313,12 +356,29 @@ class PageSolverConfig:
|
|
|
313
356
|
recaptcha_tile_hover_enabled: bool = True
|
|
314
357
|
slide_tolerance_px: float = 2.0
|
|
315
358
|
slide_max_corrections: int = 3
|
|
359
|
+
# Older names, still accepted. The first three have done nothing since 3.2.0 (each failure counts against
|
|
360
|
+
# max_solve_loops); the last is board_images_timeout_ms, which waits for any vendor's `images`, not only hCaptcha's.
|
|
361
|
+
post_solve_delay_ms: InitVar[Optional[int]] = None
|
|
362
|
+
max_unsupported_resolves: InitVar[Optional[int]] = None
|
|
363
|
+
max_stale_element_retries: InitVar[Optional[int]] = None
|
|
364
|
+
hcaptcha_images_timeout_ms: InitVar[Optional[int]] = None
|
|
365
|
+
|
|
366
|
+
def __post_init__(self, post_solve_delay_ms, max_unsupported_resolves, max_stale_element_retries,
|
|
367
|
+
hcaptcha_images_timeout_ms) -> None:
|
|
368
|
+
if hcaptcha_images_timeout_ms is not None:
|
|
369
|
+
self.board_images_timeout_ms = hcaptcha_images_timeout_ms
|
|
316
370
|
|
|
317
371
|
def video_budget_ms(self) -> int:
|
|
318
372
|
return (self.video_burst_max_ms + self.keyframe_wait_timeout_ms
|
|
319
373
|
+ self.video_extra_inference_ms)
|
|
320
374
|
|
|
321
375
|
|
|
376
|
+
# The old name reads and writes the new field; as an InitVar alone it was accepted by __init__ but read back as None.
|
|
377
|
+
PageSolverConfig.hcaptcha_images_timeout_ms = property( # type: ignore[assignment]
|
|
378
|
+
lambda self: self.board_images_timeout_ms,
|
|
379
|
+
lambda self, v: setattr(self, "board_images_timeout_ms", v))
|
|
380
|
+
|
|
381
|
+
|
|
322
382
|
class Widget(NamedTuple):
|
|
323
383
|
"""What detection found: the handle to photograph, the locator to search inside, and which table entry named it."""
|
|
324
384
|
|
|
@@ -366,6 +426,16 @@ def _frame_of(at: Any) -> Optional[Any]:
|
|
|
366
426
|
return None
|
|
367
427
|
|
|
368
428
|
|
|
429
|
+
def _host_scopes(page: Any, enclaves: Sequence[str]) -> List[Any]:
|
|
430
|
+
"""Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave
|
|
431
|
+
is skipped because Playwright judges an element visible within its own document, whatever its frame's visibility."""
|
|
432
|
+
return [page, *(f for f in map(_frame_of, _visible(page, enclaves)) if f is not None)]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _visible_in(scopes: Sequence[Any], selectors: Sequence[str]) -> List[Any]:
|
|
436
|
+
return [at for scope in scopes for at in _visible(scope, selectors)]
|
|
437
|
+
|
|
438
|
+
|
|
369
439
|
def _input_value(handle: Any) -> str:
|
|
370
440
|
try:
|
|
371
441
|
return str(handle.input_value() or "")
|
|
@@ -380,8 +450,9 @@ def _has_text(handle: Any) -> bool:
|
|
|
380
450
|
return False
|
|
381
451
|
|
|
382
452
|
|
|
383
|
-
|
|
384
|
-
|
|
453
|
+
# The nudge that shows the tracker what the handle moves: enough to separate the part from still ground, little enough
|
|
454
|
+
# that a piece resting against the board's edge stays on it.
|
|
455
|
+
SLIDE_PROBE_PX = 8.0
|
|
385
456
|
|
|
386
457
|
# No prompt and no pictures both read as ready: waiting for `.prompt-text` to exist once paid the whole timeout
|
|
387
458
|
# before every board of a challenge that draws none (tower_stack: 18s a round, 2/2 -> 0/2).
|
|
@@ -409,6 +480,8 @@ _RESOURCES_JS = """() => {
|
|
|
409
480
|
class PageSolver:
|
|
410
481
|
"""Drives one Playwright-compatible `page` through a captcha. Reusable across solves."""
|
|
411
482
|
|
|
483
|
+
_sel: SelectorTable = DEFAULT_TABLE
|
|
484
|
+
|
|
412
485
|
def __init__(self, config: Optional[PageSolverConfig] = None,
|
|
413
486
|
solver: Optional[CaptchaSolver] = None, **solver_kwargs: Any) -> None:
|
|
414
487
|
self.config = config or PageSolverConfig()
|
|
@@ -416,10 +489,14 @@ class PageSolver:
|
|
|
416
489
|
solver_kwargs.setdefault("expert", self.config.expert)
|
|
417
490
|
self._solver = solver or CaptchaSolver(**solver_kwargs)
|
|
418
491
|
self._human: Humanizer = resolve_humanizer(self.config)
|
|
492
|
+
self._sel = SelectorTable(self.config.custom_selectors, config=self.config.selectors)
|
|
493
|
+
# The config's `detector`: which element detector finds this solver's grids and tick boxes.
|
|
494
|
+
from .pointer import detector as _detector
|
|
495
|
+
_detector.configure(self._sel.config.detector)
|
|
419
496
|
self._last_submit_frame_hash: Optional[str] = None
|
|
420
497
|
self._deadline_ms: Optional[float] = None
|
|
421
498
|
self._budget: Optional[PhaseBudget] = None
|
|
422
|
-
self._animated_plan: Optional[Tuple[
|
|
499
|
+
self._animated_plan: Optional[Tuple[AnimatedRecording, str, Any, Any]] = None
|
|
423
500
|
self._film_frames: List[Any] = []
|
|
424
501
|
self._film_ms: float = 0.0
|
|
425
502
|
# The digests of every screen the film holds: what a later burst is checked against to answer
|
|
@@ -464,6 +541,7 @@ class PageSolver:
|
|
|
464
541
|
|
|
465
542
|
def _reset_animated_state(self) -> None:
|
|
466
543
|
self._acted_on_board = False
|
|
544
|
+
self._submitted_on_board = False
|
|
467
545
|
self._known_animated = False
|
|
468
546
|
self._animated_probe_armed = False
|
|
469
547
|
self._animated_probe_done = False
|
|
@@ -472,37 +550,75 @@ class PageSolver:
|
|
|
472
550
|
self._keyframe_mode: Optional[KeyframeMode] = None
|
|
473
551
|
self._keyframe_steady_screens = 0
|
|
474
552
|
self._last_answer_sig: Optional[str] = None
|
|
553
|
+
self._tried_answers: Set[str] = set()
|
|
554
|
+
self._refreshed_boards: Set[str] = set()
|
|
555
|
+
self._round_board: Optional[str] = None
|
|
475
556
|
self._no_progress_rounds = 0
|
|
476
|
-
self.
|
|
477
|
-
self.
|
|
557
|
+
self._refused_rounds = 0
|
|
558
|
+
self._fallback_board: Optional[str] = None
|
|
559
|
+
self._exchanges_at_answer = 0
|
|
560
|
+
self._drop_pointer_history()
|
|
561
|
+
self._apply_refusals()
|
|
478
562
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
563
|
+
def _answer_signature(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> Optional[str]:
|
|
564
|
+
"""Keyed on the BOARD and the retry mode, not the answer alone.
|
|
565
|
+
|
|
566
|
+
A paged puzzle's next arrow is at the same point on every page, so a signature over the actions
|
|
567
|
+
alone would call a correct page turn a repeated answer and stop pressing it.
|
|
568
|
+
"""
|
|
482
569
|
try:
|
|
483
570
|
parts = [(a.get("action"), _round_pts(a.get("target_bounding_boxes")),
|
|
484
571
|
_round_pts(a.get("target_bounding_box")), _round_pts(a.get("target_coordinates")),
|
|
485
572
|
_round_pts(a.get("source_bounding_box")), a.get("text"))
|
|
486
573
|
for a in map(_as_dict, actions)]
|
|
487
|
-
return repr((retry_mode, parts))
|
|
574
|
+
return repr((self._fallback_board, retry_mode, parts))
|
|
488
575
|
except Exception:
|
|
489
576
|
return None
|
|
490
577
|
|
|
491
578
|
def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> bool:
|
|
492
|
-
"""True when this answer already ran and changed nothing:
|
|
579
|
+
"""True when this answer already ran and changed nothing: re-ask avoiding it, and arm the probe."""
|
|
493
580
|
sig = self._answer_signature(actions, retry_mode)
|
|
494
|
-
|
|
581
|
+
# A refresh asks for a different board, so the same refresh twice is two boards, not a refused answer. An
|
|
582
|
+
# answer that could not be signed could not be read either, and is not looked into.
|
|
583
|
+
if sig is not None and any(_as_dict(a).get("action") == ActionKind.REFRESH for a in actions):
|
|
584
|
+
self._no_progress_rounds = 0
|
|
585
|
+
self._last_answer_sig = None
|
|
586
|
+
if self._round_board is not None:
|
|
587
|
+
self._refreshed_boards.add(self._round_board)
|
|
588
|
+
return False
|
|
589
|
+
# A vendor can deal the same picture again, which clears the plans but not what was pressed on it. A paged
|
|
590
|
+
# puzzle is exempt: coming back to a page after the others is a new question, because the history differs.
|
|
591
|
+
tried = (self._fallback_board is not None and not self._pointer_history and sig in self._tried_answers)
|
|
592
|
+
if sig is not None and (sig == self._last_answer_sig or tried):
|
|
495
593
|
self._no_progress_rounds += 1
|
|
496
594
|
_log(f"[no-progress] the model returned the same answer again ({self._no_progress_rounds} in a row)")
|
|
497
|
-
|
|
498
|
-
self.
|
|
595
|
+
# A board that said nothing to its server has refused nothing, and another plan is one more press.
|
|
596
|
+
if not self._board_stayed_silent():
|
|
597
|
+
self._refused_rounds += 1
|
|
598
|
+
self._apply_refusals()
|
|
499
599
|
self._invalidate_animated_answer()
|
|
500
600
|
self._arm_animated_probe()
|
|
501
601
|
return True
|
|
502
602
|
self._no_progress_rounds = 0
|
|
503
603
|
self._last_answer_sig = sig
|
|
604
|
+
if sig is not None:
|
|
605
|
+
self._tried_answers.add(sig)
|
|
606
|
+
self._exchanges_at_answer = self._verdicts.exchanges if self._verdicts else 0
|
|
504
607
|
return False
|
|
505
608
|
|
|
609
|
+
def _another_move_left(self, retry_mode: Optional[RetryMode]) -> bool:
|
|
610
|
+
"""Whether the round after a repeat can differ. At temperature 0 the same question gets the same answer,
|
|
611
|
+
so it takes a different question (a retry mode, a film) or one of the model's other plans."""
|
|
612
|
+
target = getattr(self._solver, "planner", None)
|
|
613
|
+
pointer = bool(getattr(getattr(target, "prompts", None), "pointer", False))
|
|
614
|
+
return bool(retry_mode or self._known_animated
|
|
615
|
+
or (self.config.video_solve_enabled and self._animated_probe_armed)
|
|
616
|
+
or (pointer and target.avoid_refused))
|
|
617
|
+
|
|
618
|
+
def _board_stayed_silent(self) -> bool:
|
|
619
|
+
log = self._verdicts
|
|
620
|
+
return log is not None and log.listening and log.exchanges == self._exchanges_at_answer
|
|
621
|
+
|
|
506
622
|
def _bound_ask_to_the_budget(self) -> None:
|
|
507
623
|
"""Give the planner what is LEFT of the solve, so one ask cannot outlive the whole thing.
|
|
508
624
|
|
|
@@ -517,13 +633,101 @@ class PageSolver:
|
|
|
517
633
|
target.request_timeout_s = max(planner.MIN_REQUEST_TIMEOUT_S,
|
|
518
634
|
min(planner.DEFAULT_REQUEST_TIMEOUT_S, left_s))
|
|
519
635
|
|
|
520
|
-
def
|
|
636
|
+
def _apply_refusals(self) -> None:
|
|
521
637
|
target = getattr(self._solver, "planner", None)
|
|
522
638
|
if target is None:
|
|
523
639
|
return
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
640
|
+
# A pointer model is an argmax and samples nothing. Once an answer has come back unchanged and refused,
|
|
641
|
+
# every ask on this board names the answers already tried, and the pointer returns its likeliest other one.
|
|
642
|
+
target.avoid_refused = self._refused_rounds > 0
|
|
643
|
+
if not self._refused_rounds and hasattr(target, "pointer_answers"):
|
|
644
|
+
target.pointer_answers.clear()
|
|
645
|
+
if hasattr(target, "pointer_fallbacks"):
|
|
646
|
+
target.pointer_fallbacks.clear()
|
|
647
|
+
if hasattr(target, "pointer_has_actions"):
|
|
648
|
+
target.pointer_has_actions = False
|
|
649
|
+
self._fallback_board = None
|
|
650
|
+
|
|
651
|
+
def _drop_pointer_history(self) -> None:
|
|
652
|
+
"""A paged puzzle's earlier screens do not belong to the next puzzle."""
|
|
653
|
+
directory = getattr(self, "_pointer_history_dir", None)
|
|
654
|
+
if directory:
|
|
655
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
656
|
+
self._pointer_history: List[str] = []
|
|
657
|
+
self._pointer_history_dir: Optional[str] = None
|
|
658
|
+
planner = getattr(self._solver, "planner", None)
|
|
659
|
+
if planner is not None and hasattr(planner, "pointer_history"):
|
|
660
|
+
planner.pointer_history = self._pointer_history
|
|
661
|
+
|
|
662
|
+
def _keep_pointer_screen(self, shot: str, puzzle_source: Vendor, actions: Sequence[Any]) -> None:
|
|
663
|
+
"""A FunCaptcha page turn's answer depends on the screens already seen; keep each one for the next ask.
|
|
664
|
+
|
|
665
|
+
The model reads the earlier screens of the same puzzle as history. Sent only the current page, it
|
|
666
|
+
has nothing to compare against and keeps answering "next arrow", including on the match.
|
|
667
|
+
"""
|
|
668
|
+
if puzzle_source != Vendor.FUNCAPTCHA:
|
|
669
|
+
return
|
|
670
|
+
kinds = {_as_dict(a).get("action") for a in actions}
|
|
671
|
+
if kinds != {ActionKind.CLICK}:
|
|
672
|
+
return
|
|
673
|
+
if self._pointer_history_dir is None:
|
|
674
|
+
self._pointer_history_dir = tempfile.mkdtemp(prefix="ck_pghist_")
|
|
675
|
+
path = os.path.join(self._pointer_history_dir, f"{len(self._pointer_history):02d}.png")
|
|
676
|
+
try:
|
|
677
|
+
shutil.copyfile(shot, path)
|
|
678
|
+
except OSError:
|
|
679
|
+
return
|
|
680
|
+
self._pointer_history.append(path)
|
|
681
|
+
while len(self._pointer_history) > 8:
|
|
682
|
+
old = self._pointer_history.pop(0)
|
|
683
|
+
_unlink(old)
|
|
684
|
+
planner = getattr(self._solver, "planner", None)
|
|
685
|
+
if planner is not None and hasattr(planner, "pointer_history"):
|
|
686
|
+
planner.pointer_history = self._pointer_history
|
|
687
|
+
|
|
688
|
+
def _board_hash(self, element: Any, shot: str, filmed: bool) -> str:
|
|
689
|
+
"""The board's identity, always from a plain photograph: a filmed frame of an unchanged board differs in bytes."""
|
|
690
|
+
if not filmed:
|
|
691
|
+
return _sha1(shot)
|
|
692
|
+
plain = _tmp_png("board")
|
|
693
|
+
try:
|
|
694
|
+
self._screenshot(element, plain, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
695
|
+
return _sha1(plain)
|
|
696
|
+
finally:
|
|
697
|
+
_unlink(plain)
|
|
698
|
+
|
|
699
|
+
def _guard_fallbacks_to_board(self, board: str) -> None:
|
|
700
|
+
"""A fallback plan answers the board it was read off. Reuse it only while that board is still up.
|
|
701
|
+
|
|
702
|
+
The next round's screenshot is the test. Without it, a vendor that deals a new board without
|
|
703
|
+
saying so has the previous board's second plan pressed onto the new one.
|
|
704
|
+
|
|
705
|
+
A refresh that brought the same picture back has replaced nothing, and every plan has been spent on it.
|
|
706
|
+
"""
|
|
707
|
+
if board in self._refreshed_boards:
|
|
708
|
+
raise CaptchaSolveError("no progress: the board came back unchanged after a refresh")
|
|
709
|
+
self._round_board = board
|
|
710
|
+
target = getattr(self._solver, "planner", None)
|
|
711
|
+
if target is None or not getattr(getattr(target, "prompts", None), "pointer", False):
|
|
712
|
+
return
|
|
713
|
+
if self._fallback_board is not None and board != self._fallback_board:
|
|
714
|
+
if self._refused_rounds or getattr(target, "pointer_fallbacks", None):
|
|
715
|
+
_log("[pointer] the board changed; dropping the previous answer's fallbacks")
|
|
716
|
+
self._refused_rounds = 0
|
|
717
|
+
self._apply_refusals()
|
|
718
|
+
self._fallback_board = board
|
|
719
|
+
|
|
720
|
+
def _keep_plans_of_this_many_clicks(self, actions: List[Any]) -> None:
|
|
721
|
+
"""A click board with no Verify submits itself at a count, so a plan of any other count leaves its tiles
|
|
722
|
+
ticked and never submits, and the next plan's clicks untick them."""
|
|
723
|
+
plan = [_as_dict(a) for a in actions]
|
|
724
|
+
if not plan or any(a.get("action") != ActionKind.CLICK for a in plan):
|
|
725
|
+
return
|
|
726
|
+
clicks = sum(len(a.get("target_bounding_boxes") or [1]) for a in plan)
|
|
727
|
+
target = getattr(self._solver, "planner", None)
|
|
728
|
+
if getattr(target, "pointer_fallbacks", None):
|
|
729
|
+
target.pointer_fallbacks = [f for f in target.pointer_fallbacks if f.get("action") != ActionKind.CLICK
|
|
730
|
+
or len(f.get("points") or []) == clicks]
|
|
527
731
|
|
|
528
732
|
def _fresh_board(self) -> None:
|
|
529
733
|
# ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer describes the
|
|
@@ -535,9 +739,11 @@ class PageSolver:
|
|
|
535
739
|
# Its own second look, but only once IT fails: an arm set on the previous board is evidence about that one.
|
|
536
740
|
self._animated_probe_armed = False
|
|
537
741
|
self._animated_probe_done = False
|
|
538
|
-
self.
|
|
742
|
+
self._refused_rounds = 0
|
|
539
743
|
self._acted_on_board = False
|
|
540
|
-
self.
|
|
744
|
+
self._submitted_on_board = False
|
|
745
|
+
self._drop_pointer_history()
|
|
746
|
+
self._apply_refusals()
|
|
541
747
|
|
|
542
748
|
def _check_deadline(self, where: str) -> None:
|
|
543
749
|
# Called inside loops too: a camoufox session once ran ten minutes past a 120s timeout because only
|
|
@@ -604,11 +810,20 @@ class PageSolver:
|
|
|
604
810
|
|
|
605
811
|
def _get_solution(self, image_path: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
606
812
|
text_mode: bool = False):
|
|
607
|
-
|
|
608
|
-
|
|
813
|
+
def solve():
|
|
814
|
+
try:
|
|
815
|
+
return self._solver.solve(image_path, puzzle_source=puzzle_source, retry_mode=retry_mode,
|
|
816
|
+
text_mode=text_mode)
|
|
817
|
+
except UnsupportedCaptchaError:
|
|
818
|
+
# No answer is an empty plan, which presses Skip for a new board. An empty text box is a wrong answer.
|
|
819
|
+
if text_mode:
|
|
820
|
+
raise
|
|
821
|
+
return []
|
|
822
|
+
return self._ask(solve)
|
|
609
823
|
|
|
610
|
-
def _get_keyframe_solution(self,
|
|
611
|
-
|
|
824
|
+
def _get_keyframe_solution(self, recording: AnimatedRecording):
|
|
825
|
+
"""The animated ask: keyframes or video, whichever the model's prompt generation was trained on."""
|
|
826
|
+
return self._ask(lambda: self._solver.solve_animated(recording))
|
|
612
827
|
|
|
613
828
|
# ── gestures ─────────────────────────────────────────────────────────
|
|
614
829
|
|
|
@@ -657,6 +872,17 @@ class PageSolver:
|
|
|
657
872
|
return float(coords[0]) * element_box["width"], float(coords[1]) * element_box["height"]
|
|
658
873
|
return None
|
|
659
874
|
|
|
875
|
+
@staticmethod
|
|
876
|
+
def _hold_point_for(action: Dict[str, Any], element_box: Dict[str, float]) -> Optional[Tuple[float, float]]:
|
|
877
|
+
"""Element-relative press point of a hold: the model's own point, the middle of its box. A hold can be on a
|
|
878
|
+
bar narrower than that box, and a random spot in it lands off the bar, or close enough to its edge that the
|
|
879
|
+
hand's drift lets go: the widget then never fills, and the same answer fails the same way."""
|
|
880
|
+
bbox = action.get("target_bounding_box")
|
|
881
|
+
if not bbox:
|
|
882
|
+
return None
|
|
883
|
+
x1, y1, x2, y2 = (float(v) for v in bbox)
|
|
884
|
+
return (x1 + x2) / 2 * element_box["width"], (y1 + y2) / 2 * element_box["height"]
|
|
885
|
+
|
|
660
886
|
def _execute_click(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float],
|
|
661
887
|
element: Any = None, await_keyframe: Optional[str] = None) -> None:
|
|
662
888
|
# Every gesture marks the board acted on so the speculative burst is never re-run over a touched board.
|
|
@@ -679,6 +905,121 @@ class PageSolver:
|
|
|
679
905
|
element_box: Dict[str, float], await_keyframe: str) -> None:
|
|
680
906
|
self._execute_click(page, action, element_box, element, await_keyframe)
|
|
681
907
|
|
|
908
|
+
def _execute_hold(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float], element: Any,
|
|
909
|
+
completes_itself: bool = False) -> bool:
|
|
910
|
+
"""Press-and-hold until the widget completes: True when it did, False when the cap ran out.
|
|
911
|
+
|
|
912
|
+
Complete is the vendor's done signal, the widget closing, or its picture having changed (the fill)
|
|
913
|
+
and then sat still for HOLD_SETTLE_MS. A widget that draws no progress at all is held to HOLD_MAX_MS.
|
|
914
|
+
When the answer names the fill's line, the fill reaching its end is complete too: see `_fill_reached`.
|
|
915
|
+
Except on a widget that `completes_itself` (HUMAN's button): it verifies only once it has shown its
|
|
916
|
+
done state, and letting go AT full resets it, so only its own signals end the hold.
|
|
917
|
+
"""
|
|
918
|
+
self._acted_on_board = True
|
|
919
|
+
rel = self._hold_point_for(action, element_box)
|
|
920
|
+
if rel is None:
|
|
921
|
+
_log("hold action without a bounding box; skipping")
|
|
922
|
+
return False
|
|
923
|
+
at = (element_box["x"] + rel[0], element_box["y"] + rel[1])
|
|
924
|
+
first = self._element_frame_hash(element)
|
|
925
|
+
seen = {"hash": first, "since": time.monotonic(), "changed": False}
|
|
926
|
+
line = action.get("fill_from_bounding_box") and action.get("fill_to_bounding_box")
|
|
927
|
+
shots = (_tmp_png("hold"), _tmp_png("hold")) if line and not completes_itself else None
|
|
928
|
+
looks: List[Dict[str, Any]] = []
|
|
929
|
+
|
|
930
|
+
def done() -> bool:
|
|
931
|
+
if self.is_captcha_solved(page):
|
|
932
|
+
_debug("hold: the vendor reports solved")
|
|
933
|
+
return True
|
|
934
|
+
try:
|
|
935
|
+
if not element.bounding_box():
|
|
936
|
+
_debug("hold: the widget closed")
|
|
937
|
+
return True
|
|
938
|
+
except Exception:
|
|
939
|
+
_debug("hold: the widget closed")
|
|
940
|
+
return True
|
|
941
|
+
n = len(looks)
|
|
942
|
+
if shots and self._fill_reached(element, action, element_box, shots, looks):
|
|
943
|
+
return True
|
|
944
|
+
# A look that just shot the widget is its frame too: a second screenshot per look slowed the looks
|
|
945
|
+
# enough for a fast fill to pass the mark unseen between two.
|
|
946
|
+
fresh = shots[0 if n == 0 else 1] if shots and len(looks) > n else None
|
|
947
|
+
h, now = (_sha1(fresh) if fresh else self._element_frame_hash(element)), time.monotonic()
|
|
948
|
+
if h != seen["hash"]:
|
|
949
|
+
seen.update(hash=h, since=now, changed=seen["changed"] or h != first)
|
|
950
|
+
return False
|
|
951
|
+
settled = seen["changed"] and (now - seen["since"]) * 1000.0 >= HOLD_SETTLE_MS
|
|
952
|
+
if settled:
|
|
953
|
+
_debug(f"hold: the picture sat still for {HOLD_SETTLE_MS}ms")
|
|
954
|
+
return settled
|
|
955
|
+
|
|
956
|
+
try:
|
|
957
|
+
with self._gesture():
|
|
958
|
+
completed = hold(self._human, page, at, done, HOLD_MAX_MS)
|
|
959
|
+
finally:
|
|
960
|
+
for shot in shots or ():
|
|
961
|
+
_unlink(shot)
|
|
962
|
+
_log("held until the widget completed." if completed else f"released the hold at the {HOLD_MAX_MS}ms cap.")
|
|
963
|
+
return completed
|
|
964
|
+
|
|
965
|
+
def _fill_reached(self, element: Any, action: Dict[str, Any], element_box: Dict[str, float],
|
|
966
|
+
shots: Tuple[str, str], looks: List[Dict[str, Any]]) -> bool:
|
|
967
|
+
"""One look at a held widget's fill (`tool_calls.fill_tracker`): True once it reaches the answer's `to`, after
|
|
968
|
+
waiting out the tracker's `release_in` when the fill arrives between looks.
|
|
969
|
+
|
|
970
|
+
The first look only shoots the reference, after the press, so the button's own pressed look is not read as
|
|
971
|
+
fill; `looks` carries what each later look saw. A look that cannot be shot or read is not a release: the
|
|
972
|
+
widget's own signals still end the hold."""
|
|
973
|
+
from .tool_calls.fill_tracker import fill_step
|
|
974
|
+
|
|
975
|
+
first, current = shots
|
|
976
|
+
w, h = element_box["width"], element_box["height"]
|
|
977
|
+
timeout = self.config.element_screenshot_timeout_ms
|
|
978
|
+
try:
|
|
979
|
+
if not looks:
|
|
980
|
+
shot_at = time.monotonic()
|
|
981
|
+
self._screenshot(element, first, timeout_ms=timeout)
|
|
982
|
+
looks.append({"k": self._shot_scale(first, w), "t": (shot_at + time.monotonic()) / 2})
|
|
983
|
+
return False
|
|
984
|
+
shot_at = time.monotonic()
|
|
985
|
+
self._screenshot(element, current, timeout_ms=timeout)
|
|
986
|
+
# The shot was taken somewhere inside the call; its middle is the best guess, and the release is timed
|
|
987
|
+
# from it.
|
|
988
|
+
shot_at = (shot_at + time.monotonic()) / 2
|
|
989
|
+
k, history = looks[0]["k"], looks[1:]
|
|
990
|
+
|
|
991
|
+
def at(bbox: Sequence[float]) -> Tuple[float, float]:
|
|
992
|
+
return (float(bbox[0]) + float(bbox[2])) / 2 * w * k, (float(bbox[1]) + float(bbox[3])) / 2 * h * k
|
|
993
|
+
|
|
994
|
+
gaps = [b["t"] - a["t"] for a, b in zip(history, history[1:])]
|
|
995
|
+
reply = fill_step(first, current, start=at(action["fill_from_bounding_box"]),
|
|
996
|
+
end=at(action["fill_to_bounding_box"]), t=shot_at, history=history, first_t=looks[0]["t"],
|
|
997
|
+
poll_s=sum(gaps) / len(gaps) if gaps else 0.25, tol=self.config.slide_tolerance_px * k)
|
|
998
|
+
except Exception as exc:
|
|
999
|
+
_debug(f"hold: the fill was not read ({exc})")
|
|
1000
|
+
return False
|
|
1001
|
+
looks.append(reply["observation"])
|
|
1002
|
+
if reply["release"]:
|
|
1003
|
+
_log(f"hold: the fill reaches its mark {reply['release_in'] or 0:.2f}s after a look "
|
|
1004
|
+
f"{(reply['length'] - reply['edge']) / k:+.1f}px short of it")
|
|
1005
|
+
_delay((shot_at + (reply["release_in"] or 0.0) - time.monotonic()) * 1000.0)
|
|
1006
|
+
return bool(reply["release"])
|
|
1007
|
+
|
|
1008
|
+
def _execute_refresh(self, page: Any, scope: Any, action: Dict[str, Any],
|
|
1009
|
+
element_box: Optional[Dict[str, float]]) -> bool:
|
|
1010
|
+
"""Deal a new puzzle: the vendor's own control, else the model's point on one. False when there is neither."""
|
|
1011
|
+
control = self._find_control(scope, self._sel.refresh)
|
|
1012
|
+
if control is not None:
|
|
1013
|
+
self._acted_on_board = True
|
|
1014
|
+
self._move_and_click(page, control)
|
|
1015
|
+
elif action.get("target_bounding_box") and element_box:
|
|
1016
|
+
self._execute_click(page, action, element_box)
|
|
1017
|
+
else:
|
|
1018
|
+
_log("the model asked for a new puzzle, and there is no control to deal one; ending the round.")
|
|
1019
|
+
return False
|
|
1020
|
+
_log("asked for a new puzzle instead of solving this one.")
|
|
1021
|
+
return True
|
|
1022
|
+
|
|
682
1023
|
def _execute_drag(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]) -> None:
|
|
683
1024
|
self._acted_on_board = True
|
|
684
1025
|
|
|
@@ -693,31 +1034,16 @@ class PageSolver:
|
|
|
693
1034
|
first = next(iter(_visible(scope, selectors)), None)
|
|
694
1035
|
return _handle_of(first) if first is not None else None
|
|
695
1036
|
|
|
696
|
-
def _measure_piece_box(self, scope: Any, widget_width: float) -> Optional[Dict[str, float]]:
|
|
697
|
-
"""The slider piece's box: the first visible match small enough to be a piece.
|
|
698
|
-
|
|
699
|
-
Runs during detection, so it must not mark the board acted on: doing so once disabled the
|
|
700
|
-
speculative burst on every slide solve.
|
|
701
|
-
"""
|
|
702
|
-
for candidate in _handles(_visible(scope, PIECE_SELECTORS)):
|
|
703
|
-
try:
|
|
704
|
-
b = candidate.bounding_box()
|
|
705
|
-
except Exception:
|
|
706
|
-
continue
|
|
707
|
-
if b and SLIDE_PIECE_MIN_PX <= b["width"] <= widget_width * SLIDE_PIECE_MAX_FRACTION:
|
|
708
|
-
return b
|
|
709
|
-
return None
|
|
710
|
-
|
|
711
1037
|
def _answer_box(self, scope: Any, at: Any = None) -> Optional[Any]:
|
|
712
1038
|
"""The text box inside the widget, else a vendor-named one in its enclosing fieldset/form.
|
|
713
1039
|
|
|
714
1040
|
Only vendor-named selectors widen: BotDetect's `.BDC_CaptchaDiv` is 280x50 and holds only the image,
|
|
715
1041
|
with `#captchaCode` in a sibling div. The generic tail there would take a login form's own box.
|
|
716
1042
|
"""
|
|
717
|
-
inside = self._find_control(scope,
|
|
1043
|
+
inside = self._find_control(scope, self._sel.text_input)
|
|
718
1044
|
if inside is not None or at is None:
|
|
719
1045
|
return inside
|
|
720
|
-
around = (self._find_control(at.locator(f"xpath={axis}"),
|
|
1046
|
+
around = (self._find_control(at.locator(f"xpath={axis}"), self._sel.text_input_vendor)
|
|
721
1047
|
for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"))
|
|
722
1048
|
return next((found for found in around if found is not None), None)
|
|
723
1049
|
|
|
@@ -736,20 +1062,6 @@ class PageSolver:
|
|
|
736
1062
|
_log(f"typed {len(text)} character(s) into the captcha field")
|
|
737
1063
|
return True
|
|
738
1064
|
|
|
739
|
-
def _track_piece(self, element: Any, before: str, after: str, exclude: Sequence[float],
|
|
740
|
-
travel: float = 0.0) -> Optional[Dict[str, Any]]:
|
|
741
|
-
from .tool_calls.track_piece import changed_bbox, locate_piece
|
|
742
|
-
|
|
743
|
-
try:
|
|
744
|
-
self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
745
|
-
bbox = changed_bbox(before, after, exclude)
|
|
746
|
-
if bbox is None:
|
|
747
|
-
return None
|
|
748
|
-
return {"bbox": bbox, "piece": locate_piece(before, after, travel, exclude)}
|
|
749
|
-
except Exception as exc:
|
|
750
|
-
_debug(f"track_piece failed: {exc}")
|
|
751
|
-
return None
|
|
752
|
-
|
|
753
1065
|
@staticmethod
|
|
754
1066
|
def _shot_scale(shot: str, css_width: float) -> float:
|
|
755
1067
|
dims = _read_png_dimensions(shot)
|
|
@@ -757,77 +1069,71 @@ class PageSolver:
|
|
|
757
1069
|
|
|
758
1070
|
def _execute_slide(self, page: Any, element: Any, scope: Any, action: Dict[str, Any],
|
|
759
1071
|
element_box: Dict[str, float]) -> bool:
|
|
760
|
-
"""
|
|
1072
|
+
"""Press the handle, nudge it to see what it moves, then steer that part's point onto its target.
|
|
1073
|
+
|
|
1074
|
+
A generation-3 answer names all three points, so nothing is read from the page but pixels. A generation-2
|
|
1075
|
+
answer names only the target: its handle comes from the selector table and the part it moves is whatever
|
|
1076
|
+
moved most. That path goes when the last generation-2 model does.
|
|
1077
|
+
"""
|
|
1078
|
+
from .tool_calls.slide_tracker import TrackingError, slide_step
|
|
1079
|
+
|
|
761
1080
|
self._acted_on_board = True
|
|
762
|
-
|
|
763
|
-
target_x = (float(tb[0]) + float(tb[2])) / 2 * element_box["width"]
|
|
1081
|
+
w, h = element_box["width"], element_box["height"]
|
|
764
1082
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1083
|
+
def at(bbox: Sequence[float]) -> Tuple[float, float]:
|
|
1084
|
+
return (float(bbox[0]) + float(bbox[2])) / 2 * w, (float(bbox[1]) + float(bbox[3])) / 2 * h
|
|
1085
|
+
|
|
1086
|
+
goal = at(action["target_bounding_box"])
|
|
1087
|
+
if action.get("handle_bounding_box"):
|
|
1088
|
+
handle, seed = at(action["handle_bounding_box"]), at(action["source_bounding_box"])
|
|
1089
|
+
else:
|
|
1090
|
+
slider = self._find_control(scope, self._sel.slider_handle)
|
|
1091
|
+
piece = None if slider is not None else self._find_control(scope, self._sel.piece)
|
|
1092
|
+
box = (slider or piece).bounding_box() if (slider or piece) is not None else None
|
|
769
1093
|
if not box:
|
|
770
1094
|
_log("slide action, but the widget has neither a slider nor a draggable piece")
|
|
771
1095
|
return False
|
|
772
|
-
|
|
773
|
-
with
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
return False
|
|
781
|
-
start_x, hold_y = hbox["x"] + hbox["width"] / 2, hbox["y"] + hbox["height"] / 2
|
|
782
|
-
# Mask from the handle's band to the widget's bottom: the filled track moves too.
|
|
783
|
-
pad = max(4.0, hbox["height"] * 0.35)
|
|
784
|
-
band = [0.0, hbox["y"] - element_box["y"] - pad, element_box["width"], element_box["height"]]
|
|
785
|
-
shots = [_tmp_png("slide") for _ in range(2)]
|
|
1096
|
+
handle = (box["x"] + box["width"] / 2 - element_box["x"], box["y"] + box["height"] / 2 - element_box["y"])
|
|
1097
|
+
# A piece with no track is the handle itself, and what it moves is it.
|
|
1098
|
+
seed = handle if piece is not None else None
|
|
1099
|
+
|
|
1100
|
+
press = (element_box["x"] + handle[0], element_box["y"] + handle[1])
|
|
1101
|
+
probe = SLIDE_PROBE_PX if goal[0] >= (seed or handle)[0] else -SLIDE_PROBE_PX
|
|
1102
|
+
timeout = self.config.element_screenshot_timeout_ms
|
|
1103
|
+
first, probed, current = (_tmp_png("slide") for _ in range(3))
|
|
786
1104
|
try:
|
|
787
|
-
self.
|
|
1105
|
+
self._smooth_move(page, *press)
|
|
788
1106
|
self._human.press(page)
|
|
789
1107
|
self._human.pause(PauseKind.GRAB)
|
|
790
|
-
self._screenshot(element,
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
|
|
813
|
-
live = live_center()
|
|
814
|
-
if live is not None:
|
|
815
|
-
center = live
|
|
816
|
-
elif last_piece is not None:
|
|
817
|
-
center = last_piece["centre"] / scale
|
|
818
|
-
elif last_box is not None and piece_w is not None:
|
|
819
|
-
center = last_box[2] / scale - piece_w / 2.0
|
|
820
|
-
else:
|
|
821
|
-
continue
|
|
822
|
-
steered = True
|
|
823
|
-
error = target_x - center
|
|
824
|
-
_debug(f"slider: center={center:.1f} target={target_x:.1f} error={error:+.1f} ratio={ratio:.3f}")
|
|
825
|
-
if abs(error) <= self.config.slide_tolerance_px:
|
|
1108
|
+
self._screenshot(element, first, timeout_ms=timeout)
|
|
1109
|
+
k = self._shot_scale(first, w)
|
|
1110
|
+
self._smooth_move(page, press[0] + probe, press[1])
|
|
1111
|
+
self._human.pause(PauseKind.PROBE)
|
|
1112
|
+
self._screenshot(element, probed, timeout_ms=timeout)
|
|
1113
|
+
ask = dict(probe_s=probe * k, goal=(goal[0] * k, goal[1] * k), seed=seed and (seed[0] * k, seed[1] * k),
|
|
1114
|
+
handle=(handle[0] * k, handle[1] * k), bounds=(-handle[0] * k, (w - handle[0]) * k),
|
|
1115
|
+
tol=self.config.slide_tolerance_px * k)
|
|
1116
|
+
look, s, history, moves = probed, probe * k, [], 0
|
|
1117
|
+
while True:
|
|
1118
|
+
try:
|
|
1119
|
+
reply = slide_step(first, probed, look, s=s, history=history, **ask)
|
|
1120
|
+
except TrackingError as exc:
|
|
1121
|
+
_log(f"slider: {exc}; released where the handle is")
|
|
1122
|
+
break
|
|
1123
|
+
if look is not probed:
|
|
1124
|
+
history.append(reply["observation"])
|
|
1125
|
+
_debug(f"slider: point at ({reply['pos'][0] / k:.1f}, {reply['pos'][1] / k:.1f}), "
|
|
1126
|
+
f"{reply['error'] / k:.1f}px from its target, {reply['kind']}")
|
|
1127
|
+
if reply["done"] or moves >= self.config.slide_max_corrections:
|
|
1128
|
+
if reply["error"] > ask["tol"]:
|
|
1129
|
+
_log(f"slider: releasing {reply['error'] / k:.1f}px off target")
|
|
826
1130
|
break
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1131
|
+
s = reply["next"]
|
|
1132
|
+
dx, dy = (s[0], s[1]) if reply["grabbed"] else (s, 0.0)
|
|
1133
|
+
self._smooth_move(page, press[0] + dx / k, press[1] + dy / k)
|
|
1134
|
+
self._human.pause(PauseKind.PROBE)
|
|
1135
|
+
self._screenshot(element, current, timeout_ms=timeout)
|
|
1136
|
+
look, moves = current, moves + 1
|
|
831
1137
|
self._human.pause(PauseKind.SETTLE)
|
|
832
1138
|
finally:
|
|
833
1139
|
try:
|
|
@@ -835,42 +1141,21 @@ class PageSolver:
|
|
|
835
1141
|
except Exception:
|
|
836
1142
|
pass
|
|
837
1143
|
self._last_input_ms = _now()
|
|
838
|
-
for shot in
|
|
1144
|
+
for shot in (first, probed, current):
|
|
839
1145
|
_unlink(shot)
|
|
840
1146
|
return True
|
|
841
1147
|
|
|
842
|
-
@staticmethod
|
|
843
|
-
def _solve_slide_geometry(widths: Sequence[Tuple[float, float]],
|
|
844
|
-
widget_width: float) -> Tuple[Optional[float], float]:
|
|
845
|
-
"""Piece width and handle-to-piece ratio from `(offset, changed width)` readings.
|
|
846
|
-
|
|
847
|
-
The widest-apart pair over a 16px spread: changed widths are ±1px, so the ratio error is ±0.13 at the
|
|
848
|
-
floor and ±0.5 at 4px. An implausible ratio falls back to 1:1 (true of every vendor observed) rather
|
|
849
|
-
than a 0.02 that sends the handle off the track and camoufox into a move that never returns.
|
|
850
|
-
"""
|
|
851
|
-
if not widths:
|
|
852
|
-
return None, 1.0
|
|
853
|
-
piece_w, ratio = None, 1.0
|
|
854
|
-
(o1, w1), (o2, w2) = min(widths, key=lambda m: m[0]), max(widths, key=lambda m: m[0])
|
|
855
|
-
if o2 - o1 >= 16.0 and 0.2 <= (w2 - w1) / (o2 - o1) <= 3.0:
|
|
856
|
-
ratio = (w2 - w1) / (o2 - o1)
|
|
857
|
-
piece_w = w1 - ratio * o1
|
|
858
|
-
if piece_w is None:
|
|
859
|
-
o, w = widths[-1]
|
|
860
|
-
piece_w = w - ratio * o
|
|
861
|
-
if not (SLIDE_PIECE_MIN_PX <= piece_w <= widget_width * SLIDE_PIECE_MAX_FRACTION):
|
|
862
|
-
return None, ratio
|
|
863
|
-
return piece_w, ratio
|
|
864
|
-
|
|
865
1148
|
# ── detection ────────────────────────────────────────────────────────
|
|
866
1149
|
|
|
867
|
-
def _has_value(self,
|
|
1150
|
+
def _has_value(self, scopes: Sequence[Any], selector: str) -> bool:
|
|
868
1151
|
"""Any element at `selector` carrying a non-blank value; response fields are hidden, so no visibility filter."""
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1152
|
+
def fields(scope: Any) -> List[Any]:
|
|
1153
|
+
try:
|
|
1154
|
+
return _handles(scope.locator(selector).all())
|
|
1155
|
+
except Exception:
|
|
1156
|
+
return []
|
|
1157
|
+
|
|
1158
|
+
return any(_input_value(f).strip() for scope in scopes for f in fields(scope))
|
|
874
1159
|
|
|
875
1160
|
def _visible_with_text(self, scope: Any, selector: str) -> bool:
|
|
876
1161
|
"""A visible match carrying text; an empty banner or prompt is a placeholder, not a signal."""
|
|
@@ -891,24 +1176,25 @@ class PageSolver:
|
|
|
891
1176
|
except Exception:
|
|
892
1177
|
return 0
|
|
893
1178
|
|
|
894
|
-
return any(count(p.selector) > 0 for p in
|
|
1179
|
+
return any(count(p.selector) > 0 for p in self._sel.widget_probes)
|
|
895
1180
|
|
|
896
1181
|
def detect_captcha(self, page: Any) -> Optional[Widget]:
|
|
897
1182
|
"""Open challenges first, then unsolved checkboxes, then the inline vendors."""
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1183
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1184
|
+
for probe in self._sel.widget_probes:
|
|
1185
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
1186
|
+
if at is None or self._already_accepted(scopes, at, probe):
|
|
901
1187
|
continue
|
|
902
1188
|
element = _handle_of(at)
|
|
903
1189
|
return Widget(element, at, probe.vendor, probe.role) if element is not None else None
|
|
904
1190
|
return None
|
|
905
1191
|
|
|
906
|
-
def _already_accepted(self,
|
|
1192
|
+
def _already_accepted(self, scopes: Sequence[Any], at: Any, probe: WidgetProbe) -> bool:
|
|
907
1193
|
"""A checkbox the vendor has already accepted is not a captcha to solve."""
|
|
908
|
-
s =
|
|
1194
|
+
s = self._sel.selectors[probe.vendor]
|
|
909
1195
|
if probe.role != FrameRole.CHECKBOX:
|
|
910
1196
|
return False
|
|
911
|
-
return bool(s.response and self._has_value(
|
|
1197
|
+
return bool(s.response and self._has_value(scopes, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
|
|
912
1198
|
|
|
913
1199
|
def vendors_on_the_wire(self, page: Any) -> List[Vendor]:
|
|
914
1200
|
"""Which vendors' code the page loaded. Resource timing, because a request listener would have to have been attached before navigation."""
|
|
@@ -917,13 +1203,14 @@ class PageSolver:
|
|
|
917
1203
|
except Exception:
|
|
918
1204
|
return []
|
|
919
1205
|
blob = " ".join(str(n) for n in (names or []))
|
|
920
|
-
return [vendor for vendor, s in
|
|
1206
|
+
return [vendor for vendor, s in self._sel.vendors if any(h in blob for h in s.hosts)]
|
|
921
1207
|
|
|
922
1208
|
def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
|
|
923
1209
|
"""Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame
|
|
924
|
-
(an invisible badge) is not a widget, so what is left is markup the table no longer matches."""
|
|
925
|
-
|
|
926
|
-
|
|
1210
|
+
(an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches."""
|
|
1211
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1212
|
+
return [vendor for vendor, s in self._sel.vendors if s.hosts and len(_visible_in(scopes, [f'iframe[src*="{h}"]' for h in s.hosts]))
|
|
1213
|
+
> len(_visible_in(scopes, s.passive))]
|
|
927
1214
|
|
|
928
1215
|
def _no_widget_message(self, page: Any) -> str:
|
|
929
1216
|
base = f"no interactive captcha widget detected within {self.config.detection_timeout_ms}ms"
|
|
@@ -954,11 +1241,12 @@ class PageSolver:
|
|
|
954
1241
|
|
|
955
1242
|
def _is_blocked(self, page: Any) -> bool:
|
|
956
1243
|
"""The vendor showing its refusal screen: rate-limited or flagged, with no board to answer."""
|
|
957
|
-
|
|
958
|
-
|
|
1244
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1245
|
+
for probe in self._sel.widget_probes:
|
|
1246
|
+
blocked = self._sel.selectors[probe.vendor].blocked
|
|
959
1247
|
if probe.role != FrameRole.CHALLENGE or not blocked:
|
|
960
1248
|
continue
|
|
961
|
-
at = next(iter(
|
|
1249
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
962
1250
|
frame = _frame_of(at) if at is not None else None
|
|
963
1251
|
if frame and any(self._visible_with_text(frame, selector) for selector in blocked):
|
|
964
1252
|
return True
|
|
@@ -972,13 +1260,14 @@ class PageSolver:
|
|
|
972
1260
|
def is_captcha_solved(self, page: Any) -> bool:
|
|
973
1261
|
"""The vendor's own done signal: a response token, a painted success state, or a checked box."""
|
|
974
1262
|
try:
|
|
975
|
-
|
|
1263
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1264
|
+
if any(self._has_value(scopes, s) for s in self._sel.response) or _visible_in(scopes, self._sel.accepted):
|
|
976
1265
|
return True
|
|
977
|
-
for probe in
|
|
978
|
-
checked =
|
|
1266
|
+
for probe in self._sel.widget_probes:
|
|
1267
|
+
checked = self._sel.selectors[probe.vendor].checked
|
|
979
1268
|
if probe.role != FrameRole.CHECKBOX or not checked:
|
|
980
1269
|
continue
|
|
981
|
-
at = next(iter(
|
|
1270
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
982
1271
|
if at is not None and self._checked_in(at, checked):
|
|
983
1272
|
return True
|
|
984
1273
|
except Exception:
|
|
@@ -988,11 +1277,12 @@ class PageSolver:
|
|
|
988
1277
|
def _is_challenge_freshly_rendered(self, page: Any) -> bool:
|
|
989
1278
|
"""A next round has painted, as opposed to the answered frame animating closed."""
|
|
990
1279
|
try:
|
|
991
|
-
|
|
992
|
-
|
|
1280
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1281
|
+
for probe in self._sel.widget_probes:
|
|
1282
|
+
fresh = self._sel.selectors[probe.vendor].fresh
|
|
993
1283
|
if probe.role != FrameRole.CHALLENGE or not fresh:
|
|
994
1284
|
continue
|
|
995
|
-
at = next(iter(
|
|
1285
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
996
1286
|
element = _handle_of(at) if at is not None else None
|
|
997
1287
|
if element is None:
|
|
998
1288
|
continue
|
|
@@ -1005,18 +1295,15 @@ class PageSolver:
|
|
|
1005
1295
|
pass
|
|
1006
1296
|
return False
|
|
1007
1297
|
|
|
1008
|
-
@staticmethod
|
|
1009
|
-
def _banner_is_fatal_after_retry(kind: Optional[RecaptchaBanner]) -> bool:
|
|
1010
|
-
return kind in (RecaptchaBanner.SELECT_MORE, RecaptchaBanner.REJECTED)
|
|
1011
|
-
|
|
1012
1298
|
def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
|
|
1013
1299
|
"""Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
|
|
1014
1300
|
try:
|
|
1015
|
-
|
|
1016
|
-
|
|
1301
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1302
|
+
for probe in self._sel.widget_probes:
|
|
1303
|
+
banners = self._sel.selectors[probe.vendor].banners
|
|
1017
1304
|
if probe.role != FrameRole.CHALLENGE or not banners:
|
|
1018
1305
|
continue
|
|
1019
|
-
at = next(iter(
|
|
1306
|
+
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
1020
1307
|
frame = _frame_of(at) if at is not None else None
|
|
1021
1308
|
if not frame:
|
|
1022
1309
|
continue
|
|
@@ -1033,11 +1320,15 @@ class PageSolver:
|
|
|
1033
1320
|
Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering
|
|
1034
1321
|
the checkbox picture with a drag.
|
|
1035
1322
|
"""
|
|
1036
|
-
box = self._checkbox_box(frame, widget.vendor)
|
|
1323
|
+
box = self._checkbox_box(frame, widget.vendor) if frame is not None else (
|
|
1324
|
+
widget.element if self._sel.selectors[widget.vendor].box else None)
|
|
1037
1325
|
if box is not None:
|
|
1038
1326
|
self._move_to_element(page, box)
|
|
1039
1327
|
else:
|
|
1040
|
-
self.
|
|
1328
|
+
at = self._find_checkbox_on_screen(page, widget.element)
|
|
1329
|
+
if at is None:
|
|
1330
|
+
return
|
|
1331
|
+
self._smooth_move(page, *at)
|
|
1041
1332
|
self._human.pause(PauseKind.BETWEEN)
|
|
1042
1333
|
with self._gesture():
|
|
1043
1334
|
self._human.click(page, self._last_mouse)
|
|
@@ -1048,7 +1339,7 @@ class PageSolver:
|
|
|
1048
1339
|
|
|
1049
1340
|
Turnstile's box is in a closed shadow root, and a frame can refuse access; both are found on screen instead.
|
|
1050
1341
|
"""
|
|
1051
|
-
box =
|
|
1342
|
+
box = self._sel.selectors[vendor].box
|
|
1052
1343
|
if frame is None or not box:
|
|
1053
1344
|
return None
|
|
1054
1345
|
try:
|
|
@@ -1057,8 +1348,25 @@ class PageSolver:
|
|
|
1057
1348
|
_log(f"[checkbox] the box is out of the DOM's reach ({str(exc).splitlines()[0]}); finding it on screen")
|
|
1058
1349
|
return None
|
|
1059
1350
|
|
|
1060
|
-
def _find_checkbox_on_screen(self, element: Any) -> Tuple[float, float]:
|
|
1061
|
-
"""
|
|
1351
|
+
def _find_checkbox_on_screen(self, page: Any, element: Any) -> Optional[Tuple[float, float]]:
|
|
1352
|
+
"""A point inside the tick box, in page coordinates, or None when the widget passed without drawing one.
|
|
1353
|
+
|
|
1354
|
+
Turnstile shows "Verifying…" before its box, so an empty capture is looked at again until
|
|
1355
|
+
`detection_timeout_ms`.
|
|
1356
|
+
"""
|
|
1357
|
+
deadline = _now() + self.config.detection_timeout_ms
|
|
1358
|
+
while True:
|
|
1359
|
+
at = self._checkbox_on_screen(element)
|
|
1360
|
+
if at is not None:
|
|
1361
|
+
return at
|
|
1362
|
+
if self.is_captcha_solved(page):
|
|
1363
|
+
return None
|
|
1364
|
+
if _now() >= deadline:
|
|
1365
|
+
raise UnsupportedCaptchaError("the checkbox widget shows no tick box to click")
|
|
1366
|
+
_delay(_DETECTION_POLL_MS)
|
|
1367
|
+
|
|
1368
|
+
def _checkbox_on_screen(self, element: Any) -> Optional[Tuple[float, float]]:
|
|
1369
|
+
"""One capture and the element detector, no model: a point inside the tick box, or None when none is drawn."""
|
|
1062
1370
|
from .tool_calls.find_checkbox import find_checkbox
|
|
1063
1371
|
|
|
1064
1372
|
box = element.bounding_box()
|
|
@@ -1073,7 +1381,7 @@ class PageSolver:
|
|
|
1073
1381
|
finally:
|
|
1074
1382
|
_unlink(shot)
|
|
1075
1383
|
if found is None:
|
|
1076
|
-
|
|
1384
|
+
return None
|
|
1077
1385
|
x, y, w, h = (v / scale for v in found)
|
|
1078
1386
|
return box["x"] + x + w * (0.3 + random.random() * 0.4), box["y"] + y + h * (0.3 + random.random() * 0.4)
|
|
1079
1387
|
|
|
@@ -1091,7 +1399,7 @@ class PageSolver:
|
|
|
1091
1399
|
_delay(self.config.post_solve_outcome_poll_ms)
|
|
1092
1400
|
|
|
1093
1401
|
def _get_verify_button(self, scope: Any) -> Optional[Any]:
|
|
1094
|
-
return self._find_control(scope,
|
|
1402
|
+
return self._find_control(scope, self._sel.submit)
|
|
1095
1403
|
|
|
1096
1404
|
# ── frames and polling ───────────────────────────────────────────────
|
|
1097
1405
|
|
|
@@ -1211,17 +1519,32 @@ class PageSolver:
|
|
|
1211
1519
|
"waiting for the challenge to settle") or SettleVerdict.TIMEOUT
|
|
1212
1520
|
|
|
1213
1521
|
def _wait_for_grid_cells_loaded(self, element: Any) -> bool:
|
|
1522
|
+
"""Hold the next ask until every tile has painted: a model asked about a half-loaded board answers it.
|
|
1523
|
+
|
|
1524
|
+
Past `grid_load_timeout_ms` a blank tile that has stopped changing is taken as painted (a pale photo,
|
|
1525
|
+
or a tile whose image never came), and a board with no grid to read is let through. A tile still
|
|
1526
|
+
painting is waited on until GRID_LOAD_CAP_FACTOR times that, or the solve's deadline.
|
|
1527
|
+
"""
|
|
1214
1528
|
cfg = self.config
|
|
1215
1529
|
|
|
1216
|
-
def judge(frames,
|
|
1530
|
+
def judge(frames, elapsed):
|
|
1217
1531
|
if len(frames) < 2:
|
|
1218
1532
|
return None
|
|
1219
1533
|
boxes = self._find_grid(frames[1])
|
|
1220
|
-
|
|
1221
|
-
|
|
1534
|
+
if not boxes:
|
|
1535
|
+
return False if elapsed >= cfg.grid_load_timeout_ms else None
|
|
1536
|
+
states = self._grid_cell_states(frames[0], frames[1], boxes)
|
|
1537
|
+
if not states or states["changing"] or not states["loaded"]:
|
|
1538
|
+
return None
|
|
1539
|
+
if not states["empty"] or elapsed >= cfg.grid_load_timeout_ms:
|
|
1540
|
+
return True
|
|
1541
|
+
return None
|
|
1222
1542
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1543
|
+
verdict = self._poll(element, cfg.grid_load_timeout_ms * GRID_LOAD_CAP_FACTOR, cfg.grid_load_poll_interval_ms,
|
|
1544
|
+
judge, "waiting for grid cells to load")
|
|
1545
|
+
if verdict is not True:
|
|
1546
|
+
_log("[recaptcha-grid] the board never finished loading; asking about it as it stands")
|
|
1547
|
+
return verdict is True
|
|
1225
1548
|
|
|
1226
1549
|
def _wait_for_board_painted(self, element: Any) -> int:
|
|
1227
1550
|
"""A still board is not a loaded board: hold until the panel's centre carries structure."""
|
|
@@ -1250,7 +1573,7 @@ class PageSolver:
|
|
|
1250
1573
|
"""Best-effort: hold until the board's pictures have painted."""
|
|
1251
1574
|
try:
|
|
1252
1575
|
frame.wait_for_function(_BOARD_IMAGES_READY_JS, arg={"prompt": selectors.fresh or "", "images": ",".join(selectors.images)},
|
|
1253
|
-
timeout=self.config.
|
|
1576
|
+
timeout=self.config.board_images_timeout_ms)
|
|
1254
1577
|
except Exception:
|
|
1255
1578
|
pass
|
|
1256
1579
|
|
|
@@ -1368,7 +1691,7 @@ class PageSolver:
|
|
|
1368
1691
|
Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
|
|
1369
1692
|
number_with_highest_value_video and failed every seed either way.
|
|
1370
1693
|
"""
|
|
1371
|
-
import
|
|
1694
|
+
from .keyframes import read_frame
|
|
1372
1695
|
|
|
1373
1696
|
cfg = self.config
|
|
1374
1697
|
floor_ms = float(cfg.video_burst_duration_ms)
|
|
@@ -1394,7 +1717,7 @@ class PageSolver:
|
|
|
1394
1717
|
if _now() - self._last_input_ms >= INPUT_SETTLE_MS:
|
|
1395
1718
|
try:
|
|
1396
1719
|
self._screenshot(element, shot, animations="allow")
|
|
1397
|
-
img =
|
|
1720
|
+
img = read_frame(shot)
|
|
1398
1721
|
except Exception as exc:
|
|
1399
1722
|
_debug(f"burst frame failed: {exc}")
|
|
1400
1723
|
if img is not None:
|
|
@@ -1446,20 +1769,17 @@ class PageSolver:
|
|
|
1446
1769
|
f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
|
|
1447
1770
|
return frames, order, cycle_closed or animating, elapsed_ms, cycle_closed
|
|
1448
1771
|
|
|
1449
|
-
def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[
|
|
1450
|
-
"""Cut a burst with the training-side slicer;
|
|
1451
|
-
from .keyframes import extract_keyframes, write_keyframes
|
|
1452
|
-
|
|
1453
|
-
kfset = extract_keyframes(frames, fps=measured_fps(len(frames), burst_ms, self.config.video_burst_fps))
|
|
1454
|
-
self._keyframe_mode = kfset.mode
|
|
1455
|
-
self._keyframe_steady_screens = kfset.steady_screens
|
|
1772
|
+
def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[AnimatedRecording, str]:
|
|
1773
|
+
"""Cut a burst with the training-side slicer; its keyframes steer the wait gate whatever the model sees."""
|
|
1456
1774
|
temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1775
|
+
recording = cut_recording(frames, measured_fps(len(frames), burst_ms, self.config.video_burst_fps), temp_dir)
|
|
1776
|
+
self._keyframe_mode = recording.keyframes.mode
|
|
1777
|
+
self._keyframe_steady_screens = recording.keyframes.steady_screens
|
|
1778
|
+
_log(f"[animated] sliced to {len(recording.keyframe_paths)} keyframe(s) (mode={recording.keyframes.mode})")
|
|
1779
|
+
return recording, temp_dir
|
|
1460
1780
|
|
|
1461
|
-
def _record_keyframes(self, element: Any) -> Tuple[
|
|
1462
|
-
"""Record the widget and return `(
|
|
1781
|
+
def _record_keyframes(self, element: Any) -> Tuple[AnimatedRecording, str, bool]:
|
|
1782
|
+
"""Record the widget and return `(recording, temp_dir, moved)`; the caller removes the dir.
|
|
1463
1783
|
|
|
1464
1784
|
Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
|
|
1465
1785
|
|
|
@@ -1526,8 +1846,8 @@ class PageSolver:
|
|
|
1526
1846
|
if len(self._film_frames) > len(frames):
|
|
1527
1847
|
_log(f"[animated] slicing {len(self._film_frames)} frames filmed over "
|
|
1528
1848
|
f"{self._film_ms / 1000:.1f}s — every round of this board, not just this one")
|
|
1529
|
-
|
|
1530
|
-
return
|
|
1849
|
+
recording, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
|
|
1850
|
+
return recording, temp_dir, moved
|
|
1531
1851
|
|
|
1532
1852
|
def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
1533
1853
|
text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
|
|
@@ -1556,10 +1876,10 @@ class PageSolver:
|
|
|
1556
1876
|
self._known_animated = True
|
|
1557
1877
|
fut.result()
|
|
1558
1878
|
self._grant_video_budget()
|
|
1559
|
-
|
|
1879
|
+
recording, keyframe_dir = self._slice(frames, burst_ms)
|
|
1560
1880
|
with self._phase(Phase.INFERENCE):
|
|
1561
|
-
actions, usage = self._get_keyframe_solution(
|
|
1562
|
-
self._animated_plan = (
|
|
1881
|
+
actions, usage = self._get_keyframe_solution(recording)
|
|
1882
|
+
self._animated_plan = (recording, keyframe_dir, actions, usage)
|
|
1563
1883
|
return actions, usage, keyframe_dir
|
|
1564
1884
|
finally:
|
|
1565
1885
|
pool.shutdown(wait=True)
|
|
@@ -1597,9 +1917,7 @@ class PageSolver:
|
|
|
1597
1917
|
|
|
1598
1918
|
def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
|
|
1599
1919
|
"""Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
|
|
1600
|
-
import
|
|
1601
|
-
|
|
1602
|
-
from .keyframes import MATCH_REGION_TOLERANCE, region_diff_ratio
|
|
1920
|
+
from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_diff_ratio
|
|
1603
1921
|
|
|
1604
1922
|
folder, base = os.path.dirname(keyframe_path), os.path.basename(keyframe_path)
|
|
1605
1923
|
prefix = re.sub(r"_\d+\.png$", "_", base, flags=re.IGNORECASE)
|
|
@@ -1611,18 +1929,16 @@ class PageSolver:
|
|
|
1611
1929
|
except OSError:
|
|
1612
1930
|
return False
|
|
1613
1931
|
for other in siblings:
|
|
1614
|
-
img =
|
|
1932
|
+
img = read_frame(other)
|
|
1615
1933
|
if img is not None and img.shape == ref.shape and region_diff_ratio(ref, img, box) <= MATCH_REGION_TOLERANCE:
|
|
1616
1934
|
return True
|
|
1617
1935
|
return False
|
|
1618
1936
|
|
|
1619
1937
|
def _wait_for_keyframe(self, element: Any, keyframe_path: str, point_norm: Tuple[float, float]) -> bool:
|
|
1620
1938
|
"""Hold until the widget looks like the keyframe around the action point, or the wait is spent."""
|
|
1621
|
-
import
|
|
1939
|
+
from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_box, region_diff_ratio
|
|
1622
1940
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
ref = cv2.imread(keyframe_path)
|
|
1941
|
+
ref = read_frame(keyframe_path)
|
|
1626
1942
|
if ref is None:
|
|
1627
1943
|
return False
|
|
1628
1944
|
box = region_box(ref.shape[1::-1], point_norm)
|
|
@@ -1640,7 +1956,7 @@ class PageSolver:
|
|
|
1640
1956
|
self._check_deadline("waiting for the challenge keyframe")
|
|
1641
1957
|
try:
|
|
1642
1958
|
self._screenshot(element, probe, animations="allow")
|
|
1643
|
-
live =
|
|
1959
|
+
live = read_frame(probe)
|
|
1644
1960
|
except Exception:
|
|
1645
1961
|
live = None
|
|
1646
1962
|
if live is not None:
|
|
@@ -1669,13 +1985,20 @@ class PageSolver:
|
|
|
1669
1985
|
session.element_box["y"] + (y1 + y2) / 2 * session.scale_y)
|
|
1670
1986
|
|
|
1671
1987
|
def _hover_cell(self, page: Any, session: _GridSession, cell: int) -> None:
|
|
1988
|
+
"""Rest the hand on a tile: reach it once, then drift a few pixels inside it."""
|
|
1672
1989
|
if not self._human.hovers:
|
|
1673
1990
|
return
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1991
|
+
x1, y1, x2, y2 = session.grid_boxes[cell - 1]
|
|
1992
|
+
left, top = session.element_box["x"] + x1 * session.scale_x, session.element_box["y"] + y1 * session.scale_y
|
|
1993
|
+
right, bottom = session.element_box["x"] + x2 * session.scale_x, session.element_box["y"] + y2 * session.scale_y
|
|
1994
|
+
x, y = self._last_mouse
|
|
1995
|
+
if left <= x <= right and top <= y <= bottom:
|
|
1996
|
+
x, y = x + random.gauss(0.0, IDLE_DRIFT_PX), y + random.gauss(0.0, IDLE_DRIFT_PX)
|
|
1997
|
+
else:
|
|
1998
|
+
cx, cy = self._cell_center_page(cell, session)
|
|
1999
|
+
x, y = cx + (random.random() - 0.5) * (right - left) * 0.4, cy + (random.random() - 0.5) * (bottom - top) * 0.4
|
|
2000
|
+
inset = min(4.0, (right - left) / 4, (bottom - top) / 4)
|
|
2001
|
+
self._smooth_move(page, min(max(x, left + inset), right - inset), min(max(y, top + inset), bottom - inset))
|
|
1679
2002
|
|
|
1680
2003
|
@staticmethod
|
|
1681
2004
|
def _bbox_to_cell(bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]],
|
|
@@ -1696,11 +2019,14 @@ class PageSolver:
|
|
|
1696
2019
|
def _hover_loop(self, page: Any, session: _GridSession, cells: Sequence[int]) -> Optional[Callable[[], None]]:
|
|
1697
2020
|
if not cells:
|
|
1698
2021
|
return None
|
|
1699
|
-
|
|
2022
|
+
due = [0.0]
|
|
1700
2023
|
|
|
1701
2024
|
def hover():
|
|
2025
|
+
if _now() < due[0]:
|
|
2026
|
+
return
|
|
2027
|
+
due[0] = _now() + random.uniform(*IDLE_DRIFT_EVERY_MS)
|
|
1702
2028
|
try:
|
|
1703
|
-
self._hover_cell(page, session,
|
|
2029
|
+
self._hover_cell(page, session, cells[0])
|
|
1704
2030
|
except Exception:
|
|
1705
2031
|
pass
|
|
1706
2032
|
|
|
@@ -1789,6 +2115,10 @@ class PageSolver:
|
|
|
1789
2115
|
loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
|
|
1790
2116
|
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
1791
2117
|
continue
|
|
2118
|
+
if kind == ActionKind.REFRESH:
|
|
2119
|
+
# A new board replaces this one: nothing clicked on it is submitted; the next loop reads the new one.
|
|
2120
|
+
refreshed = self._execute_refresh(page, element.content_frame(), action, element_box)
|
|
2121
|
+
return refreshed or performed, all_usage
|
|
1792
2122
|
if kind != ActionKind.CLICK:
|
|
1793
2123
|
_log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
|
|
1794
2124
|
continue
|
|
@@ -1800,6 +2130,10 @@ class PageSolver:
|
|
|
1800
2130
|
clicked_this_round: List[int] = []
|
|
1801
2131
|
for bbox in bboxes:
|
|
1802
2132
|
cell = self._bbox_to_cell(bbox, session.grid_boxes, session.screenshot_w, session.screenshot_h)
|
|
2133
|
+
if cell is not None and cell in clicked_this_round:
|
|
2134
|
+
# Two points in one tile: a second press deselects what the first selected.
|
|
2135
|
+
_log(f"[recaptcha-grid] round {round_index}: cell {cell} is already clicked; not pressing it again.")
|
|
2136
|
+
continue
|
|
1803
2137
|
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
1804
2138
|
if cell is not None:
|
|
1805
2139
|
clicked_order.append(cell)
|
|
@@ -1843,13 +2177,13 @@ class PageSolver:
|
|
|
1843
2177
|
self._click_checkbox(page, widget, frame)
|
|
1844
2178
|
return True, []
|
|
1845
2179
|
|
|
1846
|
-
if frame and role == FrameRole.CHALLENGE and
|
|
2180
|
+
if frame and role == FrameRole.CHALLENGE and self._sel.selectors[puzzle_source].images:
|
|
1847
2181
|
if self._last_submit_frame_hash:
|
|
1848
2182
|
with self._phase(Phase.AWAIT_NEXT_ROUND):
|
|
1849
2183
|
self._wait_for_change_since(element, self._last_submit_frame_hash)
|
|
1850
2184
|
self._last_submit_frame_hash = None
|
|
1851
|
-
with self._phase(Phase.
|
|
1852
|
-
self._wait_for_board_images(frame,
|
|
2185
|
+
with self._phase(Phase.BOARD_IMAGES):
|
|
2186
|
+
self._wait_for_board_images(frame, self._sel.selectors[puzzle_source])
|
|
1853
2187
|
|
|
1854
2188
|
# THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of
|
|
1855
2189
|
# the DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet
|
|
@@ -1863,9 +2197,11 @@ class PageSolver:
|
|
|
1863
2197
|
self._wait_for_board_painted(element)
|
|
1864
2198
|
|
|
1865
2199
|
# Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
1866
|
-
|
|
2200
|
+
answer_box = None if puzzle_source in VENDORS_WITH_BESPOKE_HANDLING else self._answer_box(scope, widget.at)
|
|
2201
|
+
text_mode = answer_box is not None
|
|
1867
2202
|
if text_mode:
|
|
1868
2203
|
_log("widget has a text box; solving as a distorted-text captcha")
|
|
2204
|
+
element = _WithAnswerBox(element, answer_box)
|
|
1869
2205
|
|
|
1870
2206
|
# A reCAPTCHA board is read by its grid below, not filmed.
|
|
1871
2207
|
filmable = puzzle_source != Vendor.RECAPTCHA and not text_mode
|
|
@@ -1892,20 +2228,21 @@ class PageSolver:
|
|
|
1892
2228
|
self._wait_for_board_painted(element)
|
|
1893
2229
|
|
|
1894
2230
|
shot = _tmp_png("captcha")
|
|
2231
|
+
self._round_board = None
|
|
1895
2232
|
performed = slid = answered = have_shot = False
|
|
1896
2233
|
all_usage: List[Dict[str, Any]] = []
|
|
1897
2234
|
keyframe_dir: Optional[str] = None
|
|
1898
2235
|
try:
|
|
1899
2236
|
if is_animated:
|
|
1900
2237
|
if self._animated_plan is not None:
|
|
1901
|
-
|
|
2238
|
+
recording, keyframe_dir, actions, all_usage = self._animated_plan
|
|
1902
2239
|
reused = actions is not None
|
|
1903
2240
|
if not reused:
|
|
1904
2241
|
# Drop the ANSWER, keep the FRAMES — and the frames grow. Filming another chunk and
|
|
1905
2242
|
# re-slicing the accumulation puts a better question up; re-asking the same frames
|
|
1906
2243
|
# puts the same question up and gets the same answer back.
|
|
1907
2244
|
with self._phase(Phase.BURST):
|
|
1908
|
-
|
|
2245
|
+
recording, new_dir, moved = self._record_keyframes(element)
|
|
1909
2246
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1910
2247
|
keyframe_dir = new_dir
|
|
1911
2248
|
self._known_animated = self._known_animated or moved
|
|
@@ -1914,27 +2251,28 @@ class PageSolver:
|
|
|
1914
2251
|
else:
|
|
1915
2252
|
reused = False
|
|
1916
2253
|
with self._phase(Phase.BURST):
|
|
1917
|
-
|
|
2254
|
+
recording, keyframe_dir, moved = self._record_keyframes(element)
|
|
1918
2255
|
self._known_animated = self._known_animated or moved
|
|
1919
2256
|
# The clip answers the question the probe asked: a board that never moved is a still, and the
|
|
1920
2257
|
# video expert can only answer a still with a frame number the widget will not take.
|
|
1921
|
-
if len(
|
|
2258
|
+
if len(recording.keyframe_paths) < 2 or not self._known_animated:
|
|
1922
2259
|
_log("[animated] the recording shows a still board; solving it as a still")
|
|
1923
2260
|
is_animated = False
|
|
1924
|
-
shutil.copyfile(
|
|
2261
|
+
shutil.copyfile(recording.keyframe_paths[-1], shot)
|
|
1925
2262
|
have_shot = True
|
|
1926
2263
|
self._stop_animated_film()
|
|
1927
2264
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1928
2265
|
keyframe_dir = None
|
|
1929
2266
|
elif not reused:
|
|
1930
2267
|
with self._phase(Phase.INFERENCE):
|
|
1931
|
-
actions, all_usage = self._get_keyframe_solution(
|
|
1932
|
-
self._animated_plan = (
|
|
2268
|
+
actions, all_usage = self._get_keyframe_solution(recording)
|
|
2269
|
+
self._animated_plan = (recording, keyframe_dir, actions, all_usage)
|
|
1933
2270
|
|
|
1934
2271
|
if not is_animated:
|
|
1935
2272
|
if not have_shot:
|
|
1936
2273
|
with self._phase(Phase.SCREENSHOT):
|
|
1937
2274
|
self._screenshot(element, shot, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
2275
|
+
self._guard_fallbacks_to_board(self._board_hash(element, shot, filmed=have_shot))
|
|
1938
2276
|
if self._should_speculate(puzzle_source, text_mode):
|
|
1939
2277
|
actions, all_usage, keyframe_dir = self._speculate(element, shot, puzzle_source, retry_mode, text_mode)
|
|
1940
2278
|
is_animated = keyframe_dir is not None
|
|
@@ -1957,10 +2295,12 @@ class PageSolver:
|
|
|
1957
2295
|
|
|
1958
2296
|
_log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
|
|
1959
2297
|
# A repeated answer is not re-performed: the widget already refused it, and every extra press is
|
|
1960
|
-
# behaviour a vendor scores. Re-asking
|
|
2298
|
+
# behaviour a vendor scores. Re-asking, or asking a recording, is the round's only move.
|
|
1961
2299
|
# `_note_answer` drops the refused animated answer on its way through, so the next round re-asks.
|
|
1962
2300
|
if self._note_answer(actions, retry_mode):
|
|
1963
2301
|
return False, all_usage
|
|
2302
|
+
if not is_animated:
|
|
2303
|
+
self._keep_pointer_screen(shot, puzzle_source, actions)
|
|
1964
2304
|
_log(f"executing {len(actions)} action(s)")
|
|
1965
2305
|
|
|
1966
2306
|
for raw_action in actions:
|
|
@@ -1980,7 +2320,7 @@ class PageSolver:
|
|
|
1980
2320
|
if bbox:
|
|
1981
2321
|
self._human.pause(PauseKind.BETWEEN)
|
|
1982
2322
|
performed = answered = True
|
|
1983
|
-
elif kind == ActionKind.DRAG and not action.get("source_bounding_box"):
|
|
2323
|
+
elif kind == ActionKind.SLIDE or (kind == ActionKind.DRAG and not action.get("source_bounding_box")):
|
|
1984
2324
|
if self._execute_slide(page, element, scope, action, element_box):
|
|
1985
2325
|
performed = slid = True
|
|
1986
2326
|
elif kind == ActionKind.DRAG:
|
|
@@ -1991,6 +2331,13 @@ class PageSolver:
|
|
|
1991
2331
|
elif kind == ActionKind.TYPE:
|
|
1992
2332
|
if self._execute_type(page, scope, action, widget.at):
|
|
1993
2333
|
performed = answered = True
|
|
2334
|
+
elif kind == ActionKind.HOLD:
|
|
2335
|
+
# The widget submits itself when the hold completes, like a slide on release.
|
|
2336
|
+
self._execute_hold(page, action, element_box, element, self._sel.selectors[widget.vendor].hold_completes)
|
|
2337
|
+
performed = slid = True
|
|
2338
|
+
elif kind == ActionKind.REFRESH:
|
|
2339
|
+
# Nothing after a refresh, Verify included, belongs to the board it replaces.
|
|
2340
|
+
return self._execute_refresh(page, scope, action, element_box), all_usage
|
|
1994
2341
|
elif kind == ActionKind.WAIT:
|
|
1995
2342
|
duration = int(action.get("duration_ms") or 0)
|
|
1996
2343
|
if duration > 0:
|
|
@@ -1998,13 +2345,19 @@ class PageSolver:
|
|
|
1998
2345
|
performed = True
|
|
1999
2346
|
|
|
2000
2347
|
# A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
|
|
2001
|
-
# submit the guarded form while the verdict is in flight
|
|
2002
|
-
|
|
2003
|
-
|
|
2348
|
+
# submit the guarded form while the verdict is in flight, unless this board has already taken a Verify
|
|
2349
|
+
# from us: then that button is the widget's, and a slide on it submits nothing by itself. An empty or
|
|
2350
|
+
# `done` plan still presses Verify/Skip. FunCaptcha's answer is the click itself: an arrow pages and
|
|
2351
|
+
# Submit submits. The generic Verify fallback would submit the page an arrow just turned to.
|
|
2352
|
+
submits = self._submitted_on_board if slid else answered or not performed
|
|
2353
|
+
verify_button = self._get_verify_button(scope) if submits and puzzle_source != Vendor.FUNCAPTCHA else None
|
|
2354
|
+
if verify_button:
|
|
2004
2355
|
_log(f"clicking Verify to submit ({puzzle_source}).")
|
|
2005
2356
|
self._move_and_click(page, verify_button)
|
|
2006
|
-
performed = True
|
|
2357
|
+
performed = self._submitted_on_board = True
|
|
2007
2358
|
self._last_submit_frame_hash = self._element_frame_hash(element)
|
|
2359
|
+
elif answered and puzzle_source != Vendor.FUNCAPTCHA:
|
|
2360
|
+
self._keep_plans_of_this_many_clicks(actions)
|
|
2008
2361
|
finally:
|
|
2009
2362
|
_unlink(shot)
|
|
2010
2363
|
held = self._animated_plan[1] if self._animated_plan else None
|
|
@@ -2221,8 +2574,15 @@ class PageSolver:
|
|
|
2221
2574
|
raise blocked("it is showing its try-again-later screen")
|
|
2222
2575
|
if said == Verdict.REJECTED:
|
|
2223
2576
|
_log(f"[verdict] the vendor rejected the answer after {verdict_ms:.0f}ms")
|
|
2224
|
-
|
|
2225
|
-
|
|
2577
|
+
# Only a refusal earns the server's next plan. A round that ended with no verdict, or on a
|
|
2578
|
+
# board that merely repainted, has not refused anything, and every extra press is behaviour
|
|
2579
|
+
# a vendor scores.
|
|
2580
|
+
self._refused_rounds += 1
|
|
2581
|
+
self._apply_refusals()
|
|
2582
|
+
|
|
2583
|
+
# Only "select all matching images" means tiles were missed; "please try again" is a wrong answer and
|
|
2584
|
+
# deals a new board, which is asked fresh.
|
|
2585
|
+
if self._banner_kind(page) == RecaptchaBanner.SELECT_MORE:
|
|
2226
2586
|
_log("reCAPTCHA under-selection error; the next round asks for the missed tiles.")
|
|
2227
2587
|
pending_retry_mode = RetryMode.MISSED_TILES
|
|
2228
2588
|
|
|
@@ -2231,9 +2591,9 @@ class PageSolver:
|
|
|
2231
2591
|
if self._no_progress_rounds:
|
|
2232
2592
|
stuck = CaptchaSolveError(f"no progress: the model returned the same answer "
|
|
2233
2593
|
f"{self._no_progress_rounds + 1} times running")
|
|
2234
|
-
# A
|
|
2235
|
-
|
|
2236
|
-
|
|
2594
|
+
# A repeat pressed nothing, so a further round is only worth its 3-7s if it can do something else.
|
|
2595
|
+
if (self._no_progress_rounds >= cfg.max_no_progress_rounds
|
|
2596
|
+
or not self._another_move_left(pending_retry_mode)):
|
|
2237
2597
|
raise stuck
|
|
2238
2598
|
again(stuck)
|
|
2239
2599
|
elif not did_interact:
|
|
@@ -2242,6 +2602,8 @@ class PageSolver:
|
|
|
2242
2602
|
if cfg.video_solve_enabled:
|
|
2243
2603
|
self._arm_animated_probe()
|
|
2244
2604
|
again(CaptchaSolveError("the solver performed no interactions this round"))
|
|
2605
|
+
else:
|
|
2606
|
+
last_failure = None
|
|
2245
2607
|
|
|
2246
2608
|
if isinstance(last_failure, UnsupportedChallengeError):
|
|
2247
2609
|
raise UnsupportedChallengeError(f"{last_failure} (after {cfg.max_solve_loops} solve loops)")
|