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,716 @@
1
+ "use strict";
2
+ /**
3
+ * How the driver MOVES. One pluggable object per input device.
4
+ *
5
+ * The TypeScript side of `python/src/captchakraken/humanize.py`; the two are one
6
+ * design in two languages, and `tests/test_humanizer_parity.py` in
7
+ * CaptchaKrakenFinetune pins that the mode names and the pause vocabulary have
8
+ * not drifted.
9
+ *
10
+ * Humanisation used to be wired straight into `solver.ts`: every gesture was a
11
+ * `page.mouse.*` call with a Bezier trajectory in front of it and a `Math.
12
+ * random()` sleep behind it, and there was no way to ask for anything else. That
13
+ * is wrong in three directions at once —
14
+ *
15
+ * - a caller driving a MOBILE page has no cursor. Dispatching mousemove at a
16
+ * touch-only widget is not weak humanisation, it is the wrong event type:
17
+ * the page's touch handlers never fire, and a vendor that scores pointer
18
+ * telemetry sees a desktop mouse on a phone.
19
+ * - a caller who has their OWN humanisation (a hardware pointer, a proxied
20
+ * device farm, a model of their own users) was composing two of them. That
21
+ * is not hypothetical — camoufox's `humanize` juggler re-humanises every
22
+ * `mouse.move()` it is handed, and running both measured 82.1s against
23
+ * 13.4s on one geetest_v4_slide solve, because each of the 60 trajectory
24
+ * points became its own humanised sub-trajectory.
25
+ * - a caller on their own infrastructure, against their own fixtures, is
26
+ * paying for texture nobody is measuring.
27
+ *
28
+ * So the vocabulary of gestures is an INTERFACE and the humanisation is an
29
+ * implementation of it. The solver says "tap here", "drag this there", "type
30
+ * that"; a `Humanizer` decides what events that is and how long it takes.
31
+ *
32
+ * Four modes, selected by `CaptchaKrakenConfig.humanization`:
33
+ *
34
+ * 'mouse' MouseHumanizer — the default, and byte-for-byte what the
35
+ * driver did before this module existed.
36
+ * 'mobile' MobileHumanizer — touch events, finger kinematics. Dispatches
37
+ * through a `TouchBackend`, so it drives either
38
+ * a Chromium-family page (CDP) or an
39
+ * Appium/WebdriverIO driver on a real device.
40
+ * 'none' NullHumanizer — the shortest legal path to the same DOM
41
+ * effect.
42
+ * custom anything satisfying `Humanizer`, passed as
43
+ * `CaptchaKrakenConfig.humanizer`.
44
+ *
45
+ * THE POINTER POSITION LIVES HERE, not in the solver. A humanizer that
46
+ * dispatches no motion at all (mobile, between taps) still has to answer "where
47
+ * is the pointer", because that is what the next gesture starts from and what
48
+ * `SolveResult.finalMousePosition` reports.
49
+ */
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.MODES = exports.NullHumanizer = exports.MobileHumanizer = exports.TouchscreenTouchBackend = exports.AppiumTouchBackend = exports.CdpTouchBackend = exports.MouseHumanizer = exports.BaseHumanizer = exports.PAUSE_KINDS = void 0;
52
+ exports.touchBackendFor = touchBackendFor;
53
+ exports.resolveHumanizer = resolveHumanizer;
54
+ const trajectory_js_1 = require("./trajectory.js");
55
+ const delay = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
56
+ const log = (m) => console.log(`[captchakraken] ${m}`);
57
+ const uniform = (lo, hi) => lo + Math.random() * (hi - lo);
58
+ /**
59
+ * A move to where the pointer already is emits nothing.
60
+ *
61
+ * `moveAndClick` travels to the element and THEN clicks at the point it landed
62
+ * on, so without this every click would dispatch one redundant move at the
63
+ * coordinate the trajectory just finished on.
64
+ */
65
+ const samePoint = (a, b) => Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
66
+ /** Box-Muller, as in trajectory.ts — the contact wobble wants a normal deviate. */
67
+ function gauss(mu, sigma) {
68
+ let u = 0;
69
+ while (u === 0)
70
+ u = Math.random();
71
+ const v = Math.random();
72
+ return mu + sigma * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
73
+ }
74
+ exports.PAUSE_KINDS = [
75
+ 'tap',
76
+ 'between',
77
+ 'grab',
78
+ 'drop',
79
+ 'probe',
80
+ 'settle',
81
+ 'key',
82
+ ];
83
+ class BaseHumanizer {
84
+ constructor(start = [0, 0]) {
85
+ this.at = [Number(start[0]), Number(start[1])];
86
+ }
87
+ async reset(_page) { }
88
+ pauseMs(kind) {
89
+ const range = this.pauses[kind];
90
+ return range ? uniform(range[0], range[1]) : 0;
91
+ }
92
+ async pause(kind) {
93
+ await delay(this.pauseMs(kind));
94
+ }
95
+ async click(page, to) {
96
+ await this.move(page, to);
97
+ await this.press(page);
98
+ await this.pause('tap');
99
+ await this.release(page);
100
+ }
101
+ async drag(page, src, dst) {
102
+ await this.move(page, src);
103
+ await this.press(page);
104
+ await this.pause('grab');
105
+ await this.move(page, dst);
106
+ await this.pause('drop');
107
+ await this.release(page);
108
+ }
109
+ }
110
+ exports.BaseHumanizer = BaseHumanizer;
111
+ // ───────────────────────────────────────────────────────────────────── mouse
112
+ /**
113
+ * A hand on a mouse. Bezier arcs, Fitts's-law durations, overshoot.
114
+ *
115
+ * Everything in here was `solver.performSmoothMove` / `tracePath` before this
116
+ * module existed, and is unchanged: the constants were measured and a refactor
117
+ * is not the place to revisit them.
118
+ */
119
+ class MouseHumanizer extends BaseHumanizer {
120
+ constructor(start = [0, 0], frequency = 60) {
121
+ super(start);
122
+ this.name = 'mouse';
123
+ this.hovers = true;
124
+ /** Inclusive ranges, in ms. See PauseKind. */
125
+ this.pauses = {
126
+ tap: [20, 50],
127
+ between: [80, 160],
128
+ grab: [50, 100],
129
+ drop: [50, 100],
130
+ probe: [40, 80],
131
+ settle: [90, 210],
132
+ key: [45, 135],
133
+ };
134
+ this.frequency = frequency;
135
+ }
136
+ async move(page, to) {
137
+ if (samePoint(this.at, to))
138
+ return;
139
+ const [points, timings] = (0, trajectory_js_1.generate_trajectory)(this.at, to, this.frequency);
140
+ await this.trace(page, points, timings);
141
+ }
142
+ async press(page) {
143
+ await page.mouse.down();
144
+ }
145
+ async release(page) {
146
+ await page.mouse.up();
147
+ }
148
+ async typeText(page, _field, text) {
149
+ // A retry round arrives with the previous attempt still in the box, and
150
+ // typing would APPEND to it — submitting a string the model never read.
151
+ try {
152
+ await page.keyboard.press('Control+A');
153
+ }
154
+ catch {
155
+ /* not every field supports it; the type below still replaces on most */
156
+ }
157
+ // Per character rather than one `type(text, {delay})` call: a constant
158
+ // inter-key delay is itself a signal, and these are the vendors that score
159
+ // typing cadence.
160
+ for (const ch of text) {
161
+ try {
162
+ await page.keyboard.type(ch);
163
+ }
164
+ catch (e) {
165
+ log(`could not type into the captcha field: ${e}`);
166
+ return false;
167
+ }
168
+ await this.pause('key');
169
+ }
170
+ return true;
171
+ }
172
+ async trace(page, points, timings) {
173
+ // KNOWN DIVERGENCE from the Python port, carried over unchanged rather than
174
+ // fixed inside a refactor. Python clamps only when the viewport is actually
175
+ // known, because camoufox reports `viewportSize` null and a coordinate
176
+ // pinned to the edge of a GUESSED viewport is what deadlocks its humanised-
177
+ // mouse juggler (upstream #225) — see MouseHumanizer._viewport there. This
178
+ // port still assumes 1920x1080. Worth fixing on its own, with its own test.
179
+ let viewport = { width: 1920, height: 1080 };
180
+ try {
181
+ const vp = page.viewportSize();
182
+ if (vp)
183
+ viewport = vp;
184
+ }
185
+ catch {
186
+ /* keep the default */
187
+ }
188
+ const startTime = Date.now();
189
+ for (let i = 0; i < points.length; i++) {
190
+ try {
191
+ const cx = Math.max(0, Math.min(points[i][0], viewport.width));
192
+ const cy = Math.max(0, Math.min(points[i][1], viewport.height));
193
+ await page.mouse.move(cx, cy);
194
+ this.at = [cx, cy];
195
+ if (timings[i] !== undefined) {
196
+ const wait = startTime + timings[i] - Date.now();
197
+ if (wait > 0)
198
+ await delay(wait);
199
+ }
200
+ }
201
+ catch (error) {
202
+ const msg = error instanceof Error ? error.message : String(error);
203
+ if (msg.includes('Target closed') || msg.includes('Session closed')) {
204
+ log('Warning: could not move mouse, page or session closed.');
205
+ return;
206
+ }
207
+ // Any other per-sample failure is skipped rather than fatal — losing
208
+ // one mousemove must not lose the solve.
209
+ }
210
+ }
211
+ }
212
+ }
213
+ exports.MouseHumanizer = MouseHumanizer;
214
+ /**
215
+ * `Input.dispatchTouchEvent` over a CDP session on a Playwright page.
216
+ *
217
+ * Chromium-family only, and that is checked at construction rather than
218
+ * discovered per gesture: WebKit and Firefox (camoufox included) expose no touch
219
+ * dispatch through Playwright at all, so the alternatives there are to fail
220
+ * loudly or to quietly emit MOUSE events at a touch-only widget. The second is
221
+ * worse than not running — the page's touch handlers never fire, the solve fails
222
+ * for a reason nothing reports, and the report reads as a model that cannot
223
+ * solve mobile puzzles.
224
+ *
225
+ * The context must also have been created with `hasTouch: true`, or the page
226
+ * advertises no touch support to feature detection and a mobile widget renders
227
+ * its desktop branch.
228
+ */
229
+ class CdpTouchBackend {
230
+ constructor(session) {
231
+ this.name = 'cdp';
232
+ this.session = session;
233
+ }
234
+ static async open(page) {
235
+ try {
236
+ const context = page.context();
237
+ return new CdpTouchBackend(await context.newCDPSession(page));
238
+ }
239
+ catch (e) {
240
+ throw new Error(`mobile humanisation needs touch dispatch, and this page offers none (${e}). ` +
241
+ 'Use a Chromium-family Playwright browser launched with hasTouch: true, ' +
242
+ 'or pass an Appium driver as CaptchaKrakenConfig.touchDriver.');
243
+ }
244
+ }
245
+ async send(kind, points) {
246
+ await this.session.send('Input.dispatchTouchEvent', {
247
+ type: kind,
248
+ touchPoints: points.map(([x, y]) => ({
249
+ // radiusX/Y and force are what a real digitizer reports and a synthetic
250
+ // tap does not. A vendor reading `Touch.radiusX === 0` has a free bot
251
+ // signal otherwise.
252
+ x,
253
+ y,
254
+ radiusX: uniform(8, 14),
255
+ radiusY: uniform(8, 14),
256
+ force: uniform(0.35, 0.75),
257
+ id: 1,
258
+ })),
259
+ });
260
+ }
261
+ async down(x, y) {
262
+ await this.send('touchStart', [[x, y]]);
263
+ }
264
+ async move(path) {
265
+ for (const [x, y, dt] of path) {
266
+ if (dt > 0)
267
+ await delay(dt);
268
+ await this.send('touchMove', [[x, y]]);
269
+ }
270
+ }
271
+ async up(_x, _y) {
272
+ // touchEnd carries no touchPoints: the point being lifted is identified by
273
+ // its absence, which is what the protocol says and what a real release
274
+ // looks like.
275
+ await this.send('touchEnd', []);
276
+ }
277
+ }
278
+ exports.CdpTouchBackend = CdpTouchBackend;
279
+ /**
280
+ * W3C pointer actions with `pointerType: 'touch'`, for Appium and WebdriverIO.
281
+ *
282
+ * Batches a whole leg into ONE action chain with per-sample durations, so the
283
+ * kinematics are reproduced BY THE DEVICE rather than by a JS loop round-tripping
284
+ * over the wire — over which a 90Hz path is not 90Hz at all.
285
+ *
286
+ * Press and release are separate `performActions()` calls on purpose. W3C input
287
+ * state is per SESSION, so a pointer put down in one chain stays down across
288
+ * later chains until it is lifted; that is what lets the puzzle-slider driver
289
+ * press, screenshot, steer, screenshot and only then release. (It is also why
290
+ * the spec has a "release actions" endpoint at all.)
291
+ *
292
+ * COORDINATES. Everything upstream of here is CSS pixels in the page's viewport,
293
+ * because that is what `boundingBox()` returns. A real device wants screen
294
+ * pixels, and the two differ by the device pixel ratio and by whatever chrome
295
+ * sits above the webview. Neither is guessable from here, so both are options:
296
+ *
297
+ * new AppiumTouchBackend(driver, { scale: 3, origin: [0, 132] })
298
+ *
299
+ * `scale` is usually `window.devicePixelRatio`; `origin` is the top-left of the
300
+ * webview in screen coordinates. Defaults are the identity transform, which is
301
+ * correct for browser mobile emulation and for any caller who has already mapped
302
+ * the coordinates themselves.
303
+ */
304
+ class AppiumTouchBackend {
305
+ constructor(driver, transform = {}, page) {
306
+ this.name = 'appium';
307
+ this.checked = false;
308
+ this.driver = driver;
309
+ this.scaleGiven = transform.scale !== undefined;
310
+ this.scale = transform.scale ?? 1;
311
+ this.origin = transform.origin ?? [0, 0];
312
+ this.page = page;
313
+ }
314
+ /**
315
+ * Refuse an unset scale on a session that reports it is not 1.
316
+ *
317
+ * A wrong transform fails SILENTLY on both sides of the wire: the chain is
318
+ * valid W3C, the device performs it, and the finger lands somewhere else. The
319
+ * solve then fails looking exactly like a model that cannot read the puzzle.
320
+ *
321
+ * Read once, not per gesture — it is a round trip into the page and a solve
322
+ * makes hundreds of these. An unreadable ratio is absent evidence, not
323
+ * evidence of a mismatch, so it falls through to the identity. `origin`
324
+ * cannot be measured from here at all, so it is named in the refusal rather
325
+ * than guessed at.
326
+ */
327
+ async checkScale() {
328
+ this.checked = true;
329
+ if (this.scaleGiven || !this.page)
330
+ return;
331
+ let dpr;
332
+ try {
333
+ dpr = Number(await this.page.evaluate('() => window.devicePixelRatio'));
334
+ }
335
+ catch {
336
+ return; // cannot measure is not a mismatch
337
+ }
338
+ if (!Number.isFinite(dpr) || Math.abs(dpr - 1) < 1e-6)
339
+ return;
340
+ throw new Error(`the touch driver maps CSS pixels onto a device reporting devicePixelRatio ` +
341
+ `${dpr}, and no scale was given. Every gesture would land at ` +
342
+ `${(1 / dpr).toFixed(2)}x the intended offset, and nothing would report it — ` +
343
+ `the chain is valid, the device performs it, and the solve fails looking ` +
344
+ `like a weak model.\n` +
345
+ ` Fix: touchTransform: { scale: ${dpr}, origin: [x, y] }, where origin is ` +
346
+ `the webview's top-left in SCREEN coordinates. That half cannot be measured ` +
347
+ `from here.\n` +
348
+ ` Or pass scale: 1 to assert the coordinates are already mapped.`);
349
+ }
350
+ map(x, y) {
351
+ return [
352
+ Math.round(this.origin[0] + x * this.scale),
353
+ Math.round(this.origin[1] + y * this.scale),
354
+ ];
355
+ }
356
+ /**
357
+ * One W3C action chain on a single touch pointer.
358
+ *
359
+ * Sent as the raw protocol payload rather than through a client's action
360
+ * builder, so this imports nothing and works against any client that speaks
361
+ * WebDriver — WebdriverIO (`performActions`), selenium-webdriver, or a thin
362
+ * HTTP wrapper of someone's own.
363
+ */
364
+ async perform(actions) {
365
+ const chain = [
366
+ {
367
+ type: 'pointer',
368
+ id: 'ck-finger',
369
+ parameters: { pointerType: 'touch' },
370
+ actions,
371
+ },
372
+ ];
373
+ if (typeof this.driver.performActions === 'function') {
374
+ await this.driver.performActions(chain);
375
+ return;
376
+ }
377
+ if (typeof this.driver.execute === 'function') {
378
+ await this.driver.execute('actions', { actions: chain });
379
+ return;
380
+ }
381
+ throw new Error("the touch driver speaks neither performActions() nor execute('actions', …); " +
382
+ 'pass a WebDriver-compatible driver or a custom TouchBackend.');
383
+ }
384
+ async down(x, y) {
385
+ if (!this.checked)
386
+ await this.checkScale();
387
+ const [mx, my] = this.map(x, y);
388
+ await this.perform([
389
+ { type: 'pointerMove', duration: 0, origin: 'viewport', x: mx, y: my },
390
+ { type: 'pointerDown', button: 0 },
391
+ ]);
392
+ }
393
+ async move(path) {
394
+ if (!this.checked)
395
+ await this.checkScale();
396
+ if (!path.length)
397
+ return;
398
+ await this.perform(path.map(([x, y, dt]) => {
399
+ const [mx, my] = this.map(x, y);
400
+ // The per-sample gap becomes the move's DURATION, not a pause before it:
401
+ // the device then interpolates over that window and reports intermediate
402
+ // samples of its own, which is what a finger sliding across a digitizer
403
+ // actually produces.
404
+ return { type: 'pointerMove', duration: Math.max(0, Math.round(dt)), origin: 'viewport', x: mx, y: my };
405
+ }));
406
+ }
407
+ async up(_x, _y) {
408
+ if (!this.checked)
409
+ await this.checkScale();
410
+ await this.perform([{ type: 'pointerUp', button: 0 }]);
411
+ }
412
+ }
413
+ exports.AppiumTouchBackend = AppiumTouchBackend;
414
+ /**
415
+ * Playwright's `page.touchscreen.tap()` — taps only, no travel.
416
+ *
417
+ * The fallback for a browser with touch support but no CDP (WebKit). A tap still
418
+ * lands correctly; a DRAG cannot be expressed at all, so it throws rather than
419
+ * approximating one with a mouse, for the reason in `CdpTouchBackend`.
420
+ */
421
+ class TouchscreenTouchBackend {
422
+ constructor(page) {
423
+ this.name = 'touchscreen';
424
+ this.pending = null;
425
+ this.page = page;
426
+ }
427
+ async down(x, y) {
428
+ this.pending = [x, y];
429
+ }
430
+ async move(_path) {
431
+ throw new Error('this browser exposes taps but not touch travel, so a drag/slide puzzle ' +
432
+ 'cannot be driven on it. Use a Chromium-family browser (CDP touch dispatch) ' +
433
+ 'or an Appium driver.');
434
+ }
435
+ async up(x, y) {
436
+ const at = this.pending ?? [x, y];
437
+ this.pending = null;
438
+ await this.page.touchscreen.tap(at[0], at[1]);
439
+ }
440
+ }
441
+ exports.TouchscreenTouchBackend = TouchscreenTouchBackend;
442
+ /**
443
+ * Pick a backend. An explicit `driver` always wins.
444
+ *
445
+ * `driver` is what a caller passes when the thing under automation is not the
446
+ * thing being touched — an Appium session driving a real handset while the page
447
+ * object is a webview bridge over it.
448
+ */
449
+ async function touchBackendFor(page, driver, transform) {
450
+ if (driver) {
451
+ if (typeof driver.down === 'function' && typeof driver.up === 'function') {
452
+ return driver;
453
+ }
454
+ // The page comes along so the backend can ask what it is mapping ONTO.
455
+ return new AppiumTouchBackend(driver, transform, page);
456
+ }
457
+ try {
458
+ return await CdpTouchBackend.open(page);
459
+ }
460
+ catch (e) {
461
+ if (page.touchscreen) {
462
+ log('no CDP session; falling back to tap-only touch dispatch');
463
+ return new TouchscreenTouchBackend(page);
464
+ }
465
+ throw e;
466
+ }
467
+ }
468
+ // ──────────────────────────────────────────────────────────────────── mobile
469
+ /**
470
+ * A finger on glass.
471
+ *
472
+ * What differs from `MouseHumanizer`, and none of it is cosmetic:
473
+ *
474
+ * - **A move that is not touching dispatches nothing.** There is no hover on a
475
+ * touchscreen. The position is still RECORDED, because the next gesture
476
+ * starts from it, but no event is emitted — emitting one would be the
477
+ * desktop tell this mode exists to remove.
478
+ * - **Taps carry a contact wobble.** A finger held on glass for 90ms does not
479
+ * report one unchanging coordinate; the centroid of the contact patch rolls
480
+ * a pixel or two under pressure. A tap with zero movement between touchstart
481
+ * and touchend is a synthetic tap.
482
+ * - **Touch kinematics**, via `generate_swipe` — see its docstring for why that
483
+ * is a different model rather than the mouse one retuned.
484
+ * - **Longer, more variable pauses.** Every measured touch interaction is
485
+ * slower than its mouse equivalent, and a phone's are more variable because
486
+ * the hand holding the device is also moving.
487
+ */
488
+ class MobileHumanizer extends BaseHumanizer {
489
+ constructor(start = [0, 0], options = {}) {
490
+ super(start);
491
+ this.name = 'mobile';
492
+ this.hovers = false;
493
+ this.pauses = {
494
+ // A tap's press-to-release. Measured human touch dwell clusters at
495
+ // 60-120ms; a synthesised one is usually 0.
496
+ tap: [55, 130],
497
+ between: [140, 320],
498
+ grab: [90, 190],
499
+ drop: [80, 170],
500
+ probe: [70, 140],
501
+ settle: [140, 300],
502
+ // Soft-keyboard typing, which is ~3x slower than a physical keyboard and
503
+ // much more variable.
504
+ key: [110, 320],
505
+ };
506
+ this.down = false;
507
+ this.backend = options.backend ?? null;
508
+ this.driver = options.driver;
509
+ this.transform = options.transform;
510
+ this.frequency = options.frequency ?? 90;
511
+ }
512
+ async reset(page) {
513
+ // A solve that ended mid-gesture (a timeout inside the slider) leaves a
514
+ // pointer down in the SESSION's input state, and the next chain would then
515
+ // start from a finger already on the glass. Lift it.
516
+ if (this.down) {
517
+ try {
518
+ await (await this.touch(page)).up(this.at[0], this.at[1]);
519
+ }
520
+ catch {
521
+ /* best effort; a stale pointer must not fail a solve */
522
+ }
523
+ this.down = false;
524
+ }
525
+ }
526
+ async touch(page) {
527
+ if (!this.backend) {
528
+ this.backend = await touchBackendFor(page, this.driver, this.transform);
529
+ }
530
+ return this.backend;
531
+ }
532
+ async move(page, to) {
533
+ const dest = [Number(to[0]), Number(to[1])];
534
+ if (!this.down) {
535
+ // No contact, no events. Just remember where the next touch lands.
536
+ this.at = dest;
537
+ return;
538
+ }
539
+ const [points, timings] = (0, trajectory_js_1.generate_swipe)(this.at, dest, this.frequency);
540
+ const path = points.map(([x, y], i) => [
541
+ x,
542
+ y,
543
+ timings[i] - (i ? timings[i - 1] : 0),
544
+ ]);
545
+ await (await this.touch(page)).move(path);
546
+ this.at = dest;
547
+ }
548
+ async press(page) {
549
+ await (await this.touch(page)).down(this.at[0], this.at[1]);
550
+ this.down = true;
551
+ }
552
+ async release(page) {
553
+ await (await this.touch(page)).up(this.at[0], this.at[1]);
554
+ this.down = false;
555
+ }
556
+ /** Tap. The wobble is the point — see the class docstring. */
557
+ async click(page, to) {
558
+ await this.move(page, to);
559
+ await this.press(page);
560
+ const held = this.pauseMs('tap');
561
+ await delay(held / 2);
562
+ try {
563
+ await (await this.touch(page)).move([
564
+ [this.at[0] + gauss(0, 0.9), this.at[1] + gauss(0, 0.9), 0],
565
+ ]);
566
+ }
567
+ catch {
568
+ /* a tap-only backend cannot wobble; the tap still lands */
569
+ }
570
+ await delay(held / 2);
571
+ await this.release(page);
572
+ }
573
+ /**
574
+ * Into the field, at soft-keyboard pace.
575
+ *
576
+ * The mouse path clears with Control+A. There is no Control on a phone
577
+ * keyboard, so the box is cleared through the element instead — which also
578
+ * works on an Appium element, where `page.keyboard` does not exist.
579
+ */
580
+ async typeText(page, field, text) {
581
+ const f = field;
582
+ for (const [name, arg] of [['clear', undefined], ['fill', '']]) {
583
+ if (typeof f?.[name] !== 'function')
584
+ continue;
585
+ try {
586
+ await f[name](arg);
587
+ break;
588
+ }
589
+ catch {
590
+ /* an uncleared box is recoverable; a crash is not */
591
+ }
592
+ }
593
+ for (const ch of text) {
594
+ try {
595
+ if (typeof f?.sendKeys === 'function')
596
+ await f.sendKeys(ch); // Appium / WebDriver
597
+ else
598
+ await page.keyboard.type(ch); // mobile emulation
599
+ }
600
+ catch (e) {
601
+ log(`could not type into the captcha field: ${e}`);
602
+ return false;
603
+ }
604
+ await this.pause('key');
605
+ }
606
+ return true;
607
+ }
608
+ }
609
+ exports.MobileHumanizer = MobileHumanizer;
610
+ // ────────────────────────────────────────────────────────────────────── none
611
+ /**
612
+ * No humanisation: the shortest legal path to the same DOM effect.
613
+ *
614
+ * One mousemove per gesture instead of sixty, no dwell, no jitter, and text goes
615
+ * in with a single `fill()`. Roughly an order of magnitude faster on a slider
616
+ * puzzle, and it will be detected by anything that scores pointer telemetry —
617
+ * which is the whole trade, stated plainly. For fixtures, for self-hosted
618
+ * targets, and for callers whose stack humanises somewhere else.
619
+ *
620
+ * Still moves the mouse and still presses and releases: the events are what make
621
+ * the widget respond at all, and a click dispatched with no preceding move fails
622
+ * on the vendors that require a hover state first.
623
+ */
624
+ class NullHumanizer extends BaseHumanizer {
625
+ constructor() {
626
+ super(...arguments);
627
+ this.name = 'none';
628
+ this.hovers = false;
629
+ this.pauses = {};
630
+ }
631
+ async move(page, to) {
632
+ if (samePoint(this.at, to))
633
+ return;
634
+ this.at = [Number(to[0]), Number(to[1])];
635
+ try {
636
+ await page.mouse.move(this.at[0], this.at[1]);
637
+ }
638
+ catch (e) {
639
+ const msg = e instanceof Error ? e.message : String(e);
640
+ if (msg.includes('Target closed') || msg.includes('Session closed')) {
641
+ log('Warning: could not move mouse, page or session closed.');
642
+ }
643
+ }
644
+ }
645
+ async press(page) {
646
+ await page.mouse.down();
647
+ }
648
+ async release(page) {
649
+ await page.mouse.up();
650
+ }
651
+ async typeText(page, field, text) {
652
+ try {
653
+ await field.fill(text); // replaces, so no Control+A round is needed
654
+ return true;
655
+ }
656
+ catch {
657
+ /* fall through to the keyboard */
658
+ }
659
+ try {
660
+ await page.keyboard.press('Control+A');
661
+ }
662
+ catch {
663
+ /* optional */
664
+ }
665
+ try {
666
+ await page.keyboard.type(text);
667
+ return true;
668
+ }
669
+ catch (e) {
670
+ log(`could not type into the captcha field: ${e}`);
671
+ return false;
672
+ }
673
+ }
674
+ }
675
+ exports.NullHumanizer = NullHumanizer;
676
+ exports.MODES = ['mouse', 'mobile', 'none'];
677
+ /**
678
+ * The humanizer one solver will use, from its config.
679
+ *
680
+ * Precedence, and the reasoning for it:
681
+ *
682
+ * 1. `config.humanizer` — a caller who handed us an OBJECT has already
683
+ * decided; there is nothing left to select.
684
+ * 2. `config.humanization` — an explicit mode set in code.
685
+ * 3. `CAPTCHA_HUMANIZATION` — for a caller who cannot edit the code.
686
+ * 4. 'mouse'.
687
+ *
688
+ * Note that the env var loses to code, which is the opposite of this package's
689
+ * model-identity settings. Deliberate: which mode is right is a property of the
690
+ * PAGE the caller is driving, and an env var flipping a desktop solve to touch
691
+ * dispatch would break every one of them silently. Pinning a model is a
692
+ * deployment decision; picking an input device is not.
693
+ */
694
+ function resolveHumanizer(config = {}) {
695
+ if (config.humanizer)
696
+ return config.humanizer;
697
+ const raw = (config.humanization ?? process.env.CAPTCHA_HUMANIZATION ?? 'mouse')
698
+ .toString()
699
+ .trim()
700
+ .toLowerCase();
701
+ if (!exports.MODES.includes(raw)) {
702
+ throw new Error(`unknown humanization mode '${raw}'; expected one of ${exports.MODES.join(', ')}, ` +
703
+ 'or pass your own object as CaptchaKrakenConfig.humanizer');
704
+ }
705
+ const p = config.startingMousePosition;
706
+ const start = p ? [p.x, p.y] : [0, 0];
707
+ if (raw === 'mobile') {
708
+ return new MobileHumanizer(start, {
709
+ driver: config.touchDriver,
710
+ transform: config.touchTransform,
711
+ });
712
+ }
713
+ if (raw === 'none')
714
+ return new NullHumanizer(start);
715
+ return new MouseHumanizer(start);
716
+ }