Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ability to associate timestamps with movements #124

Merged
merged 9 commits into from
May 16, 2024
24 changes: 23 additions & 1 deletion 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, { showTimestamps: 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 @@ -146,7 +168,7 @@ Generates a set of points for mouse movement between two coordinates.
- **optionsOrSpread (optional):** Additional options for generating the path.
aw1875 marked this conversation as resolved.
Show resolved Hide resolved
- `spreadOverride (number):` Override the spread of the generated path.
- `moveSpeed (number):` Speed of mouse movement. Default is random.

- `showTimestamps (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 @@ -77,3 +80,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)
}
77 changes: 66 additions & 11 deletions src/spoof.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ElementHandle, Page, BoundingBox, CDPSession } from 'puppeteer'
import { ElementHandle, Page, BoundingBox, CDPSession, Protocol } from 'puppeteer'
import debug from 'debug'
import {
Vector,
TimedVector,
bezierCurve,
bezierCurveSpeed,
direction,
magnitude,
origin,
Expand Down Expand Up @@ -76,6 +78,7 @@ export interface PathOptions {
* Default is random.
*/
readonly moveSpeed?: number
readonly showTimestamps?: boolean
aw1875 marked this conversation as resolved.
Show resolved Hide resolved
}

export interface RandomMoveOptions extends Pick<MoveOptions, 'moveDelay' | 'randomizeMoveDelay' | 'moveSpeed'> {
Expand Down Expand Up @@ -214,11 +217,11 @@ 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 spreadOverride = typeof optionsOrSpread === 'number' ? optionsOrSpread : optionsOrSpread?.spreadOverride
const moveSpeed = typeof optionsOrSpread === 'object' && optionsOrSpread.moveSpeed
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 spreadOverride = typeof options === 'number' ? options : options?.spreadOverride
const moveSpeed = typeof options === 'object' && options.moveSpeed

const defaultWidth = 100
const minSteps = 25
Expand All @@ -230,17 +233,57 @@ export function path (start: Vector, end: BoundingBox | Vector, optionsOrSpread?
const baseTime = speed * minSteps
const steps = Math.ceil((Math.log2(fitts(length, width) + 1) + baseTime) * 3)
const re = curve.getLUT(steps)
return clampPositive(re)
return clampPositive(re, options)
}

const clampPositive = (vectors: Vector[]): Vector[] => {
const clampPositive = (vectors: Vector[], options?: number | PathOptions): Vector[] | TimedVector[] => {
const clamp0 = (elem: number): number => Math.max(0, elem)
return vectors.map((vector) => {
const clampedVectors = vectors.map((vector) => {
return {
x: clamp0(vector.x),
y: clamp0(vector.y)
}
})

return (typeof options === 'number' || options?.showTimestamps === false) ? clampedVectors : generateTimestamps(clampedVectors, options)
}

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 = vectors.map((vector) => {
aw1875 marked this conversation as resolved.
Show resolved Hide resolved
return {
...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] as TimedVector).timestamp + time
aw1875 marked this conversation as resolved.
Show resolved Hide resolved
}
}

return timedVectors
}

const shouldOvershoot = (a: Vector, b: Vector, threshold: number): boolean =>
Expand Down Expand Up @@ -315,16 +358,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