captchakraken 2.6.1 → 2.9.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 (38) hide show
  1. package/README.md +90 -7
  2. package/dist/cli-invocation.d.ts +16 -16
  3. package/dist/cli-invocation.js +33 -21
  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/model-name.d.ts +1 -1
  10. package/dist/model-name.js +1 -1
  11. package/dist/playwright-types.d.ts +18 -0
  12. package/dist/solver.d.ts +314 -16
  13. package/dist/solver.js +1208 -278
  14. package/dist/timing.d.ts +48 -0
  15. package/dist/timing.js +103 -0
  16. package/dist/trajectory.d.ts +28 -1
  17. package/dist/trajectory.js +105 -1
  18. package/dist/types.d.ts +113 -2
  19. package/package.json +2 -1
  20. package/python/README.md +43 -0
  21. package/python/pyproject.toml +1 -1
  22. package/python/src/captchakraken/__init__.py +36 -1
  23. package/python/src/captchakraken/cli.py +66 -4
  24. package/python/src/captchakraken/config.py +28 -2
  25. package/python/src/captchakraken/errors.py +24 -0
  26. package/python/src/captchakraken/humanize.py +929 -0
  27. package/python/src/captchakraken/keyframes.py +184 -12
  28. package/python/src/captchakraken/models.json +180 -18
  29. package/python/src/captchakraken/page_solver.py +1132 -321
  30. package/python/src/captchakraken/pinned_model.json +3 -2
  31. package/python/src/captchakraken/planner.py +204 -16
  32. package/python/src/captchakraken/prompts.py +186 -4
  33. package/python/src/captchakraken/server_manager.py +9 -4
  34. package/python/src/captchakraken/solver.py +98 -58
  35. package/python/src/captchakraken/timing.py +8 -3
  36. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  37. package/python/src/captchakraken/trajectory.py +117 -0
  38. 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,9 +12,13 @@
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
- * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
21
+ * measured 82.1s vs 13.4s on one a GeeTest v4 slider solve, because each of the 60
18
22
  * points below became its own humanised sub-trajectory. Drive with humanize off.
19
23
  */
20
24
  export type Point = [number, number];
@@ -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,13 +13,18 @@
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
- * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
22
+ * measured 82.1s vs 13.4s on one a GeeTest v4 slider solve, because each of the 60
19
23
  * points below became its own humanised sub-trajectory. Drive with humanize off.
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,18 @@ export interface CaptchaKrakenConfig {
330
407
  */
331
408
  keyframeWaitTimeoutMs?: number;
332
409
  keyframeWaitPollMs?: number;
410
+ /**
411
+ * Hard ceiling on a burst that never repeats a screen. The burst normally
412
+ * ends when the cycle closes; this bounds a continuous animation, which never
413
+ * closes one. Default 12000ms.
414
+ */
415
+ videoBurstMaxMs?: number;
416
+ /**
417
+ * Record while the still screenshot is being read, so a cycling board is
418
+ * known before its answer is acted on. Default on. Off restores the older
419
+ * shape, which learned the same thing two rounds later.
420
+ */
421
+ speculativeBurstEnabled?: boolean;
333
422
  /**
334
423
  * Extra wall clock (ms) granted ONCE, the first time a solve escalates to a
335
424
  * recording. NOT a looser `overallSolveTimeoutMs`.
@@ -343,8 +432,8 @@ export interface CaptchaKrakenConfig {
343
432
  * timeout — which reads as a slow model rather than as a budget with no room
344
433
  * for what the solver had just decided to do.
345
434
  *
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
435
+ * Measured 2026-08-22, a full driver-gate run: an hCaptcha click-by-trait board,
436
+ * an hCaptcha connect-the-path board and an hCaptcha 3x3 property grid each failed this port
348
437
  * with "Captcha solve timed out after 45000ms (attempt 6/6)" at 49-59s, and
349
438
  * solve in 14-20s on the rounds the still path answers them.
350
439
  *
@@ -379,6 +468,18 @@ export interface CaptchaKrakenConfig {
379
468
  * Default: 8000
380
469
  */
381
470
  elementScreenshotTimeoutMs?: number;
471
+ /**
472
+ * How long an `onStep` observer's screenshot may take, ms (default 2000).
473
+ *
474
+ * Deliberately separate from `elementScreenshotTimeoutMs`, which is sized for
475
+ * the picture the MODEL reads. This one is a trace snapshot: it is taken with
476
+ * `animations: 'disabled'`, which makes Playwright wait for the element to
477
+ * stop moving first, and on a still-animating widget that ran to the full 8s
478
+ * on every step — 8.0s of a measured 12.0s solve. An observer must never cost
479
+ * more than the action it observes, and a missed frame in a trace costs
480
+ * nothing. Ignored entirely when no `onStep` is set.
481
+ */
482
+ stepScreenshotTimeoutMs?: number;
382
483
  /**
383
484
  * After a submit, hCaptcha may replace the challenge iframe for the next
384
485
  * round, detaching the handle we just detected ("element is not visible").
@@ -495,4 +596,14 @@ export interface SolveResult {
495
596
  cachedInputTokens: number;
496
597
  estimatedCost: number;
497
598
  };
599
+ /**
600
+ * Where this solve's wall-clock went, in milliseconds per phase — the same
601
+ * partition `CAPTCHA_TIMINGS=1` prints, handed to the caller instead of to
602
+ * stderr. Mirrors `SolveResult.phases` on the Python port.
603
+ *
604
+ * Returned rather than only logged because "the solve took 12s" is not
605
+ * actionable and "the settle monitor spent 4s of it" is, and a caller
606
+ * measuring a fleet cannot scrape another process's stderr.
607
+ */
608
+ phases?: Record<string, number>;
498
609
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captchakraken",
3
- "version": "2.6.1",
3
+ "version": "2.9.0",
4
4
  "description": "Captcha solver for Playwright and Puppeteer. Finds the captcha, reads the puzzle with a fine-tuned Qwen3.5-9B vision model, and clicks, drags, slides or types through to a token — 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",
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "prebuild": "node scripts/copy-python.mjs",
15
15
  "build": "tsc",
16
+ "demo": "tsx examples/demoSites.ts",
16
17
  "typecheck": "tsc --noEmit -p tsconfig.json",
17
18
  "test": "tsc -p tsconfig.test.json && node --test .test-build/*.test.js",
18
19
  "prepublishOnly": "npm run build",
package/python/README.md CHANGED
@@ -21,6 +21,26 @@ nothing at all.
21
21
  > self-hosting guide, see the main repo
22
22
  > **[CaptchaKraken](https://github.com/JWriter20/CaptchaKraken)**.
23
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
+
24
44
  ## What it solves
25
45
 
26
46
  | Vendor | Puzzles |
@@ -107,6 +127,29 @@ Everything model-specific lives in `captchakraken.config` and is env-overridable
107
127
  | `CAPTCHA_LORA_ADAPTER` | Captcha adapter (HF id or path) | `CaptchaKraken/CaptchaKraken-Lora-v1.2` |
108
128
  | `CAPTCHA_LORA_NAME` | Served adapter name the client requests | `captcha-v12` |
109
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
+ ```
110
153
 
111
154
  ## License
112
155
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "2.6.1"
7
+ version = "2.9.0"
8
8
  description = "Captcha-solving engine and CLI: OpenCV grid detection plus a fine-tuned Qwen3.5-9B vision model, for image grids, click and drag puzzles, sliders, distorted text and animated challenges. Hosted API or self-hosted on vLLM."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -32,6 +32,27 @@ from .action_types import (
32
32
  from .image_processor import ImageProcessor
33
33
  from .overlay import add_overlays_to_image
34
34
 
35
+ # The hosted API's refusal type. Outside the try below on purpose: `errors`
36
+ # imports nothing but `typing`, and docs/hosted-api.md tells every caller to
37
+ # branch on its `.code` — a recipe that has to work in both ports, and in a
38
+ # minimal install that has no serving stack.
39
+ from .errors import CaptchaKrakenAPIError
40
+
41
+ # How gestures are PERFORMED — mouse, touch, or nothing. Deliberately outside
42
+ # the try below: `humanize` imports only `trajectory`, so it has no dependency
43
+ # floor at all, and a caller writing a custom Humanizer must be able to import
44
+ # the base class without the serving stack installed.
45
+ from .humanize import (
46
+ AppiumTouchBackend,
47
+ CdpTouchBackend,
48
+ Humanizer,
49
+ MobileHumanizer,
50
+ MouseHumanizer,
51
+ NullHumanizer,
52
+ TouchBackend,
53
+ TouchscreenTouchBackend,
54
+ )
55
+
35
56
  # The planner (requests) and solver (torch/vllm/transformers) pull in the heavy
36
57
  # serving stack. Keep them optional so leaf modules — e.g. tool_calls.find_grid,
37
58
  # which needs only cv2 + numpy + pillow — can be imported in a minimal env (CI's
@@ -70,6 +91,20 @@ __all__ = [
70
91
  "TypeAction",
71
92
  "WaitAction",
72
93
  "add_overlays_to_image",
94
+ "CaptchaKrakenAPIError",
95
+ "Humanizer",
96
+ "MouseHumanizer",
97
+ "MobileHumanizer",
98
+ "NullHumanizer",
99
+ "TouchBackend",
100
+ "CdpTouchBackend",
101
+ "AppiumTouchBackend",
102
+ "TouchscreenTouchBackend",
73
103
  ]
74
104
 
75
- __version__ = "2.6.0"
105
+ # KEEP IN STEP with python/pyproject.toml and js/package.json — the two ports
106
+ # ship together (rule 1c) and this is the only one importable code can read.
107
+ # It said 2.6.0 while the wheel on PyPI said 2.6.1, so anyone gating on the
108
+ # runtime attribute saw a version that had not been current for weeks.
109
+ # tests/test_public_contract.py compares all three.
110
+ __version__ = "2.9.0"