captchakraken 2.6.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -7
- package/dist/cli-invocation.d.ts +16 -16
- package/dist/cli-invocation.js +33 -21
- 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/model-name.d.ts +1 -1
- package/dist/model-name.js +1 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +314 -16
- package/dist/solver.js +1208 -278
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +28 -1
- package/dist/trajectory.js +105 -1
- package/dist/types.d.ts +113 -2
- package/package.json +2 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +66 -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 +184 -12
- package/python/src/captchakraken/models.json +180 -18
- package/python/src/captchakraken/page_solver.py +1132 -321
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +204 -16
- package/python/src/captchakraken/prompts.py +186 -4
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +98 -58
- 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/humanize.js
ADDED
|
@@ -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 `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.
|
|
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 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.
|
|
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
|
+
}
|