Skip to content

Commit

Permalink
feat: ability to associate timestamps with movements (#124)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
aw1875 authored May 16, 2024
1 parent 2acbb4e commit 6de4f44
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 17 deletions.
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions src/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
84 changes: 70 additions & 14 deletions src/spoof.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<MoveOptions, 'moveDelay' | 'randomizeMoveDelay' | 'moveSpeed'> {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -316,16 +360,28 @@ export const createCursor = (

// Move the mouse over a number of vectors
const tracePath = async (
vectors: Iterable<Vector>,
vectors: Iterable<Vector | TimedVector>,
abortOnMove: boolean = false
): Promise<void> => {
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
Expand Down

0 comments on commit 6de4f44

Please sign in to comment.