captchakraken 3.2.1 → 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 +93 -11
- package/dist/selectors.js +226 -116
- package/dist/solver.d.ts +92 -12
- package/dist/solver.js +562 -203
- 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 +595 -251
- 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 +221 -135
- 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,10 +426,10 @@ def _frame_of(at: Any) -> Optional[Any]:
|
|
|
366
426
|
return None
|
|
367
427
|
|
|
368
428
|
|
|
369
|
-
def _host_scopes(page: Any) -> List[Any]:
|
|
429
|
+
def _host_scopes(page: Any, enclaves: Sequence[str]) -> List[Any]:
|
|
370
430
|
"""Where the host-page selectors are read: the page, then the inside of every VISIBLE vendor enclave. A hidden enclave
|
|
371
431
|
is skipped because Playwright judges an element visible within its own document, whatever its frame's visibility."""
|
|
372
|
-
return [page, *(f for f in map(_frame_of, _visible(page,
|
|
432
|
+
return [page, *(f for f in map(_frame_of, _visible(page, enclaves)) if f is not None)]
|
|
373
433
|
|
|
374
434
|
|
|
375
435
|
def _visible_in(scopes: Sequence[Any], selectors: Sequence[str]) -> List[Any]:
|
|
@@ -390,8 +450,9 @@ def _has_text(handle: Any) -> bool:
|
|
|
390
450
|
return False
|
|
391
451
|
|
|
392
452
|
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
395
456
|
|
|
396
457
|
# No prompt and no pictures both read as ready: waiting for `.prompt-text` to exist once paid the whole timeout
|
|
397
458
|
# before every board of a challenge that draws none (tower_stack: 18s a round, 2/2 -> 0/2).
|
|
@@ -419,6 +480,8 @@ _RESOURCES_JS = """() => {
|
|
|
419
480
|
class PageSolver:
|
|
420
481
|
"""Drives one Playwright-compatible `page` through a captcha. Reusable across solves."""
|
|
421
482
|
|
|
483
|
+
_sel: SelectorTable = DEFAULT_TABLE
|
|
484
|
+
|
|
422
485
|
def __init__(self, config: Optional[PageSolverConfig] = None,
|
|
423
486
|
solver: Optional[CaptchaSolver] = None, **solver_kwargs: Any) -> None:
|
|
424
487
|
self.config = config or PageSolverConfig()
|
|
@@ -426,10 +489,14 @@ class PageSolver:
|
|
|
426
489
|
solver_kwargs.setdefault("expert", self.config.expert)
|
|
427
490
|
self._solver = solver or CaptchaSolver(**solver_kwargs)
|
|
428
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)
|
|
429
496
|
self._last_submit_frame_hash: Optional[str] = None
|
|
430
497
|
self._deadline_ms: Optional[float] = None
|
|
431
498
|
self._budget: Optional[PhaseBudget] = None
|
|
432
|
-
self._animated_plan: Optional[Tuple[
|
|
499
|
+
self._animated_plan: Optional[Tuple[AnimatedRecording, str, Any, Any]] = None
|
|
433
500
|
self._film_frames: List[Any] = []
|
|
434
501
|
self._film_ms: float = 0.0
|
|
435
502
|
# The digests of every screen the film holds: what a later burst is checked against to answer
|
|
@@ -474,6 +541,7 @@ class PageSolver:
|
|
|
474
541
|
|
|
475
542
|
def _reset_animated_state(self) -> None:
|
|
476
543
|
self._acted_on_board = False
|
|
544
|
+
self._submitted_on_board = False
|
|
477
545
|
self._known_animated = False
|
|
478
546
|
self._animated_probe_armed = False
|
|
479
547
|
self._animated_probe_done = False
|
|
@@ -482,37 +550,75 @@ class PageSolver:
|
|
|
482
550
|
self._keyframe_mode: Optional[KeyframeMode] = None
|
|
483
551
|
self._keyframe_steady_screens = 0
|
|
484
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
|
|
485
556
|
self._no_progress_rounds = 0
|
|
486
|
-
self.
|
|
487
|
-
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()
|
|
488
562
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
"""
|
|
492
569
|
try:
|
|
493
570
|
parts = [(a.get("action"), _round_pts(a.get("target_bounding_boxes")),
|
|
494
571
|
_round_pts(a.get("target_bounding_box")), _round_pts(a.get("target_coordinates")),
|
|
495
572
|
_round_pts(a.get("source_bounding_box")), a.get("text"))
|
|
496
573
|
for a in map(_as_dict, actions)]
|
|
497
|
-
return repr((retry_mode, parts))
|
|
574
|
+
return repr((self._fallback_board, retry_mode, parts))
|
|
498
575
|
except Exception:
|
|
499
576
|
return None
|
|
500
577
|
|
|
501
578
|
def _note_answer(self, actions: Sequence[Any], retry_mode: Optional[RetryMode]) -> bool:
|
|
502
|
-
"""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."""
|
|
503
580
|
sig = self._answer_signature(actions, retry_mode)
|
|
504
|
-
|
|
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):
|
|
505
593
|
self._no_progress_rounds += 1
|
|
506
594
|
_log(f"[no-progress] the model returned the same answer again ({self._no_progress_rounds} in a row)")
|
|
507
|
-
|
|
508
|
-
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()
|
|
509
599
|
self._invalidate_animated_answer()
|
|
510
600
|
self._arm_animated_probe()
|
|
511
601
|
return True
|
|
512
602
|
self._no_progress_rounds = 0
|
|
513
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
|
|
514
607
|
return False
|
|
515
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
|
+
|
|
516
622
|
def _bound_ask_to_the_budget(self) -> None:
|
|
517
623
|
"""Give the planner what is LEFT of the solve, so one ask cannot outlive the whole thing.
|
|
518
624
|
|
|
@@ -527,13 +633,101 @@ class PageSolver:
|
|
|
527
633
|
target.request_timeout_s = max(planner.MIN_REQUEST_TIMEOUT_S,
|
|
528
634
|
min(planner.DEFAULT_REQUEST_TIMEOUT_S, left_s))
|
|
529
635
|
|
|
530
|
-
def
|
|
636
|
+
def _apply_refusals(self) -> None:
|
|
531
637
|
target = getattr(self._solver, "planner", None)
|
|
532
638
|
if target is None:
|
|
533
639
|
return
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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]
|
|
537
731
|
|
|
538
732
|
def _fresh_board(self) -> None:
|
|
539
733
|
# ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer describes the
|
|
@@ -545,9 +739,11 @@ class PageSolver:
|
|
|
545
739
|
# Its own second look, but only once IT fails: an arm set on the previous board is evidence about that one.
|
|
546
740
|
self._animated_probe_armed = False
|
|
547
741
|
self._animated_probe_done = False
|
|
548
|
-
self.
|
|
742
|
+
self._refused_rounds = 0
|
|
549
743
|
self._acted_on_board = False
|
|
550
|
-
self.
|
|
744
|
+
self._submitted_on_board = False
|
|
745
|
+
self._drop_pointer_history()
|
|
746
|
+
self._apply_refusals()
|
|
551
747
|
|
|
552
748
|
def _check_deadline(self, where: str) -> None:
|
|
553
749
|
# Called inside loops too: a camoufox session once ran ten minutes past a 120s timeout because only
|
|
@@ -614,11 +810,20 @@ class PageSolver:
|
|
|
614
810
|
|
|
615
811
|
def _get_solution(self, image_path: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
616
812
|
text_mode: bool = False):
|
|
617
|
-
|
|
618
|
-
|
|
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)
|
|
619
823
|
|
|
620
|
-
def _get_keyframe_solution(self,
|
|
621
|
-
|
|
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))
|
|
622
827
|
|
|
623
828
|
# ── gestures ─────────────────────────────────────────────────────────
|
|
624
829
|
|
|
@@ -667,6 +872,17 @@ class PageSolver:
|
|
|
667
872
|
return float(coords[0]) * element_box["width"], float(coords[1]) * element_box["height"]
|
|
668
873
|
return None
|
|
669
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
|
+
|
|
670
886
|
def _execute_click(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float],
|
|
671
887
|
element: Any = None, await_keyframe: Optional[str] = None) -> None:
|
|
672
888
|
# Every gesture marks the board acted on so the speculative burst is never re-run over a touched board.
|
|
@@ -689,6 +905,121 @@ class PageSolver:
|
|
|
689
905
|
element_box: Dict[str, float], await_keyframe: str) -> None:
|
|
690
906
|
self._execute_click(page, action, element_box, element, await_keyframe)
|
|
691
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
|
+
|
|
692
1023
|
def _execute_drag(self, page: Any, action: Dict[str, Any], element_box: Dict[str, float]) -> None:
|
|
693
1024
|
self._acted_on_board = True
|
|
694
1025
|
|
|
@@ -703,31 +1034,16 @@ class PageSolver:
|
|
|
703
1034
|
first = next(iter(_visible(scope, selectors)), None)
|
|
704
1035
|
return _handle_of(first) if first is not None else None
|
|
705
1036
|
|
|
706
|
-
def _measure_piece_box(self, scope: Any, widget_width: float) -> Optional[Dict[str, float]]:
|
|
707
|
-
"""The slider piece's box: the first visible match small enough to be a piece.
|
|
708
|
-
|
|
709
|
-
Runs during detection, so it must not mark the board acted on: doing so once disabled the
|
|
710
|
-
speculative burst on every slide solve.
|
|
711
|
-
"""
|
|
712
|
-
for candidate in _handles(_visible(scope, PIECE_SELECTORS)):
|
|
713
|
-
try:
|
|
714
|
-
b = candidate.bounding_box()
|
|
715
|
-
except Exception:
|
|
716
|
-
continue
|
|
717
|
-
if b and SLIDE_PIECE_MIN_PX <= b["width"] <= widget_width * SLIDE_PIECE_MAX_FRACTION:
|
|
718
|
-
return b
|
|
719
|
-
return None
|
|
720
|
-
|
|
721
1037
|
def _answer_box(self, scope: Any, at: Any = None) -> Optional[Any]:
|
|
722
1038
|
"""The text box inside the widget, else a vendor-named one in its enclosing fieldset/form.
|
|
723
1039
|
|
|
724
1040
|
Only vendor-named selectors widen: BotDetect's `.BDC_CaptchaDiv` is 280x50 and holds only the image,
|
|
725
1041
|
with `#captchaCode` in a sibling div. The generic tail there would take a login form's own box.
|
|
726
1042
|
"""
|
|
727
|
-
inside = self._find_control(scope,
|
|
1043
|
+
inside = self._find_control(scope, self._sel.text_input)
|
|
728
1044
|
if inside is not None or at is None:
|
|
729
1045
|
return inside
|
|
730
|
-
around = (self._find_control(at.locator(f"xpath={axis}"),
|
|
1046
|
+
around = (self._find_control(at.locator(f"xpath={axis}"), self._sel.text_input_vendor)
|
|
731
1047
|
for axis in ("ancestor::fieldset[1]", "ancestor::form[1]"))
|
|
732
1048
|
return next((found for found in around if found is not None), None)
|
|
733
1049
|
|
|
@@ -746,20 +1062,6 @@ class PageSolver:
|
|
|
746
1062
|
_log(f"typed {len(text)} character(s) into the captcha field")
|
|
747
1063
|
return True
|
|
748
1064
|
|
|
749
|
-
def _track_piece(self, element: Any, before: str, after: str, exclude: Sequence[float],
|
|
750
|
-
travel: float = 0.0) -> Optional[Dict[str, Any]]:
|
|
751
|
-
from .tool_calls.track_piece import changed_bbox, locate_piece
|
|
752
|
-
|
|
753
|
-
try:
|
|
754
|
-
self._screenshot(element, after, timeout_ms=self.config.element_screenshot_timeout_ms)
|
|
755
|
-
bbox = changed_bbox(before, after, exclude)
|
|
756
|
-
if bbox is None:
|
|
757
|
-
return None
|
|
758
|
-
return {"bbox": bbox, "piece": locate_piece(before, after, travel, exclude)}
|
|
759
|
-
except Exception as exc:
|
|
760
|
-
_debug(f"track_piece failed: {exc}")
|
|
761
|
-
return None
|
|
762
|
-
|
|
763
1065
|
@staticmethod
|
|
764
1066
|
def _shot_scale(shot: str, css_width: float) -> float:
|
|
765
1067
|
dims = _read_png_dimensions(shot)
|
|
@@ -767,77 +1069,71 @@ class PageSolver:
|
|
|
767
1069
|
|
|
768
1070
|
def _execute_slide(self, page: Any, element: Any, scope: Any, action: Dict[str, Any],
|
|
769
1071
|
element_box: Dict[str, float]) -> bool:
|
|
770
|
-
"""
|
|
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
|
+
|
|
771
1080
|
self._acted_on_board = True
|
|
772
|
-
|
|
773
|
-
|
|
1081
|
+
w, h = element_box["width"], element_box["height"]
|
|
1082
|
+
|
|
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
|
|
774
1085
|
|
|
775
|
-
|
|
776
|
-
if
|
|
777
|
-
|
|
778
|
-
|
|
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
|
|
779
1093
|
if not box:
|
|
780
1094
|
_log("slide action, but the widget has neither a slider nor a draggable piece")
|
|
781
1095
|
return False
|
|
782
|
-
|
|
783
|
-
with
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
return False
|
|
791
|
-
start_x, hold_y = hbox["x"] + hbox["width"] / 2, hbox["y"] + hbox["height"] / 2
|
|
792
|
-
# Mask from the handle's band to the widget's bottom: the filled track moves too.
|
|
793
|
-
pad = max(4.0, hbox["height"] * 0.35)
|
|
794
|
-
band = [0.0, hbox["y"] - element_box["y"] - pad, element_box["width"], element_box["height"]]
|
|
795
|
-
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))
|
|
796
1104
|
try:
|
|
797
|
-
self.
|
|
1105
|
+
self._smooth_move(page, *press)
|
|
798
1106
|
self._human.press(page)
|
|
799
1107
|
self._human.pause(PauseKind.GRAB)
|
|
800
|
-
self._screenshot(element,
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
piece_w, ratio = self._solve_slide_geometry(widths, element_box["width"])
|
|
823
|
-
live = live_center()
|
|
824
|
-
if live is not None:
|
|
825
|
-
center = live
|
|
826
|
-
elif last_piece is not None:
|
|
827
|
-
center = last_piece["centre"] / scale
|
|
828
|
-
elif last_box is not None and piece_w is not None:
|
|
829
|
-
center = last_box[2] / scale - piece_w / 2.0
|
|
830
|
-
else:
|
|
831
|
-
continue
|
|
832
|
-
steered = True
|
|
833
|
-
error = target_x - center
|
|
834
|
-
_debug(f"slider: center={center:.1f} target={target_x:.1f} error={error:+.1f} ratio={ratio:.3f}")
|
|
835
|
-
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")
|
|
836
1130
|
break
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
841
1137
|
self._human.pause(PauseKind.SETTLE)
|
|
842
1138
|
finally:
|
|
843
1139
|
try:
|
|
@@ -845,33 +1141,10 @@ class PageSolver:
|
|
|
845
1141
|
except Exception:
|
|
846
1142
|
pass
|
|
847
1143
|
self._last_input_ms = _now()
|
|
848
|
-
for shot in
|
|
1144
|
+
for shot in (first, probed, current):
|
|
849
1145
|
_unlink(shot)
|
|
850
1146
|
return True
|
|
851
1147
|
|
|
852
|
-
@staticmethod
|
|
853
|
-
def _solve_slide_geometry(widths: Sequence[Tuple[float, float]],
|
|
854
|
-
widget_width: float) -> Tuple[Optional[float], float]:
|
|
855
|
-
"""Piece width and handle-to-piece ratio from `(offset, changed width)` readings.
|
|
856
|
-
|
|
857
|
-
The widest-apart pair over a 16px spread: changed widths are ±1px, so the ratio error is ±0.13 at the
|
|
858
|
-
floor and ±0.5 at 4px. An implausible ratio falls back to 1:1 (true of every vendor observed) rather
|
|
859
|
-
than a 0.02 that sends the handle off the track and camoufox into a move that never returns.
|
|
860
|
-
"""
|
|
861
|
-
if not widths:
|
|
862
|
-
return None, 1.0
|
|
863
|
-
piece_w, ratio = None, 1.0
|
|
864
|
-
(o1, w1), (o2, w2) = min(widths, key=lambda m: m[0]), max(widths, key=lambda m: m[0])
|
|
865
|
-
if o2 - o1 >= 16.0 and 0.2 <= (w2 - w1) / (o2 - o1) <= 3.0:
|
|
866
|
-
ratio = (w2 - w1) / (o2 - o1)
|
|
867
|
-
piece_w = w1 - ratio * o1
|
|
868
|
-
if piece_w is None:
|
|
869
|
-
o, w = widths[-1]
|
|
870
|
-
piece_w = w - ratio * o
|
|
871
|
-
if not (SLIDE_PIECE_MIN_PX <= piece_w <= widget_width * SLIDE_PIECE_MAX_FRACTION):
|
|
872
|
-
return None, ratio
|
|
873
|
-
return piece_w, ratio
|
|
874
|
-
|
|
875
1148
|
# ── detection ────────────────────────────────────────────────────────
|
|
876
1149
|
|
|
877
1150
|
def _has_value(self, scopes: Sequence[Any], selector: str) -> bool:
|
|
@@ -903,12 +1176,12 @@ class PageSolver:
|
|
|
903
1176
|
except Exception:
|
|
904
1177
|
return 0
|
|
905
1178
|
|
|
906
|
-
return any(count(p.selector) > 0 for p in
|
|
1179
|
+
return any(count(p.selector) > 0 for p in self._sel.widget_probes)
|
|
907
1180
|
|
|
908
1181
|
def detect_captcha(self, page: Any) -> Optional[Widget]:
|
|
909
1182
|
"""Open challenges first, then unsolved checkboxes, then the inline vendors."""
|
|
910
|
-
scopes = _host_scopes(page)
|
|
911
|
-
for probe in
|
|
1183
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1184
|
+
for probe in self._sel.widget_probes:
|
|
912
1185
|
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
913
1186
|
if at is None or self._already_accepted(scopes, at, probe):
|
|
914
1187
|
continue
|
|
@@ -918,7 +1191,7 @@ class PageSolver:
|
|
|
918
1191
|
|
|
919
1192
|
def _already_accepted(self, scopes: Sequence[Any], at: Any, probe: WidgetProbe) -> bool:
|
|
920
1193
|
"""A checkbox the vendor has already accepted is not a captcha to solve."""
|
|
921
|
-
s =
|
|
1194
|
+
s = self._sel.selectors[probe.vendor]
|
|
922
1195
|
if probe.role != FrameRole.CHECKBOX:
|
|
923
1196
|
return False
|
|
924
1197
|
return bool(s.response and self._has_value(scopes, s.response)) or bool(s.checked and self._checked_in(at, s.checked))
|
|
@@ -930,13 +1203,13 @@ class PageSolver:
|
|
|
930
1203
|
except Exception:
|
|
931
1204
|
return []
|
|
932
1205
|
blob = " ".join(str(n) for n in (names or []))
|
|
933
|
-
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)]
|
|
934
1207
|
|
|
935
1208
|
def _unmatched_vendor_frames(self, page: Any) -> List[Vendor]:
|
|
936
1209
|
"""Vendors showing a frame no selector names. Only asked once detection has found nothing, and a passive frame
|
|
937
1210
|
(an invisible badge, an enclave) is not a widget, so what is left is markup the table no longer matches."""
|
|
938
|
-
scopes = _host_scopes(page)
|
|
939
|
-
return [vendor for vendor, s in
|
|
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]))
|
|
940
1213
|
> len(_visible_in(scopes, s.passive))]
|
|
941
1214
|
|
|
942
1215
|
def _no_widget_message(self, page: Any) -> str:
|
|
@@ -968,9 +1241,9 @@ class PageSolver:
|
|
|
968
1241
|
|
|
969
1242
|
def _is_blocked(self, page: Any) -> bool:
|
|
970
1243
|
"""The vendor showing its refusal screen: rate-limited or flagged, with no board to answer."""
|
|
971
|
-
scopes = _host_scopes(page)
|
|
972
|
-
for probe in
|
|
973
|
-
blocked =
|
|
1244
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1245
|
+
for probe in self._sel.widget_probes:
|
|
1246
|
+
blocked = self._sel.selectors[probe.vendor].blocked
|
|
974
1247
|
if probe.role != FrameRole.CHALLENGE or not blocked:
|
|
975
1248
|
continue
|
|
976
1249
|
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
@@ -987,11 +1260,11 @@ class PageSolver:
|
|
|
987
1260
|
def is_captcha_solved(self, page: Any) -> bool:
|
|
988
1261
|
"""The vendor's own done signal: a response token, a painted success state, or a checked box."""
|
|
989
1262
|
try:
|
|
990
|
-
scopes = _host_scopes(page)
|
|
991
|
-
if any(self._has_value(scopes, s) for s in
|
|
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):
|
|
992
1265
|
return True
|
|
993
|
-
for probe in
|
|
994
|
-
checked =
|
|
1266
|
+
for probe in self._sel.widget_probes:
|
|
1267
|
+
checked = self._sel.selectors[probe.vendor].checked
|
|
995
1268
|
if probe.role != FrameRole.CHECKBOX or not checked:
|
|
996
1269
|
continue
|
|
997
1270
|
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
@@ -1004,9 +1277,9 @@ class PageSolver:
|
|
|
1004
1277
|
def _is_challenge_freshly_rendered(self, page: Any) -> bool:
|
|
1005
1278
|
"""A next round has painted, as opposed to the answered frame animating closed."""
|
|
1006
1279
|
try:
|
|
1007
|
-
scopes = _host_scopes(page)
|
|
1008
|
-
for probe in
|
|
1009
|
-
fresh =
|
|
1280
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1281
|
+
for probe in self._sel.widget_probes:
|
|
1282
|
+
fresh = self._sel.selectors[probe.vendor].fresh
|
|
1010
1283
|
if probe.role != FrameRole.CHALLENGE or not fresh:
|
|
1011
1284
|
continue
|
|
1012
1285
|
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
@@ -1022,16 +1295,12 @@ class PageSolver:
|
|
|
1022
1295
|
pass
|
|
1023
1296
|
return False
|
|
1024
1297
|
|
|
1025
|
-
@staticmethod
|
|
1026
|
-
def _banner_is_fatal_after_retry(kind: Optional[RecaptchaBanner]) -> bool:
|
|
1027
|
-
return kind in (RecaptchaBanner.SELECT_MORE, RecaptchaBanner.REJECTED)
|
|
1028
|
-
|
|
1029
1298
|
def _banner_kind(self, page: Any) -> Optional[RecaptchaBanner]:
|
|
1030
1299
|
"""Which verdict banner the open challenge shows; `dynamic-more` is the dynamic board's normal flow, not an error."""
|
|
1031
1300
|
try:
|
|
1032
|
-
scopes = _host_scopes(page)
|
|
1033
|
-
for probe in
|
|
1034
|
-
banners =
|
|
1301
|
+
scopes = _host_scopes(page, self._sel.enclave)
|
|
1302
|
+
for probe in self._sel.widget_probes:
|
|
1303
|
+
banners = self._sel.selectors[probe.vendor].banners
|
|
1035
1304
|
if probe.role != FrameRole.CHALLENGE or not banners:
|
|
1036
1305
|
continue
|
|
1037
1306
|
at = next(iter(_visible_in(scopes, (probe.selector,))), None)
|
|
@@ -1051,11 +1320,15 @@ class PageSolver:
|
|
|
1051
1320
|
Treated as a board it cost 70 screenshots and three model calls in one measured solve, two of them answering
|
|
1052
1321
|
the checkbox picture with a drag.
|
|
1053
1322
|
"""
|
|
1054
|
-
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)
|
|
1055
1325
|
if box is not None:
|
|
1056
1326
|
self._move_to_element(page, box)
|
|
1057
1327
|
else:
|
|
1058
|
-
self.
|
|
1328
|
+
at = self._find_checkbox_on_screen(page, widget.element)
|
|
1329
|
+
if at is None:
|
|
1330
|
+
return
|
|
1331
|
+
self._smooth_move(page, *at)
|
|
1059
1332
|
self._human.pause(PauseKind.BETWEEN)
|
|
1060
1333
|
with self._gesture():
|
|
1061
1334
|
self._human.click(page, self._last_mouse)
|
|
@@ -1066,7 +1339,7 @@ class PageSolver:
|
|
|
1066
1339
|
|
|
1067
1340
|
Turnstile's box is in a closed shadow root, and a frame can refuse access; both are found on screen instead.
|
|
1068
1341
|
"""
|
|
1069
|
-
box =
|
|
1342
|
+
box = self._sel.selectors[vendor].box
|
|
1070
1343
|
if frame is None or not box:
|
|
1071
1344
|
return None
|
|
1072
1345
|
try:
|
|
@@ -1075,8 +1348,25 @@ class PageSolver:
|
|
|
1075
1348
|
_log(f"[checkbox] the box is out of the DOM's reach ({str(exc).splitlines()[0]}); finding it on screen")
|
|
1076
1349
|
return None
|
|
1077
1350
|
|
|
1078
|
-
def _find_checkbox_on_screen(self, element: Any) -> Tuple[float, float]:
|
|
1079
|
-
"""
|
|
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."""
|
|
1080
1370
|
from .tool_calls.find_checkbox import find_checkbox
|
|
1081
1371
|
|
|
1082
1372
|
box = element.bounding_box()
|
|
@@ -1091,7 +1381,7 @@ class PageSolver:
|
|
|
1091
1381
|
finally:
|
|
1092
1382
|
_unlink(shot)
|
|
1093
1383
|
if found is None:
|
|
1094
|
-
|
|
1384
|
+
return None
|
|
1095
1385
|
x, y, w, h = (v / scale for v in found)
|
|
1096
1386
|
return box["x"] + x + w * (0.3 + random.random() * 0.4), box["y"] + y + h * (0.3 + random.random() * 0.4)
|
|
1097
1387
|
|
|
@@ -1109,7 +1399,7 @@ class PageSolver:
|
|
|
1109
1399
|
_delay(self.config.post_solve_outcome_poll_ms)
|
|
1110
1400
|
|
|
1111
1401
|
def _get_verify_button(self, scope: Any) -> Optional[Any]:
|
|
1112
|
-
return self._find_control(scope,
|
|
1402
|
+
return self._find_control(scope, self._sel.submit)
|
|
1113
1403
|
|
|
1114
1404
|
# ── frames and polling ───────────────────────────────────────────────
|
|
1115
1405
|
|
|
@@ -1229,17 +1519,32 @@ class PageSolver:
|
|
|
1229
1519
|
"waiting for the challenge to settle") or SettleVerdict.TIMEOUT
|
|
1230
1520
|
|
|
1231
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
|
+
"""
|
|
1232
1528
|
cfg = self.config
|
|
1233
1529
|
|
|
1234
|
-
def judge(frames,
|
|
1530
|
+
def judge(frames, elapsed):
|
|
1235
1531
|
if len(frames) < 2:
|
|
1236
1532
|
return None
|
|
1237
1533
|
boxes = self._find_grid(frames[1])
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
|
1240
1542
|
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
|
1243
1548
|
|
|
1244
1549
|
def _wait_for_board_painted(self, element: Any) -> int:
|
|
1245
1550
|
"""A still board is not a loaded board: hold until the panel's centre carries structure."""
|
|
@@ -1268,7 +1573,7 @@ class PageSolver:
|
|
|
1268
1573
|
"""Best-effort: hold until the board's pictures have painted."""
|
|
1269
1574
|
try:
|
|
1270
1575
|
frame.wait_for_function(_BOARD_IMAGES_READY_JS, arg={"prompt": selectors.fresh or "", "images": ",".join(selectors.images)},
|
|
1271
|
-
timeout=self.config.
|
|
1576
|
+
timeout=self.config.board_images_timeout_ms)
|
|
1272
1577
|
except Exception:
|
|
1273
1578
|
pass
|
|
1274
1579
|
|
|
@@ -1386,7 +1691,7 @@ class PageSolver:
|
|
|
1386
1691
|
Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
|
|
1387
1692
|
number_with_highest_value_video and failed every seed either way.
|
|
1388
1693
|
"""
|
|
1389
|
-
import
|
|
1694
|
+
from .keyframes import read_frame
|
|
1390
1695
|
|
|
1391
1696
|
cfg = self.config
|
|
1392
1697
|
floor_ms = float(cfg.video_burst_duration_ms)
|
|
@@ -1412,7 +1717,7 @@ class PageSolver:
|
|
|
1412
1717
|
if _now() - self._last_input_ms >= INPUT_SETTLE_MS:
|
|
1413
1718
|
try:
|
|
1414
1719
|
self._screenshot(element, shot, animations="allow")
|
|
1415
|
-
img =
|
|
1720
|
+
img = read_frame(shot)
|
|
1416
1721
|
except Exception as exc:
|
|
1417
1722
|
_debug(f"burst frame failed: {exc}")
|
|
1418
1723
|
if img is not None:
|
|
@@ -1464,20 +1769,17 @@ class PageSolver:
|
|
|
1464
1769
|
f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
|
|
1465
1770
|
return frames, order, cycle_closed or animating, elapsed_ms, cycle_closed
|
|
1466
1771
|
|
|
1467
|
-
def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[
|
|
1468
|
-
"""Cut a burst with the training-side slicer;
|
|
1469
|
-
from .keyframes import extract_keyframes, write_keyframes
|
|
1470
|
-
|
|
1471
|
-
kfset = extract_keyframes(frames, fps=measured_fps(len(frames), burst_ms, self.config.video_burst_fps))
|
|
1472
|
-
self._keyframe_mode = kfset.mode
|
|
1473
|
-
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."""
|
|
1474
1774
|
temp_dir = tempfile.mkdtemp(prefix="ck_keyframes_")
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
|
1478
1780
|
|
|
1479
|
-
def _record_keyframes(self, element: Any) -> Tuple[
|
|
1480
|
-
"""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.
|
|
1481
1783
|
|
|
1482
1784
|
Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
|
|
1483
1785
|
|
|
@@ -1544,8 +1846,8 @@ class PageSolver:
|
|
|
1544
1846
|
if len(self._film_frames) > len(frames):
|
|
1545
1847
|
_log(f"[animated] slicing {len(self._film_frames)} frames filmed over "
|
|
1546
1848
|
f"{self._film_ms / 1000:.1f}s — every round of this board, not just this one")
|
|
1547
|
-
|
|
1548
|
-
return
|
|
1849
|
+
recording, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
|
|
1850
|
+
return recording, temp_dir, moved
|
|
1549
1851
|
|
|
1550
1852
|
def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
1551
1853
|
text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
|
|
@@ -1574,10 +1876,10 @@ class PageSolver:
|
|
|
1574
1876
|
self._known_animated = True
|
|
1575
1877
|
fut.result()
|
|
1576
1878
|
self._grant_video_budget()
|
|
1577
|
-
|
|
1879
|
+
recording, keyframe_dir = self._slice(frames, burst_ms)
|
|
1578
1880
|
with self._phase(Phase.INFERENCE):
|
|
1579
|
-
actions, usage = self._get_keyframe_solution(
|
|
1580
|
-
self._animated_plan = (
|
|
1881
|
+
actions, usage = self._get_keyframe_solution(recording)
|
|
1882
|
+
self._animated_plan = (recording, keyframe_dir, actions, usage)
|
|
1581
1883
|
return actions, usage, keyframe_dir
|
|
1582
1884
|
finally:
|
|
1583
1885
|
pool.shutdown(wait=True)
|
|
@@ -1615,9 +1917,7 @@ class PageSolver:
|
|
|
1615
1917
|
|
|
1616
1918
|
def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
|
|
1617
1919
|
"""Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
|
|
1618
|
-
import
|
|
1619
|
-
|
|
1620
|
-
from .keyframes import MATCH_REGION_TOLERANCE, region_diff_ratio
|
|
1920
|
+
from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_diff_ratio
|
|
1621
1921
|
|
|
1622
1922
|
folder, base = os.path.dirname(keyframe_path), os.path.basename(keyframe_path)
|
|
1623
1923
|
prefix = re.sub(r"_\d+\.png$", "_", base, flags=re.IGNORECASE)
|
|
@@ -1629,18 +1929,16 @@ class PageSolver:
|
|
|
1629
1929
|
except OSError:
|
|
1630
1930
|
return False
|
|
1631
1931
|
for other in siblings:
|
|
1632
|
-
img =
|
|
1932
|
+
img = read_frame(other)
|
|
1633
1933
|
if img is not None and img.shape == ref.shape and region_diff_ratio(ref, img, box) <= MATCH_REGION_TOLERANCE:
|
|
1634
1934
|
return True
|
|
1635
1935
|
return False
|
|
1636
1936
|
|
|
1637
1937
|
def _wait_for_keyframe(self, element: Any, keyframe_path: str, point_norm: Tuple[float, float]) -> bool:
|
|
1638
1938
|
"""Hold until the widget looks like the keyframe around the action point, or the wait is spent."""
|
|
1639
|
-
import
|
|
1640
|
-
|
|
1641
|
-
from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
|
|
1939
|
+
from .keyframes import MATCH_REGION_TOLERANCE, read_frame, region_box, region_diff_ratio
|
|
1642
1940
|
|
|
1643
|
-
ref =
|
|
1941
|
+
ref = read_frame(keyframe_path)
|
|
1644
1942
|
if ref is None:
|
|
1645
1943
|
return False
|
|
1646
1944
|
box = region_box(ref.shape[1::-1], point_norm)
|
|
@@ -1658,7 +1956,7 @@ class PageSolver:
|
|
|
1658
1956
|
self._check_deadline("waiting for the challenge keyframe")
|
|
1659
1957
|
try:
|
|
1660
1958
|
self._screenshot(element, probe, animations="allow")
|
|
1661
|
-
live =
|
|
1959
|
+
live = read_frame(probe)
|
|
1662
1960
|
except Exception:
|
|
1663
1961
|
live = None
|
|
1664
1962
|
if live is not None:
|
|
@@ -1687,13 +1985,20 @@ class PageSolver:
|
|
|
1687
1985
|
session.element_box["y"] + (y1 + y2) / 2 * session.scale_y)
|
|
1688
1986
|
|
|
1689
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."""
|
|
1690
1989
|
if not self._human.hovers:
|
|
1691
1990
|
return
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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))
|
|
1697
2002
|
|
|
1698
2003
|
@staticmethod
|
|
1699
2004
|
def _bbox_to_cell(bbox: Sequence[float], grid_boxes: Sequence[Sequence[int]],
|
|
@@ -1714,11 +2019,14 @@ class PageSolver:
|
|
|
1714
2019
|
def _hover_loop(self, page: Any, session: _GridSession, cells: Sequence[int]) -> Optional[Callable[[], None]]:
|
|
1715
2020
|
if not cells:
|
|
1716
2021
|
return None
|
|
1717
|
-
|
|
2022
|
+
due = [0.0]
|
|
1718
2023
|
|
|
1719
2024
|
def hover():
|
|
2025
|
+
if _now() < due[0]:
|
|
2026
|
+
return
|
|
2027
|
+
due[0] = _now() + random.uniform(*IDLE_DRIFT_EVERY_MS)
|
|
1720
2028
|
try:
|
|
1721
|
-
self._hover_cell(page, session,
|
|
2029
|
+
self._hover_cell(page, session, cells[0])
|
|
1722
2030
|
except Exception:
|
|
1723
2031
|
pass
|
|
1724
2032
|
|
|
@@ -1807,6 +2115,10 @@ class PageSolver:
|
|
|
1807
2115
|
loading, _ = self._watch_clicked_tiles(page, element, session, clicked_order)
|
|
1808
2116
|
self._wait_for_any_clicked_tile_loaded(page, element, session, loading)
|
|
1809
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
|
|
1810
2122
|
if kind != ActionKind.CLICK:
|
|
1811
2123
|
_log(f"[recaptcha-grid] round {round_index}: unexpected action; re-solving.")
|
|
1812
2124
|
continue
|
|
@@ -1818,6 +2130,10 @@ class PageSolver:
|
|
|
1818
2130
|
clicked_this_round: List[int] = []
|
|
1819
2131
|
for bbox in bboxes:
|
|
1820
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
|
|
1821
2137
|
self._execute_click(page, {"target_bounding_box": bbox}, element_box)
|
|
1822
2138
|
if cell is not None:
|
|
1823
2139
|
clicked_order.append(cell)
|
|
@@ -1861,13 +2177,13 @@ class PageSolver:
|
|
|
1861
2177
|
self._click_checkbox(page, widget, frame)
|
|
1862
2178
|
return True, []
|
|
1863
2179
|
|
|
1864
|
-
if frame and role == FrameRole.CHALLENGE and
|
|
2180
|
+
if frame and role == FrameRole.CHALLENGE and self._sel.selectors[puzzle_source].images:
|
|
1865
2181
|
if self._last_submit_frame_hash:
|
|
1866
2182
|
with self._phase(Phase.AWAIT_NEXT_ROUND):
|
|
1867
2183
|
self._wait_for_change_since(element, self._last_submit_frame_hash)
|
|
1868
2184
|
self._last_submit_frame_hash = None
|
|
1869
|
-
with self._phase(Phase.
|
|
1870
|
-
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])
|
|
1871
2187
|
|
|
1872
2188
|
# THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of
|
|
1873
2189
|
# the DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet
|
|
@@ -1881,9 +2197,11 @@ class PageSolver:
|
|
|
1881
2197
|
self._wait_for_board_painted(element)
|
|
1882
2198
|
|
|
1883
2199
|
# Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
1884
|
-
|
|
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
|
|
1885
2202
|
if text_mode:
|
|
1886
2203
|
_log("widget has a text box; solving as a distorted-text captcha")
|
|
2204
|
+
element = _WithAnswerBox(element, answer_box)
|
|
1887
2205
|
|
|
1888
2206
|
# A reCAPTCHA board is read by its grid below, not filmed.
|
|
1889
2207
|
filmable = puzzle_source != Vendor.RECAPTCHA and not text_mode
|
|
@@ -1910,20 +2228,21 @@ class PageSolver:
|
|
|
1910
2228
|
self._wait_for_board_painted(element)
|
|
1911
2229
|
|
|
1912
2230
|
shot = _tmp_png("captcha")
|
|
2231
|
+
self._round_board = None
|
|
1913
2232
|
performed = slid = answered = have_shot = False
|
|
1914
2233
|
all_usage: List[Dict[str, Any]] = []
|
|
1915
2234
|
keyframe_dir: Optional[str] = None
|
|
1916
2235
|
try:
|
|
1917
2236
|
if is_animated:
|
|
1918
2237
|
if self._animated_plan is not None:
|
|
1919
|
-
|
|
2238
|
+
recording, keyframe_dir, actions, all_usage = self._animated_plan
|
|
1920
2239
|
reused = actions is not None
|
|
1921
2240
|
if not reused:
|
|
1922
2241
|
# Drop the ANSWER, keep the FRAMES — and the frames grow. Filming another chunk and
|
|
1923
2242
|
# re-slicing the accumulation puts a better question up; re-asking the same frames
|
|
1924
2243
|
# puts the same question up and gets the same answer back.
|
|
1925
2244
|
with self._phase(Phase.BURST):
|
|
1926
|
-
|
|
2245
|
+
recording, new_dir, moved = self._record_keyframes(element)
|
|
1927
2246
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1928
2247
|
keyframe_dir = new_dir
|
|
1929
2248
|
self._known_animated = self._known_animated or moved
|
|
@@ -1932,27 +2251,28 @@ class PageSolver:
|
|
|
1932
2251
|
else:
|
|
1933
2252
|
reused = False
|
|
1934
2253
|
with self._phase(Phase.BURST):
|
|
1935
|
-
|
|
2254
|
+
recording, keyframe_dir, moved = self._record_keyframes(element)
|
|
1936
2255
|
self._known_animated = self._known_animated or moved
|
|
1937
2256
|
# The clip answers the question the probe asked: a board that never moved is a still, and the
|
|
1938
2257
|
# video expert can only answer a still with a frame number the widget will not take.
|
|
1939
|
-
if len(
|
|
2258
|
+
if len(recording.keyframe_paths) < 2 or not self._known_animated:
|
|
1940
2259
|
_log("[animated] the recording shows a still board; solving it as a still")
|
|
1941
2260
|
is_animated = False
|
|
1942
|
-
shutil.copyfile(
|
|
2261
|
+
shutil.copyfile(recording.keyframe_paths[-1], shot)
|
|
1943
2262
|
have_shot = True
|
|
1944
2263
|
self._stop_animated_film()
|
|
1945
2264
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1946
2265
|
keyframe_dir = None
|
|
1947
2266
|
elif not reused:
|
|
1948
2267
|
with self._phase(Phase.INFERENCE):
|
|
1949
|
-
actions, all_usage = self._get_keyframe_solution(
|
|
1950
|
-
self._animated_plan = (
|
|
2268
|
+
actions, all_usage = self._get_keyframe_solution(recording)
|
|
2269
|
+
self._animated_plan = (recording, keyframe_dir, actions, all_usage)
|
|
1951
2270
|
|
|
1952
2271
|
if not is_animated:
|
|
1953
2272
|
if not have_shot:
|
|
1954
2273
|
with self._phase(Phase.SCREENSHOT):
|
|
1955
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))
|
|
1956
2276
|
if self._should_speculate(puzzle_source, text_mode):
|
|
1957
2277
|
actions, all_usage, keyframe_dir = self._speculate(element, shot, puzzle_source, retry_mode, text_mode)
|
|
1958
2278
|
is_animated = keyframe_dir is not None
|
|
@@ -1975,10 +2295,12 @@ class PageSolver:
|
|
|
1975
2295
|
|
|
1976
2296
|
_log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
|
|
1977
2297
|
# A repeated answer is not re-performed: the widget already refused it, and every extra press is
|
|
1978
|
-
# behaviour a vendor scores. Re-asking
|
|
2298
|
+
# behaviour a vendor scores. Re-asking, or asking a recording, is the round's only move.
|
|
1979
2299
|
# `_note_answer` drops the refused animated answer on its way through, so the next round re-asks.
|
|
1980
2300
|
if self._note_answer(actions, retry_mode):
|
|
1981
2301
|
return False, all_usage
|
|
2302
|
+
if not is_animated:
|
|
2303
|
+
self._keep_pointer_screen(shot, puzzle_source, actions)
|
|
1982
2304
|
_log(f"executing {len(actions)} action(s)")
|
|
1983
2305
|
|
|
1984
2306
|
for raw_action in actions:
|
|
@@ -1998,7 +2320,7 @@ class PageSolver:
|
|
|
1998
2320
|
if bbox:
|
|
1999
2321
|
self._human.pause(PauseKind.BETWEEN)
|
|
2000
2322
|
performed = answered = True
|
|
2001
|
-
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")):
|
|
2002
2324
|
if self._execute_slide(page, element, scope, action, element_box):
|
|
2003
2325
|
performed = slid = True
|
|
2004
2326
|
elif kind == ActionKind.DRAG:
|
|
@@ -2009,6 +2331,13 @@ class PageSolver:
|
|
|
2009
2331
|
elif kind == ActionKind.TYPE:
|
|
2010
2332
|
if self._execute_type(page, scope, action, widget.at):
|
|
2011
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
|
|
2012
2341
|
elif kind == ActionKind.WAIT:
|
|
2013
2342
|
duration = int(action.get("duration_ms") or 0)
|
|
2014
2343
|
if duration > 0:
|
|
@@ -2016,13 +2345,19 @@ class PageSolver:
|
|
|
2016
2345
|
performed = True
|
|
2017
2346
|
|
|
2018
2347
|
# A slide submits itself on release, and any Verify found afterwards belongs to the host page and would
|
|
2019
|
-
# submit the guarded form while the verdict is in flight
|
|
2020
|
-
|
|
2021
|
-
|
|
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:
|
|
2022
2355
|
_log(f"clicking Verify to submit ({puzzle_source}).")
|
|
2023
2356
|
self._move_and_click(page, verify_button)
|
|
2024
|
-
performed = True
|
|
2357
|
+
performed = self._submitted_on_board = True
|
|
2025
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)
|
|
2026
2361
|
finally:
|
|
2027
2362
|
_unlink(shot)
|
|
2028
2363
|
held = self._animated_plan[1] if self._animated_plan else None
|
|
@@ -2239,8 +2574,15 @@ class PageSolver:
|
|
|
2239
2574
|
raise blocked("it is showing its try-again-later screen")
|
|
2240
2575
|
if said == Verdict.REJECTED:
|
|
2241
2576
|
_log(f"[verdict] the vendor rejected the answer after {verdict_ms:.0f}ms")
|
|
2242
|
-
|
|
2243
|
-
|
|
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:
|
|
2244
2586
|
_log("reCAPTCHA under-selection error; the next round asks for the missed tiles.")
|
|
2245
2587
|
pending_retry_mode = RetryMode.MISSED_TILES
|
|
2246
2588
|
|
|
@@ -2249,9 +2591,9 @@ class PageSolver:
|
|
|
2249
2591
|
if self._no_progress_rounds:
|
|
2250
2592
|
stuck = CaptchaSolveError(f"no progress: the model returned the same answer "
|
|
2251
2593
|
f"{self._no_progress_rounds + 1} times running")
|
|
2252
|
-
# A
|
|
2253
|
-
|
|
2254
|
-
|
|
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)):
|
|
2255
2597
|
raise stuck
|
|
2256
2598
|
again(stuck)
|
|
2257
2599
|
elif not did_interact:
|
|
@@ -2260,6 +2602,8 @@ class PageSolver:
|
|
|
2260
2602
|
if cfg.video_solve_enabled:
|
|
2261
2603
|
self._arm_animated_probe()
|
|
2262
2604
|
again(CaptchaSolveError("the solver performed no interactions this round"))
|
|
2605
|
+
else:
|
|
2606
|
+
last_failure = None
|
|
2263
2607
|
|
|
2264
2608
|
if isinstance(last_failure, UnsupportedChallengeError):
|
|
2265
2609
|
raise UnsupportedChallengeError(f"{last_failure} (after {cfg.max_solve_loops} solve loops)")
|