Path: blob/main/plugins/default-human-emulator/index.ts
1030 views
import {1IInteractionGroups,2IInteractionStep,3IKeyboardCommand,4IMousePosition,5IMousePositionXY,6InteractionCommand,7} from '@secret-agent/interfaces/IInteractions';8import { HumanEmulatorClassDecorator } from '@secret-agent/interfaces/ICorePlugin';9import IRect from '@secret-agent/interfaces/IRect';10import IInteractionsHelper from '@secret-agent/interfaces/IInteractionsHelper';11import IPoint from '@secret-agent/interfaces/IPoint';12import IViewport from '@secret-agent/interfaces/IViewport';13import HumanEmulator from '@secret-agent/plugin-utils/lib/HumanEmulator';14import generateVector from './generateVector';15import * as pkg from './package.json';1617// ATTRIBUTION: heavily borrowed/inspired by https://github.com/Xetera/ghost-cursor1819@HumanEmulatorClassDecorator20export default class DefaultHumanEmulator extends HumanEmulator {21public static id = pkg.name.replace('@secret-agent/', '');2223public static overshootSpread = 2;24public static overshootRadius = 5;25public static overshootThreshold = 250;26public static boxPaddingPercent = 33;27public static maxScrollIncrement = 500;28public static maxScrollDelayMillis = 15;29public static maxDelayBetweenInteractions = 200;3031public static wordsPerMinuteRange = [30, 50];3233private millisPerCharacter: number;3435public getStartingMousePoint(helper: IInteractionsHelper): Promise<IPoint> {36const viewport = helper.viewport;37return Promise.resolve(38getRandomRectPoint({39x: 0,40y: 0,41width: viewport.width,42height: viewport.height,43}),44);45}4647public async playInteractions(48interactionGroups: IInteractionGroups,49runFn: (interactionStep: IInteractionStep) => Promise<void>,50helper: IInteractionsHelper,51): Promise<void> {52const millisPerCharacter = this.calculateMillisPerChar();5354for (let i = 0; i < interactionGroups.length; i += 1) {55if (i > 0) {56const millis = Math.random() * DefaultHumanEmulator.maxDelayBetweenInteractions;57await delay(millis);58}59for (const step of interactionGroups[i]) {60if (step.command === InteractionCommand.scroll) {61await this.scroll(step, runFn, helper);62continue;63}6465if (step.command === InteractionCommand.move) {66await this.moveMouse(step, runFn, helper);67continue;68}6970if (71step.command === InteractionCommand.click ||72step.command === InteractionCommand.doubleclick73) {74await this.moveMouseAndClick(step, runFn, helper);75continue;76}7778if (step.command === InteractionCommand.type) {79for (const keyboardCommand of step.keyboardCommands) {80if ('string' in keyboardCommand) {81for (const char of keyboardCommand.string) {82await runFn(this.getKeyboardCommandWithDelay({ string: char }, millisPerCharacter));83}84} else {85await runFn(this.getKeyboardCommandWithDelay(keyboardCommand, millisPerCharacter));86}87}88continue;89}9091if (step.command === InteractionCommand.willDismissDialog) {92const millis = Math.random() * DefaultHumanEmulator.maxDelayBetweenInteractions;93await delay(millis);94continue;95}96await runFn(step);97}98}99}100101protected async scroll(102interactionStep: IInteractionStep,103run: (interactionStep: IInteractionStep) => Promise<void>,104helper: IInteractionsHelper,105): Promise<void> {106const scrollVector = await this.getScrollVector(interactionStep.mousePosition, helper);107108let counter = 0;109for (const { x, y } of scrollVector) {110await delay(Math.random() * DefaultHumanEmulator.maxScrollDelayMillis);111112const shouldAddMouseJitter = counter % Math.round(Math.random() * 6) === 0;113if (shouldAddMouseJitter) {114await this.jitterMouse(helper, run);115}116117await run({118mousePosition: [x, y],119command: InteractionCommand.scroll,120});121counter += 1;122}123}124125protected async moveMouseAndClick(126interactionStep: IInteractionStep,127runFn: (interactionStep: IInteractionStep) => Promise<void>,128helper: IInteractionsHelper,129lockedNodeId?: number,130retries = 0,131): Promise<void> {132const originalMousePosition = [...interactionStep.mousePosition];133interactionStep.delayMillis = Math.floor(Math.random() * 100);134135let targetRect = await helper.lookupBoundingRect(136lockedNodeId ? [lockedNodeId] : interactionStep.mousePosition,137true,138true,139);140141const { nodeId } = targetRect;142143let targetPoint = getRandomRectPoint(targetRect, DefaultHumanEmulator.boxPaddingPercent);144const didMoveMouse = await this.moveMouseToPoint(targetPoint, targetRect.width, runFn, helper);145if (didMoveMouse) {146targetRect = await helper.lookupBoundingRect([nodeId], true, true);147targetPoint = getRandomRectPoint(targetRect, DefaultHumanEmulator.boxPaddingPercent);148}149150if (targetRect.elementTag === 'option') {151// if this is an option element, we have to do a specialized click, so let the Interactor handle152return await runFn(interactionStep);153}154155const viewport = helper.viewport;156const isRectInViewport =157isVisible(targetRect.y, targetRect.height, viewport.height) &&158isVisible(targetRect.x, targetRect.width, viewport.width);159160// make sure target is still visible161if (162!targetRect.nodeVisibility.isVisible ||163!isRectInViewport ||164!isWithinRect(targetPoint, targetRect)165) {166// need to try again167if (retries < 2) {168const isScroll = !isRectInViewport;169helper.logger.info(170`"Click" mousePosition not in viewport after mouse moves. Moving${171isScroll ? ' and scrolling' : ''172} to a new point.`,173{174interactionStep,175nodeId,176nodeVisibility: targetRect.nodeVisibility,177retries,178},179);180if (isScroll) {181const scrollToStep = { ...interactionStep };182if (nodeId) scrollToStep.mousePosition = [nodeId];183await this.scroll(scrollToStep, runFn, helper);184}185return this.moveMouseAndClick(interactionStep, runFn, helper, nodeId, retries + 1);186}187188helper.logger.error(189'Interaction.click - mousePosition not in viewport after mouse moves to prepare for click.',190{191'Interaction.mousePosition': originalMousePosition,192target: {193nodeId,194nodeVisibility: targetRect.nodeVisibility,195domCoordinates: { x: targetPoint.x, y: targetPoint.y },196},197viewport,198},199);200201throw new Error(202'Element or mousePosition remains out of viewport after 2 attempts to move it into view',203);204}205206let clickConfirm: () => Promise<any>;207if (nodeId) {208const listener = await helper.createMouseupTrigger(nodeId);209clickConfirm = listener.didTrigger.bind(listener, originalMousePosition, true);210}211212interactionStep.mousePosition = [targetPoint.x, targetPoint.y];213214await runFn(interactionStep);215if (clickConfirm) await clickConfirm();216}217218protected async moveMouse(219interactionStep: IInteractionStep,220run: (interactionStep: IInteractionStep) => Promise<void>,221helper: IInteractionsHelper,222): Promise<void> {223const rect = await helper.lookupBoundingRect(interactionStep.mousePosition);224const targetPoint = getRandomRectPoint(rect, DefaultHumanEmulator.boxPaddingPercent);225226await this.moveMouseToPoint(targetPoint, rect.width, run, helper);227}228229protected async moveMouseToPoint(230targetPoint: IPoint,231targetWidth: number,232runFn: (interactionStep: IInteractionStep) => Promise<void>,233helper: IInteractionsHelper,234): Promise<boolean> {235const mousePosition = helper.mousePosition;236const vector = generateVector(mousePosition, targetPoint, targetWidth, {237threshold: DefaultHumanEmulator.overshootThreshold,238radius: DefaultHumanEmulator.overshootRadius,239spread: DefaultHumanEmulator.overshootSpread,240});241242if (!vector.length) return false;243for (const { x, y } of vector) {244await runFn({245mousePosition: [x, y],246command: InteractionCommand.move,247});248}249return true;250}251252protected async jitterMouse(253helper: IInteractionsHelper,254runFn: (interactionStep: IInteractionStep) => Promise<void>,255): Promise<void> {256const mousePosition = helper.mousePosition;257const jitterX = Math.max(mousePosition.x + Math.round(getRandomPositiveOrNegativeNumber()), 0);258const jitterY = Math.max(mousePosition.y + Math.round(getRandomPositiveOrNegativeNumber()), 0);259if (jitterX !== mousePosition.x || jitterY !== mousePosition.y) {260// jitter mouse261await runFn({262mousePosition: [jitterX, jitterY],263command: InteractionCommand.move,264});265}266}267268/////// KEYBOARD /////////////////////////////////////////////////////////////////////////////////////////////////////269270protected getKeyboardCommandWithDelay(keyboardCommand: IKeyboardCommand, millisPerChar: number) {271const randomFactor = getRandomPositiveOrNegativeNumber() * (millisPerChar / 2);272const delayMillis = Math.floor(randomFactor + millisPerChar);273const keyboardKeyupDelay = Math.max(Math.ceil(Math.random() * 60), 10);274return {275command: InteractionCommand.type,276keyboardCommands: [keyboardCommand],277keyboardDelayBetween: delayMillis - keyboardKeyupDelay,278keyboardKeyupDelay,279};280}281282protected calculateMillisPerChar(): number {283if (!this.millisPerCharacter) {284const wpmRange =285DefaultHumanEmulator.wordsPerMinuteRange[1] - DefaultHumanEmulator.wordsPerMinuteRange[0];286const wpm =287Math.floor(Math.random() * wpmRange) + DefaultHumanEmulator.wordsPerMinuteRange[0];288289const averageWordLength = 5;290const charsPerSecond = (wpm * averageWordLength) / 60;291this.millisPerCharacter = Math.round(1000 / charsPerSecond);292}293return this.millisPerCharacter;294}295296private async getScrollVector(297mousePosition: IMousePosition,298helper: IInteractionsHelper,299): Promise<IPoint[]> {300const isCoordinates =301typeof mousePosition[0] === 'number' && typeof mousePosition[1] === 'number';302let shouldScrollX: boolean;303let shouldScrollY: boolean;304let scrollToPoint: IPoint;305const startScrollOffset = await helper.scrollOffset;306307if (!isCoordinates) {308const targetRect = await helper.lookupBoundingRect(mousePosition);309// figure out if target is in view310const viewport = helper.viewport;311shouldScrollY = isVisible(targetRect.y, targetRect.height, viewport.height) === false;312shouldScrollX = isVisible(targetRect.x, targetRect.width, viewport.width) === false;313314// positions are all relative to viewport, so act like we're at 0,0315scrollToPoint = getScrollRectPoint(targetRect, viewport);316317if (shouldScrollY) scrollToPoint.y += startScrollOffset.y;318else scrollToPoint.y = startScrollOffset.y;319320if (shouldScrollX) scrollToPoint.x += startScrollOffset.x;321else scrollToPoint.x = startScrollOffset.x;322} else {323const [x, y] = mousePosition as IMousePositionXY;324scrollToPoint = { x, y };325shouldScrollY = y !== startScrollOffset.y;326shouldScrollX = x !== startScrollOffset.x;327}328329if (!shouldScrollY && !shouldScrollX) return [];330331let lastPoint: IPoint = startScrollOffset;332const scrollVector = generateVector(startScrollOffset, scrollToPoint, 200, {333threshold: DefaultHumanEmulator.overshootThreshold,334radius: DefaultHumanEmulator.overshootRadius,335spread: DefaultHumanEmulator.overshootSpread,336});337338const points: IPoint[] = [];339for (let point of scrollVector) {340// convert points into deltas from previous scroll point341const scrollX = shouldScrollX ? Math.round(point.x) : startScrollOffset.x;342const scrollY = shouldScrollY ? Math.round(point.y) : startScrollOffset.y;343if (scrollY === lastPoint.y && scrollX === lastPoint.x) continue;344if (scrollY < 0 || scrollX < 0) continue;345346point = {347x: scrollX,348y: scrollY,349};350351const scrollYPixels = Math.abs(scrollY - lastPoint.y);352// if too big a jump, backfill smaller jumps353if (scrollYPixels > DefaultHumanEmulator.maxScrollIncrement) {354const isNegative = scrollY < lastPoint.y;355const chunks = splitIntoMaxLengthSegments(356scrollYPixels,357DefaultHumanEmulator.maxScrollIncrement,358);359for (const chunk of chunks) {360const deltaY = isNegative ? -chunk : chunk;361const scrollYChunk = Math.max(lastPoint.y + deltaY, 0);362if (scrollYChunk === lastPoint.y) continue;363364const newPoint = {365x: scrollX,366y: scrollYChunk,367};368points.push(newPoint);369lastPoint = newPoint;370}371}372373const lastEntry = points[points.length - 1];374// if same point added, yank it now375if (!lastEntry || lastEntry.x !== point.x || lastEntry.y !== point.y) {376points.push(point);377lastPoint = point;378}379}380if (lastPoint.y !== scrollToPoint.y || lastPoint.x !== scrollToPoint.x) {381points.push(scrollToPoint);382}383return points;384}385}386387function isWithinRect(targetPoint: IPoint, finalRect: IRect): boolean {388if (targetPoint.x < finalRect.x || targetPoint.x > finalRect.x + finalRect.width) return false;389if (targetPoint.y < finalRect.y || targetPoint.y > finalRect.y + finalRect.height) return false;390391return true;392}393394export function isVisible(coordinate: number, length: number, boundaryLength: number): boolean {395if (length > boundaryLength) {396length = boundaryLength;397}398const midpointOffset = Math.round(coordinate + length / 2);399if (coordinate >= 0) {400// midpoint passes end401if (midpointOffset >= boundaryLength) {402return false;403}404} else {405// midpoint before start406// eslint-disable-next-line no-lonely-if407if (midpointOffset <= 0) {408return false;409}410}411return true;412}413414async function delay(millis: number): Promise<void> {415if (!millis) return;416await new Promise<void>(resolve => setTimeout(resolve, Math.floor(millis)).unref());417}418419function splitIntoMaxLengthSegments(total: number, maxValue: number): number[] {420const values: number[] = [];421let currentSum = 0;422while (currentSum < total) {423let nextValue = Math.round(Math.random() * maxValue * 10) / 10;424if (currentSum + nextValue > total) {425nextValue = total - currentSum;426}427currentSum += nextValue;428values.push(nextValue);429}430return values;431}432433function getRandomPositiveOrNegativeNumber(): number {434const negativeMultiplier = Math.random() < 0.5 ? -1 : 1;435436return Math.random() * negativeMultiplier;437}438439function getScrollRectPoint(targetRect: IRect, viewport: IViewport): IPoint {440let { y, x } = targetRect;441const fudge = 2 * Math.random();442// target rect inside bounds443const midViewportHeight = Math.round(viewport.height / 2 + fudge);444const midViewportWidth = Math.round(viewport.width / 2 + fudge);445446if (y < -(midViewportHeight + 1)) y -= midViewportHeight;447else if (y > midViewportHeight + 1) y -= midViewportHeight;448449if (x < -(midViewportWidth + 1)) x -= midViewportWidth;450else if (x > midViewportWidth + 1) x -= midViewportWidth;451452x = Math.round(x * 10) / 10;453y = Math.round(y * 10) / 10;454455return { x, y };456}457458function getRandomRectPoint(targetRect: IRect, paddingPercent?: number): IPoint {459const { y, x, height, width } = targetRect;460461let paddingWidth = 0;462let paddingHeight = 0;463464if (paddingPercent !== undefined && paddingPercent > 0 && paddingPercent < 100) {465paddingWidth = (width * paddingPercent) / 100;466paddingHeight = (height * paddingPercent) / 100;467}468469return {470x: Math.round(x + paddingWidth / 2 + Math.random() * (width - paddingWidth)),471y: Math.round(y + paddingHeight / 2 + Math.random() * (height - paddingHeight)),472};473}474475476