captchakraken 2.6.0 → 2.8.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.
Files changed (36) hide show
  1. package/README.md +126 -16
  2. package/dist/cli-invocation.d.ts +2 -0
  3. package/dist/cli-invocation.js +5 -1
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/solver.d.ts +115 -5
  11. package/dist/solver.js +583 -190
  12. package/dist/timing.d.ts +48 -0
  13. package/dist/timing.js +103 -0
  14. package/dist/trajectory.d.ts +27 -0
  15. package/dist/trajectory.js +104 -0
  16. package/dist/types.d.ts +129 -0
  17. package/package.json +11 -3
  18. package/python/README.md +76 -5
  19. package/python/pyproject.toml +21 -4
  20. package/python/src/captchakraken/__init__.py +36 -1
  21. package/python/src/captchakraken/cli.py +62 -4
  22. package/python/src/captchakraken/config.py +28 -2
  23. package/python/src/captchakraken/errors.py +24 -0
  24. package/python/src/captchakraken/humanize.py +929 -0
  25. package/python/src/captchakraken/keyframes.py +99 -0
  26. package/python/src/captchakraken/models.json +149 -13
  27. package/python/src/captchakraken/page_solver.py +588 -291
  28. package/python/src/captchakraken/pinned_model.json +3 -2
  29. package/python/src/captchakraken/planner.py +118 -9
  30. package/python/src/captchakraken/prompts.py +182 -0
  31. package/python/src/captchakraken/server_manager.py +9 -4
  32. package/python/src/captchakraken/solver.py +97 -57
  33. package/python/src/captchakraken/timing.py +8 -3
  34. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  35. package/python/src/captchakraken/trajectory.py +117 -0
  36. package/python/src/captchakraken/updater.py +62 -1
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where one solve's wall-clock went, by phase.
3
+ *
4
+ * The TypeScript half of `python/src/captchakraken/timing.py`; the two are one
5
+ * implementation in two languages and `test_timing_parity.py` pins that they
6
+ * name the same phases. That parity is the point: without it the ports can only
7
+ * be compared on their totals, and "the JS port is four seconds slower" is not
8
+ * a bug report — "the JS port spends four seconds starting a Python
9
+ * interpreter it did not need to start" is.
10
+ *
11
+ * Always on. It is a few map updates per phase against multi-second waits, and
12
+ * a budget you have to opt into is one nobody has when the slow solve happens.
13
+ * Only PRINTED under `CAPTCHA_TIMINGS=1`; always RETURNED on `SolveResult`.
14
+ */
15
+ /**
16
+ * What a phase's time COUNTS AS, when asking how much of a solve was useful.
17
+ *
18
+ * Only two kinds of second are worth spending: one the model is thinking in,
19
+ * and one the pointer is travelling in (which has to look human, so it cannot
20
+ * be rushed). Everything else is the driver waiting on a clock, and every such
21
+ * wait is a candidate for deletion or for overlapping with something real.
22
+ */
23
+ export declare const PRODUCTIVE: Set<string>;
24
+ export declare function timingsEnabled(): boolean;
25
+ export declare class PhaseBudget {
26
+ readonly totals: Map<string, number>;
27
+ readonly counts: Map<string, number>;
28
+ private readonly open;
29
+ private readonly t0;
30
+ /**
31
+ * Attribute `fn`'s wall-clock to `name`.
32
+ *
33
+ * Phases may nest (a burst contains its screenshots). Only the OUTERMOST of a
34
+ * given NAME accumulates, so re-entering one cannot double-count it — but a
35
+ * phase nested inside a differently-named one counts under both, deliberately:
36
+ * the cursor drifting over the widget WHILE the model generates is genuinely
37
+ * both `mouse` and `inference`, and hiding either would misreport what the
38
+ * solve was doing. The totals are therefore an attribution, not a partition,
39
+ * and can exceed the elapsed time.
40
+ */
41
+ phase<T>(name: string, fn: () => Promise<T>): Promise<T>;
42
+ /** Record a measured span directly, for blocks that would need re-indenting. */
43
+ add(name: string, ms: number): void;
44
+ elapsedMs(): number;
45
+ /** Plain object for `SolveResult.phases`, with the solve total folded in. */
46
+ toObject(): Record<string, number>;
47
+ report(): string;
48
+ }
package/dist/timing.js ADDED
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ /**
3
+ * Where one solve's wall-clock went, by phase.
4
+ *
5
+ * The TypeScript half of `python/src/captchakraken/timing.py`; the two are one
6
+ * implementation in two languages and `test_timing_parity.py` pins that they
7
+ * name the same phases. That parity is the point: without it the ports can only
8
+ * be compared on their totals, and "the JS port is four seconds slower" is not
9
+ * a bug report — "the JS port spends four seconds starting a Python
10
+ * interpreter it did not need to start" is.
11
+ *
12
+ * Always on. It is a few map updates per phase against multi-second waits, and
13
+ * a budget you have to opt into is one nobody has when the slow solve happens.
14
+ * Only PRINTED under `CAPTCHA_TIMINGS=1`; always RETURNED on `SolveResult`.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.PhaseBudget = exports.PRODUCTIVE = void 0;
18
+ exports.timingsEnabled = timingsEnabled;
19
+ /**
20
+ * What a phase's time COUNTS AS, when asking how much of a solve was useful.
21
+ *
22
+ * Only two kinds of second are worth spending: one the model is thinking in,
23
+ * and one the pointer is travelling in (which has to look human, so it cannot
24
+ * be rushed). Everything else is the driver waiting on a clock, and every such
25
+ * wait is a candidate for deletion or for overlapping with something real.
26
+ */
27
+ exports.PRODUCTIVE = new Set(['inference', 'mouse']);
28
+ function timingsEnabled() {
29
+ return process.env.CAPTCHA_TIMINGS === '1';
30
+ }
31
+ class PhaseBudget {
32
+ constructor() {
33
+ this.totals = new Map();
34
+ this.counts = new Map();
35
+ this.open = [];
36
+ this.t0 = Date.now();
37
+ }
38
+ /**
39
+ * Attribute `fn`'s wall-clock to `name`.
40
+ *
41
+ * Phases may nest (a burst contains its screenshots). Only the OUTERMOST of a
42
+ * given NAME accumulates, so re-entering one cannot double-count it — but a
43
+ * phase nested inside a differently-named one counts under both, deliberately:
44
+ * the cursor drifting over the widget WHILE the model generates is genuinely
45
+ * both `mouse` and `inference`, and hiding either would misreport what the
46
+ * solve was doing. The totals are therefore an attribution, not a partition,
47
+ * and can exceed the elapsed time.
48
+ */
49
+ async phase(name, fn) {
50
+ if (this.open.includes(name))
51
+ return fn();
52
+ this.open.push(name);
53
+ const t0 = Date.now();
54
+ try {
55
+ return await fn();
56
+ }
57
+ finally {
58
+ this.open.splice(this.open.indexOf(name), 1);
59
+ this.add(name, Date.now() - t0);
60
+ }
61
+ }
62
+ /** Record a measured span directly, for blocks that would need re-indenting. */
63
+ add(name, ms) {
64
+ this.totals.set(name, (this.totals.get(name) ?? 0) + ms);
65
+ this.counts.set(name, (this.counts.get(name) ?? 0) + 1);
66
+ }
67
+ elapsedMs() {
68
+ return Date.now() - this.t0;
69
+ }
70
+ /** Plain object for `SolveResult.phases`, with the solve total folded in. */
71
+ toObject() {
72
+ const out = {};
73
+ for (const [k, v] of this.totals)
74
+ out[k] = v;
75
+ out.total = this.elapsedMs();
76
+ return out;
77
+ }
78
+ report() {
79
+ const total = this.elapsedMs();
80
+ let useful = 0;
81
+ for (const [k, v] of this.totals)
82
+ if (exports.PRODUCTIVE.has(k))
83
+ useful += v;
84
+ const rows = [...this.totals.entries()].sort((a, b) => b[1] - a[1]);
85
+ const lines = [
86
+ `[BUDGET] solve ${(total / 1000).toFixed(1)}s — ` +
87
+ `${(useful / 1000).toFixed(1)}s useful (${total ? Math.round((100 * useful) / total) : 0}%), ` +
88
+ `${((total - useful) / 1000).toFixed(1)}s waiting`,
89
+ ];
90
+ for (const [name, ms] of rows) {
91
+ const tag = exports.PRODUCTIVE.has(name) ? '*' : ' ';
92
+ lines.push(`[BUDGET] ${tag} ${name.padEnd(22)} ${(ms / 1000).toFixed(2).padStart(6)}s x${this.counts.get(name)}`);
93
+ }
94
+ let attributed = 0;
95
+ for (const v of this.totals.values())
96
+ attributed += v;
97
+ if (total - attributed > 50) {
98
+ lines.push(`[BUDGET] ${'(unattributed)'.padEnd(22)} ${((total - attributed) / 1000).toFixed(2).padStart(6)}s`);
99
+ }
100
+ return lines.join('\n');
101
+ }
102
+ }
103
+ exports.PhaseBudget = PhaseBudget;
@@ -12,6 +12,10 @@
12
12
  * - a small overshoot-and-correct on longer moves, which is the single most
13
13
  * recognisable human tell and the one a linear interpolation never produces
14
14
  *
15
+ * `generate_swipe` is the SAME contract for a FINGER, and it is a different
16
+ * model rather than the mouse model with different constants — see its
17
+ * docstring.
18
+ *
15
19
  * Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
16
20
  * re-humanises every `mouse.move()` it is handed. Running both composes them —
17
21
  * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
@@ -29,3 +33,26 @@ export type Point = [number, number];
29
33
  * detectable.
30
34
  */
31
35
  export declare function generate_trajectory(target_start: readonly number[], target_end: readonly number[], frequency?: number, frequencyRandomizer?: number): [Point[], number[]];
36
+ /**
37
+ * A FINGER travelling from `target_start` to `target_end`, same contract as
38
+ * `generate_trajectory`: `[points, timings]`, timings cumulative from 0, last
39
+ * point exactly `target_end`.
40
+ *
41
+ * A separate model rather than `generate_trajectory` with other constants,
42
+ * because the three things that make the mouse output human are each WRONG
43
+ * here:
44
+ *
45
+ * - **No overshoot-and-correct.** That tell is a hand arriving past a target
46
+ * it cannot see under the cursor. A finger occludes its own target and
47
+ * commits; the correction, when there is one, is a second gesture.
48
+ * - **A different velocity profile.** `easeTouch`, not `easeInOut` — above.
49
+ * - **Different jitter.** The mouse model adds white noise scaled by speed (a
50
+ * hand tracking a path imprecisely). A digitizer instead reports a wandering
51
+ * centroid, which is low-frequency and roughly speed INDEPENDENT, so this is
52
+ * a smoothed random walk.
53
+ *
54
+ * `frequency` defaults to 90: digitizers sample at 120Hz+, but touchmove is
55
+ * coalesced to the compositor, so more samples than this buys nothing and costs
56
+ * a dispatch round-trip each.
57
+ */
58
+ export declare function generate_swipe(target_start: readonly number[], target_end: readonly number[], frequency?: number, frequencyRandomizer?: number): [Point[], number[]];
@@ -13,6 +13,10 @@
13
13
  * - a small overshoot-and-correct on longer moves, which is the single most
14
14
  * recognisable human tell and the one a linear interpolation never produces
15
15
  *
16
+ * `generate_swipe` is the SAME contract for a FINGER, and it is a different
17
+ * model rather than the mouse model with different constants — see its
18
+ * docstring.
19
+ *
16
20
  * Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
17
21
  * re-humanises every `mouse.move()` it is handed. Running both composes them —
18
22
  * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
@@ -20,6 +24,7 @@
20
24
  */
21
25
  Object.defineProperty(exports, "__esModule", { value: true });
22
26
  exports.generate_trajectory = generate_trajectory;
27
+ exports.generate_swipe = generate_swipe;
23
28
  // Fitts's law: MT = a + b * log2(distance / width + 1). The constants are the
24
29
  // usual empirical range for a mouse; `WIDTH` stands in for target precision.
25
30
  const FITTS_A_MS = 90.0;
@@ -160,3 +165,102 @@ function generate_trajectory(target_start, target_end, frequency = 60, frequency
160
165
  points[points.length - 1] = end;
161
166
  return [points, timings];
162
167
  }
168
+ // ── Touch ───────────────────────────────────────────────────────────────────
169
+ //
170
+ // Fitts's law holds for direct touch too, but with a slower intercept and a much
171
+ // wider effective target: a fingertip contact patch is ~9mm across, which is the
172
+ // 44pt / 48dp minimum both platform guidelines are built around. So the same
173
+ // distance takes longer AND is aimed less precisely than with a mouse.
174
+ const TOUCH_FITTS_A_MS = 160.0;
175
+ const TOUCH_FITTS_B_MS = 135.0;
176
+ const TOUCH_WIDTH_PX = 44.0;
177
+ // How flick-like the launch is. 0 would be the mouse's symmetric bell; 1 would
178
+ // be an instantaneous jump at t=0. See `easeTouch`.
179
+ const TOUCH_LAUNCH = 0.7;
180
+ // A finger drags across glass on a short wrist/thumb pivot, so it bows far less
181
+ // than a hand moving a mouse across a desk.
182
+ const TOUCH_BOW_CAP_PX = 40.0;
183
+ // The reported contact point wanders, because it is the CENTROID of a soft patch
184
+ // rolling under pressure rather than a rigid sensor. `TOUCH_WOBBLE_DECAY` is the
185
+ // AR(1) coefficient that makes that wander low-frequency: white per-sample noise
186
+ // would show up in a spectrum as nothing a finger produces.
187
+ const TOUCH_WOBBLE_PX = 0.55;
188
+ const TOUCH_WOBBLE_DECAY = 0.82;
189
+ /**
190
+ * Asymmetric ease: a finger leaves fast and brakes late.
191
+ *
192
+ * A mouse hand is symmetric (`easeInOut` — accelerate, decelerate, equally). A
193
+ * finger is not: the launch is a flick off the contact point and the arrival is
194
+ * a brake. Blending the two rather than using a pure ease-out is what keeps the
195
+ * launch from being an instantaneous jump at t=0, which no digitizer would ever
196
+ * report.
197
+ */
198
+ function easeTouch(t) {
199
+ return (1.0 - TOUCH_LAUNCH) * easeInOut(t) + TOUCH_LAUNCH * (1.0 - Math.pow(1.0 - t, 2.4));
200
+ }
201
+ /**
202
+ * A FINGER travelling from `target_start` to `target_end`, same contract as
203
+ * `generate_trajectory`: `[points, timings]`, timings cumulative from 0, last
204
+ * point exactly `target_end`.
205
+ *
206
+ * A separate model rather than `generate_trajectory` with other constants,
207
+ * because the three things that make the mouse output human are each WRONG
208
+ * here:
209
+ *
210
+ * - **No overshoot-and-correct.** That tell is a hand arriving past a target
211
+ * it cannot see under the cursor. A finger occludes its own target and
212
+ * commits; the correction, when there is one, is a second gesture.
213
+ * - **A different velocity profile.** `easeTouch`, not `easeInOut` — above.
214
+ * - **Different jitter.** The mouse model adds white noise scaled by speed (a
215
+ * hand tracking a path imprecisely). A digitizer instead reports a wandering
216
+ * centroid, which is low-frequency and roughly speed INDEPENDENT, so this is
217
+ * a smoothed random walk.
218
+ *
219
+ * `frequency` defaults to 90: digitizers sample at 120Hz+, but touchmove is
220
+ * coalesced to the compositor, so more samples than this buys nothing and costs
221
+ * a dispatch round-trip each.
222
+ */
223
+ function generate_swipe(target_start, target_end, frequency = 90, frequencyRandomizer = 0.1) {
224
+ const start = [Number(target_start[0]), Number(target_start[1])];
225
+ const end = [Number(target_end[0]), Number(target_end[1])];
226
+ const dx = end[0] - start[0];
227
+ const dy = end[1] - start[1];
228
+ const distance = Math.hypot(dx, dy);
229
+ if (distance < 1e-6)
230
+ return [[end], [0.0]];
231
+ let durationMs = TOUCH_FITTS_A_MS + TOUCH_FITTS_B_MS * Math.log2(distance / TOUCH_WIDTH_PX + 1.0);
232
+ durationMs *= uniform(0.85, 1.2);
233
+ const steps = Math.max(2, Math.round((durationMs / 1000.0) * Math.max(1, frequency)));
234
+ let c1;
235
+ let c2;
236
+ if (distance < SHORT_MOVE_PX) {
237
+ c1 = start;
238
+ c2 = end;
239
+ }
240
+ else {
241
+ [c1, c2] = controlPoints(start, end, distance);
242
+ // Pull the mouse model's bow in: `controlPoints` is shared, and its arc is
243
+ // sized for a hand crossing a desk.
244
+ const shrink = Math.min(1.0, TOUCH_BOW_CAP_PX / Math.max(TOUCH_BOW_CAP_PX, distance * 0.16));
245
+ c1 = [start[0] + (c1[0] - start[0]) * shrink, start[1] + (c1[1] - start[1]) * shrink];
246
+ c2 = [start[0] + (c2[0] - start[0]) * shrink, start[1] + (c2[1] - start[1]) * shrink];
247
+ }
248
+ const points = [];
249
+ const timings = [];
250
+ let elapsed = 0.0;
251
+ const perStep = durationMs / steps;
252
+ let wobX = 0.0;
253
+ let wobY = 0.0;
254
+ for (let i = 0; i <= steps; i++) {
255
+ const t = easeTouch(i / steps);
256
+ const [px, py] = cubicBezier(start, c1, c2, end, t);
257
+ wobX = wobX * TOUCH_WOBBLE_DECAY + gauss(0.0, TOUCH_WOBBLE_PX);
258
+ wobY = wobY * TOUCH_WOBBLE_DECAY + gauss(0.0, TOUCH_WOBBLE_PX);
259
+ points.push([px + wobX, py + wobY]);
260
+ timings.push(elapsed);
261
+ elapsed += perStep * uniform(1.0 - frequencyRandomizer, 1.0 + frequencyRandomizer);
262
+ }
263
+ // Land exactly where the caller asked, same reason as the mouse model.
264
+ points[points.length - 1] = end;
265
+ return [points, timings];
266
+ }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Humanizer, HumanizationMode, TouchTransform } from './humanize.js';
1
2
  /**
2
3
  * Lifecycle event emitted by {@link CaptchaKrakenConfig.onStep}. One is fired
3
4
  * before any interaction (`stage: 'initial'`) and one after every executed
@@ -46,6 +47,65 @@ export interface SolveStepEvent {
46
47
  meta?: Record<string, any>;
47
48
  }
48
49
  export interface CaptchaKrakenConfig {
50
+ /**
51
+ * How the driver MOVES — a choice of input DEVICE, not a realism dial.
52
+ *
53
+ * - `'mouse'` (default) Bezier arcs, Fitts's-law durations, overshoot.
54
+ * - `'mobile'` touch events with finger kinematics. On a touch-only widget
55
+ * this is the difference between the page's handlers firing and
56
+ * not; a mousemove there is the wrong event, not a weaker one.
57
+ * Needs a Chromium-family page launched with `hasTouch: true`,
58
+ * or an Appium/WebdriverIO driver in {@link touchDriver}.
59
+ * - `'none'` the shortest legal path to the same DOM effect. Much faster,
60
+ * and detectable by anything scoring pointer telemetry — for
61
+ * fixtures, self-hosted targets, and stacks that humanise
62
+ * elsewhere.
63
+ *
64
+ * Unset falls back to `CAPTCHA_HUMANIZATION`, then `'mouse'`. Set
65
+ * {@link humanizer} instead to supply your own implementation.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * const context = await browser.newContext({ ...devices['Pixel 7'], hasTouch: true });
70
+ * const solver = new CaptchaKrakenSolver({ humanization: 'mobile' });
71
+ * await solver.solve(await context.newPage());
72
+ * ```
73
+ */
74
+ humanization?: HumanizationMode;
75
+ /**
76
+ * Your own {@link Humanizer}. Overrides {@link humanization} entirely — the
77
+ * driver then makes no decisions about pointer motion at all.
78
+ *
79
+ * Use it when you already model your users' input (a hardware pointer, a
80
+ * device farm, a recorded trace), or when the browser humanises for you:
81
+ * camoufox's `humanize` juggler re-humanises every `mouse.move()` it is
82
+ * handed, and composing both measured 82.1s against 13.4s on one solve.
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * import { NullHumanizer, type Humanizer } from 'captchakraken';
87
+ *
88
+ * class MyPointer extends NullHumanizer implements Humanizer {
89
+ * async move(page, to) { await myHardwareMouse.glideTo(to[0], to[1]); this.at = to; }
90
+ * }
91
+ * new CaptchaKrakenSolver({ humanizer: new MyPointer() });
92
+ * ```
93
+ */
94
+ humanizer?: Humanizer;
95
+ /**
96
+ * `humanization: 'mobile'` only. The thing that is actually TOUCHED, when it
97
+ * is not the page object — an Appium / WebdriverIO / Selenium driver on a real
98
+ * handset. Left unset, the mode dispatches CDP touch events at the page it
99
+ * was given, which is what browser mobile emulation wants.
100
+ */
101
+ touchDriver?: any;
102
+ /**
103
+ * `humanization: 'mobile'` only. CSS-pixel → device-pixel transform for
104
+ * {@link touchDriver}. `scale` is usually `window.devicePixelRatio`; `origin`
105
+ * is the top-left of the webview in screen coordinates. The default identity
106
+ * is right for emulation and for callers who map coordinates themselves.
107
+ */
108
+ touchTransform?: TouchTransform;
49
109
  /**
50
110
  * Optional observer fired at each intermediate solve stage. Receives a
51
111
  * baseline screenshot before any action and one after every executed action.
@@ -78,6 +138,23 @@ export interface CaptchaKrakenConfig {
78
138
  * with the vLLM server; see model-name.ts.
79
139
  */
80
140
  model?: string;
141
+ /**
142
+ * Force ONE expert of a ROUTED model — 'pixel' | 'grid' | 'video' | 'text'.
143
+ *
144
+ * A routed model (Abyss) is four LoRA adapters behind one endpoint, and the
145
+ * router is the prompt family each request is about to send — so leaving this
146
+ * unset already reaches the right expert. This is the override: serve one
147
+ * arm, drive only the puzzles it owns, which is what a per-arm benchmark
148
+ * needs and what a licence holder pinning a single expert wants.
149
+ *
150
+ * Refused against a model that serves a single adapter — every model
151
+ * published so far — rather than ignored, because a run that quietly measured
152
+ * the generalist while reporting an expert is a number nobody can catch.
153
+ *
154
+ * Mirrors the Python `PageSolverConfig.expert`; also settable as
155
+ * CAPTCHA_EXPERT, which this forwards to the CLI ahead of.
156
+ */
157
+ expert?: string;
81
158
  /**
82
159
  * Bearer token for the vLLM server (also picked up from VLLM_API_KEY env).
83
160
  */
@@ -330,6 +407,36 @@ export interface CaptchaKrakenConfig {
330
407
  */
331
408
  keyframeWaitTimeoutMs?: number;
332
409
  keyframeWaitPollMs?: number;
410
+ /**
411
+ * Extra wall clock (ms) granted ONCE, the first time a solve escalates to a
412
+ * recording. NOT a looser `overallSolveTimeoutMs`.
413
+ *
414
+ * That budget counts ROUNDS — `maxSolveLoops` x ~7000ms, which
415
+ * no-progress.test.ts pins. A recording is not a round: it is a fixed extra
416
+ * stage costing the burst, the slice, one MULTI-IMAGE inference (six
417
+ * keyframes, several times a still's) and the wait for the widget to come
418
+ * back round to the chosen frame. Nothing in the 45s was set aside for it, so
419
+ * an escalation late in a solve simply ran the clock out and reported a
420
+ * timeout — which reads as a slow model rather than as a budget with no room
421
+ * for what the solver had just decided to do.
422
+ *
423
+ * Measured 2026-08-22, Tier 3 run 32596340560: hcaptcha_click_image_by_traits,
424
+ * hcaptcha_connect_path and hcaptcha_grid_3x3_property each failed this port
425
+ * with "Captcha solve timed out after 45000ms (attempt 6/6)" at 49-59s, and
426
+ * solve in 14-20s on the rounds the still path answers them.
427
+ *
428
+ * The total granted is `videoBurstDurationMs + keyframeWaitTimeoutMs + this`,
429
+ * derived so a longer burst carries its own budget — page_solver.py's
430
+ * `video_budget_ms` is the same arithmetic, since the two ports must not
431
+ * disagree about how long a video solve may take.
432
+ *
433
+ * Granted only when `videoSolveEnabled`: a caller who wants a hard deadline
434
+ * turns recording off, which already means "fail fast rather than spend the
435
+ * recording time".
436
+ *
437
+ * Default: 8000
438
+ */
439
+ videoExtraInferenceMs?: number;
333
440
  /**
334
441
  * After clicking Submit/Verify, the solver EXPECTS the frame to change (advance
335
442
  * to the next round, or close because it was accepted). This is how long (ms)
@@ -349,6 +456,18 @@ export interface CaptchaKrakenConfig {
349
456
  * Default: 8000
350
457
  */
351
458
  elementScreenshotTimeoutMs?: number;
459
+ /**
460
+ * How long an `onStep` observer's screenshot may take, ms (default 2000).
461
+ *
462
+ * Deliberately separate from `elementScreenshotTimeoutMs`, which is sized for
463
+ * the picture the MODEL reads. This one is a trace snapshot: it is taken with
464
+ * `animations: 'disabled'`, which makes Playwright wait for the element to
465
+ * stop moving first, and on a still-animating widget that ran to the full 8s
466
+ * on every step — 8.0s of a measured 12.0s solve. An observer must never cost
467
+ * more than the action it observes, and a missed frame in a trace costs
468
+ * nothing. Ignored entirely when no `onStep` is set.
469
+ */
470
+ stepScreenshotTimeoutMs?: number;
352
471
  /**
353
472
  * After a submit, hCaptcha may replace the challenge iframe for the next
354
473
  * round, detaching the handle we just detected ("element is not visible").
@@ -465,4 +584,14 @@ export interface SolveResult {
465
584
  cachedInputTokens: number;
466
585
  estimatedCost: number;
467
586
  };
587
+ /**
588
+ * Where this solve's wall-clock went, in milliseconds per phase — the same
589
+ * partition `CAPTCHA_TIMINGS=1` prints, handed to the caller instead of to
590
+ * stderr. Mirrors `SolveResult.phases` on the Python port.
591
+ *
592
+ * Returned rather than only logged because "the solve took 12s" is not
593
+ * actionable and "the settle monitor spent 4s of it" is, and a caller
594
+ * measuring a fleet cannot scrape another process's stderr.
595
+ */
596
+ phases?: Record<string, number>;
468
597
  }
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.8.0",
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,62 @@
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
+ ## Watch it work
25
+
26
+ <p align="center">
27
+ <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/demo/hcaptcha_grid.webp" width="260"
28
+ alt="A live hCaptcha image select challenge being solved end to end">
29
+ <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/demo/recaptcha_4x4.webp" width="260"
30
+ alt="A live reCAPTCHA 4×4 tile grid challenge being solved end to end">
31
+ <img src="https://raw.githubusercontent.com/JWriter20/CaptchaKraken/main/docs/assets/demo/geetest_slide.webp" width="260"
32
+ alt="A live GeeTest slide jigsaw challenge being solved end to end">
33
+ </p>
34
+
35
+ hCaptcha image select 12/12 in 10.5s · reCAPTCHA 4×4 tile grid 9/10 in 8.7s · GeeTest slide jigsaw 10/10 in 7.6s — median of the solved attempts, measured 2026-08-19 on **captcha-v12**
36
+ against each vendor's own public demo page. Counts rather than percentages
37
+ because ten attempts is not a percentage. Idle time is cut from the clips, so
38
+ they run shorter than the solves they show.
39
+
40
+ **Ten more puzzle types**, as video and with the full method, at
41
+ [captchakraken.com](https://captchakraken.com) and in the
42
+ [main repo](https://github.com/JWriter20/CaptchaKraken#watch-it-work).
43
+
44
+ ## What it solves
45
+
46
+ | Vendor | Puzzles |
47
+ |---|---|
48
+ | **reCAPTCHA** | 3×3 and 4×4 image grids, including the dynamic re-deal |
49
+ | **hCaptcha** | Image grids, click, drag, connect-the-path, tetris-fit, animated |
50
+ | **GeeTest** v3 + v4 | Slide, icon, nine, svg, gobang, iconcrush |
51
+ | **NetEase Yidun** | Jigsaw, picture-click, icon-click |
52
+ | **Tencent, Lemin, Prosopo** | Slide, cropped-image and grid flows |
53
+ | **BotDetect, MTCaptcha, Yandex** | Distorted text — read and typed, not clicked |
54
+ | **Cloudflare Turnstile** | Via the checkbox flow (free on the hosted API) |
55
+
56
+ **44 puzzle types**, driven end to end in CI against generated fixtures on both
57
+ the TypeScript and Python ports. Animated challenges are recorded, sliced into
58
+ keyframes and answered with the frame the action belongs to.
59
+
12
60
  ## Install
13
61
 
14
62
  ```bash
@@ -79,6 +127,29 @@ Everything model-specific lives in `captchakraken.config` and is env-overridable
79
127
  | `CAPTCHA_LORA_ADAPTER` | Captcha adapter (HF id or path) | `CaptchaKraken/CaptchaKraken-Lora-v1.2` |
80
128
  | `CAPTCHA_LORA_NAME` | Served adapter name the client requests | `captcha-v12` |
81
129
  | `CAPTCHA_KRAKEN_AUTOSTART` | `0` disables local auto-start | `1` |
130
+ | `CAPTCHA_HUMANIZATION` | How gestures are performed: `mouse`, `mobile` or `none` | `mouse` |
131
+
132
+ **How it moves** is a choice of input device, not a realism dial. `mobile`
133
+ dispatches real touch events with finger kinematics and never touches
134
+ `page.mouse`; `none` goes straight to the DOM effect. Set it in code (which wins
135
+ over the env var, because the right mode is a property of the page you are
136
+ driving), and pass your own object to override ours entirely:
137
+
138
+ ```python
139
+ from captchakraken import PageSolver
140
+ from captchakraken.page_solver import PageSolverConfig
141
+
142
+ PageSolver(config=PageSolverConfig(humanization="mobile"))
143
+ PageSolver(config=PageSolverConfig(humanization="none"))
144
+ PageSolver(config=PageSolverConfig(humanizer=my_own))
145
+
146
+ # A real handset over Appium / Selenium — W3C touch pointer actions.
147
+ PageSolver(config=PageSolverConfig(
148
+ humanization="mobile",
149
+ touch_driver=driver,
150
+ touch_transform={"scale": 3.0, "origin": (0, 132)}, # CSS px -> screen px
151
+ ))
152
+ ```
82
153
 
83
154
  ## License
84
155
 
@@ -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.8.0"
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"]