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 +36 -9
- package/dist/solver.d.ts +3 -0
- package/dist/solver.js +60 -20
- package/dist/types.d.ts +30 -0
- package/package.json +11 -3
- package/python/README.md +33 -5
- package/python/pyproject.toml +21 -4
- package/python/src/captchakraken/page_solver.py +114 -23
package/README.md
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
19
|
-
|
|
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 →
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
package/python/pyproject.toml
CHANGED
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "2.6.
|
|
8
|
-
description = "
|
|
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 = [
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
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
|