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/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.0.0",
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",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "3.0.0"
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"
@@ -99,4 +99,4 @@ __all__ = [
99
99
  "Vendor",
100
100
  ]
101
101
 
102
- __version__ = "3.0.0"
102
+ __version__ = "3.1.0"
@@ -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
- _STALE_HANDLE_RE = re.compile(r"Timeout .*exceeded|not visible|not attached|detached", re.IGNORECASE)
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: List[Dict[str, Any]]) -> bool:
101
- """An allow-list of what needs nothing: the other direction raises instead of clicking at the origin."""
102
- return any((a or {}).get("action") != ActionKind.DONE for a in actions)
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._discard_animated_plan()
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
- if verdict != SettleVerdict.ANIMATED:
1030
- # 'settled' is not proof of static; a repeated answer arms one recording to find out.
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 = True
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
- _log("[animated] challenge is animated — solving it from keyframes")
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) -> Tuple[List[Any], List[str], bool, float]:
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
- Returns `(frames, distinct_digests, moved, elapsed_ms)`. `moved` is a closed cycle OR a board still
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 and (next_at - t0) * 1000.0 < max_ms:
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
- frames, _order, _closed, burst_ms = self._burst(element)
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
- return self._slice(frames, burst_ms)
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._discard_animated_plan()
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._discard_animated_plan()
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
- with self._phase(Phase.BOARD_PAINT):
1475
- self._wait_for_board_painted(element)
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 the frames already recorded")
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
- if len(keyframes) < 2:
1493
- _log("[animated] the recording shows one picture; solving it as a still")
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[0], shot)
1669
+ shutil.copyfile(keyframes[-1], shot)
1496
1670
  have_shot = True
1497
- self._discard_animated_plan()
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
- if attempt >= 2:
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
- if _now() - start > cfg.overall_solve_timeout_ms:
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 {cfg.overall_solve_timeout_ms}ms (attempt {attempt}/{cfg.max_solve_loops})")
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, timeout=120)
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)