diff --git a/package.json b/package.json index 41f4dee..6957148 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,17 @@ "version": "1.4.1", "description": "Move your mouse like a human in puppeteer or generate realistic movements on any 2D plane", "repository": "https://github.com/Xetera/ghost-cursor", + "exports": { + ".": { + "types": "./lib/spoof.d.ts", + "default": "./lib/spoof.js" + }, + "./core": { + "types": "./lib/core.d.ts", + "default": "./lib/core.js" + }, + "./package.json": "./package.json" + }, "main": "lib/spoof.js", "types": "lib/spoof.d.ts", "scripts": { diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..138ef5d --- /dev/null +++ b/src/core.ts @@ -0,0 +1,112 @@ +import { + type Vector, + type TimedVector, + type Rectangle, + bezierCurve, + bezierCurveSpeed, + extrapolate +} from './math' + +export interface PathOptions { + /** + * Override the spread of the generated path. + */ + readonly spreadOverride?: number + /** + * Speed of mouse movement. + * Default is random. + */ + readonly moveSpeed?: number + + /** + * Generate timestamps for each point in the path. + */ + readonly useTimestamps?: boolean +} + +/** + * Calculate the amount of time needed to move from (x1, y1) to (x2, y2) + * given the width of the element being clicked on + * https://en.wikipedia.org/wiki/Fitts%27s_law + */ +const fitts = (distance: number, width: number): number => { + const a = 0 + const b = 2 + const id = Math.log2(distance / width + 1) + return a + b * id +} + +/** Generates a set of points for mouse movement between two coordinates. */ +export function path ( + start: Vector, + end: Vector | Rectangle, + /** + * Additional options for generating the path. + * Can also be a number which will set `spreadOverride`. + */ + // TODO: remove number arg in next major version change, fine to just allow `spreadOverride` in object. + options?: number | PathOptions): Vector[] | TimedVector[] { + const optionsResolved: PathOptions = typeof options === 'number' + ? { spreadOverride: options } + : { ...options } + + const DEFAULT_WIDTH = 100 + const MIN_STEPS = 25 + const width = 'width' in end && end.width !== 0 ? end.width : DEFAULT_WIDTH + const curve = bezierCurve(start, end, optionsResolved.spreadOverride) + const length = curve.length() * 0.8 + + const speed = optionsResolved.moveSpeed !== undefined && optionsResolved.moveSpeed > 0 + ? (25 / optionsResolved.moveSpeed) + : Math.random() + 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, 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 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[] = [] + + for (let i = 0; i < vectors.length; i++) { + if (i === 0) { + timedVectors.push({ ...vectors[i], timestamp: Date.now() }) + } else { + const P0 = vectors[i - 1] + const P1 = vectors[i] + const P2 = i + 1 < vectors.length ? vectors[i + 1] : extrapolate(P0, P1) + const P3 = i + 2 < vectors.length ? vectors[i + 2] : extrapolate(P1, P2) + const time = timeToMove(P0, P1, P2, P3, vectors.length) + + timedVectors.push({ + ...vectors[i], + timestamp: timedVectors[i - 1].timestamp + time + }) + } + } + + return timedVectors +} diff --git a/src/math.ts b/src/math.ts index f324926..8af8ffe 100644 --- a/src/math.ts +++ b/src/math.ts @@ -7,6 +7,11 @@ export interface Vector { export interface TimedVector extends Vector { timestamp: number } +export interface Rectangle extends Vector { + width: number + height: number +} + export const origin: Vector = { x: 0, y: 0 } // maybe i should've just imported a vector library lol diff --git a/src/spoof.ts b/src/spoof.ts index ea6d4dc..a34d163 100644 --- a/src/spoof.ts +++ b/src/spoof.ts @@ -1,23 +1,20 @@ import type { ElementHandle, Page, BoundingBox, CDPSession, Protocol } from 'puppeteer' import debug from 'debug' +import { type PathOptions, path } from './core' import { type Vector, - type TimedVector, - bezierCurve, - bezierCurveSpeed, direction, magnitude, origin, overshoot, add, clamp, - scale, - extrapolate + scale } from './math' import { installMouseHelper } from './mouse-helper' // TODO: remove in next major version, is now wrapped in the GhostCursor class. -export { installMouseHelper } +export { type PathOptions, path, installMouseHelper } const log = debug('ghost-cursor') @@ -128,23 +125,6 @@ export interface ClickOptions extends MoveOptions { readonly clickCount?: number } -export interface PathOptions { - /** - * Override the spread of the generated path. - */ - readonly spreadOverride?: number - /** - * Speed of mouse movement. - * Default is random. - */ - readonly moveSpeed?: number - - /** - * Generate timestamps for each point in the path. - */ - readonly useTimestamps?: boolean -} - export interface RandomMoveOptions extends Pick { /** * @default 2000 @@ -205,18 +185,6 @@ const delay = async (ms: number): Promise => { return await new Promise((resolve) => setTimeout(resolve, ms)) } -/** - * Calculate the amount of time needed to move from (x1, y1) to (x2, y2) - * given the width of the element being clicked on - * https://en.wikipedia.org/wiki/Fitts%27s_law - */ -const fitts = (distance: number, width: number): number => { - const a = 0 - const b = 2 - const id = Math.log2(distance / width + 1) - return a + b * id -} - /** Get a random point on a box */ const getRandomBoxPoint = ( { x, y, width, height }: BoundingBox, @@ -310,81 +278,6 @@ export const getElementBox = async ( } } -/** Generates a set of points for mouse movement between two coordinates. */ -export function path ( - start: Vector, - end: Vector | BoundingBox, - /** - * Additional options for generating the path. - * Can also be a number which will set `spreadOverride`. - */ - // TODO: remove number arg in next major version change, fine to just allow `spreadOverride` in object. - options?: number | PathOptions): Vector[] | TimedVector[] { - const optionsResolved: PathOptions = typeof options === 'number' - ? { spreadOverride: options } - : { ...options } - - const DEFAULT_WIDTH = 100 - const MIN_STEPS = 25 - const width = 'width' in end && end.width !== 0 ? end.width : DEFAULT_WIDTH - const curve = bezierCurve(start, end, optionsResolved.spreadOverride) - const length = curve.length() * 0.8 - - const speed = optionsResolved.moveSpeed !== undefined && optionsResolved.moveSpeed > 0 - ? (25 / optionsResolved.moveSpeed) - : Math.random() - 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, 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 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[] = [] - - for (let i = 0; i < vectors.length; i++) { - if (i === 0) { - timedVectors.push({ ...vectors[i], timestamp: Date.now() }) - } else { - const P0 = vectors[i - 1] - const P1 = vectors[i] - const P2 = i + 1 < vectors.length ? vectors[i + 1] : extrapolate(P0, P1) - const P3 = i + 2 < vectors.length ? vectors[i + 2] : extrapolate(P1, P2) - const time = timeToMove(P0, P1, P2, P3, vectors.length) - - timedVectors.push({ - ...vectors[i], - timestamp: timedVectors[i - 1].timestamp + time - }) - } - } - - return timedVectors -} - const shouldOvershoot = (a: Vector, b: Vector, threshold: number): boolean => magnitude(direction(a, b)) > threshold