Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/plugins/default-human-emulator/index.ts
1030 views
1
import {
2
IInteractionGroups,
3
IInteractionStep,
4
IKeyboardCommand,
5
IMousePosition,
6
IMousePositionXY,
7
InteractionCommand,
8
} from '@secret-agent/interfaces/IInteractions';
9
import { HumanEmulatorClassDecorator } from '@secret-agent/interfaces/ICorePlugin';
10
import IRect from '@secret-agent/interfaces/IRect';
11
import IInteractionsHelper from '@secret-agent/interfaces/IInteractionsHelper';
12
import IPoint from '@secret-agent/interfaces/IPoint';
13
import IViewport from '@secret-agent/interfaces/IViewport';
14
import HumanEmulator from '@secret-agent/plugin-utils/lib/HumanEmulator';
15
import generateVector from './generateVector';
16
import * as pkg from './package.json';
17
18
// ATTRIBUTION: heavily borrowed/inspired by https://github.com/Xetera/ghost-cursor
19
20
@HumanEmulatorClassDecorator
21
export default class DefaultHumanEmulator extends HumanEmulator {
22
public static id = pkg.name.replace('@secret-agent/', '');
23
24
public static overshootSpread = 2;
25
public static overshootRadius = 5;
26
public static overshootThreshold = 250;
27
public static boxPaddingPercent = 33;
28
public static maxScrollIncrement = 500;
29
public static maxScrollDelayMillis = 15;
30
public static maxDelayBetweenInteractions = 200;
31
32
public static wordsPerMinuteRange = [30, 50];
33
34
private millisPerCharacter: number;
35
36
public getStartingMousePoint(helper: IInteractionsHelper): Promise<IPoint> {
37
const viewport = helper.viewport;
38
return Promise.resolve(
39
getRandomRectPoint({
40
x: 0,
41
y: 0,
42
width: viewport.width,
43
height: viewport.height,
44
}),
45
);
46
}
47
48
public async playInteractions(
49
interactionGroups: IInteractionGroups,
50
runFn: (interactionStep: IInteractionStep) => Promise<void>,
51
helper: IInteractionsHelper,
52
): Promise<void> {
53
const millisPerCharacter = this.calculateMillisPerChar();
54
55
for (let i = 0; i < interactionGroups.length; i += 1) {
56
if (i > 0) {
57
const millis = Math.random() * DefaultHumanEmulator.maxDelayBetweenInteractions;
58
await delay(millis);
59
}
60
for (const step of interactionGroups[i]) {
61
if (step.command === InteractionCommand.scroll) {
62
await this.scroll(step, runFn, helper);
63
continue;
64
}
65
66
if (step.command === InteractionCommand.move) {
67
await this.moveMouse(step, runFn, helper);
68
continue;
69
}
70
71
if (
72
step.command === InteractionCommand.click ||
73
step.command === InteractionCommand.doubleclick
74
) {
75
await this.moveMouseAndClick(step, runFn, helper);
76
continue;
77
}
78
79
if (step.command === InteractionCommand.type) {
80
for (const keyboardCommand of step.keyboardCommands) {
81
if ('string' in keyboardCommand) {
82
for (const char of keyboardCommand.string) {
83
await runFn(this.getKeyboardCommandWithDelay({ string: char }, millisPerCharacter));
84
}
85
} else {
86
await runFn(this.getKeyboardCommandWithDelay(keyboardCommand, millisPerCharacter));
87
}
88
}
89
continue;
90
}
91
92
if (step.command === InteractionCommand.willDismissDialog) {
93
const millis = Math.random() * DefaultHumanEmulator.maxDelayBetweenInteractions;
94
await delay(millis);
95
continue;
96
}
97
await runFn(step);
98
}
99
}
100
}
101
102
protected async scroll(
103
interactionStep: IInteractionStep,
104
run: (interactionStep: IInteractionStep) => Promise<void>,
105
helper: IInteractionsHelper,
106
): Promise<void> {
107
const scrollVector = await this.getScrollVector(interactionStep.mousePosition, helper);
108
109
let counter = 0;
110
for (const { x, y } of scrollVector) {
111
await delay(Math.random() * DefaultHumanEmulator.maxScrollDelayMillis);
112
113
const shouldAddMouseJitter = counter % Math.round(Math.random() * 6) === 0;
114
if (shouldAddMouseJitter) {
115
await this.jitterMouse(helper, run);
116
}
117
118
await run({
119
mousePosition: [x, y],
120
command: InteractionCommand.scroll,
121
});
122
counter += 1;
123
}
124
}
125
126
protected async moveMouseAndClick(
127
interactionStep: IInteractionStep,
128
runFn: (interactionStep: IInteractionStep) => Promise<void>,
129
helper: IInteractionsHelper,
130
lockedNodeId?: number,
131
retries = 0,
132
): Promise<void> {
133
const originalMousePosition = [...interactionStep.mousePosition];
134
interactionStep.delayMillis = Math.floor(Math.random() * 100);
135
136
let targetRect = await helper.lookupBoundingRect(
137
lockedNodeId ? [lockedNodeId] : interactionStep.mousePosition,
138
true,
139
true,
140
);
141
142
const { nodeId } = targetRect;
143
144
let targetPoint = getRandomRectPoint(targetRect, DefaultHumanEmulator.boxPaddingPercent);
145
const didMoveMouse = await this.moveMouseToPoint(targetPoint, targetRect.width, runFn, helper);
146
if (didMoveMouse) {
147
targetRect = await helper.lookupBoundingRect([nodeId], true, true);
148
targetPoint = getRandomRectPoint(targetRect, DefaultHumanEmulator.boxPaddingPercent);
149
}
150
151
if (targetRect.elementTag === 'option') {
152
// if this is an option element, we have to do a specialized click, so let the Interactor handle
153
return await runFn(interactionStep);
154
}
155
156
const viewport = helper.viewport;
157
const isRectInViewport =
158
isVisible(targetRect.y, targetRect.height, viewport.height) &&
159
isVisible(targetRect.x, targetRect.width, viewport.width);
160
161
// make sure target is still visible
162
if (
163
!targetRect.nodeVisibility.isVisible ||
164
!isRectInViewport ||
165
!isWithinRect(targetPoint, targetRect)
166
) {
167
// need to try again
168
if (retries < 2) {
169
const isScroll = !isRectInViewport;
170
helper.logger.info(
171
`"Click" mousePosition not in viewport after mouse moves. Moving${
172
isScroll ? ' and scrolling' : ''
173
} to a new point.`,
174
{
175
interactionStep,
176
nodeId,
177
nodeVisibility: targetRect.nodeVisibility,
178
retries,
179
},
180
);
181
if (isScroll) {
182
const scrollToStep = { ...interactionStep };
183
if (nodeId) scrollToStep.mousePosition = [nodeId];
184
await this.scroll(scrollToStep, runFn, helper);
185
}
186
return this.moveMouseAndClick(interactionStep, runFn, helper, nodeId, retries + 1);
187
}
188
189
helper.logger.error(
190
'Interaction.click - mousePosition not in viewport after mouse moves to prepare for click.',
191
{
192
'Interaction.mousePosition': originalMousePosition,
193
target: {
194
nodeId,
195
nodeVisibility: targetRect.nodeVisibility,
196
domCoordinates: { x: targetPoint.x, y: targetPoint.y },
197
},
198
viewport,
199
},
200
);
201
202
throw new Error(
203
'Element or mousePosition remains out of viewport after 2 attempts to move it into view',
204
);
205
}
206
207
let clickConfirm: () => Promise<any>;
208
if (nodeId) {
209
const listener = await helper.createMouseupTrigger(nodeId);
210
clickConfirm = listener.didTrigger.bind(listener, originalMousePosition, true);
211
}
212
213
interactionStep.mousePosition = [targetPoint.x, targetPoint.y];
214
215
await runFn(interactionStep);
216
if (clickConfirm) await clickConfirm();
217
}
218
219
protected async moveMouse(
220
interactionStep: IInteractionStep,
221
run: (interactionStep: IInteractionStep) => Promise<void>,
222
helper: IInteractionsHelper,
223
): Promise<void> {
224
const rect = await helper.lookupBoundingRect(interactionStep.mousePosition);
225
const targetPoint = getRandomRectPoint(rect, DefaultHumanEmulator.boxPaddingPercent);
226
227
await this.moveMouseToPoint(targetPoint, rect.width, run, helper);
228
}
229
230
protected async moveMouseToPoint(
231
targetPoint: IPoint,
232
targetWidth: number,
233
runFn: (interactionStep: IInteractionStep) => Promise<void>,
234
helper: IInteractionsHelper,
235
): Promise<boolean> {
236
const mousePosition = helper.mousePosition;
237
const vector = generateVector(mousePosition, targetPoint, targetWidth, {
238
threshold: DefaultHumanEmulator.overshootThreshold,
239
radius: DefaultHumanEmulator.overshootRadius,
240
spread: DefaultHumanEmulator.overshootSpread,
241
});
242
243
if (!vector.length) return false;
244
for (const { x, y } of vector) {
245
await runFn({
246
mousePosition: [x, y],
247
command: InteractionCommand.move,
248
});
249
}
250
return true;
251
}
252
253
protected async jitterMouse(
254
helper: IInteractionsHelper,
255
runFn: (interactionStep: IInteractionStep) => Promise<void>,
256
): Promise<void> {
257
const mousePosition = helper.mousePosition;
258
const jitterX = Math.max(mousePosition.x + Math.round(getRandomPositiveOrNegativeNumber()), 0);
259
const jitterY = Math.max(mousePosition.y + Math.round(getRandomPositiveOrNegativeNumber()), 0);
260
if (jitterX !== mousePosition.x || jitterY !== mousePosition.y) {
261
// jitter mouse
262
await runFn({
263
mousePosition: [jitterX, jitterY],
264
command: InteractionCommand.move,
265
});
266
}
267
}
268
269
/////// KEYBOARD /////////////////////////////////////////////////////////////////////////////////////////////////////
270
271
protected getKeyboardCommandWithDelay(keyboardCommand: IKeyboardCommand, millisPerChar: number) {
272
const randomFactor = getRandomPositiveOrNegativeNumber() * (millisPerChar / 2);
273
const delayMillis = Math.floor(randomFactor + millisPerChar);
274
const keyboardKeyupDelay = Math.max(Math.ceil(Math.random() * 60), 10);
275
return {
276
command: InteractionCommand.type,
277
keyboardCommands: [keyboardCommand],
278
keyboardDelayBetween: delayMillis - keyboardKeyupDelay,
279
keyboardKeyupDelay,
280
};
281
}
282
283
protected calculateMillisPerChar(): number {
284
if (!this.millisPerCharacter) {
285
const wpmRange =
286
DefaultHumanEmulator.wordsPerMinuteRange[1] - DefaultHumanEmulator.wordsPerMinuteRange[0];
287
const wpm =
288
Math.floor(Math.random() * wpmRange) + DefaultHumanEmulator.wordsPerMinuteRange[0];
289
290
const averageWordLength = 5;
291
const charsPerSecond = (wpm * averageWordLength) / 60;
292
this.millisPerCharacter = Math.round(1000 / charsPerSecond);
293
}
294
return this.millisPerCharacter;
295
}
296
297
private async getScrollVector(
298
mousePosition: IMousePosition,
299
helper: IInteractionsHelper,
300
): Promise<IPoint[]> {
301
const isCoordinates =
302
typeof mousePosition[0] === 'number' && typeof mousePosition[1] === 'number';
303
let shouldScrollX: boolean;
304
let shouldScrollY: boolean;
305
let scrollToPoint: IPoint;
306
const startScrollOffset = await helper.scrollOffset;
307
308
if (!isCoordinates) {
309
const targetRect = await helper.lookupBoundingRect(mousePosition);
310
// figure out if target is in view
311
const viewport = helper.viewport;
312
shouldScrollY = isVisible(targetRect.y, targetRect.height, viewport.height) === false;
313
shouldScrollX = isVisible(targetRect.x, targetRect.width, viewport.width) === false;
314
315
// positions are all relative to viewport, so act like we're at 0,0
316
scrollToPoint = getScrollRectPoint(targetRect, viewport);
317
318
if (shouldScrollY) scrollToPoint.y += startScrollOffset.y;
319
else scrollToPoint.y = startScrollOffset.y;
320
321
if (shouldScrollX) scrollToPoint.x += startScrollOffset.x;
322
else scrollToPoint.x = startScrollOffset.x;
323
} else {
324
const [x, y] = mousePosition as IMousePositionXY;
325
scrollToPoint = { x, y };
326
shouldScrollY = y !== startScrollOffset.y;
327
shouldScrollX = x !== startScrollOffset.x;
328
}
329
330
if (!shouldScrollY && !shouldScrollX) return [];
331
332
let lastPoint: IPoint = startScrollOffset;
333
const scrollVector = generateVector(startScrollOffset, scrollToPoint, 200, {
334
threshold: DefaultHumanEmulator.overshootThreshold,
335
radius: DefaultHumanEmulator.overshootRadius,
336
spread: DefaultHumanEmulator.overshootSpread,
337
});
338
339
const points: IPoint[] = [];
340
for (let point of scrollVector) {
341
// convert points into deltas from previous scroll point
342
const scrollX = shouldScrollX ? Math.round(point.x) : startScrollOffset.x;
343
const scrollY = shouldScrollY ? Math.round(point.y) : startScrollOffset.y;
344
if (scrollY === lastPoint.y && scrollX === lastPoint.x) continue;
345
if (scrollY < 0 || scrollX < 0) continue;
346
347
point = {
348
x: scrollX,
349
y: scrollY,
350
};
351
352
const scrollYPixels = Math.abs(scrollY - lastPoint.y);
353
// if too big a jump, backfill smaller jumps
354
if (scrollYPixels > DefaultHumanEmulator.maxScrollIncrement) {
355
const isNegative = scrollY < lastPoint.y;
356
const chunks = splitIntoMaxLengthSegments(
357
scrollYPixels,
358
DefaultHumanEmulator.maxScrollIncrement,
359
);
360
for (const chunk of chunks) {
361
const deltaY = isNegative ? -chunk : chunk;
362
const scrollYChunk = Math.max(lastPoint.y + deltaY, 0);
363
if (scrollYChunk === lastPoint.y) continue;
364
365
const newPoint = {
366
x: scrollX,
367
y: scrollYChunk,
368
};
369
points.push(newPoint);
370
lastPoint = newPoint;
371
}
372
}
373
374
const lastEntry = points[points.length - 1];
375
// if same point added, yank it now
376
if (!lastEntry || lastEntry.x !== point.x || lastEntry.y !== point.y) {
377
points.push(point);
378
lastPoint = point;
379
}
380
}
381
if (lastPoint.y !== scrollToPoint.y || lastPoint.x !== scrollToPoint.x) {
382
points.push(scrollToPoint);
383
}
384
return points;
385
}
386
}
387
388
function isWithinRect(targetPoint: IPoint, finalRect: IRect): boolean {
389
if (targetPoint.x < finalRect.x || targetPoint.x > finalRect.x + finalRect.width) return false;
390
if (targetPoint.y < finalRect.y || targetPoint.y > finalRect.y + finalRect.height) return false;
391
392
return true;
393
}
394
395
export function isVisible(coordinate: number, length: number, boundaryLength: number): boolean {
396
if (length > boundaryLength) {
397
length = boundaryLength;
398
}
399
const midpointOffset = Math.round(coordinate + length / 2);
400
if (coordinate >= 0) {
401
// midpoint passes end
402
if (midpointOffset >= boundaryLength) {
403
return false;
404
}
405
} else {
406
// midpoint before start
407
// eslint-disable-next-line no-lonely-if
408
if (midpointOffset <= 0) {
409
return false;
410
}
411
}
412
return true;
413
}
414
415
async function delay(millis: number): Promise<void> {
416
if (!millis) return;
417
await new Promise<void>(resolve => setTimeout(resolve, Math.floor(millis)).unref());
418
}
419
420
function splitIntoMaxLengthSegments(total: number, maxValue: number): number[] {
421
const values: number[] = [];
422
let currentSum = 0;
423
while (currentSum < total) {
424
let nextValue = Math.round(Math.random() * maxValue * 10) / 10;
425
if (currentSum + nextValue > total) {
426
nextValue = total - currentSum;
427
}
428
currentSum += nextValue;
429
values.push(nextValue);
430
}
431
return values;
432
}
433
434
function getRandomPositiveOrNegativeNumber(): number {
435
const negativeMultiplier = Math.random() < 0.5 ? -1 : 1;
436
437
return Math.random() * negativeMultiplier;
438
}
439
440
function getScrollRectPoint(targetRect: IRect, viewport: IViewport): IPoint {
441
let { y, x } = targetRect;
442
const fudge = 2 * Math.random();
443
// target rect inside bounds
444
const midViewportHeight = Math.round(viewport.height / 2 + fudge);
445
const midViewportWidth = Math.round(viewport.width / 2 + fudge);
446
447
if (y < -(midViewportHeight + 1)) y -= midViewportHeight;
448
else if (y > midViewportHeight + 1) y -= midViewportHeight;
449
450
if (x < -(midViewportWidth + 1)) x -= midViewportWidth;
451
else if (x > midViewportWidth + 1) x -= midViewportWidth;
452
453
x = Math.round(x * 10) / 10;
454
y = Math.round(y * 10) / 10;
455
456
return { x, y };
457
}
458
459
function getRandomRectPoint(targetRect: IRect, paddingPercent?: number): IPoint {
460
const { y, x, height, width } = targetRect;
461
462
let paddingWidth = 0;
463
let paddingHeight = 0;
464
465
if (paddingPercent !== undefined && paddingPercent > 0 && paddingPercent < 100) {
466
paddingWidth = (width * paddingPercent) / 100;
467
paddingHeight = (height * paddingPercent) / 100;
468
}
469
470
return {
471
x: Math.round(x + paddingWidth / 2 + Math.random() * (width - paddingWidth)),
472
y: Math.round(y + paddingHeight / 2 + Math.random() * (height - paddingHeight)),
473
};
474
}
475
476