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.
- package/README.md +126 -16
- package/dist/cli-invocation.d.ts +2 -0
- package/dist/cli-invocation.js +5 -1
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +115 -5
- package/dist/solver.js +583 -190
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +27 -0
- package/dist/trajectory.js +104 -0
- package/dist/types.d.ts +129 -0
- package/package.json +11 -3
- package/python/README.md +76 -5
- package/python/pyproject.toml +21 -4
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +62 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +99 -0
- package/python/src/captchakraken/models.json +149 -13
- package/python/src/captchakraken/page_solver.py +588 -291
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +118 -9
- package/python/src/captchakraken/prompts.py +182 -0
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +97 -57
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
package/dist/timing.d.ts
ADDED
|
@@ -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;
|
package/dist/trajectory.d.ts
CHANGED
|
@@ -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[]];
|
package/dist/trajectory.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
+
## 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
|
|
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.
|
|
8
|
-
description = "
|
|
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 = [
|
|
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"]
|