captchakraken 3.0.0 → 3.1.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/dist/solver.d.ts +43 -2
- package/dist/solver.js +416 -40
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +1 -1
- package/python/src/captchakraken/page_solver.py +264 -37
- package/python/src/captchakraken/planner.py +16 -1
package/dist/types.d.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface CaptchaKrakenConfig {
|
|
|
105
105
|
keyframeWaitPollMs?: number;
|
|
106
106
|
/** Burst ceiling for a board that never repeats a screen. Default 12000. */
|
|
107
107
|
videoBurstMaxMs?: number;
|
|
108
|
+
/** Safety ceiling on the camera that films an animated board across rounds. Default 120000: it is meant to end with the solve, not on this clock. */
|
|
109
|
+
videoFilmMaxMs?: number;
|
|
108
110
|
/** Consecutive moved settle polls that mean animated. Default 5. */
|
|
109
111
|
animatedMotionStreak?: number;
|
|
110
112
|
/** Record while the still is being read, so a cycling board is known before its answer runs. Default true. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "captchakraken",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Captcha solver for Playwright and Puppeteer. Finds the captcha, reads the puzzle with a fine-tuned Qwen3.5-9B vision model, and clicks, drags, slides or types through to a token \u2014 reCAPTCHA, hCaptcha, GeeTest, NetEase Yidun, Tencent, Lemin, Prosopo, Turnstile and distorted text. Hosted API or self-hosted.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/python/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.1.0"
|
|
8
8
|
description = "Captcha-solving engine and CLI: OpenCV grid detection plus a fine-tuned Qwen3.5-9B vision model, for image grids, click and drag puzzles, sliders, distorted text and animated challenges. Hosted API or self-hosted on vLLM."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -16,7 +16,7 @@ import uuid
|
|
|
16
16
|
from concurrent.futures import ThreadPoolExecutor
|
|
17
17
|
from contextlib import contextmanager
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
|
-
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple
|
|
19
|
+
from typing import AbstractSet, Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple
|
|
20
20
|
|
|
21
21
|
from . import planner
|
|
22
22
|
from .action_types import CaptchaAction
|
|
@@ -78,6 +78,15 @@ class AnimatedChallengeError(CaptchaSolveError):
|
|
|
78
78
|
pass
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
class NothingFilmedError(AnimatedChallengeError):
|
|
82
|
+
"""The burst caught no frame at all.
|
|
83
|
+
|
|
84
|
+
`_burst` only returns nothing when EVERY screenshot in its window failed, so this is not a verdict
|
|
85
|
+
about the board — a still photographs fine. It is a widget that would not screenshot, and the
|
|
86
|
+
commonest reason for that is that it is closing, because the answer was accepted.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
81
90
|
class UnsupportedChallengeError(CaptchaSolveError):
|
|
82
91
|
pass
|
|
83
92
|
|
|
@@ -90,16 +99,30 @@ class PageClosedError(CaptchaSolveError):
|
|
|
90
99
|
pass
|
|
91
100
|
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
#: `bounding box of captcha element` is in here because the JS port has always had it
|
|
103
|
+
#: (`isStaleHandleError`): a widget with no box has moved on under us — hCaptcha swapped rounds, GeeTest
|
|
104
|
+
#: closed on accept — and re-detecting is right where failing the attempt is not. Without it the Python
|
|
105
|
+
#: port turned an accepted board into a failed solve.
|
|
106
|
+
_STALE_HANDLE_RE = re.compile(
|
|
107
|
+
r"Timeout .*exceeded|not visible|not attached|detached|bounding box of captcha element",
|
|
108
|
+
re.IGNORECASE)
|
|
94
109
|
# A closed target is not a stale handle: retried as one, a dead page was re-detected three times and the
|
|
95
110
|
# error that finally escaped named whichever call ran last.
|
|
96
111
|
_CLOSED_TARGET_RE = re.compile(
|
|
97
112
|
r"Target (?:page, context or browser )?(?:has been )?closed|Session closed", re.IGNORECASE)
|
|
98
113
|
|
|
99
114
|
|
|
100
|
-
def answer_needs_element_box(actions:
|
|
101
|
-
"""An allow-list of what needs nothing: the other direction raises instead of clicking at the origin.
|
|
102
|
-
|
|
115
|
+
def answer_needs_element_box(actions: Sequence[Any]) -> bool:
|
|
116
|
+
"""An allow-list of what needs nothing: the other direction raises instead of clicking at the origin.
|
|
117
|
+
|
|
118
|
+
Takes actions in EITHER shape. The planner returns typed actions (`ClickAction`, `TypeAction`,
|
|
119
|
+
`DoneAction`) and every other reader here goes through `_as_dict` first; this one called `.get` on
|
|
120
|
+
them. It only runs when the element has no bounding box, which is what a widget that is CLOSING
|
|
121
|
+
looks like — so a solve the vendor had just accepted ended as
|
|
122
|
+
`AttributeError: 'ClickAction' object has no attribute 'get'` instead of as a success. Tier 3 saw
|
|
123
|
+
it as a lost solve on five types.
|
|
124
|
+
"""
|
|
125
|
+
return any(_as_dict(a).get("action") != ActionKind.DONE for a in actions if a is not None)
|
|
103
126
|
|
|
104
127
|
|
|
105
128
|
@dataclass
|
|
@@ -194,6 +217,9 @@ class PageSolverConfig:
|
|
|
194
217
|
animated_probe_enabled: bool = True
|
|
195
218
|
video_burst_duration_ms: int = 4_000
|
|
196
219
|
video_burst_max_ms: int = 12_000
|
|
220
|
+
# Safety ceiling on the film an animated board accumulates across rounds. It is meant to end with the
|
|
221
|
+
# solve, not on this clock; frames are decoded images, so this bounds memory rather than the recording.
|
|
222
|
+
video_film_max_ms: int = 120_000
|
|
197
223
|
speculative_burst_enabled: bool = True
|
|
198
224
|
video_burst_fps: int = 10
|
|
199
225
|
# GeeTest svg dwells up to 2.7s a screen, so a 3-screen cycle is 8.1s; 6s gave up one screen short.
|
|
@@ -320,6 +346,14 @@ class PageSolver:
|
|
|
320
346
|
self._deadline_ms: Optional[float] = None
|
|
321
347
|
self._budget: Optional[PhaseBudget] = None
|
|
322
348
|
self._animated_plan: Optional[Tuple[List[str], str, Any, Any]] = None
|
|
349
|
+
self._film_frames: List[Any] = []
|
|
350
|
+
self._film_ms: float = 0.0
|
|
351
|
+
# The digests of every screen the film holds: what a later burst is checked against to answer
|
|
352
|
+
# "is this still the same board?" — see `_record_keyframes`.
|
|
353
|
+
self._film_digests: Set[str] = set()
|
|
354
|
+
# Has this board EVER shown the same screen twice? A board that has not cannot prove it was replaced
|
|
355
|
+
# and has no cycle left to find, so a re-ask neither re-films it at length nor throws its film away.
|
|
356
|
+
self._film_cycled: bool = False
|
|
323
357
|
self._reset_animated_state()
|
|
324
358
|
|
|
325
359
|
@property
|
|
@@ -346,7 +380,8 @@ class PageSolver:
|
|
|
346
380
|
self._animated_probe_armed = False
|
|
347
381
|
self._animated_probe_done = False
|
|
348
382
|
self._video_budget_granted = False
|
|
349
|
-
self.
|
|
383
|
+
self._retried_unusable_answer = False
|
|
384
|
+
self._stop_animated_film()
|
|
350
385
|
self._keyframe_mode: Optional[KeyframeMode] = None
|
|
351
386
|
self._keyframe_steady_screens = 0
|
|
352
387
|
self._last_answer_sig: Optional[str] = None
|
|
@@ -382,6 +417,20 @@ class PageSolver:
|
|
|
382
417
|
self._last_answer_sig = sig
|
|
383
418
|
return False
|
|
384
419
|
|
|
420
|
+
def _bound_ask_to_the_budget(self) -> None:
|
|
421
|
+
"""Give the planner what is LEFT of the solve, so one ask cannot outlive the whole thing.
|
|
422
|
+
|
|
423
|
+
A request in flight cannot be cancelled — `_check_deadline` is only read between steps — so the
|
|
424
|
+
only thing that bounds it is the timeout it was sent with. Measured on the hosted endpoint: one
|
|
425
|
+
ask hung and gave up after ~124s inside a 45s budget, and the attempt ran 143s.
|
|
426
|
+
"""
|
|
427
|
+
target = getattr(self._solver, "planner", None)
|
|
428
|
+
if target is None or self._deadline_ms is None:
|
|
429
|
+
return
|
|
430
|
+
left_s = (self._deadline_ms - _now()) / 1000.0
|
|
431
|
+
target.request_timeout_s = max(planner.MIN_REQUEST_TIMEOUT_S,
|
|
432
|
+
min(planner.DEFAULT_REQUEST_TIMEOUT_S, left_s))
|
|
433
|
+
|
|
385
434
|
def _apply_sampling(self) -> None:
|
|
386
435
|
target = getattr(self._solver, "planner", None)
|
|
387
436
|
if target is None:
|
|
@@ -391,6 +440,15 @@ class PageSolver:
|
|
|
391
440
|
_log(f"[resample] temperature {sampling['temperature']}, seed {sampling['seed']}")
|
|
392
441
|
|
|
393
442
|
def _fresh_board(self) -> None:
|
|
443
|
+
# ONE FILM, ONE BOARD — enforced where the board actually changes. The recorded answer describes the
|
|
444
|
+
# board it was cut from, so replaying it here presses that board's coordinates onto this one: live, a
|
|
445
|
+
# keyframe answer from an earlier board landed on hCaptcha's reference photo and then pressed Skip,
|
|
446
|
+
# six rounds running.
|
|
447
|
+
self._stop_animated_film()
|
|
448
|
+
self._known_animated = False
|
|
449
|
+
# Its own second look, but only once IT fails: an arm set on the previous board is evidence about that one.
|
|
450
|
+
self._animated_probe_armed = False
|
|
451
|
+
self._animated_probe_done = False
|
|
394
452
|
self._resample_level = 0
|
|
395
453
|
self._acted_on_board = False
|
|
396
454
|
self._apply_sampling()
|
|
@@ -1026,16 +1084,19 @@ class PageSolver:
|
|
|
1026
1084
|
return True
|
|
1027
1085
|
with self._phase(Phase.SETTLE):
|
|
1028
1086
|
verdict = self._wait_for_element_settled(element)
|
|
1029
|
-
|
|
1030
|
-
|
|
1087
|
+
animated = verdict == SettleVerdict.ANIMATED
|
|
1088
|
+
if not animated:
|
|
1089
|
+
# 'settled' is not proof of static; a repeated answer arms one recording to find out. The clip
|
|
1090
|
+
# that recording brings back is what decides — `_known_animated` is set from it, not from here.
|
|
1031
1091
|
if not self._animated_probe_armed or self._animated_probe_done:
|
|
1032
1092
|
return False
|
|
1033
1093
|
self._animated_probe_done, self._animated_probe_armed = True, False
|
|
1034
1094
|
_log("[animated] a second look at a board that did not solve as a still — recording it")
|
|
1035
|
-
self._known_animated =
|
|
1095
|
+
self._known_animated = animated
|
|
1036
1096
|
if not self.config.video_solve_enabled:
|
|
1037
1097
|
raise AnimatedChallengeError("the challenge never settles and video_solve_enabled is off")
|
|
1038
|
-
|
|
1098
|
+
if animated:
|
|
1099
|
+
_log("[animated] challenge is animated — solving it from keyframes")
|
|
1039
1100
|
return True
|
|
1040
1101
|
|
|
1041
1102
|
def _arm_animated_probe(self) -> None:
|
|
@@ -1049,10 +1110,15 @@ class PageSolver:
|
|
|
1049
1110
|
return (cfg.video_solve_enabled and cfg.speculative_burst_enabled and not self._acted_on_board
|
|
1050
1111
|
and puzzle_source != Vendor.RECAPTCHA and not text_mode)
|
|
1051
1112
|
|
|
1052
|
-
def _burst(self, element: Any
|
|
1113
|
+
def _burst(self, element: Any, known: AbstractSet[str] = frozenset(),
|
|
1114
|
+
max_ms: Optional[float] = None) -> Tuple[List[Any], List[str], bool, float, bool]:
|
|
1053
1115
|
"""Film the widget until a screen comes back (a cycle) or nothing new appears for a floor window.
|
|
1054
1116
|
|
|
1055
|
-
|
|
1117
|
+
`known` is the screens an earlier burst of this solve already holds. One of them coming back proves
|
|
1118
|
+
the board was not replaced, and the film in hand already describes it, so there is nothing left to
|
|
1119
|
+
film — that exit is the difference between a re-ask costing a frame and costing a whole window.
|
|
1120
|
+
|
|
1121
|
+
Returns `(frames, distinct_digests, moved, elapsed_ms, cycle_closed)`. `moved` is a closed cycle OR a board still
|
|
1056
1122
|
producing new screens past the floor with more than BURST_ANIMATED_SCREENS seen: a continuous animation
|
|
1057
1123
|
never repeats and never settles, so without that verdict it read as a still and was clicked as one.
|
|
1058
1124
|
Both bounds are wall-clock. There is no "enough screens, stop" exit: both spellings were measured on
|
|
@@ -1062,7 +1128,7 @@ class PageSolver:
|
|
|
1062
1128
|
|
|
1063
1129
|
cfg = self.config
|
|
1064
1130
|
floor_ms = float(cfg.video_burst_duration_ms)
|
|
1065
|
-
max_ms = max(floor_ms, float(cfg.video_burst_max_ms))
|
|
1131
|
+
max_ms = max(floor_ms, float(cfg.video_burst_max_ms if max_ms is None else max_ms))
|
|
1066
1132
|
interval = 1.0 / max(1, int(cfg.video_burst_fps))
|
|
1067
1133
|
frames: List[Any] = []
|
|
1068
1134
|
order: List[str] = []
|
|
@@ -1088,6 +1154,12 @@ class PageSolver:
|
|
|
1088
1154
|
if img is not None:
|
|
1089
1155
|
frames.append(img)
|
|
1090
1156
|
d = _sha1(shot)
|
|
1157
|
+
if d in known:
|
|
1158
|
+
_log("[animated] a screen the film already holds came back — the board is unchanged")
|
|
1159
|
+
cycle_closed = True
|
|
1160
|
+
if d not in order:
|
|
1161
|
+
order.append(d)
|
|
1162
|
+
break
|
|
1091
1163
|
if d != last_digest:
|
|
1092
1164
|
if d in order and len(order) >= 2:
|
|
1093
1165
|
cycle_closed = True
|
|
@@ -1105,8 +1177,14 @@ class PageSolver:
|
|
|
1105
1177
|
# Sleep to a fixed grid, not `interval - work`: per-frame overshoot would otherwise accumulate and a
|
|
1106
1178
|
# loaded runner films fewer frames than the floor window holds. A stalled frame skips, not bunches.
|
|
1107
1179
|
next_at = max(next_at + interval, time.monotonic())
|
|
1180
|
+
# END on the frame that would land past the ceiling rather than declining to sleep for it:
|
|
1181
|
+
# declining left the loop spinning at whatever rate the screenshots came back, which on a
|
|
1182
|
+
# ceiling that is not a whole number of intervals filmed thousands of junk frames into the
|
|
1183
|
+
# tail of the clip (measured: 2487 frames in a 150ms window).
|
|
1184
|
+
if (next_at - t0) * 1000.0 >= max_ms:
|
|
1185
|
+
break
|
|
1108
1186
|
wait = next_at - time.monotonic()
|
|
1109
|
-
if wait > 0
|
|
1187
|
+
if wait > 0:
|
|
1110
1188
|
time.sleep(wait)
|
|
1111
1189
|
finally:
|
|
1112
1190
|
_unlink(shot)
|
|
@@ -1120,7 +1198,7 @@ class PageSolver:
|
|
|
1120
1198
|
why = "no new screen for a full floor window — it moved once and settled"
|
|
1121
1199
|
_log(f"[animated] burst verdict after {elapsed_ms / 1000:.1f}s: "
|
|
1122
1200
|
f"{'ANIMATED' if cycle_closed or animating else 'still'} ({len(order)} screens; {why})")
|
|
1123
|
-
return frames, order, cycle_closed or animating, elapsed_ms
|
|
1201
|
+
return frames, order, cycle_closed or animating, elapsed_ms, cycle_closed
|
|
1124
1202
|
|
|
1125
1203
|
def _slice(self, frames: List[Any], burst_ms: float) -> Tuple[List[str], str]:
|
|
1126
1204
|
"""Cut a burst with the training-side slicer; the model answers with a frame number into it."""
|
|
@@ -1134,10 +1212,31 @@ class PageSolver:
|
|
|
1134
1212
|
_log(f"[animated] sliced to {len(paths)} keyframe(s) (mode={kfset.mode})")
|
|
1135
1213
|
return paths, temp_dir
|
|
1136
1214
|
|
|
1137
|
-
def _record_keyframes(self, element: Any) -> Tuple[List[str], str]:
|
|
1138
|
-
"""Record the widget and return `(keyframe_paths, temp_dir)`; the caller removes the dir.
|
|
1215
|
+
def _record_keyframes(self, element: Any) -> Tuple[List[str], str, bool]:
|
|
1216
|
+
"""Record the widget and return `(keyframe_paths, temp_dir, moved)`; the caller removes the dir.
|
|
1139
1217
|
|
|
1140
1218
|
Frames stay in memory: the intermediate mp4 this used to write was mp4v, which the serving side may not decode.
|
|
1219
|
+
|
|
1220
|
+
THE FILM IS NEVER THROWN AWAY MID-SOLVE. One burst can only show the model the screens that fell
|
|
1221
|
+
inside its window — measured, a 4s burst against a 5.3s cycle showed two screens of three — and the
|
|
1222
|
+
answer it produces is identical next round by construction, because sampling is greedy over the same
|
|
1223
|
+
frames. So a refused answer used to be re-pressed until the no-progress fence tripped. Every round
|
|
1224
|
+
now films ANOTHER chunk and slices the whole accumulation, so the next ask is a strictly better
|
|
1225
|
+
question: more repetitions for `_detect_cycle` to confirm, and, on a board that never repeats, the
|
|
1226
|
+
slicer's even picks spread across the whole solve rather than across four seconds.
|
|
1227
|
+
|
|
1228
|
+
WHAT IT IS NOT IS A FILM OF TWO BOARDS. Accumulating across a board the vendor replaced is worse
|
|
1229
|
+
than not accumulating at all — six keyframes cut across both states describe neither, `_detect_cycle`
|
|
1230
|
+
gives up on the extra screens, and the frame the model names is one the widget will never show
|
|
1231
|
+
again. A screen coming back is the proof the board is the same one; nothing coming back restarts
|
|
1232
|
+
the film below.
|
|
1233
|
+
|
|
1234
|
+
THE JS PORT FILMS CONTINUOUSLY AND THIS ONE CANNOT. There the recorder is an async loop on the same
|
|
1235
|
+
event loop, so it keeps filming through inference and through the verdict wait. Sync Playwright is
|
|
1236
|
+
thread-affine — `_speculate` says so where it hands only the HTTP call to a worker — so a background
|
|
1237
|
+
camera here would be a second thread touching the page, which Playwright forbids. Accumulating
|
|
1238
|
+
per-round chunks is the same contract with a sparser film: both ports re-ask a refused animated
|
|
1239
|
+
answer against strictly more of the board than the last ask saw.
|
|
1141
1240
|
"""
|
|
1142
1241
|
cfg = self.config
|
|
1143
1242
|
self._grant_video_budget()
|
|
@@ -1147,12 +1246,46 @@ class PageSolver:
|
|
|
1147
1246
|
f"budget is left and an animated recording needs {cfg.video_burst_duration_ms}ms — not "
|
|
1148
1247
|
"starting one that would be cut off mid-way. Raise overall_solve_timeout_ms or "
|
|
1149
1248
|
"video_extra_inference_ms, or set video_solve_enabled=False.")
|
|
1150
|
-
|
|
1249
|
+
# A board that has never repeated a screen has nothing further to show: another ceiling-long window
|
|
1250
|
+
# is more of the same animation, and three of them is most of the solve budget. Measured on the gate:
|
|
1251
|
+
# boards at 54.6s and 59.1s against a 49s ceiling, all of them boards that never repeat.
|
|
1252
|
+
reask = bool(self._film_frames)
|
|
1253
|
+
frames, order, moved, burst_ms, cycled = self._burst(
|
|
1254
|
+
element, known=self._film_digests,
|
|
1255
|
+
max_ms=float(cfg.video_burst_duration_ms) if reask and not self._film_cycled else None)
|
|
1151
1256
|
if not frames:
|
|
1257
|
+
# ONLY the speculative second look gets the soft landing. A board this solve has PROVEN
|
|
1258
|
+
# animated is a real dead end when it will not film, and must fail loudly as it always
|
|
1259
|
+
# has: measured, treating both the same took the two video types from 3 solved and 10
|
|
1260
|
+
# keyframe calls to 0 and 0, because the first failed film spends `_animated_probe_done`
|
|
1261
|
+
# and every round after it is answered as a still.
|
|
1262
|
+
if not self._known_animated:
|
|
1263
|
+
raise NothingFilmedError("could not record the animated challenge (no frame screenshotted)")
|
|
1152
1264
|
raise AnimatedChallengeError("could not record the animated challenge (no frame screenshotted)")
|
|
1153
1265
|
_log(f"[animated] recorded {len(frames)} frames in {burst_ms / 1000:.1f}s "
|
|
1154
1266
|
f"({measured_fps(len(frames), burst_ms, cfg.video_burst_fps):.1f}fps)")
|
|
1155
|
-
|
|
1267
|
+
# ONE FILM, ONE BOARD. A vendor that deals a fresh puzzle on a miss has replaced everything the film
|
|
1268
|
+
# holds, and keyframes cut across both name screens that no longer exist: the model answers with one
|
|
1269
|
+
# and the click waits out `keyframe_wait_timeout_ms` for a picture that is gone. Nothing coming back
|
|
1270
|
+
# is what says so, and the film restarts on the board that is actually there.
|
|
1271
|
+
# Cut ONLY on proof of a replacement: this burst repeated a screen, and none of what it repeated was
|
|
1272
|
+
# in the film. A board that never repeats cannot produce that proof either way and has not been
|
|
1273
|
+
# replaced — it has simply never repeated, and its film is the only record of it.
|
|
1274
|
+
if reask and cycled and not (self._film_digests & set(order)):
|
|
1275
|
+
_log("[animated] nothing the film holds came back — the board was replaced; the film restarts here")
|
|
1276
|
+
self._film_frames, self._film_ms, self._film_digests = [], 0.0, set()
|
|
1277
|
+
# The cap is a safety net on memory, not the working limit: frames are decoded images, and a board
|
|
1278
|
+
# that never settles would otherwise film for the whole solve budget.
|
|
1279
|
+
if self._film_ms < cfg.video_film_max_ms:
|
|
1280
|
+
self._film_frames.extend(frames)
|
|
1281
|
+
self._film_ms += burst_ms
|
|
1282
|
+
self._film_digests.update(order)
|
|
1283
|
+
self._film_cycled = self._film_cycled or cycled
|
|
1284
|
+
if len(self._film_frames) > len(frames):
|
|
1285
|
+
_log(f"[animated] slicing {len(self._film_frames)} frames filmed over "
|
|
1286
|
+
f"{self._film_ms / 1000:.1f}s — every round of this board, not just this one")
|
|
1287
|
+
paths, temp_dir = self._slice(self._film_frames or frames, self._film_ms or burst_ms)
|
|
1288
|
+
return paths, temp_dir, moved
|
|
1156
1289
|
|
|
1157
1290
|
def _speculate(self, element: Any, shot: str, puzzle_source: Vendor, retry_mode: Optional[RetryMode],
|
|
1158
1291
|
text_mode: bool) -> Tuple[List[CaptchaAction], List[Dict[str, Any]], Optional[str]]:
|
|
@@ -1164,7 +1297,7 @@ class PageSolver:
|
|
|
1164
1297
|
try:
|
|
1165
1298
|
fut = pool.submit(self._get_solution, shot, puzzle_source, retry_mode, text_mode)
|
|
1166
1299
|
with self._phase(Phase.BURST):
|
|
1167
|
-
frames, order, moved, burst_ms = self._burst(element)
|
|
1300
|
+
frames, order, moved, burst_ms, _cycled = self._burst(element)
|
|
1168
1301
|
if not moved:
|
|
1169
1302
|
with self._phase(Phase.INFERENCE):
|
|
1170
1303
|
actions, usage = fut.result()
|
|
@@ -1178,6 +1311,7 @@ class PageSolver:
|
|
|
1178
1311
|
_debug(f"settled re-read failed: {exc}")
|
|
1179
1312
|
return actions, usage, None
|
|
1180
1313
|
_log("[animated] the widget moved while the model was reading it — finishing the recording")
|
|
1314
|
+
self._known_animated = True
|
|
1181
1315
|
fut.result()
|
|
1182
1316
|
self._grant_video_budget()
|
|
1183
1317
|
paths, keyframe_dir = self._slice(frames, burst_ms)
|
|
@@ -1200,11 +1334,25 @@ class PageSolver:
|
|
|
1200
1334
|
self._animated_plan = (plan[0], plan[1], None, None)
|
|
1201
1335
|
|
|
1202
1336
|
def _discard_animated_plan(self) -> None:
|
|
1337
|
+
"""Drop the ANSWER, keep the film.
|
|
1338
|
+
|
|
1339
|
+
An answer the widget refused is spent: reusing it presses the same wrong thing until the fence trips
|
|
1340
|
+
three rounds in, with half the budget unspent. The frames are the opposite — every chunk added makes
|
|
1341
|
+
the next ask a better question — so only a board that is GONE clears them, via `_stop_animated_film`.
|
|
1342
|
+
"""
|
|
1203
1343
|
plan = self._animated_plan
|
|
1204
1344
|
self._animated_plan = None
|
|
1205
1345
|
if plan and plan[1]:
|
|
1206
1346
|
shutil.rmtree(plan[1], ignore_errors=True)
|
|
1207
1347
|
|
|
1348
|
+
def _stop_animated_film(self) -> None:
|
|
1349
|
+
"""The board is gone: its film can never describe the next one."""
|
|
1350
|
+
self._discard_animated_plan()
|
|
1351
|
+
self._film_frames = []
|
|
1352
|
+
self._film_ms = 0.0
|
|
1353
|
+
self._film_digests = set()
|
|
1354
|
+
self._film_cycled = False
|
|
1355
|
+
|
|
1208
1356
|
def _answer_region_recurs(self, keyframe_path: str, ref: Any, box: Any) -> bool:
|
|
1209
1357
|
"""Does the chosen keyframe's answer area appear in a sibling keyframe of the same clip?"""
|
|
1210
1358
|
import cv2
|
|
@@ -1262,12 +1410,12 @@ class PageSolver:
|
|
|
1262
1410
|
polls += 1
|
|
1263
1411
|
if polls >= _NOT_THIS_BOARD_POLLS and best > _NOT_THIS_BOARD_DIFF:
|
|
1264
1412
|
_log("[animated] the widget no longer resembles the recorded board")
|
|
1265
|
-
self.
|
|
1413
|
+
self._stop_animated_film()
|
|
1266
1414
|
return False
|
|
1267
1415
|
_delay(cfg.keyframe_wait_poll_ms)
|
|
1268
1416
|
finally:
|
|
1269
1417
|
_unlink(probe)
|
|
1270
|
-
self.
|
|
1418
|
+
self._stop_animated_film()
|
|
1271
1419
|
_log(f"[animated] widget never matched the chosen keyframe within {wait_ms}ms (closest diff={best:.4f})")
|
|
1272
1420
|
return False
|
|
1273
1421
|
|
|
@@ -1441,10 +1589,6 @@ class PageSolver:
|
|
|
1441
1589
|
element, puzzle_source, role = widget.element, widget.vendor, widget.role
|
|
1442
1590
|
frame = element.content_frame()
|
|
1443
1591
|
scope = frame or widget.at
|
|
1444
|
-
# Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
1445
|
-
text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
|
|
1446
|
-
if text_mode:
|
|
1447
|
-
_log("widget has a text box; solving as a distorted-text captcha")
|
|
1448
1592
|
|
|
1449
1593
|
if frame and role == FrameRole.CHALLENGE and SELECTORS[puzzle_source].images:
|
|
1450
1594
|
if self._last_submit_frame_hash:
|
|
@@ -1454,6 +1598,22 @@ class PageSolver:
|
|
|
1454
1598
|
with self._phase(Phase.HCAPTCHA_IMAGES):
|
|
1455
1599
|
self._wait_for_board_images(frame, SELECTORS[puzzle_source])
|
|
1456
1600
|
|
|
1601
|
+
# THE TWO QUESTIONS BELOW ARE PUT TO A PAINTED BOARD. Which expert answers the round is read out of
|
|
1602
|
+
# the DOM and whether the board cycles is read off its motion, and a widget that has not drawn yet
|
|
1603
|
+
# has no text box to find and no motion but its own arrival.
|
|
1604
|
+
#
|
|
1605
|
+
# A reCAPTCHA board is asked NEITHER — it is never typed and never filmed — and it waits for its
|
|
1606
|
+
# board at the grid gate below, on the cells themselves. Waiting here as well put 0.8-1.8s in front
|
|
1607
|
+
# of that gate on every dynamic round, on the family with the tightest per-board budget.
|
|
1608
|
+
if puzzle_source != Vendor.RECAPTCHA:
|
|
1609
|
+
with self._phase(Phase.BOARD_PAINT):
|
|
1610
|
+
self._wait_for_board_painted(element)
|
|
1611
|
+
|
|
1612
|
+
# Only the DOM can tell a typed captcha from a click puzzle; hCaptcha and reCAPTCHA never type.
|
|
1613
|
+
text_mode = puzzle_source not in VENDORS_WITH_BESPOKE_HANDLING and self._answer_box(scope, widget.at) is not None
|
|
1614
|
+
if text_mode:
|
|
1615
|
+
_log("widget has a text box; solving as a distorted-text captcha")
|
|
1616
|
+
|
|
1457
1617
|
# A checkbox is clicked, not filmed, and a reCAPTCHA board is read by its grid below.
|
|
1458
1618
|
filmable = role != FrameRole.CHECKBOX and puzzle_source != Vendor.RECAPTCHA and not text_mode
|
|
1459
1619
|
is_animated = filmable and self._settle_or_animated(element)
|
|
@@ -1471,8 +1631,10 @@ class PageSolver:
|
|
|
1471
1631
|
if element_box:
|
|
1472
1632
|
return self._solve_recaptcha_grid(page, element, retry_mode, grid, element_box)
|
|
1473
1633
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1634
|
+
# The board a reCAPTCHA round photographs, for the rounds that do not take the grid path above.
|
|
1635
|
+
if puzzle_source == Vendor.RECAPTCHA:
|
|
1636
|
+
with self._phase(Phase.BOARD_PAINT):
|
|
1637
|
+
self._wait_for_board_painted(element)
|
|
1476
1638
|
|
|
1477
1639
|
shot = _tmp_png("captcha")
|
|
1478
1640
|
performed = slid = answered = have_shot = False
|
|
@@ -1483,18 +1645,30 @@ class PageSolver:
|
|
|
1483
1645
|
if self._animated_plan is not None:
|
|
1484
1646
|
keyframes, keyframe_dir, actions, all_usage = self._animated_plan
|
|
1485
1647
|
reused = actions is not None
|
|
1648
|
+
if not reused:
|
|
1649
|
+
# Drop the ANSWER, keep the FRAMES — and the frames grow. Filming another chunk and
|
|
1650
|
+
# re-slicing the accumulation puts a better question up; re-asking the same frames
|
|
1651
|
+
# puts the same question up and gets the same answer back.
|
|
1652
|
+
with self._phase(Phase.BURST):
|
|
1653
|
+
keyframes, new_dir, moved = self._record_keyframes(element)
|
|
1654
|
+
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1655
|
+
keyframe_dir = new_dir
|
|
1656
|
+
self._known_animated = self._known_animated or moved
|
|
1486
1657
|
_log("[animated] reusing the recorded answer" if reused
|
|
1487
|
-
else "[animated] re-asking on
|
|
1658
|
+
else "[animated] re-asking on a film a round longer")
|
|
1488
1659
|
else:
|
|
1489
1660
|
reused = False
|
|
1490
1661
|
with self._phase(Phase.BURST):
|
|
1491
|
-
keyframes, keyframe_dir = self._record_keyframes(element)
|
|
1492
|
-
|
|
1493
|
-
|
|
1662
|
+
keyframes, keyframe_dir, moved = self._record_keyframes(element)
|
|
1663
|
+
self._known_animated = self._known_animated or moved
|
|
1664
|
+
# The clip answers the question the probe asked: a board that never moved is a still, and the
|
|
1665
|
+
# video expert can only answer a still with a frame number the widget will not take.
|
|
1666
|
+
if len(keyframes) < 2 or not self._known_animated:
|
|
1667
|
+
_log("[animated] the recording shows a still board; solving it as a still")
|
|
1494
1668
|
is_animated = False
|
|
1495
|
-
shutil.copyfile(keyframes[
|
|
1669
|
+
shutil.copyfile(keyframes[-1], shot)
|
|
1496
1670
|
have_shot = True
|
|
1497
|
-
self.
|
|
1671
|
+
self._stop_animated_film()
|
|
1498
1672
|
shutil.rmtree(keyframe_dir, ignore_errors=True)
|
|
1499
1673
|
keyframe_dir = None
|
|
1500
1674
|
elif not reused:
|
|
@@ -1517,11 +1691,19 @@ class PageSolver:
|
|
|
1517
1691
|
|
|
1518
1692
|
element_box = element.bounding_box()
|
|
1519
1693
|
if not element_box and answer_needs_element_box(actions):
|
|
1694
|
+
# A WIDGET WITH NO BOX IS USUALLY A WIDGET THAT IS CLOSING, and it closes because the
|
|
1695
|
+
# answer was accepted. Asking costs one call; not asking cost the solve — measured on
|
|
1696
|
+
# 2026-09-17, five types graded `solved: true` on the board and then ended the attempt
|
|
1697
|
+
# here, which Tier 3 reports as an answer the board would have taken, reported failed.
|
|
1698
|
+
if self.is_captcha_solved(page):
|
|
1699
|
+
_log("the widget has no box because it is closing on a solved board; finishing.")
|
|
1700
|
+
return False, all_usage
|
|
1520
1701
|
raise CaptchaSolveError("could not get bounding box of captcha element")
|
|
1521
1702
|
|
|
1522
1703
|
_log("[answer] " + json.dumps({"actions": [_as_dict(a) for a in actions]}, default=str))
|
|
1523
1704
|
# A repeated answer is not re-performed: the widget already refused it, and every extra press is
|
|
1524
1705
|
# behaviour a vendor scores. Re-asking with a fresh sample or a recording is the round's only move.
|
|
1706
|
+
# `_note_answer` drops the refused animated answer on its way through, so the next round re-asks.
|
|
1525
1707
|
if self._note_answer(actions, retry_mode):
|
|
1526
1708
|
return False, all_usage
|
|
1527
1709
|
_log(f"executing {len(actions)} action(s)")
|
|
@@ -1627,11 +1809,20 @@ class PageSolver:
|
|
|
1627
1809
|
return SolveResult(True, self._last_mouse, _aggregate(usage))
|
|
1628
1810
|
|
|
1629
1811
|
for attempt in range(1, cfg.max_solve_loops + 1):
|
|
1630
|
-
|
|
1812
|
+
# A round that failed arms the second look only while its board is still up. A board the vendor has
|
|
1813
|
+
# replaced (`_fresh_board` clears `_acted_on_board`) has failed nothing yet, and arming it anyway
|
|
1814
|
+
# filmed every still board after a solve's first miss: 4-8s each, a 41.8s session became 49s.
|
|
1815
|
+
if attempt >= 2 and self._acted_on_board:
|
|
1631
1816
|
self._arm_animated_probe()
|
|
1632
|
-
|
|
1817
|
+
self._bound_ask_to_the_budget()
|
|
1818
|
+
# The deadline, not the bare config: a recording extends it once per solve (`_grant_video_budget`), and
|
|
1819
|
+
# a loop head reading only overall_solve_timeout_ms quit video solves at 45s with 29s still granted.
|
|
1820
|
+
deadline = self._deadline_ms if self._deadline_ms is not None else start + cfg.overall_solve_timeout_ms
|
|
1821
|
+
if _now() > deadline:
|
|
1822
|
+
granted = deadline - start - cfg.overall_solve_timeout_ms
|
|
1633
1823
|
raise CaptchaSolveError(
|
|
1634
|
-
f"captcha solve timed out after {
|
|
1824
|
+
f"captcha solve timed out after {deadline - start:.0f}ms (attempt {attempt}/{cfg.max_solve_loops})"
|
|
1825
|
+
+ (f", including {granted:.0f}ms granted for recording an animated challenge" if granted > 0 else ""))
|
|
1635
1826
|
if has_interacted and self.is_captcha_solved(page):
|
|
1636
1827
|
_log("captcha reports solved; finishing.")
|
|
1637
1828
|
return done()
|
|
@@ -1656,6 +1847,30 @@ class PageSolver:
|
|
|
1656
1847
|
retry_mode, pending_retry_mode = pending_retry_mode, None
|
|
1657
1848
|
try:
|
|
1658
1849
|
did_interact, round_usage = self._solve_single(page, widget, retry_mode)
|
|
1850
|
+
except NothingFilmedError as nothing_filmed:
|
|
1851
|
+
# A WIDGET THAT WILL NOT SCREENSHOT IS USUALLY A WIDGET THAT IS CLOSING, and it closes
|
|
1852
|
+
# because the answer was accepted. Every other failure in this loop asks
|
|
1853
|
+
# `is_captcha_solved` before giving up; this one re-raised through the branch below and
|
|
1854
|
+
# threw away boards the vendor had already taken. Measured: prosopo_grid_3x3 was 8/8
|
|
1855
|
+
# green across six runs on 09-12 and 09-13, then lost four attempts on 09-17 to exactly
|
|
1856
|
+
# this — each one after its FIRST board came back from /fx/verify graded `solved: true`,
|
|
1857
|
+
# with the solve dying on the second board the vendor dealt.
|
|
1858
|
+
if has_interacted:
|
|
1859
|
+
try:
|
|
1860
|
+
if self.is_captcha_solved(page):
|
|
1861
|
+
_log("nothing left to film because the board was accepted; finishing.")
|
|
1862
|
+
return done()
|
|
1863
|
+
except Exception:
|
|
1864
|
+
pass
|
|
1865
|
+
# Not solved: the handle is stale for the same reason it is unscreenshottable, so
|
|
1866
|
+
# take the stale-handle recovery rather than ending a solve with loops still in it.
|
|
1867
|
+
if stale_retries < cfg.max_stale_element_retries:
|
|
1868
|
+
stale_retries += 1
|
|
1869
|
+
_log(f"the widget would not screenshot; re-detecting "
|
|
1870
|
+
f"({stale_retries}/{cfg.max_stale_element_retries}).")
|
|
1871
|
+
_delay(cfg.stale_element_backoff_ms)
|
|
1872
|
+
continue
|
|
1873
|
+
raise
|
|
1659
1874
|
except AnimatedChallengeError:
|
|
1660
1875
|
raise
|
|
1661
1876
|
except UnsupportedCaptchaError as unsupported:
|
|
@@ -1739,6 +1954,18 @@ class PageSolver:
|
|
|
1739
1954
|
if not self.detect_captcha(page):
|
|
1740
1955
|
return done()
|
|
1741
1956
|
if not did_interact and not self._no_progress_rounds:
|
|
1957
|
+
# AN ANSWER WITH NOTHING TO EXECUTE IS NOT PROOF THE PAGE IS STUCK. The abort below
|
|
1958
|
+
# exists for a driver that cannot act at all; an answer the driver could not use is a
|
|
1959
|
+
# different thing, and on an animated board it is what a still expert returns when the
|
|
1960
|
+
# board is not a still — measured on the hosted arms: a drag with no source box, "slide
|
|
1961
|
+
# action, but the widget has neither a slider nor a draggable piece", solve over in 6s
|
|
1962
|
+
# with the recording never taken. So buy the recording path one round first.
|
|
1963
|
+
if self.config.video_solve_enabled and not self._retried_unusable_answer:
|
|
1964
|
+
self._retried_unusable_answer = True
|
|
1965
|
+
self._arm_animated_probe()
|
|
1966
|
+
_log("the answer had nothing this widget could execute; taking a second look "
|
|
1967
|
+
"before giving up")
|
|
1968
|
+
continue
|
|
1742
1969
|
raise CaptchaSolveError(
|
|
1743
1970
|
"captcha still detected but the solver performed no interactions; aborting to avoid an infinite loop")
|
|
1744
1971
|
|
|
@@ -162,6 +162,16 @@ def _sampling_from_env() -> Dict[str, Any]:
|
|
|
162
162
|
return {}
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
#: The ceiling on one ask, and the old hardcoded value. Measured why it matters: against the hosted
|
|
166
|
+
#: endpoint a single request hung and returned "the write operation timed out" after ~124s, inside a
|
|
167
|
+
#: solve whose whole budget was 45s + 29s of recording — the attempt ran 143s. A timeout longer than
|
|
168
|
+
#: the budget it sits inside cannot protect it, so callers with a deadline pass their own.
|
|
169
|
+
DEFAULT_REQUEST_TIMEOUT_S: float = 120.0
|
|
170
|
+
#: Never ask for less than this: a keyframe ask carries six images and a short timeout would turn a
|
|
171
|
+
#: busy endpoint into a guaranteed failure rather than a slow success.
|
|
172
|
+
MIN_REQUEST_TIMEOUT_S: float = 10.0
|
|
173
|
+
|
|
174
|
+
|
|
165
175
|
class ActionPlanner:
|
|
166
176
|
|
|
167
177
|
def __init__(
|
|
@@ -178,6 +188,10 @@ class ActionPlanner:
|
|
|
178
188
|
self.base_url = base_url or config.base_url()
|
|
179
189
|
self.api_key = api_key or config.api_key()
|
|
180
190
|
self.sampling: Dict[str, Any] = {}
|
|
191
|
+
#: Seconds a single ask may take. The CALLER owns this: a solve has a budget, and a request
|
|
192
|
+
#: that outlives it cannot be cancelled once it is in flight — the deadline is only read
|
|
193
|
+
#: between steps. Left alone it is the old ceiling; `PageSolver` lowers it to what remains.
|
|
194
|
+
self.request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
|
|
181
195
|
_prompt_key = (self.model if prompts.canonical_model_id(self.model)
|
|
182
196
|
else config.lora_adapter())
|
|
183
197
|
self.prompts = prompts.resolve(_prompt_key)
|
|
@@ -292,7 +306,8 @@ class ActionPlanner:
|
|
|
292
306
|
self._log(f"POST {url} model={model} max_tokens={max_tokens} "
|
|
293
307
|
f"images={len(parts)}")
|
|
294
308
|
|
|
295
|
-
resp = self._http.post(url, headers=headers, json=payload,
|
|
309
|
+
resp = self._http.post(url, headers=headers, json=payload,
|
|
310
|
+
timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
|
|
296
311
|
|
|
297
312
|
if not resp.ok:
|
|
298
313
|
raise errors.from_response(resp, url)
|