captchakraken 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,20 @@
1
- # captchakraken
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/logo-card.png" alt="CaptchaKraken" width="128" height="128">
3
+ </p>
2
4
 
3
- The TypeScript browser driver for [CaptchaKraken](https://github.com/JWriter20/CaptchaKraken).
4
- Hand it a Playwright/Puppeteer `Page`; it finds the captcha, reads the grid with
5
- a fine-tuned **Qwen3.5-9B** vision model on **vLLM**, clicks human-like, and
6
- verifies through to a token.
5
+ <h1 align="center">captchakraken</h1>
6
+
7
+ <p align="center">
8
+ <b>A captcha solver for browser automation.</b><br>
9
+ The TypeScript driver for <a href="https://github.com/JWriter20/CaptchaKraken">CaptchaKraken</a>.
10
+ </p>
11
+
12
+ Hand it a Playwright or Puppeteer `Page`. It finds the captcha, reads the whole
13
+ puzzle with a fine-tuned **Qwen3.5-9B** vision model, and clicks, drags, slides
14
+ or types human-like through to a token.
15
+
16
+ Run the model on **your own hardware**, or point it at the **hosted API** and run
17
+ nothing at all.
7
18
 
8
19
  > Full docs — demo videos, accuracy, self-hosting — live in the main repo
9
20
  > **[CaptchaKraken](https://github.com/JWriter20/CaptchaKraken)**.
@@ -15,9 +26,9 @@ npm install captchakraken
15
26
  ```
16
27
 
17
28
  The package bundles the Python engine (`captchakraken`) and, on `postinstall`,
18
- creates a local venv with its lightweight core deps so grid detection + the vLLM
19
- planner work out of the box. It ships **no browser** — bring your own
20
- Playwright-compatible launcher.
29
+ creates a local venv with its lightweight core deps, so tile detection and the
30
+ inference client work out of the box. It ships **no browser** — bring your own
31
+ Playwright-compatible launcher (camoufox and patchright both work).
21
32
 
22
33
  - Skip the Python bootstrap: `CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1`
23
34
  - To **self-host** the model, run the repo's `setup.sh` (installs vLLM + weights).
@@ -37,6 +48,22 @@ claude mcp add captchakraken -- npx -y captchakraken-mcp
37
48
  `create_api_key` writes the key and the endpoint to `~/.captchakraken/credentials`,
38
49
  which the client reads on its own — **no environment variables needed**.
39
50
 
51
+ ## What it solves
52
+
53
+ | Vendor | Puzzles |
54
+ |---|---|
55
+ | **reCAPTCHA** | 3×3 and 4×4 image grids, including the dynamic re-deal |
56
+ | **hCaptcha** | Image grids, click, drag, connect-the-path, tetris-fit, animated |
57
+ | **GeeTest** v3 + v4 | Slide, icon, nine, svg, gobang, iconcrush |
58
+ | **NetEase Yidun** | Jigsaw, picture-click, icon-click |
59
+ | **Tencent, Lemin, Prosopo** | Slide, cropped-image and grid flows |
60
+ | **BotDetect, MTCaptcha, Yandex** | Distorted text — read and typed, not clicked |
61
+ | **Cloudflare Turnstile** | Via the checkbox flow (free on the hosted API) |
62
+
63
+ **44 puzzle types**, driven end to end in CI against generated fixtures on both
64
+ the TypeScript and Python ports. Animated challenges are recorded, sliced into
65
+ keyframes and answered with the frame the action belongs to.
66
+
40
67
  ## Usage
41
68
 
42
69
  ```typescript
@@ -49,7 +76,7 @@ await page.goto('https://www.google.com/recaptcha/api2/demo');
49
76
 
50
77
  // Reads VLLM_BASE_URL + CAPTCHA_KRAKEN_API_KEY from the environment.
51
78
  const solver = new CaptchaKrakenSolver();
52
- await solver.solve(page); // detect → solve grid → click → verify
79
+ await solver.solve(page); // detect → read → act → verify
53
80
 
54
81
  await browser.close();
55
82
  ```
package/dist/solver.d.ts CHANGED
@@ -15,6 +15,9 @@ export declare const SOLVE_DEFAULTS: {
15
15
  };
16
16
  export declare class CaptchaKrakenSolver {
17
17
  private config;
18
+ /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
19
+ private videoBudgetMs;
20
+ private videoBudgetGranted;
18
21
  private lastMousePosition;
19
22
  private imageCounter;
20
23
  private sessionDebugDir;
package/dist/solver.js CHANGED
@@ -252,6 +252,9 @@ exports.SOLVE_DEFAULTS = {
252
252
  };
253
253
  class CaptchaKrakenSolver {
254
254
  constructor(config = {}) {
255
+ /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
256
+ this.videoBudgetMs = 0;
257
+ this.videoBudgetGranted = false;
255
258
  this.imageCounter = 0; // Track images sent to CLI for debugging
256
259
  this.sessionDebugDir = null;
257
260
  // onStep instrumentation: monotonic step index + solve-start wall clock.
@@ -410,8 +413,13 @@ class CaptchaKrakenSolver {
410
413
  // solve loops" instead. Mirrors page_solver.py's max_render_waits.
411
414
  const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1);
412
415
  for (let attempt = 1; attempt <= maxSolveLoops; attempt++) {
413
- if (Date.now() - start > overallSolveTimeoutMs) {
414
- throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`);
416
+ // `videoBudgetMs` is 0 until this solve records something, so a solve
417
+ // that never escalates gets exactly the deadline the caller configured.
418
+ const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs;
419
+ if (Date.now() - start > budgetMs) {
420
+ throw new Error(`Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})`
421
+ + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '')
422
+ + '.');
415
423
  }
416
424
  /*
417
425
  * ASK THE VENDOR BEFORE DOING ANY MORE WORK.
@@ -929,26 +937,34 @@ class CaptchaKrakenSolver {
929
937
  await this.emitStep(captchaElement, 'wait', `waited ${action.duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
930
938
  }
931
939
  }
932
- // `scope` when there is no vendor iframe. Eight vendors render into the
933
- // HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
934
- // MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
935
- // and the button was never even SEARCHED FOR, while the text box and
936
- // the slider handle it sits beside were both found through `scope`
937
- // above. Two containers for two halves of one interaction.
938
- //
939
- // `scope` is the widget container and getVerifyButton's xpaths are
940
- // RELATIVE, so the submit of the FORM the captcha guards is out of
941
- // reach by construction. The press itself is bounded by
942
- // `shouldClickSubmit` below, which is where that hazard belongs.
943
- const lookup = frame ?? (slid ? null : scope);
944
- if (lookup) {
945
- verifyButton = await this.getVerifyButton(lookup);
946
- if (verifyButton) {
947
- await this.move(page, verifyButton);
948
- }
940
+ }
941
+ // Resolve the widget's submit control AFTER the action loop, not inside
942
+ // it. An empty plan — the correct answer to reCAPTCHA 3x3's
943
+ // `none_present` variation, where nothing matches and the control reads
944
+ // SKIP — never enters that loop, so the lookup never ran, the press below
945
+ // found `verifyButton` null, and the round aborted on 'performed no
946
+ // interactions'. The finder was never the problem: 'Skip' has always been
947
+ // in its list. It was simply never called for the one answer shape that
948
+ // performs no other action.
949
+ // `scope` when there is no vendor iframe. Eight vendors render into the
950
+ // HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
951
+ // MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
952
+ // and the button was never even SEARCHED FOR, while the text box and
953
+ // the slider handle it sits beside were both found through `scope`
954
+ // above. Two containers for two halves of one interaction.
955
+ //
956
+ // `scope` is the widget container and getVerifyButton's xpaths are
957
+ // RELATIVE, so the submit of the FORM the captcha guards is out of
958
+ // reach by construction. The press itself is bounded by
959
+ // `shouldClickSubmit` below, which is where that hazard belongs.
960
+ const lookup = frame ?? (slid ? null : scope);
961
+ if (lookup) {
962
+ verifyButton = await this.getVerifyButton(lookup);
963
+ if (verifyButton) {
964
+ await this.move(page, verifyButton);
949
965
  }
950
- // 'done' actions intentionally fall through to the Verify-button block below.
951
966
  }
967
+ // 'done' actions fall through to the submit block below, same as before.
952
968
  // Submit policy: press the widget's own submit control whenever we have
953
969
  // put an ANSWER into it — a selection, a placed piece, a typed code — or
954
970
  // when we had nothing to do and want the round to advance.
@@ -2382,6 +2398,26 @@ class CaptchaKrakenSolver {
2382
2398
  async recordKeyframeBurst(captchaElement) {
2383
2399
  const fps = Math.max(1, this.config.videoBurstFps ?? 10);
2384
2400
  const durationMs = this.config.videoBurstDurationMs ?? 4000;
2401
+ /*
2402
+ * THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
2403
+ *
2404
+ * `overallSolveTimeoutMs` counts rounds and a recording is not a round —
2405
+ * see `videoExtraInferenceMs` for the arithmetic and for what it cost not
2406
+ * to have it. Recorded as an EXTENSION rather than by loosening the config,
2407
+ * so a solve that never escalates keeps the deadline the caller asked for.
2408
+ *
2409
+ * Same one-shot, same total, same reason as page_solver.py's
2410
+ * `video_budget_ms`: the ports must not disagree about how long a video
2411
+ * solve may take, or the same fixture passes on one and times out on the
2412
+ * other and it reads as a driver bug.
2413
+ */
2414
+ if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) {
2415
+ this.videoBudgetGranted = true;
2416
+ this.videoBudgetMs =
2417
+ durationMs +
2418
+ (this.config.keyframeWaitTimeoutMs ?? 6000) +
2419
+ (this.config.videoExtraInferenceMs ?? 8000);
2420
+ }
2385
2421
  const total = Math.max(1, Math.round(durationMs / (1000 / fps)));
2386
2422
  const intervalMs = 1000 / fps;
2387
2423
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck_burst_'));
@@ -2568,6 +2604,10 @@ class CaptchaKrakenSolver {
2568
2604
  this.keyframeMode = null;
2569
2605
  this.lastAnswerSig = null;
2570
2606
  this.noProgressRounds = 0;
2607
+ // Per SOLVE, not per process: a grant leaking into the next captcha would
2608
+ // silently hand a still puzzle 18s it was never meant to have.
2609
+ this.videoBudgetMs = 0;
2610
+ this.videoBudgetGranted = false;
2571
2611
  }
2572
2612
  /**
2573
2613
  * A stable identity for "the answer this round is about to execute".
package/dist/types.d.ts CHANGED
@@ -330,6 +330,36 @@ export interface CaptchaKrakenConfig {
330
330
  */
331
331
  keyframeWaitTimeoutMs?: number;
332
332
  keyframeWaitPollMs?: number;
333
+ /**
334
+ * Extra wall clock (ms) granted ONCE, the first time a solve escalates to a
335
+ * recording. NOT a looser `overallSolveTimeoutMs`.
336
+ *
337
+ * That budget counts ROUNDS — `maxSolveLoops` x ~7000ms, which
338
+ * no-progress.test.ts pins. A recording is not a round: it is a fixed extra
339
+ * stage costing the burst, the slice, one MULTI-IMAGE inference (six
340
+ * keyframes, several times a still's) and the wait for the widget to come
341
+ * back round to the chosen frame. Nothing in the 45s was set aside for it, so
342
+ * an escalation late in a solve simply ran the clock out and reported a
343
+ * timeout — which reads as a slow model rather than as a budget with no room
344
+ * for what the solver had just decided to do.
345
+ *
346
+ * Measured 2026-08-22, Tier 3 run 32596340560: hcaptcha_click_image_by_traits,
347
+ * hcaptcha_connect_path and hcaptcha_grid_3x3_property each failed this port
348
+ * with "Captcha solve timed out after 45000ms (attempt 6/6)" at 49-59s, and
349
+ * solve in 14-20s on the rounds the still path answers them.
350
+ *
351
+ * The total granted is `videoBurstDurationMs + keyframeWaitTimeoutMs + this`,
352
+ * derived so a longer burst carries its own budget — page_solver.py's
353
+ * `video_budget_ms` is the same arithmetic, since the two ports must not
354
+ * disagree about how long a video solve may take.
355
+ *
356
+ * Granted only when `videoSolveEnabled`: a caller who wants a hard deadline
357
+ * turns recording off, which already means "fail fast rather than spend the
358
+ * recording time".
359
+ *
360
+ * Default: 8000
361
+ */
362
+ videoExtraInferenceMs?: number;
333
363
  /**
334
364
  * After clicking Submit/Verify, the solver EXPECTS the frame to change (advance
335
365
  * to the next round, or close because it was accepted). This is how long (ms)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "captchakraken",
3
- "version": "2.6.0",
4
- "description": "Browser-agnostic Playwright/Puppeteer captcha solver. Detects the captcha, reads the grid with a fine-tuned Qwen3.5-9B vision model on vLLM, and clicks through to a token.",
3
+ "version": "2.6.1",
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 — 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",
7
7
  "files": [
@@ -25,11 +25,19 @@
25
25
  },
26
26
  "keywords": [
27
27
  "captcha",
28
+ "captcha-solver",
28
29
  "recaptcha",
29
30
  "hcaptcha",
31
+ "geetest",
32
+ "turnstile",
33
+ "yidun",
34
+ "tencent-captcha",
30
35
  "playwright",
31
36
  "puppeteer",
32
- "automation",
37
+ "camoufox",
38
+ "browser-automation",
39
+ "scraping",
40
+ "anti-bot",
33
41
  "vllm",
34
42
  "qwen"
35
43
  ],
package/python/README.md CHANGED
@@ -1,14 +1,42 @@
1
- # captchakraken
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/logo-card.png" alt="CaptchaKraken" width="128" height="128">
3
+ </p>
2
4
 
3
- The Python engine + CLI behind [CaptchaKraken](https://github.com/JWriter20/CaptchaKraken):
4
- OpenCV grid detection + a fine-tuned **Qwen3.5-9B** vision LoRA served on
5
- **vLLM**. Given a screenshot of a captcha grid, it locates the tiles and returns
6
- the click plan. Ships the `captchakraken` command.
5
+ <h1 align="center">captchakraken</h1>
6
+
7
+ <p align="center">
8
+ <b>A captcha solver for browser automation.</b><br>
9
+ The Python engine and CLI behind <a href="https://github.com/JWriter20/CaptchaKraken">CaptchaKraken</a>.
10
+ </p>
11
+
12
+ OpenCV tile detection plus a fine-tuned **Qwen3.5-9B** vision model. Give it a
13
+ screenshot of a captcha and it returns the plan to solve it — which tiles to
14
+ select, where to click, what to drag, how far to slide, or what text to type.
15
+ Ships the `captchakraken` command.
16
+
17
+ Run the model on **your own hardware**, or point it at the **hosted API** and run
18
+ nothing at all.
7
19
 
8
20
  > For demo videos, accuracy numbers, the browser driver, and the full
9
21
  > self-hosting guide, see the main repo
10
22
  > **[CaptchaKraken](https://github.com/JWriter20/CaptchaKraken)**.
11
23
 
24
+ ## What it solves
25
+
26
+ | Vendor | Puzzles |
27
+ |---|---|
28
+ | **reCAPTCHA** | 3×3 and 4×4 image grids, including the dynamic re-deal |
29
+ | **hCaptcha** | Image grids, click, drag, connect-the-path, tetris-fit, animated |
30
+ | **GeeTest** v3 + v4 | Slide, icon, nine, svg, gobang, iconcrush |
31
+ | **NetEase Yidun** | Jigsaw, picture-click, icon-click |
32
+ | **Tencent, Lemin, Prosopo** | Slide, cropped-image and grid flows |
33
+ | **BotDetect, MTCaptcha, Yandex** | Distorted text — read and typed, not clicked |
34
+ | **Cloudflare Turnstile** | Via the checkbox flow (free on the hosted API) |
35
+
36
+ **44 puzzle types**, driven end to end in CI against generated fixtures on both
37
+ the TypeScript and Python ports. Animated challenges are recorded, sliced into
38
+ keyframes and answered with the frame the action belongs to.
39
+
12
40
  ## Install
13
41
 
14
42
  ```bash
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "2.6.0"
8
- description = "Self-hosted captcha solver: OpenCV grid detection + a fine-tuned Qwen3.5-9B vision LoRA served on vLLM."
7
+ version = "2.6.1"
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.10"
11
11
  # Source-available, NOT GPL. The CaptchaKraken Source-Available License permits
@@ -14,12 +14,26 @@ requires-python = ">=3.10"
14
14
  # — see LICENSE. (GPL-3.0 would wrongly permit all three.)
15
15
  license = { file = "LICENSE" }
16
16
  authors = [{ name = "Jake Writer" }]
17
- keywords = ["captcha", "recaptcha", "hcaptcha", "vllm", "qwen", "computer-vision", "automation"]
17
+ keywords = [
18
+ "captcha", "captcha-solver", "recaptcha", "hcaptcha", "geetest", "turnstile",
19
+ "yidun", "tencent-captcha", "playwright", "browser-automation", "scraping",
20
+ "anti-bot", "vllm", "qwen", "computer-vision", "ocr",
21
+ ]
18
22
  classifiers = [
23
+ "Development Status :: 5 - Production/Stable",
24
+ "Intended Audience :: Developers",
19
25
  "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
20
30
  "Operating System :: OS Independent",
21
31
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
32
+ "Topic :: Scientific/Engineering :: Image Recognition",
33
+ "Topic :: Internet :: WWW/HTTP :: Browsers",
34
+ "Topic :: Software Development :: Testing",
22
35
  "License :: Other/Proprietary License",
36
+ "Typing :: Typed",
23
37
  ]
24
38
 
25
39
  # Core = the lightweight CLIENT: OpenCV grid detection + the HTTP planner that
@@ -55,8 +69,11 @@ dev = [
55
69
  captchakraken = "captchakraken.cli:main"
56
70
 
57
71
  [project.urls]
58
- Homepage = "https://github.com/JWriter20/CaptchaKraken"
72
+ Homepage = "https://captchakraken.com"
73
+ Documentation = "https://github.com/JWriter20/CaptchaKraken#readme"
74
+ Source = "https://github.com/JWriter20/CaptchaKraken"
59
75
  Issues = "https://github.com/JWriter20/CaptchaKraken/issues"
76
+ Changelog = "https://github.com/JWriter20/CaptchaKraken/blob/main/CHANGELOG.md"
60
77
 
61
78
  [tool.hatch.build.targets.wheel]
62
79
  packages = ["src/captchakraken"]
@@ -317,6 +317,38 @@ class PageSolverConfig:
317
317
  # still a better use of the remaining budget than a timeout.
318
318
  keyframe_wait_timeout_ms: int = 6_000
319
319
  keyframe_wait_poll_ms: int = 120
320
+ #: Extra wall clock granted ONCE, the first time a solve escalates to a
321
+ #: recording. NOT a looser `overall_solve_timeout_ms`.
322
+ #:
323
+ #: That budget is sized for ROUNDS — "a round costs ~4-7s, so six is the
324
+ #: budget". The recording path is not a round. It is a fixed extra stage
325
+ #: costing the burst, the slice, one MULTI-IMAGE inference (six keyframes,
326
+ #: several times a still's) and the wait for the widget to come back round
327
+ #: to the frame the model chose. Nothing in the 45s was ever set aside for
328
+ #: it, so an escalation started at ~35s ran the clock out mid-burst and
329
+ #: reported a TIMEOUT — which reads as a slow model rather than as a budget
330
+ #: with no room for the thing the solver had just decided to do.
331
+ #:
332
+ #: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on
333
+ #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and
334
+ #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during
335
+ #: recording the animated challenge", at 45.7-52.7s elapsed. The same three
336
+ #: fixtures solve in 11-20s on the rounds where the still path happens to
337
+ #: answer them, so it is the escalation that does not fit, not the puzzle.
338
+ #:
339
+ #: Granted only when `video_solve_enabled` — a caller who wants a hard
340
+ #: deadline turns recording off, which is the switch that already means
341
+ #: "fail fast rather than spend the recording time".
342
+ video_extra_inference_ms: int = 8_000
343
+
344
+ def video_budget_ms(self) -> int:
345
+ """What one escalation to recording is allowed to cost, on top.
346
+
347
+ DERIVED, so a longer burst or a longer keyframe wait carries its own
348
+ budget instead of quietly reintroducing the timeout this exists to fix.
349
+ """
350
+ return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms
351
+ + self.video_extra_inference_ms)
320
352
 
321
353
  # Grid load / dynamic-refresh timing.
322
354
  # How long to wait for hCaptcha's task images to paint before screenshotting
@@ -493,6 +525,9 @@ class PageSolver:
493
525
  self._known_animated = False
494
526
  self._animated_probe_armed = False
495
527
  self._animated_probe_done = False
528
+ # One-shot: the recording path buys its own budget the first time it is
529
+ # entered, and never again in the same solve. See video_budget_ms.
530
+ self._video_budget_granted = False
496
531
  self._keyframe_mode: Optional[str] = None
497
532
  # Repeat detection; see `max_no_progress_rounds`.
498
533
  self._last_answer_sig: Optional[str] = None
@@ -562,6 +597,7 @@ class PageSolver:
562
597
  self._known_animated = False
563
598
  self._animated_probe_armed = False
564
599
  self._animated_probe_done = False
600
+ self._video_budget_granted = False
565
601
  self._keyframe_mode = None
566
602
  self._last_answer_sig: Optional[str] = None
567
603
  self._no_progress_rounds = 0
@@ -1603,13 +1639,59 @@ class PageSolver:
1603
1639
  total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps)))
1604
1640
  interval = 1.0 / fps
1605
1641
 
1642
+ # THE ESCALATION BUYS ITS OWN BUDGET, once per solve.
1643
+ #
1644
+ # `overall_solve_timeout_ms` counts rounds, and this is not a round —
1645
+ # see `video_budget_ms` for the arithmetic and for what it cost not to
1646
+ # have it. Granted here rather than where the probe arms because this is
1647
+ # the one place every path into a recording goes through.
1648
+ if cfg.video_solve_enabled and not self._video_budget_granted:
1649
+ self._video_budget_granted = True
1650
+ if self._deadline_ms is not None:
1651
+ self._deadline_ms += cfg.video_budget_ms()
1652
+ _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path")
1653
+
1654
+ # Checked ONCE, before the first frame — never per frame.
1655
+ #
1656
+ # A HALF-RECORDED BURST IS WORTHLESS: the slicer reads a clip's temporal
1657
+ # structure, so stopping at frame 27 of 40 does not produce a shorter
1658
+ # answer, it produces a recording that may not contain the screen the
1659
+ # answer is on. Aborting mid-way therefore threw away the whole burst
1660
+ # AND the ~3s already spent making it, to report a timeout. The burst is
1661
+ # also fixed-length and short, so it is not one of the places that "can
1662
+ # legitimately spin for a long time" that `_check_deadline` is for.
1663
+ #
1664
+ # If the budget cannot fit one, say THAT — a caller who has tightened
1665
+ # `overall_solve_timeout_ms` below what a recording costs has asked for
1666
+ # something impossible, and should hear it rather than watch a burst die
1667
+ # partway through every time.
1668
+ if self._deadline_ms is not None:
1669
+ left = self._deadline_ms - time.monotonic() * 1000.0
1670
+ if left < cfg.video_burst_duration_ms:
1671
+ raise CaptchaSolveError(
1672
+ f"only {left:.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve "
1673
+ f"budget is left and an animated recording needs "
1674
+ f"{cfg.video_burst_duration_ms}ms — not starting one that would "
1675
+ f"be cut off mid-way. Raise overall_solve_timeout_ms or "
1676
+ f"video_extra_inference_ms, or set video_solve_enabled=False."
1677
+ )
1678
+
1606
1679
  frames: List[Any] = []
1607
1680
 
1608
1681
  remaining = max(0, total - len(frames))
1609
1682
  shot = _tmp_png("burst")
1683
+ # A burst that runs far past its own length is a hung screenshot, not a
1684
+ # tight budget — bounded separately so the two cannot be confused.
1685
+ burst_deadline = (time.monotonic() * 1000.0
1686
+ + 3 * cfg.video_burst_duration_ms + 5_000)
1610
1687
  try:
1611
1688
  for i in range(remaining):
1612
- self._check_deadline("recording the animated challenge")
1689
+ if time.monotonic() * 1000.0 > burst_deadline:
1690
+ raise CaptchaSolveError(
1691
+ f"the animated recording stalled: {i} of {remaining} frames "
1692
+ f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget "
1693
+ f"is not screenshotting."
1694
+ )
1613
1695
  start = time.monotonic()
1614
1696
  try:
1615
1697
  # animations ALLOWED — see `_screenshot`. The JS port has
@@ -2446,28 +2528,37 @@ class PageSolver:
2446
2528
  _delay(duration)
2447
2529
  performed_action = True
2448
2530
 
2449
- # `scope` when there is no vendor iframe. Eight vendors render
2450
- # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2451
- # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2452
- # for all of them and the button was never even SEARCHED FOR,
2453
- # while the text box and the slider handle it sits beside were
2454
- # both found through `scope` a few lines above. Two containers
2455
- # for two halves of one interaction.
2456
- #
2457
- # This used to be gated on `typed`, for fear of turning up the
2458
- # submit of the FORM the captcha guards. `scope` is the widget
2459
- # container and the xpaths are RELATIVE, so that button is out
2460
- # of reach by construction; what the gate actually did was make
2461
- # every non-typed inline puzzle unsubmittable. Measured on the
2462
- # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2463
- # burning all ten solve loops on a puzzle they had answered on
2464
- # the first one. The press itself is still bounded by
2465
- # `should_submit` below, which is where the hazard belongs.
2466
- lookup = frame or (scope if not slid else None)
2467
- if lookup is not None:
2468
- verify_button = self._get_verify_button(lookup)
2469
- if verify_button:
2470
- self._move_to_element(page, verify_button)
2531
+
2532
+ # Resolve the widget's submit control AFTER the action loop, not
2533
+ # inside it. An empty plan — the correct answer to reCAPTCHA 3x3's
2534
+ # `none_present` variation, where nothing matches and the control
2535
+ # reads SKIP — never enters that loop, so the lookup never ran, the
2536
+ # press below found `verify_button` None, and the round aborted on
2537
+ # 'performed no interactions'. The finder was never the problem:
2538
+ # 'Skip' has always been in its list. It was simply never called for
2539
+ # the one answer shape that performs no other action.
2540
+ # `scope` when there is no vendor iframe. Eight vendors render
2541
+ # into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
2542
+ # Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
2543
+ # for all of them and the button was never even SEARCHED FOR,
2544
+ # while the text box and the slider handle it sits beside were
2545
+ # both found through `scope` a few lines above. Two containers
2546
+ # for two halves of one interaction.
2547
+ #
2548
+ # This used to be gated on `typed`, for fear of turning up the
2549
+ # submit of the FORM the captcha guards. `scope` is the widget
2550
+ # container and the xpaths are RELATIVE, so that button is out
2551
+ # of reach by construction; what the gate actually did was make
2552
+ # every non-typed inline puzzle unsubmittable. Measured on the
2553
+ # Tier 3 fixtures: 4 pairs aborting outright and 11 more types
2554
+ # burning all ten solve loops on a puzzle they had answered on
2555
+ # the first one. The press itself is still bounded by
2556
+ # `should_submit` below, which is where the hazard belongs.
2557
+ lookup = frame or (scope if not slid else None)
2558
+ if lookup is not None:
2559
+ verify_button = self._get_verify_button(lookup)
2560
+ if verify_button:
2561
+ self._move_to_element(page, verify_button)
2471
2562
 
2472
2563
  # Submit policy: press the widget's own submit control whenever we
2473
2564
  # have put an ANSWER into it — a selection, a placed piece, a typed