captchakraken 2.9.0 → 3.0.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 (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +94 -712
  28. package/dist/solver.js +923 -3362
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +75 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -883
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1052 -3098
  60. package/python/src/captchakraken/planner.py +50 -487
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
package/dist/humanize.js CHANGED
@@ -1,90 +1,48 @@
1
1
  "use strict";
2
2
  /**
3
- * How the driver MOVES. One pluggable object per input device.
3
+ * How the driver moves: one pluggable object per input device. Mirrors python/humanize.py.
4
4
  *
5
- * The TypeScript side of `python/src/captchakraken/humanize.py`; the two are one
6
- * design in two languages, and `the training repo's humanizer parity tests` in
7
- * the training repo pins that the mode names and the pause vocabulary have
8
- * not drifted.
5
+ * The pointer position lives here, not in the solver, because a touch mode that dispatches no
6
+ * motion between taps still has to say where the next gesture starts.
9
7
  *
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 a GeeTest v4 slider 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.
8
+ * Pluggable because camoufox's juggler re-humanises every mouse.move() it is handed, and running
9
+ * both measured 82s against 13s on one GeeTest slider; and a touch widget needs touch events, not
10
+ * mousemove. See TRIBAL_KNOWLEDGE.md. The 2026-09 cut removed our drag-overshoot redraw and swipe
11
+ * wobble in favour of Cursory's raw recordings; the one-pixel tap wobble stayed because a
12
+ * motionless tap is a synthetic one.
49
13
  */
50
14
  Object.defineProperty(exports, "__esModule", { value: true });
51
15
  exports.MODES = exports.NullHumanizer = exports.MobileHumanizer = exports.TouchscreenTouchBackend = exports.AppiumTouchBackend = exports.CdpTouchBackend = exports.MouseHumanizer = exports.BaseHumanizer = exports.PAUSE_KINDS = void 0;
16
+ exports.trajectory = trajectory;
52
17
  exports.touchBackendFor = touchBackendFor;
53
18
  exports.resolveHumanizer = resolveHumanizer;
54
- const trajectory_js_1 = require("./trajectory.js");
19
+ const cursory_js_1 = require("cursory-js");
20
+ const kinds_js_1 = require("./kinds.js");
55
21
  const delay = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
56
22
  const log = (m) => console.log(`[captchakraken] ${m}`);
57
23
  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
- */
24
+ // moveAndClick clicks at the landing point; without this every click would dispatch one redundant move.
65
25
  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) {
26
+ const gauss = (sigma) => {
68
27
  let u = 0;
69
28
  while (u === 0)
70
29
  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);
30
+ return sigma * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * Math.random());
31
+ };
32
+ const closedError = (e) => /Target closed|Session closed/.test(e instanceof Error ? e.message : String(e));
33
+ /** A recorded human movement morphed onto the endpoints; timings are cumulative ms. */
34
+ function trajectory(start, end, frequency) {
35
+ const { points, timings } = (0, cursory_js_1.generateTrajectory)(start, end, { frequency });
36
+ return [points.map((q) => [Number(q[0]), Number(q[1])]), timings.map(Number)];
73
37
  }
74
- exports.PAUSE_KINDS = [
75
- 'tap',
76
- 'between',
77
- 'grab',
78
- 'drop',
79
- 'probe',
80
- 'settle',
81
- 'key',
82
- ];
38
+ /** Every inter-gesture wait the driver takes, named, so each device supplies its own table. */
39
+ exports.PAUSE_KINDS = Object.values(kinds_js_1.PauseKind);
83
40
  class BaseHumanizer {
84
41
  constructor(start = [0, 0]) {
85
42
  this.at = [Number(start[0]), Number(start[1])];
86
43
  }
87
44
  async reset(_page) { }
45
+ /** An unknown kind waits nothing, so adding a pause site cannot break a custom humanizer written against an older release. */
88
46
  pauseMs(kind) {
89
47
  const range = this.pauses[kind];
90
48
  return range ? uniform(range[0], range[1]) : 0;
@@ -95,68 +53,86 @@ class BaseHumanizer {
95
53
  async click(page, to) {
96
54
  await this.move(page, to);
97
55
  await this.press(page);
98
- await this.pause('tap');
56
+ await this.pause(kinds_js_1.PauseKind.TAP);
99
57
  await this.release(page);
100
58
  }
101
59
  async drag(page, src, dst) {
102
60
  await this.move(page, src);
103
61
  await this.press(page);
104
- await this.pause('grab');
62
+ await this.pause(kinds_js_1.PauseKind.GRAB);
105
63
  await this.move(page, dst);
106
- await this.pause('drop');
64
+ await this.pause(kinds_js_1.PauseKind.DROP);
107
65
  await this.release(page);
108
66
  }
109
67
  }
110
68
  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
69
  class MouseHumanizer extends BaseHumanizer {
120
70
  constructor(start = [0, 0], frequency = 60) {
121
71
  super(start);
122
- this.name = 'mouse';
72
+ this.name = kinds_js_1.HumanizationMode.MOUSE;
123
73
  this.hovers = true;
124
- /** Inclusive ranges, in ms. See PauseKind. */
125
74
  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],
75
+ tap: [20, 50], between: [80, 160], grab: [50, 100], drop: [50, 100],
76
+ probe: [40, 80], settle: [90, 210], key: [45, 135],
133
77
  };
134
78
  this.frequency = frequency;
135
79
  }
80
+ async reset(_page) { this.viewportCache = undefined; }
81
+ /** The window to keep the cursor inside; null under camoufox, which reports no viewport and whose window we ask instead. */
82
+ async viewport(page) {
83
+ if (this.viewportCache !== undefined)
84
+ return this.viewportCache;
85
+ this.viewportCache = null;
86
+ const reads = [
87
+ () => page.viewportSize(),
88
+ () => page.evaluate ? page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })) : null,
89
+ ];
90
+ for (const read of reads) {
91
+ try {
92
+ const vp = await read();
93
+ if (vp && vp.width && vp.height) {
94
+ this.viewportCache = { width: vp.width, height: vp.height };
95
+ break;
96
+ }
97
+ }
98
+ catch { /* try the next read */ }
99
+ }
100
+ return this.viewportCache;
101
+ }
136
102
  async move(page, to) {
137
103
  if (samePoint(this.at, to))
138
104
  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();
105
+ const [points, timings] = trajectory(this.at, to, this.frequency);
106
+ // Clamp only when the viewport is known: a coordinate pinned to the edge of a guessed one deadlocks camoufox's juggler (upstream #225).
107
+ const viewport = await this.viewport(page);
108
+ const startTime = Date.now();
109
+ for (let i = 0; i < points.length; i++) {
110
+ const cx = viewport ? Math.max(1, Math.min(points[i][0], viewport.width - 1)) : points[i][0];
111
+ const cy = viewport ? Math.max(1, Math.min(points[i][1], viewport.height - 1)) : points[i][1];
112
+ try {
113
+ await page.mouse.move(cx, cy);
114
+ }
115
+ catch (e) {
116
+ if (closedError(e)) {
117
+ log('could not move mouse; page or session closed');
118
+ return;
119
+ }
120
+ continue;
121
+ }
122
+ this.at = [cx, cy];
123
+ if (timings[i] !== undefined)
124
+ await delay(startTime + timings[i] - Date.now());
125
+ }
147
126
  }
127
+ async press(page) { await page.mouse.down(); }
128
+ async release(page) { await page.mouse.up(); }
129
+ /** Per character with a humanised pause, not type(text, {delay}): a constant inter-key delay is itself a signal these vendors score. */
148
130
  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.
131
+ // Clear first: a retry round arrives with the previous attempt still in the box, and typing would append.
151
132
  try {
152
133
  await page.keyboard.press('Control+A');
153
134
  }
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.
135
+ catch { /* the type below still replaces on most */ }
160
136
  for (const ch of text) {
161
137
  try {
162
138
  await page.keyboard.type(ch);
@@ -165,76 +141,26 @@ class MouseHumanizer extends BaseHumanizer {
165
141
  log(`could not type into the captcha field: ${e}`);
166
142
  return false;
167
143
  }
168
- await this.pause('key');
144
+ await this.pause(kinds_js_1.PauseKind.KEY);
169
145
  }
170
146
  return true;
171
147
  }
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
148
  }
213
149
  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
- */
150
+ /** `Input.dispatchTouchEvent` over CDP; Chromium-family pages launched with `hasTouch: true`. */
229
151
  class CdpTouchBackend {
230
152
  constructor(session) {
231
- this.name = 'cdp';
232
153
  this.session = session;
154
+ this.name = 'cdp';
233
155
  }
156
+ /**
157
+ * Checked at construction, not per gesture. WebKit and Firefox expose no touch dispatch, and emitting
158
+ * mouse events instead is worse than not running: the page's touch handlers never fire and the report
159
+ * reads as a model that cannot solve mobile puzzles.
160
+ */
234
161
  static async open(page) {
235
162
  try {
236
- const context = page.context();
237
- return new CdpTouchBackend(await context.newCDPSession(page));
163
+ return new CdpTouchBackend(await page.context().newCDPSession(page));
238
164
  }
239
165
  catch (e) {
240
166
  throw new Error(`mobile humanisation needs touch dispatch, and this page offers none (${e}). ` +
@@ -243,24 +169,15 @@ class CdpTouchBackend {
243
169
  }
244
170
  }
245
171
  async send(kind, points) {
172
+ // radius and force are what a real digitizer reports; a synthetic tap reports zero.
246
173
  await this.session.send('Input.dispatchTouchEvent', {
247
174
  type: kind,
248
175
  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,
176
+ x, y, radiusX: uniform(8, 14), radiusY: uniform(8, 14), force: uniform(0.35, 0.75), id: 1,
258
177
  })),
259
178
  });
260
179
  }
261
- async down(x, y) {
262
- await this.send('touchStart', [[x, y]]);
263
- }
180
+ async down(x, y) { await this.send('touchStart', [[x, y]]); }
264
181
  async move(path) {
265
182
  for (const [x, y, dt] of path) {
266
183
  if (dt > 0)
@@ -268,61 +185,28 @@ class CdpTouchBackend {
268
185
  await this.send('touchMove', [[x, y]]);
269
186
  }
270
187
  }
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
- }
188
+ async up(_x, _y) { await this.send('touchEnd', []); }
277
189
  }
278
190
  exports.CdpTouchBackend = CdpTouchBackend;
279
191
  /**
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.
192
+ * W3C touch pointer actions for Appium and WebdriverIO, paced by the device from one chain per leg.
193
+ * Raw protocol payloads so this imports no client. Press and release are separate performs because W3C
194
+ * input state is per session: that is what lets the slider driver press, screenshot, steer, then release.
303
195
  */
304
196
  class AppiumTouchBackend {
305
197
  constructor(driver, transform = {}, page) {
198
+ this.driver = driver;
199
+ this.page = page;
306
200
  this.name = 'appium';
307
201
  this.checked = false;
308
- this.driver = driver;
309
202
  this.scaleGiven = transform.scale !== undefined;
310
203
  this.scale = transform.scale ?? 1;
311
204
  this.origin = transform.origin ?? [0, 0];
312
- this.page = page;
313
205
  }
314
206
  /**
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.
207
+ * Refuse an unset scale on a device whose pixel ratio is not 1: the finger would land elsewhere. An
208
+ * explicit scale of 1 is the caller's word and is not re-checked; the ratio is read once, since a solve
209
+ * makes hundreds of these, and an unreadable ratio is taken as 1.
326
210
  */
327
211
  async checkScale() {
328
212
  this.checked = true;
@@ -333,51 +217,23 @@ class AppiumTouchBackend {
333
217
  dpr = Number(await this.page.evaluate('() => window.devicePixelRatio'));
334
218
  }
335
219
  catch {
336
- return; // cannot measure is not a mismatch
220
+ return;
337
221
  }
338
222
  if (!Number.isFinite(dpr) || Math.abs(dpr - 1) < 1e-6)
339
223
  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.`);
224
+ throw new Error(`the touch driver maps CSS pixels onto a device reporting devicePixelRatio ${dpr}, and no scale ` +
225
+ `was given. Fix: touchTransform: { scale: ${dpr}, origin: [x, y] }, where origin is the webview's ` +
226
+ 'top-left in SCREEN coordinates, or pass scale: 1 to assert the coordinates are already mapped.');
349
227
  }
350
228
  map(x, y) {
351
- return [
352
- Math.round(this.origin[0] + x * this.scale),
353
- Math.round(this.origin[1] + y * this.scale),
354
- ];
229
+ return [Math.round(this.origin[0] + x * this.scale), Math.round(this.origin[1] + y * this.scale)];
355
230
  }
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
231
  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
- }
232
+ const chain = [{ type: 'pointer', id: 'ck-finger', parameters: { pointerType: 'touch' }, actions }];
233
+ if (typeof this.driver.performActions === 'function')
234
+ return this.driver.performActions(chain);
235
+ if (typeof this.driver.execute === 'function')
236
+ return this.driver.execute('actions', { actions: chain });
381
237
  throw new Error("the touch driver speaks neither performActions() nor execute('actions', …); " +
382
238
  'pass a WebDriver-compatible driver or a custom TouchBackend.');
383
239
  }
@@ -390,6 +246,7 @@ class AppiumTouchBackend {
390
246
  { type: 'pointerDown', button: 0 },
391
247
  ]);
392
248
  }
249
+ /** The per-sample gap becomes the move's `duration`, so the device interpolates the leg itself. */
393
250
  async move(path) {
394
251
  if (!this.checked)
395
252
  await this.checkScale();
@@ -397,10 +254,6 @@ class AppiumTouchBackend {
397
254
  return;
398
255
  await this.perform(path.map(([x, y, dt]) => {
399
256
  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
257
  return { type: 'pointerMove', duration: Math.max(0, Math.round(dt)), origin: 'viewport', x: mx, y: my };
405
258
  }));
406
259
  }
@@ -411,26 +264,17 @@ class AppiumTouchBackend {
411
264
  }
412
265
  }
413
266
  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
- */
267
+ /** Playwright's tap-only touchscreen, for browsers with touch but no CDP. A drag cannot be expressed. */
421
268
  class TouchscreenTouchBackend {
422
269
  constructor(page) {
270
+ this.page = page;
423
271
  this.name = 'touchscreen';
424
272
  this.pending = null;
425
- this.page = page;
426
- }
427
- async down(x, y) {
428
- this.pending = [x, y];
429
273
  }
274
+ async down(x, y) { this.pending = [x, y]; }
430
275
  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.');
276
+ throw new Error('this browser exposes taps but not touch travel, so a drag/slide puzzle cannot be ' +
277
+ 'driven on it. Use a Chromium-family browser (CDP touch dispatch) or an Appium driver.');
434
278
  }
435
279
  async up(x, y) {
436
280
  const at = this.pending ?? [x, y];
@@ -439,19 +283,10 @@ class TouchscreenTouchBackend {
439
283
  }
440
284
  }
441
285
  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
286
  async function touchBackendFor(page, driver, transform) {
450
287
  if (driver) {
451
- if (typeof driver.down === 'function' && typeof driver.up === 'function') {
288
+ if (typeof driver.down === 'function' && typeof driver.up === 'function')
452
289
  return driver;
453
- }
454
- // The page comes along so the backend can ask what it is mapping ONTO.
455
290
  return new AppiumTouchBackend(driver, transform, page);
456
291
  }
457
292
  try {
@@ -465,43 +300,16 @@ async function touchBackendFor(page, driver, transform) {
465
300
  throw e;
466
301
  }
467
302
  }
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
- */
303
+ /** A finger on glass: no hover, touch events only, slower and more variable pauses. */
488
304
  class MobileHumanizer extends BaseHumanizer {
489
305
  constructor(start = [0, 0], options = {}) {
490
306
  super(start);
491
- this.name = 'mobile';
307
+ this.name = kinds_js_1.HumanizationMode.MOBILE;
492
308
  this.hovers = false;
309
+ // tap: measured human touch dwell clusters at 60-120ms. key: a soft keyboard is ~3x slower than a physical one.
493
310
  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],
311
+ tap: [55, 130], between: [140, 320], grab: [90, 190], drop: [80, 170],
312
+ probe: [70, 140], settle: [140, 300], key: [110, 320],
505
313
  };
506
314
  this.down = false;
507
315
  this.backend = options.backend ?? null;
@@ -509,40 +317,27 @@ class MobileHumanizer extends BaseHumanizer {
509
317
  this.transform = options.transform;
510
318
  this.frequency = options.frequency ?? 90;
511
319
  }
320
+ /** A solve that ended mid-gesture leaves a pointer down in the session's input state; lift it before the next one. */
512
321
  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;
322
+ if (!this.down)
323
+ return;
324
+ try {
325
+ await (await this.touch(page)).up(this.at[0], this.at[1]);
524
326
  }
327
+ catch { /* a stale pointer must not fail a solve */ }
328
+ this.down = false;
525
329
  }
526
330
  async touch(page) {
527
- if (!this.backend) {
331
+ if (!this.backend)
528
332
  this.backend = await touchBackendFor(page, this.driver, this.transform);
529
- }
530
333
  return this.backend;
531
334
  }
532
335
  async move(page, to) {
533
336
  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;
337
+ if (this.down) {
338
+ const [points, timings] = trajectory(this.at, dest, this.frequency);
339
+ await (await this.touch(page)).move(points.map(([x, y], i) => [x, y, timings[i] - (i ? timings[i - 1] : 0)]));
538
340
  }
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
341
  this.at = dest;
547
342
  }
548
343
  async press(page) {
@@ -553,30 +348,20 @@ class MobileHumanizer extends BaseHumanizer {
553
348
  await (await this.touch(page)).up(this.at[0], this.at[1]);
554
349
  this.down = false;
555
350
  }
556
- /** Tap. The wobble is the point — see the class docstring. */
351
+ /** A tap whose contact patch wobbles a pixel while held; a motionless tap is a synthetic one. */
557
352
  async click(page, to) {
558
353
  await this.move(page, to);
559
354
  await this.press(page);
560
- const held = this.pauseMs('tap');
355
+ const held = this.pauseMs(kinds_js_1.PauseKind.TAP);
561
356
  await delay(held / 2);
562
357
  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 */
358
+ await (await this.touch(page)).move([[this.at[0] + gauss(0.9), this.at[1] + gauss(0.9), 0]]);
569
359
  }
360
+ catch { /* a tap-only backend cannot wobble; the tap still lands */ }
570
361
  await delay(held / 2);
571
362
  await this.release(page);
572
363
  }
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
- */
364
+ /** Clears through the element: there is no Control key on a phone and no `page.keyboard` on Appium. */
580
365
  async typeText(page, field, text) {
581
366
  const f = field;
582
367
  for (const [name, arg] of [['clear', undefined], ['fill', '']]) {
@@ -586,45 +371,33 @@ class MobileHumanizer extends BaseHumanizer {
586
371
  await f[name](arg);
587
372
  break;
588
373
  }
589
- catch {
590
- /* an uncleared box is recoverable; a crash is not */
591
- }
374
+ catch { /* an uncleared box is recoverable */ }
592
375
  }
593
376
  for (const ch of text) {
594
377
  try {
595
378
  if (typeof f?.sendKeys === 'function')
596
- await f.sendKeys(ch); // Appium / WebDriver
379
+ await f.sendKeys(ch);
597
380
  else
598
- await page.keyboard.type(ch); // mobile emulation
381
+ await page.keyboard.type(ch);
599
382
  }
600
383
  catch (e) {
601
384
  log(`could not type into the captcha field: ${e}`);
602
385
  return false;
603
386
  }
604
- await this.pause('key');
387
+ await this.pause(kinds_js_1.PauseKind.KEY);
605
388
  }
606
389
  return true;
607
390
  }
608
391
  }
609
392
  exports.MobileHumanizer = MobileHumanizer;
610
- // ────────────────────────────────────────────────────────────────────── none
611
393
  /**
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.
394
+ * No humanisation: one move per gesture and a single fill. Fast, and detectable. It still moves before
395
+ * it presses, because a click with no preceding move fails on the vendors that require a hover state first.
623
396
  */
624
397
  class NullHumanizer extends BaseHumanizer {
625
398
  constructor() {
626
399
  super(...arguments);
627
- this.name = 'none';
400
+ this.name = kinds_js_1.HumanizationMode.NONE;
628
401
  this.hovers = false;
629
402
  this.pauses = {};
630
403
  }
@@ -636,32 +409,22 @@ class NullHumanizer extends BaseHumanizer {
636
409
  await page.mouse.move(this.at[0], this.at[1]);
637
410
  }
638
411
  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
- }
412
+ if (closedError(e))
413
+ log('could not move mouse; page or session closed');
643
414
  }
644
415
  }
645
- async press(page) {
646
- await page.mouse.down();
647
- }
648
- async release(page) {
649
- await page.mouse.up();
650
- }
416
+ async press(page) { await page.mouse.down(); }
417
+ async release(page) { await page.mouse.up(); }
651
418
  async typeText(page, field, text) {
652
419
  try {
653
- await field.fill(text); // replaces, so no Control+A round is needed
420
+ await field.fill(text);
654
421
  return true;
655
422
  }
656
- catch {
657
- /* fall through to the keyboard */
658
- }
423
+ catch { /* fall through to the keyboard */ }
659
424
  try {
660
425
  await page.keyboard.press('Control+A');
661
426
  }
662
- catch {
663
- /* optional */
664
- }
427
+ catch { /* optional */ }
665
428
  try {
666
429
  await page.keyboard.type(text);
667
430
  return true;
@@ -673,44 +436,25 @@ class NullHumanizer extends BaseHumanizer {
673
436
  }
674
437
  }
675
438
  exports.NullHumanizer = NullHumanizer;
676
- exports.MODES = ['mouse', 'mobile', 'none'];
439
+ exports.MODES = Object.values(kinds_js_1.HumanizationMode);
677
440
  /**
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.
441
+ * `config.humanizer`, else `config.humanization`, else CAPTCHA_HUMANIZATION, else mouse. The env var loses
442
+ * to code, the opposite of the model settings, on purpose: an env var flipping a desktop solve to touch
443
+ * dispatch would break every one of them silently.
693
444
  */
694
445
  function resolveHumanizer(config = {}) {
695
446
  if (config.humanizer)
696
447
  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)) {
448
+ const raw = (config.humanization ?? process.env.CAPTCHA_HUMANIZATION ?? kinds_js_1.HumanizationMode.MOUSE).toString().trim().toLowerCase();
449
+ if (!(0, kinds_js_1.isOneOf)(kinds_js_1.HumanizationMode, raw)) {
702
450
  throw new Error(`unknown humanization mode '${raw}'; expected one of ${exports.MODES.join(', ')}, ` +
703
451
  'or pass your own object as CaptchaKrakenConfig.humanizer');
704
452
  }
705
453
  const p = config.startingMousePosition;
706
454
  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')
455
+ if (raw === kinds_js_1.HumanizationMode.MOBILE)
456
+ return new MobileHumanizer(start, { driver: config.touchDriver, transform: config.touchTransform });
457
+ if (raw === kinds_js_1.HumanizationMode.NONE)
714
458
  return new NullHumanizer(start);
715
459
  return new MouseHumanizer(start);
716
460
  }