From 6de4f444142df963f2b86948cb86067153f3adc0 Mon Sep 17 00:00:00 2001 From: Adam Wolf <4970015+aw1875@users.noreply.github.com> Date: Thu, 16 May 2024 11:31:09 -0500 Subject: [PATCH] feat: ability to associate timestamps with movements (#124) * feat: ability to associate timestamps with movements * chore: changed plural routes to route in docs * chore: utilize CDP's Input.dispatchMouseEvent directyl * chore: add missing puppeteer type * chore(conflicts): missing changes from merge conflict resolution * chore: clean up code and wording * style: fix broken whitespace --- README.md | 28 ++++++++++++++++-- src/math.ts | 15 ++++++++++ src/spoof.ts | 84 +++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index eb17f20..687b45f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,28 @@ const route = path(from, to) */ ``` +Generating movement data between 2 coordinates with timestamps. +```js +import { path } from "ghost-cursor" + +const from = { x: 100, y: 100 } +const to = { x: 600, y: 700 } + +const route = path(from, to, { useTimestamps: true }) + +/** + * [ + * { x: 100, y: 100, timestamp: 1711850430643 }, + * { x: 114.78071695023473, y: 97.52340709495319, timestamp: 1711850430697 }, + * { x: 129.1362373468682, y: 96.60141853603243, timestamp: 1711850430749 }, + * { x: 143.09468422606352, y: 97.18676354029148, timestamp: 1711850430799 }, + * { x: 156.68418062398405, y: 99.23217132478408, timestamp: 1711850430848 }, + * ... and so on + * ] + */ +``` + + Usage with puppeteer: ```js @@ -137,16 +159,16 @@ Installs a mouse helper on the page. Makes pointer visible. Use for debugging on Gets a random point on the browser window. -#### `path(point: Vector, target: Vector, optionsOrSpread?: number | PathOptions): Vector[]` +#### `path(point: Vector, target: Vector, options?: number | PathOptions): Vector[] | TimedVector[]` Generates a set of points for mouse movement between two coordinates. - **point:** Starting point of the movement. - **target:** Ending point of the movement. -- **optionsOrSpread (optional):** Additional options for generating the path. +- **options (optional):** Additional options for generating the path. Can also be a number which will set `spreadOverride`. - `spreadOverride (number):` Override the spread of the generated path. - `moveSpeed (number):` Speed of mouse movement. Default is random. - + - `useTimestamps (boolean):` Generate timestamps for each point based on the trapezoidal rule. ## How does it work diff --git a/src/math.ts b/src/math.ts index a45385e..4340db5 100644 --- a/src/math.ts +++ b/src/math.ts @@ -4,6 +4,9 @@ export interface Vector { x: number y: number } +export interface TimedVector extends Vector { + timestamp: number +} export const origin: Vector = { x: 0, y: 0 } // maybe i should've just imported a vector library lol @@ -80,3 +83,15 @@ export const bezierCurve = ( const anchors = generateBezierAnchors(start, finish, spread) return new Bezier(start, ...anchors, finish) } + +export const bezierCurveSpeed = ( + t: number, + P0: Vector, + P1: Vector, + P2: Vector, + P3: Vector +): number => { + const B1 = 3 * (1 - t) ** 2 * (P1.x - P0.x) + 6 * (1 - t) * t * (P2.x - P1.x) + 3 * t ** 2 * (P3.x - P2.x) + const B2 = 3 * (1 - t) ** 2 * (P1.y - P0.y) + 6 * (1 - t) * t * (P2.y - P1.y) + 3 * t ** 2 * (P3.y - P2.y) + return Math.sqrt(B1 ** 2 + B2 ** 2) +} diff --git a/src/spoof.ts b/src/spoof.ts index a930d71..9cc2b3d 100644 --- a/src/spoof.ts +++ b/src/spoof.ts @@ -1,8 +1,10 @@ -import type { ElementHandle, Page, BoundingBox, CDPSession } from 'puppeteer' +import type { ElementHandle, Page, BoundingBox, CDPSession, Protocol } from 'puppeteer' import debug from 'debug' import { type Vector, + type TimedVector, bezierCurve, + bezierCurveSpeed, direction, magnitude, origin, @@ -79,6 +81,11 @@ export interface PathOptions { * Default is random. */ readonly moveSpeed?: number + + /** + * Generate timestamps for each point in the path. + */ + readonly useTimestamps?: boolean } export interface RandomMoveOptions extends Pick { @@ -217,12 +224,12 @@ const getElementBox = async ( } } -export function path (point: Vector, target: Vector, optionsOrSpread?: number | PathOptions) -export function path (point: Vector, target: BoundingBox, optionsOrSpread?: number | PathOptions) -export function path (start: Vector, end: BoundingBox | Vector, optionsOrSpread?: number | PathOptions): Vector[] { - const optionsResolved: PathOptions = typeof optionsOrSpread === 'number' - ? { spreadOverride: optionsOrSpread } - : { ...optionsOrSpread } +export function path (point: Vector, target: Vector, options?: number | PathOptions) +export function path (point: Vector, target: BoundingBox, options?: number | PathOptions) +export function path (start: Vector, end: BoundingBox | Vector, options?: number | PathOptions): Vector[] | TimedVector[] { + const optionsResolved: PathOptions = typeof options === 'number' + ? { spreadOverride: options } + : { ...options } const DEFAULT_WIDTH = 100 const MIN_STEPS = 25 @@ -236,13 +243,50 @@ export function path (start: Vector, end: BoundingBox | Vector, optionsOrSpread? const baseTime = speed * MIN_STEPS const steps = Math.ceil((Math.log2(fitts(length, width) + 1) + baseTime) * 3) const re = curve.getLUT(steps) - return clampPositive(re) + return clampPositive(re, optionsResolved) +} + +const clampPositive = (vectors: Vector[], options?: PathOptions): Vector[] | TimedVector[] => { + const clampedVectors = vectors.map((vector) => ({ + x: Math.max(0, vector.x), + y: Math.max(0, vector.y) + })) + + return options?.useTimestamps === true ? generateTimestamps(clampedVectors, options) : clampedVectors } -const clampPositive = (vectors: Vector[]): Vector[] => vectors.map((vector) => ({ - x: Math.max(0, vector.x), - y: Math.max(0, vector.y) -})) +const generateTimestamps = (vectors: Vector[], options?: PathOptions): TimedVector[] => { + const speed = options?.moveSpeed ?? (Math.random() * 0.5 + 0.5) + const timeToMove = (P0: Vector, P1: Vector, P2: Vector, P3: Vector, samples: number): number => { + let total = 0 + const dt = 1 / samples + + for (let t = 0; t < 1; t += dt) { + const v1 = bezierCurveSpeed(t * dt, P0, P1, P2, P3) + const v2 = bezierCurveSpeed(t, P0, P1, P2, P3) + total += (v1 + v2) * dt / 2 + } + + return Math.round(total / speed) + } + + const timedVectors: TimedVector[] = vectors.map((vector) => ({ ...vector, timestamp: 0 })) + + for (let i = 0; i < timedVectors.length; i++) { + const P0 = i === 0 ? timedVectors[i] : timedVectors[i - 1] + const P1 = timedVectors[i] + const P2 = i === timedVectors.length - 1 ? timedVectors[i] : timedVectors[i + 1] + const P3 = i === timedVectors.length - 1 ? timedVectors[i] : timedVectors[i + 1] + const time = timeToMove(P0, P1, P2, P3, timedVectors.length) + + timedVectors[i] = { + ...timedVectors[i], + timestamp: i === 0 ? Date.now() : timedVectors[i - 1].timestamp + time + } + } + + return timedVectors +} const shouldOvershoot = (a: Vector, b: Vector, threshold: number): boolean => magnitude(direction(a, b)) > threshold @@ -316,16 +360,28 @@ export const createCursor = ( // Move the mouse over a number of vectors const tracePath = async ( - vectors: Iterable, + vectors: Iterable, abortOnMove: boolean = false ): Promise => { + const cdpClient = getCDPClient(page) + for (const v of vectors) { try { // In case this is called from random mouse movements and the users wants to move the mouse, abort if (abortOnMove && moving) { return } - await page.mouse.move(v.x, v.y) + + const dispatchParams: Protocol.Input.DispatchMouseEventRequest = { + type: 'mouseMoved', + x: v.x, + y: v.y + } + + if ('timestamp' in v) dispatchParams.timestamp = v.timestamp + + await cdpClient.send('Input.dispatchMouseEvent', dispatchParams) + previous = v } catch (error) { // Exit function if the browser is no longer connected