├── .github
└── workflows
│ ├── compile.yml
│ └── publish.yml
├── .gitignore
├── .husky
└── pre-commit
├── .npmignore
├── LICENSE
├── README.md
├── jest-puppeteer.config.js
├── package.json
├── src
├── __debug__
│ ├── browser-debug.ts
│ └── custom-page.html
├── __test__
│ ├── custom-page.html
│ └── spoof.spec.ts
├── math.ts
├── mouse-helper.ts
└── spoof.ts
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
/.github/workflows/compile.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on: [push, pull_request]
3 | jobs:
4 | test:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v4
8 | - name: Install Node v20
9 | uses: actions/setup-node@v4
10 | with:
11 | node-version: 20
12 | - name: Get yarn cache
13 | id: yarn-cache
14 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
15 | - uses: actions/cache@v4
16 | with:
17 | path: ${{ steps.yarn-cache.outputs.dir }}
18 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
19 | restore-keys: |
20 | ${{ runner.os }}-yarn-
21 | - name: Install dependencies and build
22 | run: yarn
23 | - name: Run tests
24 | run: |
25 | # See: https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
26 | echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
27 | yarn test
28 | - name: Upload compiled code as artifact
29 | uses: actions/upload-artifact@v4
30 | with:
31 | name: ghost-cursor
32 | path: lib/
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to NPM
2 | on:
3 | workflow_dispatch:
4 | push:
5 | tags:
6 | - 'v*' # only run if new tag is pushed
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - name: Install Node
13 | uses: actions/setup-node@v4
14 | with:
15 | node-version: 20
16 | - name: Install dependencies and build
17 | run: yarn
18 | - uses: JS-DevTools/npm-publish@v3
19 | with:
20 | token: ${{ secrets.NPM_TOKEN }}
21 | strategy: all # will publish any version that does not yet exist in the registry
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | .cache
3 | node_modules
4 | lib
5 | test.ts
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run prepare
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .vscode
3 | .cache
4 | dist
5 | __test__
6 | __debug__
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Xetera
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ghost Cursor
2 |
3 |
4 |
5 | Generate realistic, human-like mouse movement data between coordinates or navigate between elements with puppeteer
6 | like the definitely-not-robot you are.
7 |
8 | > Oh yeah? Could a robot do _**this?**_
9 |
10 | ## Installation
11 |
12 | ```sh
13 | yarn add ghost-cursor
14 | ```
15 | or with npm
16 | ```sh
17 | npm install ghost-cursor
18 | ```
19 |
20 | ## Usage
21 | Generating movement data between 2 coordinates.
22 |
23 | ```js
24 | import { path } from "ghost-cursor"
25 |
26 | const from = { x: 100, y: 100 }
27 | const to = { x: 600, y: 700 }
28 |
29 | const route = path(from, to)
30 |
31 | /**
32 | * [
33 | * { x: 100, y: 100 },
34 | * { x: 108.75573501957051, y: 102.83608396351725 },
35 | * { x: 117.54686481838543, y: 106.20019239793275 },
36 | * { x: 126.3749821408895, y: 110.08364505509256 },
37 | * { x: 135.24167973152743, y: 114.47776168684264 }
38 | * ... and so on
39 | * ]
40 | */
41 | ```
42 |
43 | Generating movement data between 2 coordinates with timestamps.
44 | ```js
45 | import { path } from "ghost-cursor"
46 |
47 | const from = { x: 100, y: 100 }
48 | const to = { x: 600, y: 700 }
49 |
50 | const route = path(from, to, { useTimestamps: true })
51 |
52 | /**
53 | * [
54 | * { x: 100, y: 100, timestamp: 1711850430643 },
55 | * { x: 114.78071695023473, y: 97.52340709495319, timestamp: 1711850430697 },
56 | * { x: 129.1362373468682, y: 96.60141853603243, timestamp: 1711850430749 },
57 | * { x: 143.09468422606352, y: 97.18676354029148, timestamp: 1711850430799 },
58 | * { x: 156.68418062398405, y: 99.23217132478408, timestamp: 1711850430848 },
59 | * ... and so on
60 | * ]
61 | */
62 | ```
63 |
64 |
65 | Usage with puppeteer:
66 |
67 | ```js
68 | import { createCursor } from "ghost-cursor"
69 | import puppeteer from "puppeteer"
70 |
71 | const run = async (url) => {
72 | const selector = "#sign-up button"
73 | const browser = await puppeteer.launch({ headless: false });
74 | const page = await browser.newPage()
75 | const cursor = createCursor(page)
76 | await page.goto(url)
77 | await page.waitForSelector(selector)
78 | await cursor.click(selector)
79 | // shorthand for
80 | // await cursor.move(selector)
81 | // await cursor.click()
82 | }
83 | ```
84 |
85 | ### Puppeteer-specific behavior
86 | * `cursor.move()` will automatically overshoot or slightly miss and re-adjust for elements that are too far away
87 | from the cursor's starting point.
88 | * When moving over objects, a random coordinate that's within the element will be selected instead of
89 | hovering over the exact center of the element.
90 | * The speed of the mouse will take the distance and the size of the element you're clicking on into account.
91 |
92 |
93 |
94 | 
95 |
96 | > Ghost cursor in action on a form
97 |
98 | ## Methods
99 |
100 | #### `createCursor(page: puppeteer.Page, start?: Vector, performRandomMoves?: boolean, defaultOptions?: DefaultOptions): GhostCursor`
101 |
102 | Creates the ghost cursor. Returns cursor action functions.
103 |
104 | - **page:** Puppeteer `page`.
105 | - **start (optional):** Cursor start position. Default is `{ x: 0, y: 0 }`.
106 | - **performRandomMoves (optional):** Initially perform random movements. Default is `false`.
107 | - **defaultOptions (optional):** Set custom default options for `click`, `move`, `moveTo`, and `randomMove` functions. Default values are described below.
108 |
109 | #### `toggleRandomMove(random: boolean): void`
110 |
111 | Toggles random mouse movements on or off.
112 |
113 | #### `click(selector?: string | ElementHandle, options?: ClickOptions): Promise`
114 |
115 | Simulates a mouse click at the specified selector or element.
116 |
117 | - **selector (optional):** CSS selector or ElementHandle to identify the target element.
118 | - **options (optional):** Additional options for clicking. **Extends the `options` of the `move`, `scrollIntoView`, and `getElement` functions (below)**
119 | - `hesitate (number):` Delay before initiating the click action in milliseconds. Default is `0`.
120 | - `waitForClick (number):` Delay between mousedown and mouseup in milliseconds. Default is `0`.
121 | - `moveDelay (number):` Delay after moving the mouse in milliseconds. Default is `2000`. If `randomizeMoveDelay=true`, delay is randomized from 0 to `moveDelay`.
122 | - `button (MouseButton):` Mouse button to click. Default is `left`.
123 | - `clickCount (number):` Number of times to click the button. Default is `1`.
124 |
125 | #### `move(selector: string | ElementHandle, options?: MoveOptions): Promise`
126 |
127 | Moves the mouse to the specified selector or element.
128 |
129 | - **selector:** CSS selector or ElementHandle to identify the target element.
130 | - **options (optional):** Additional options for moving. **Extends the `options` of the `scrollIntoView` and `getElement` functions (below)**
131 | - `paddingPercentage (number):` Percentage of padding to be added inside the element when determining the target point. Default is `0` (may move to anywhere within the element). `100` will always move to center of element.
132 | - `destination (Vector):` Destination to move the cursor to, relative to the top-left corner of the element. If specified, `paddingPercentage` is not used. If not specified (default), destination is random point within the `paddingPercentage`.
133 | - `moveDelay (number):` Delay after moving the mouse in milliseconds. Default is `0`. If `randomizeMoveDelay=true`, delay is randomized from 0 to `moveDelay`.
134 | - `randomizeMoveDelay (boolean):` Randomize delay between actions from `0` to `moveDelay`. Default is `true`.
135 | - `maxTries (number):` Maximum number of attempts to mouse-over the element. Default is `10`.
136 | - `moveSpeed (number):` Speed of mouse movement. Default is random.
137 | - `overshootThreshold (number):` Distance from current location to destination that triggers overshoot to occur. (Below this distance, no overshoot will occur). Default is `500`.
138 |
139 | #### `moveTo(destination: Vector, options?: MoveToOptions): Promise`
140 |
141 | Moves the mouse to the specified destination point.
142 |
143 | - **destination:** An object with `x` and `y` coordinates representing the target position. For example, `{ x: 500, y: 300 }`.
144 | - **options (optional):** Additional options for moving.
145 | - `moveSpeed (number):` Speed of mouse movement. Default is random.
146 | - `moveDelay (number):` Delay after moving the mouse in milliseconds. Default is `0`. If `randomizeMoveDelay=true`, delay is randomized from 0 to `moveDelay`.
147 | - `randomizeMoveDelay (boolean):` Randomize delay between actions from `0` to `moveDelay`. Default is `true`.
148 |
149 | #### `scrollIntoView(selector: string | ElementHandle, options?: ScrollIntoViewOptions) => Promise`
150 |
151 | Scrolls the element into view. If already in view, no scroll occurs.
152 |
153 | - **selector:** CSS selector or ElementHandle to identify the target element.
154 | - **options (optional):** Additional options for scrolling. **Extends the `options` of the `getElement` and `scroll` functions (below)**
155 | - `scrollSpeed (number):` Scroll speed (when scrolling occurs). 0 to 100. 100 is instant. Default is `100`.
156 | - `scrollDelay (number):` Time to wait after scrolling (when scrolling occurs). Default is `200`.
157 | - `inViewportMargin (number):` Margin (in px) to add around the element when ensuring it is in the viewport. Default is `0`.
158 |
159 | #### `scrollTo: (destination: Partial | 'top' | 'bottom' | 'left' | 'right', options?: ScrollOptions) => Promise`
160 |
161 | Scrolls to the specified destination point.
162 |
163 | - **destination:** An object with `x` and `y` coordinates representing the target position. For example, `{ x: 500, y: 300 }`. Can also be `"top"` or `"bottom"`.
164 | - **options (optional):** Additional options for scrolling. **Extends the `options` of the `scroll` function (below)**
165 |
166 | #### `scroll: (delta: Partial, options?: ScrollOptions) => Promise`
167 |
168 | Scrolls the page the distance set by `delta`.
169 |
170 | - **delta:** An object with `x` and `y` coordinates representing the distance to scroll from the current position.
171 | - **options (optional):** Additional options for scrolling.
172 | - `scrollSpeed (number):` Scroll speed. 0 to 100. 100 is instant. Default is `100`.
173 | - `scrollDelay (number):` Time to wait after scrolling. Default is `200`.
174 |
175 | #### `getElement(selector: string | ElementHandle, options?: GetElementOptions) => Promise`
176 |
177 | Gets the element via a selector. Can use an XPath.
178 |
179 | - **selector:** CSS selector or ElementHandle to identify the target element.
180 | - **options (optional):** Additional options.
181 | - `waitForSelector (number):` Time to wait for the selector to appear in milliseconds. Default is to not wait for selector.
182 |
183 | #### `getLocation(): Vector`
184 |
185 | Get current location of the cursor.
186 |
187 | ### Other Utility Methods
188 |
189 | #### `installMouseHelper(page: Page): Promise`
190 |
191 | Installs a mouse helper on the page. Makes pointer visible. Use for debugging only.
192 |
193 | #### `getRandomPagePoint(page: Page): Promise`
194 |
195 | Gets a random point on the browser window.
196 |
197 | #### `path(point: Vector, target: Vector, options?: number | PathOptions): Vector[] | TimedVector[]`
198 |
199 | Generates a set of points for mouse movement between two coordinates.
200 |
201 | - **point:** Starting point of the movement.
202 | - **target:** Ending point of the movement.
203 | - **options (optional):** Additional options for generating the path. Can also be a number which will set `spreadOverride`.
204 | - `spreadOverride (number):` Override the spread of the generated path.
205 | - `moveSpeed (number):` Speed of mouse movement. Default is random.
206 | - `useTimestamps (boolean):` Generate timestamps for each point based on the trapezoidal rule.
207 |
208 | ## How does it work
209 |
210 | Bezier curves do almost all the work here. They let us create an infinite amount of curves between any 2 points we want
211 | and they look quite human-like. (At least moreso than alternatives like perlin or simplex noise)
212 |
213 | 
214 |
215 | The magic comes from being able to set multiple points for the curve to go through. This is done by picking
216 | 2 coordinates randomly in a limited area above and under the curve.
217 |
218 |
219 |
220 | However, we don't want wonky looking cubic curves when using this method because nobody really moves their mouse
221 | that way, so only one side of the line is picked when generating random points.
222 |
223 |
224 | When calculating how fast the mouse should be moving we use Fitts's Law
225 | to determine the amount of points we should be returning relative to the width of the element being clicked on and the distance
226 | between the mouse and the object.
227 |
228 | ## To turn on logging, please set your DEBUG env variable like so:
229 |
230 | - OSX: `DEBUG="ghost-cursor:*"`
231 | - Linux: `DEBUG="ghost-cursor:*"`
232 | - Windows CMD: `set DEBUG=ghost-cursor:*`
233 | - Windows PowerShell: `$env:DEBUG = "ghost-cursor:*"`
234 |
--------------------------------------------------------------------------------
/jest-puppeteer.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | launch: {
3 | dumpio: true,
4 | headless: true,
5 | product: 'chrome'
6 | },
7 | browserContext: 'default'
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ghost-cursor",
3 | "version": "1.4.1",
4 | "description": "Move your mouse like a human in puppeteer or generate realistic movements on any 2D plane",
5 | "repository": "https://github.com/Xetera/ghost-cursor",
6 | "main": "lib/spoof.js",
7 | "types": "lib/spoof.d.ts",
8 | "scripts": {
9 | "prepare": "husky && yarn lint && yarn build",
10 | "build": "tsc -p tsconfig.build.json",
11 | "debug": "ts-node src/__debug__/browser-debug.ts",
12 | "lint": "yarn ts-standard --fix",
13 | "test": "jest"
14 | },
15 | "keywords": [
16 | "bezier-curve",
17 | "mouse-movement",
18 | "botting"
19 | ],
20 | "author": "Xetera",
21 | "license": "ISC",
22 | "files": [
23 | "lib/**/*"
24 | ],
25 | "dependencies": {
26 | "@types/bezier-js": "4",
27 | "bezier-js": "^6.1.3",
28 | "debug": "^4.3.4"
29 | },
30 | "devDependencies": {
31 | "@swc/core": "^1.2.194",
32 | "@swc/jest": "^0.2.21",
33 | "@types/debug": "^4.1.9",
34 | "@types/jest": "29",
35 | "husky": "9",
36 | "jest": "29",
37 | "jest-puppeteer": "11",
38 | "puppeteer": "24",
39 | "ts-node": "^10.9.2",
40 | "ts-standard": "12",
41 | "typescript": "5"
42 | },
43 | "jest": {
44 | "verbose": true,
45 | "preset": "jest-puppeteer",
46 | "modulePathIgnorePatterns": [
47 | "./lib",
48 | "./src/test.ts"
49 | ],
50 | "reporters": [
51 | "default",
52 | "github-actions"
53 | ],
54 | "transform": {
55 | "^.+\\.(t|j)sx?$": "@swc/jest"
56 | }
57 | },
58 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
59 | }
60 |
--------------------------------------------------------------------------------
/src/__debug__/browser-debug.ts:
--------------------------------------------------------------------------------
1 | import { type ClickOptions, createCursor } from '../spoof'
2 | import { join } from 'path'
3 | import { promises as fs } from 'fs'
4 | import installMouseHelper from '../mouse-helper'
5 | import puppeteer from 'puppeteer'
6 |
7 | const delay = async (ms: number): Promise => {
8 | if (ms < 1) return
9 | return await new Promise((resolve) => setTimeout(resolve, ms))
10 | }
11 |
12 | const cursorDefaultOptions = {
13 | moveDelay: 100,
14 | moveSpeed: 99,
15 | hesitate: 100,
16 | waitForClick: 10,
17 | scrollDelay: 100,
18 | scrollSpeed: 40,
19 | inViewportMargin: 50,
20 | waitForSelector: 200
21 | } as const satisfies ClickOptions
22 |
23 | puppeteer.launch({ headless: false }).then(async (browser) => {
24 | const page = await browser.newPage()
25 |
26 | await installMouseHelper(page)
27 |
28 | const cursor = createCursor(page, undefined, undefined, {
29 | move: cursorDefaultOptions,
30 | moveTo: cursorDefaultOptions,
31 | click: cursorDefaultOptions,
32 | scroll: cursorDefaultOptions,
33 | getElement: cursorDefaultOptions
34 | })
35 |
36 | const html = await fs.readFile(join(__dirname, 'custom-page.html'), 'utf8')
37 |
38 | await page.goto('data:text/html,' + encodeURIComponent(html), {
39 | waitUntil: 'networkidle2'
40 | })
41 |
42 | const performActions = async (): Promise => {
43 | await cursor.click('#box1')
44 |
45 | await cursor.click('#box2')
46 |
47 | await cursor.click('#box3')
48 |
49 | await cursor.click('#box1')
50 |
51 | // await cursor.scrollTo('right')
52 |
53 | // await cursor.scrollTo('left')
54 |
55 | // await cursor.scrollTo('bottom')
56 |
57 | // await cursor.scrollTo('top')
58 | }
59 |
60 | await performActions()
61 |
62 | // allows us to hit "refresh" button to restart the events
63 | // eslint-disable-next-line @typescript-eslint/no-misused-promises
64 | page.on('load', async () => {
65 | await delay(500)
66 | await page.evaluate(() => { window.scrollTo(0, 0) })
67 | await delay(1000)
68 |
69 | await performActions()
70 | })
71 | }).catch((e) => {
72 | console.error(e)
73 | })
74 |
--------------------------------------------------------------------------------
/src/__debug__/custom-page.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Document
8 |
17 |
18 |
19 |
20 | Box 1
24 | Box 2
28 | Box 3
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/__test__/custom-page.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Document
8 |
17 |
18 |
19 |
20 | Box 1
24 | Box 2
28 | Box 3
32 |
33 |
46 |
47 |
--------------------------------------------------------------------------------
/src/__test__/spoof.spec.ts:
--------------------------------------------------------------------------------
1 | import type { ElementHandle, Page } from 'puppeteer'
2 | import { type ClickOptions, createCursor, GhostCursor } from '../spoof'
3 | import { join } from 'path'
4 | import { readFileSync } from 'fs'
5 | import installMouseHelper from '../mouse-helper'
6 |
7 | declare const page: Page
8 |
9 | let cursor: GhostCursor
10 |
11 | const cursorDefaultOptions = {
12 | moveDelay: 0,
13 | moveSpeed: 99,
14 | hesitate: 0,
15 | waitForClick: 0,
16 | scrollDelay: 0,
17 | scrollSpeed: 99,
18 | inViewportMargin: 50
19 | } as const satisfies ClickOptions
20 |
21 | declare global {
22 | // eslint-disable-next-line no-var
23 | var boxWasClicked: boolean
24 | }
25 |
26 | describe('Mouse movements', () => {
27 | const html = readFileSync(join(__dirname, 'custom-page.html'), 'utf8')
28 |
29 | beforeAll(async () => {
30 | await installMouseHelper(page)
31 | })
32 |
33 | beforeEach(async () => {
34 | await page.goto('data:text/html,' + encodeURIComponent(html), {
35 | waitUntil: 'networkidle2'
36 | })
37 |
38 | cursor = createCursor(page, undefined, undefined, {
39 | move: cursorDefaultOptions,
40 | click: cursorDefaultOptions,
41 | moveTo: cursorDefaultOptions
42 | })
43 | })
44 |
45 | const testClick = async (clickSelector: string): Promise => {
46 | expect(await page.evaluate(() => window.boxWasClicked)).toEqual(false)
47 | await cursor.click(clickSelector)
48 | expect(await page.evaluate(() => window.boxWasClicked)).toEqual(true)
49 | }
50 |
51 | const getScrollPosition = async (): Promise<{ top: number, left: number }> => await page.evaluate(() => (
52 | { top: window.scrollY, left: window.scrollX }
53 | ))
54 |
55 | it('Should click on the element without throwing an error (CSS selector)', async () => {
56 | await testClick('#box1')
57 | })
58 |
59 | it('Should click on the element without throwing an error (XPath selector)', async () => {
60 | await testClick('//*[@id="box1"]')
61 | })
62 |
63 | it('Should scroll to elements correctly', async () => {
64 | const boxes = await Promise.all([1, 2, 3].map(async (number: number): Promise> => {
65 | const selector = `#box${number}`
66 | const box = await page.waitForSelector(selector) as ElementHandle | null
67 | if (box == null) throw new Error(`${selector} not found`)
68 | return box
69 | }))
70 |
71 | expect(await getScrollPosition()).toEqual({ top: 0, left: 0 })
72 |
73 | expect(await boxes[0].isIntersectingViewport()).toBeTruthy()
74 | await cursor.click(boxes[0])
75 | expect(await getScrollPosition()).toEqual({ top: 0, left: 0 })
76 | expect(await boxes[0].isIntersectingViewport()).toBeTruthy()
77 |
78 | expect(await boxes[1].isIntersectingViewport()).toBeFalsy()
79 | await cursor.move(boxes[1])
80 | expect(await getScrollPosition()).toEqual({ top: 2500, left: 0 })
81 | expect(await boxes[1].isIntersectingViewport()).toBeTruthy()
82 |
83 | expect(await boxes[2].isIntersectingViewport()).toBeFalsy()
84 | await cursor.move(boxes[2])
85 | expect(await getScrollPosition()).toEqual({ top: 4450, left: 2250 })
86 | expect(await boxes[2].isIntersectingViewport()).toBeTruthy()
87 |
88 | expect(await boxes[0].isIntersectingViewport()).toBeFalsy()
89 | await cursor.click(boxes[0])
90 | expect(await boxes[0].isIntersectingViewport()).toBeTruthy()
91 | })
92 |
93 | it('Should scroll to position correctly', async () => {
94 | expect(await getScrollPosition()).toEqual({ top: 0, left: 0 })
95 |
96 | await cursor.scrollTo('bottom')
97 | expect(await getScrollPosition()).toEqual({ top: 4450, left: 0 })
98 |
99 | await cursor.scrollTo('right')
100 | expect(await getScrollPosition()).toEqual({ top: 4450, left: 2250 })
101 |
102 | await cursor.scrollTo('top')
103 | expect(await getScrollPosition()).toEqual({ top: 0, left: 2250 })
104 |
105 | await cursor.scrollTo('left')
106 | expect(await getScrollPosition()).toEqual({ top: 0, left: 0 })
107 |
108 | await cursor.scrollTo({ y: 200, x: 400 })
109 | expect(await getScrollPosition()).toEqual({ top: 200, left: 400 })
110 | })
111 | })
112 |
113 | jest.setTimeout(15_000)
114 |
--------------------------------------------------------------------------------
/src/math.ts:
--------------------------------------------------------------------------------
1 | import { Bezier } from 'bezier-js'
2 |
3 | export interface Vector {
4 | x: number
5 | y: number
6 | }
7 | export interface TimedVector extends Vector {
8 | timestamp: number
9 | }
10 | export const origin: Vector = { x: 0, y: 0 }
11 |
12 | // maybe i should've just imported a vector library lol
13 | export const sub = (a: Vector, b: Vector): Vector => ({ x: a.x - b.x, y: a.y - b.y })
14 | export const div = (a: Vector, b: number): Vector => ({ x: a.x / b, y: a.y / b })
15 | export const mult = (a: Vector, b: number): Vector => ({ x: a.x * b, y: a.y * b })
16 | export const add = (a: Vector, b: Vector): Vector => ({ x: a.x + b.x, y: a.y + b.y })
17 |
18 | export const scale = (value: number, range1: [number, number], range2: [number, number]): number =>
19 | (value - range1[0]) * (range2[1] - range2[0]) / (range1[1] - range1[0]) + range2[0]
20 |
21 | export const direction = (a: Vector, b: Vector): Vector => sub(b, a)
22 | export const perpendicular = (a: Vector): Vector => ({ x: a.y, y: -1 * a.x })
23 | export const magnitude = (a: Vector): number =>
24 | Math.sqrt(Math.pow(a.x, 2) + Math.pow(a.y, 2))
25 | export const unit = (a: Vector): Vector => div(a, magnitude(a))
26 | export const setMagnitude = (a: Vector, amount: number): Vector =>
27 | mult(unit(a), amount)
28 |
29 | export const randomNumberRange = (min: number, max: number): number =>
30 | Math.random() * (max - min) + min
31 |
32 | export const randomVectorOnLine = (a: Vector, b: Vector): Vector => {
33 | const vec = direction(a, b)
34 | const multiplier = Math.random()
35 | return add(a, mult(vec, multiplier))
36 | }
37 |
38 | const randomNormalLine = (
39 | a: Vector,
40 | b: Vector,
41 | range: number
42 | ): [Vector, Vector] => {
43 | const randMid = randomVectorOnLine(a, b)
44 | const normalV = setMagnitude(perpendicular(direction(a, randMid)), range)
45 | return [randMid, normalV]
46 | }
47 |
48 | export const generateBezierAnchors = (
49 | a: Vector,
50 | b: Vector,
51 | spread: number
52 | ): [Vector, Vector] => {
53 | const side = Math.round(Math.random()) === 1 ? 1 : -1
54 | const calc = (): Vector => {
55 | const [randMid, normalV] = randomNormalLine(a, b, spread)
56 | const choice = mult(normalV, side)
57 | return randomVectorOnLine(randMid, add(randMid, choice))
58 | }
59 | return [calc(), calc()].sort((a, b) => a.x - b.x) as [Vector, Vector]
60 | }
61 |
62 | export const clamp = (target: number, min: number, max: number): number =>
63 | Math.min(max, Math.max(min, target))
64 |
65 | export const overshoot = (coordinate: Vector, radius: number): Vector => {
66 | const a = Math.random() * 2 * Math.PI
67 | const rad = radius * Math.sqrt(Math.random())
68 | const vector = { x: rad * Math.cos(a), y: rad * Math.sin(a) }
69 | return add(coordinate, vector)
70 | }
71 |
72 | export const bezierCurve = (
73 | start: Vector,
74 | finish: Vector,
75 | /**
76 | * Default is length from start to finish, clamped to 2 < x < 200
77 | */
78 | spreadOverride?: number
79 | ): Bezier => {
80 | // could be played around with
81 | const MIN_SPREAD = 2
82 | const MAX_SPREAD = 200
83 | const vec = direction(start, finish)
84 | const length = magnitude(vec)
85 | const spread = spreadOverride ?? clamp(length, MIN_SPREAD, MAX_SPREAD)
86 | const anchors = generateBezierAnchors(start, finish, spread)
87 | return new Bezier(start, ...anchors, finish)
88 | }
89 |
90 | export const bezierCurveSpeed = (
91 | t: number,
92 | P0: Vector,
93 | P1: Vector,
94 | P2: Vector,
95 | P3: Vector
96 | ): number => {
97 | 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)
98 | 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)
99 | return Math.sqrt(B1 ** 2 + B2 ** 2)
100 | }
101 |
--------------------------------------------------------------------------------
/src/mouse-helper.ts:
--------------------------------------------------------------------------------
1 | import type { Page } from 'puppeteer'
2 |
3 | /**
4 | * This injects a box into the page that moves with the mouse.
5 | * Useful for debugging.
6 | */
7 | async function installMouseHelper (page: Page): Promise {
8 | await page.evaluateOnNewDocument(() => {
9 | const attachListener = (): void => {
10 | const box = document.createElement('p-mouse-pointer')
11 | const styleElement = document.createElement('style')
12 | styleElement.innerHTML = `
13 | p-mouse-pointer {
14 | pointer-events: none;
15 | position: absolute;
16 | top: 0;
17 | z-index: 10000;
18 | left: 0;
19 | width: 20px;
20 | height: 20px;
21 | background: rgba(0,0,0,.4);
22 | border: 1px solid white;
23 | border-radius: 10px;
24 | box-sizing: border-box;
25 | margin: -10px 0 0 -10px;
26 | padding: 0;
27 | transition: background .2s, border-radius .2s, border-color .2s;
28 | }
29 | p-mouse-pointer.button-1 {
30 | transition: none;
31 | background: rgba(0,0,0,0.9);
32 | }
33 | p-mouse-pointer.button-2 {
34 | transition: none;
35 | border-color: rgba(0,0,255,0.9);
36 | }
37 | p-mouse-pointer.button-3 {
38 | transition: none;
39 | border-radius: 4px;
40 | }
41 | p-mouse-pointer.button-4 {
42 | transition: none;
43 | border-color: rgba(255,0,0,0.9);
44 | }
45 | p-mouse-pointer.button-5 {
46 | transition: none;
47 | border-color: rgba(0,255,0,0.9);
48 | }
49 | p-mouse-pointer-hide {
50 | display: none
51 | }
52 | `
53 | document.head.appendChild(styleElement)
54 | document.body.appendChild(box)
55 | document.addEventListener(
56 | 'mousemove',
57 | (event) => {
58 | console.log('event')
59 | box.style.left = String(event.pageX) + 'px'
60 | box.style.top = String(event.pageY) + 'px'
61 | box.classList.remove('p-mouse-pointer-hide')
62 | updateButtons(event.buttons)
63 | },
64 | true
65 | )
66 | document.addEventListener(
67 | 'mousedown',
68 | (event) => {
69 | updateButtons(event.buttons)
70 | box.classList.add('button-' + String(event.which))
71 | box.classList.remove('p-mouse-pointer-hide')
72 | },
73 | true
74 | )
75 | document.addEventListener(
76 | 'mouseup',
77 | (event) => {
78 | updateButtons(event.buttons)
79 | box.classList.remove('button-' + String(event.which))
80 | box.classList.remove('p-mouse-pointer-hide')
81 | },
82 | true
83 | )
84 | document.addEventListener(
85 | 'mouseleave',
86 | (event) => {
87 | updateButtons(event.buttons)
88 | box.classList.add('p-mouse-pointer-hide')
89 | },
90 | true
91 | )
92 | document.addEventListener(
93 | 'mouseenter',
94 | (event) => {
95 | updateButtons(event.buttons)
96 | box.classList.remove('p-mouse-pointer-hide')
97 | },
98 | true
99 | )
100 | function updateButtons (buttons: number): void {
101 | for (let i = 0; i < 5; i++) {
102 | box.classList.toggle('button-' + String(i), Boolean(buttons & (1 << i)))
103 | }
104 | }
105 | }
106 | if (document.readyState !== 'loading') {
107 | attachListener()
108 | } else {
109 | window.addEventListener('DOMContentLoaded', attachListener, false)
110 | }
111 | })
112 | }
113 |
114 | export default installMouseHelper
115 |
--------------------------------------------------------------------------------
/src/spoof.ts:
--------------------------------------------------------------------------------
1 | import type { ElementHandle, Page, BoundingBox, CDPSession, Protocol } from 'puppeteer'
2 | import debug from 'debug'
3 | import {
4 | type Vector,
5 | type TimedVector,
6 | bezierCurve,
7 | bezierCurveSpeed,
8 | direction,
9 | magnitude,
10 | origin,
11 | overshoot,
12 | add,
13 | clamp,
14 | scale
15 | } from './math'
16 | export { default as installMouseHelper } from './mouse-helper'
17 |
18 | const log = debug('ghost-cursor')
19 |
20 | export interface BoxOptions {
21 | /**
22 | * Percentage of padding to be added inside the element when determining the target point.
23 | * Example:
24 | * - `0` = may be anywhere within the element.
25 | * - `100` = will always be center of element.
26 | * @default 0
27 | */
28 | readonly paddingPercentage?: number
29 | /**
30 | * Destination to move the cursor to, relative to the top-left corner of the element.
31 | * If specified, `paddingPercentage` is not used.
32 | * If not specified (default), destination is random point within the `paddingPercentage`.
33 | * @default undefined (random point)
34 | */
35 | readonly destination?: Vector
36 | }
37 |
38 | export interface GetElementOptions {
39 | /**
40 | * Time to wait for the selector to appear in milliseconds.
41 | * Default is to not wait for selector.
42 | */
43 | readonly waitForSelector?: number
44 | }
45 |
46 | export interface ScrollOptions {
47 | /**
48 | * Scroll speed. 0 to 100. 100 is instant.
49 | * @default 100
50 | */
51 | readonly scrollSpeed?: number
52 | /**
53 | * Time to wait after scrolling.
54 | * @default 200
55 | */
56 | readonly scrollDelay?: number
57 | }
58 |
59 | export interface ScrollIntoViewOptions extends ScrollOptions, GetElementOptions {
60 | /**
61 | * Scroll speed (when scrolling occurs). 0 to 100. 100 is instant.
62 | * @default 100
63 | */
64 | readonly scrollSpeed?: number
65 | /**
66 | * Time to wait after scrolling (when scrolling occurs).
67 | * @default 200
68 | */
69 | readonly scrollDelay?: number
70 | /**
71 | * Margin (in px) to add around the element when ensuring it is in the viewport.
72 | * (Does not take effect if CDP scroll fails.)
73 | * @default 0
74 | */
75 | readonly inViewportMargin?: number
76 | }
77 |
78 | export interface MoveOptions extends BoxOptions, ScrollIntoViewOptions, Pick {
79 | /**
80 | * Delay after moving the mouse in milliseconds. If `randomizeMoveDelay=true`, delay is randomized from 0 to `moveDelay`.
81 | * @default 0
82 | */
83 | readonly moveDelay?: number
84 | /**
85 | * Randomize delay between actions from `0` to `moveDelay`. See `moveDelay` docs.
86 | * @default true
87 | */
88 | readonly randomizeMoveDelay?: boolean
89 | /**
90 | * Maximum number of attempts to mouse-over the element.
91 | * @default 10
92 | */
93 | readonly maxTries?: number
94 | /**
95 | * Distance from current location to destination that triggers overshoot to
96 | * occur. (Below this distance, no overshoot will occur).
97 | * @default 500
98 | */
99 | readonly overshootThreshold?: number
100 | }
101 |
102 | export interface ClickOptions extends MoveOptions {
103 | /**
104 | * Delay before initiating the click action in milliseconds.
105 | * @default 0
106 | */
107 | readonly hesitate?: number
108 | /**
109 | * Delay between mousedown and mouseup in milliseconds.
110 | * @default 0
111 | */
112 | readonly waitForClick?: number
113 | /**
114 | * @default 2000
115 | */
116 | readonly moveDelay?: number
117 | /**
118 | * @default "left"
119 | */
120 | readonly button?: Protocol.Input.MouseButton
121 | /**
122 | * @default 1
123 | */
124 | readonly clickCount?: number
125 | }
126 |
127 | export interface PathOptions {
128 | /**
129 | * Override the spread of the generated path.
130 | */
131 | readonly spreadOverride?: number
132 | /**
133 | * Speed of mouse movement.
134 | * Default is random.
135 | */
136 | readonly moveSpeed?: number
137 |
138 | /**
139 | * Generate timestamps for each point in the path.
140 | */
141 | readonly useTimestamps?: boolean
142 | }
143 |
144 | export interface RandomMoveOptions extends Pick {
145 | /**
146 | * @default 2000
147 | */
148 | readonly moveDelay?: number
149 | }
150 |
151 | export interface MoveToOptions extends PathOptions, Pick {
152 | /**
153 | * @default 0
154 | */
155 | readonly moveDelay?: number
156 | }
157 |
158 | export type ScrollToDestination = Partial | 'top' | 'bottom' | 'left' | 'right'
159 |
160 | export interface GhostCursor {
161 | toggleRandomMove: (random: boolean) => void
162 | click: (
163 | selector?: string | ElementHandle,
164 | options?: ClickOptions
165 | ) => Promise
166 | move: (
167 | selector: string | ElementHandle,
168 | options?: MoveOptions
169 | ) => Promise
170 | moveTo: (
171 | destination: Vector,
172 | options?: MoveToOptions) => Promise
173 | scrollIntoView: (
174 | selector: ElementHandle,
175 | options?: ScrollIntoViewOptions) => Promise
176 | scrollTo: (
177 | destination: ScrollToDestination,
178 | options?: ScrollOptions) => Promise
179 | scroll: (
180 | delta: Partial,
181 | options?: ScrollOptions) => Promise
182 | getElement: (
183 | selector: string | ElementHandle,
184 | options?: GetElementOptions) => Promise>
185 | getLocation: () => Vector
186 | }
187 |
188 | /** Helper function to wait a specified number of milliseconds */
189 | const delay = async (ms: number): Promise => {
190 | if (ms < 1) return
191 | return await new Promise((resolve) => setTimeout(resolve, ms))
192 | }
193 |
194 | /**
195 | * Calculate the amount of time needed to move from (x1, y1) to (x2, y2)
196 | * given the width of the element being clicked on
197 | * https://en.wikipedia.org/wiki/Fitts%27s_law
198 | */
199 | const fitts = (distance: number, width: number): number => {
200 | const a = 0
201 | const b = 2
202 | const id = Math.log2(distance / width + 1)
203 | return a + b * id
204 | }
205 |
206 | /** Get a random point on a box */
207 | const getRandomBoxPoint = (
208 | { x, y, width, height }: BoundingBox,
209 | options?: Pick
210 | ): Vector => {
211 | let paddingWidth = 0
212 | let paddingHeight = 0
213 |
214 | if (
215 | options?.paddingPercentage !== undefined &&
216 | options?.paddingPercentage > 0 &&
217 | options?.paddingPercentage <= 100
218 | ) {
219 | paddingWidth = (width * options.paddingPercentage) / 100
220 | paddingHeight = (height * options.paddingPercentage) / 100
221 | }
222 |
223 | return {
224 | x: x + paddingWidth / 2 + Math.random() * (width - paddingWidth),
225 | y: y + paddingHeight / 2 + Math.random() * (height - paddingHeight)
226 | }
227 | }
228 |
229 | /** The function signature to access the internal CDP client changed in puppeteer 14.4.1 */
230 | const getCDPClient = (page: any): CDPSession => typeof page._client === 'function' ? page._client() : page._client
231 |
232 | /** Get a random point on a browser window */
233 | export const getRandomPagePoint = async (page: Page): Promise => {
234 | const targetId: string = (page.target() as any)._targetId
235 | const window = await getCDPClient(page).send(
236 | 'Browser.getWindowForTarget',
237 | { targetId }
238 | )
239 | return getRandomBoxPoint({
240 | x: origin.x,
241 | y: origin.y,
242 | width: window.bounds.width ?? 0,
243 | height: window.bounds.height ?? 0
244 | })
245 | }
246 |
247 | /** Using this method to get correct position of Inline elements (elements like ``) */
248 | const getElementBox = async (
249 | page: Page,
250 | element: ElementHandle,
251 | relativeToMainFrame: boolean = true
252 | ): Promise => {
253 | const objectId = element.remoteObject().objectId
254 | if (objectId === undefined) {
255 | return null
256 | }
257 |
258 | try {
259 | const quads = await getCDPClient(page).send('DOM.getContentQuads', {
260 | objectId
261 | })
262 | const elementBox = {
263 | x: quads.quads[0][0],
264 | y: quads.quads[0][1],
265 | width: quads.quads[0][4] - quads.quads[0][0],
266 | height: quads.quads[0][5] - quads.quads[0][1]
267 | }
268 | if (!relativeToMainFrame) {
269 | const elementFrame = await element.contentFrame()
270 | const iframes =
271 | elementFrame != null
272 | ? await elementFrame.parentFrame()?.$$('xpath/.//iframe')
273 | : null
274 | let frame: ElementHandle | undefined
275 | if (iframes != null) {
276 | for (const iframe of iframes) {
277 | if ((await iframe.contentFrame()) === elementFrame) frame = iframe
278 | }
279 | }
280 | if (frame != null) {
281 | const boundingBox = await frame.boundingBox()
282 | elementBox.x =
283 | boundingBox !== null ? elementBox.x - boundingBox.x : elementBox.x
284 | elementBox.y =
285 | boundingBox !== null ? elementBox.y - boundingBox.y : elementBox.y
286 | }
287 | }
288 |
289 | return elementBox
290 | } catch (_) {
291 | log('Quads not found, trying regular boundingBox')
292 | return await element.boundingBox()
293 | }
294 | }
295 |
296 | export function path (point: Vector, target: Vector, options?: number | PathOptions): Vector[] | TimedVector[]
297 | export function path (point: Vector, target: BoundingBox, options?: number | PathOptions): Vector[] | TimedVector[]
298 | export function path (start: Vector, end: BoundingBox | Vector, options?: number | PathOptions): Vector[] | TimedVector[] {
299 | const optionsResolved: PathOptions = typeof options === 'number'
300 | ? { spreadOverride: options }
301 | : { ...options }
302 |
303 | const DEFAULT_WIDTH = 100
304 | const MIN_STEPS = 25
305 | const width = 'width' in end && end.width !== 0 ? end.width : DEFAULT_WIDTH
306 | const curve = bezierCurve(start, end, optionsResolved.spreadOverride)
307 | const length = curve.length() * 0.8
308 |
309 | const speed = optionsResolved.moveSpeed !== undefined && optionsResolved.moveSpeed > 0
310 | ? (25 / optionsResolved.moveSpeed)
311 | : Math.random()
312 | const baseTime = speed * MIN_STEPS
313 | const steps = Math.ceil((Math.log2(fitts(length, width) + 1) + baseTime) * 3)
314 | const re = curve.getLUT(steps)
315 | return clampPositive(re, optionsResolved)
316 | }
317 |
318 | const clampPositive = (vectors: Vector[], options?: PathOptions): Vector[] | TimedVector[] => {
319 | const clampedVectors = vectors.map((vector) => ({
320 | x: Math.max(0, vector.x),
321 | y: Math.max(0, vector.y)
322 | }))
323 |
324 | return options?.useTimestamps === true ? generateTimestamps(clampedVectors, options) : clampedVectors
325 | }
326 |
327 | const generateTimestamps = (vectors: Vector[], options?: PathOptions): TimedVector[] => {
328 | const speed = options?.moveSpeed ?? (Math.random() * 0.5 + 0.5)
329 | const timeToMove = (P0: Vector, P1: Vector, P2: Vector, P3: Vector, samples: number): number => {
330 | let total = 0
331 | const dt = 1 / samples
332 |
333 | for (let t = 0; t < 1; t += dt) {
334 | const v1 = bezierCurveSpeed(t * dt, P0, P1, P2, P3)
335 | const v2 = bezierCurveSpeed(t, P0, P1, P2, P3)
336 | total += (v1 + v2) * dt / 2
337 | }
338 |
339 | return Math.round(total / speed)
340 | }
341 |
342 | const timedVectors: TimedVector[] = vectors.map((vector) => ({ ...vector, timestamp: 0 }))
343 |
344 | for (let i = 0; i < timedVectors.length; i++) {
345 | const P0 = i === 0 ? timedVectors[i] : timedVectors[i - 1]
346 | const P1 = timedVectors[i]
347 | const P2 = i === timedVectors.length - 1 ? timedVectors[i] : timedVectors[i + 1]
348 | const P3 = i === timedVectors.length - 1 ? timedVectors[i] : timedVectors[i + 1]
349 | const time = timeToMove(P0, P1, P2, P3, timedVectors.length)
350 |
351 | timedVectors[i] = {
352 | ...timedVectors[i],
353 | timestamp: i === 0 ? Date.now() : timedVectors[i - 1].timestamp + time
354 | }
355 | }
356 |
357 | return timedVectors
358 | }
359 |
360 | const shouldOvershoot = (a: Vector, b: Vector, threshold: number): boolean =>
361 | magnitude(direction(a, b)) > threshold
362 |
363 | const intersectsElement = (vec: Vector, box: BoundingBox): boolean => {
364 | return (
365 | vec.x > box.x &&
366 | vec.x <= box.x + box.width &&
367 | vec.y > box.y &&
368 | vec.y <= box.y + box.height
369 | )
370 | }
371 |
372 | const boundingBoxWithFallback = async (
373 | page: Page,
374 | elem: ElementHandle
375 | ): Promise => {
376 | let box = await getElementBox(page, elem)
377 | if (box == null) {
378 | box = (await elem.evaluate((el: Element) =>
379 | el.getBoundingClientRect()
380 | )) as BoundingBox
381 | }
382 |
383 | return box
384 | }
385 |
386 | export const createCursor = (
387 | page: Page,
388 | /**
389 | * Cursor start position.
390 | * @default { x: 0, y: 0 }
391 | */
392 | start: Vector = origin,
393 | /**
394 | * Initially perform random movements.
395 | * If `move`,`click`, etc. is performed, these random movements end.
396 | * @default false
397 | */
398 | performRandomMoves: boolean = false,
399 | defaultOptions: {
400 | /**
401 | * Default options for the `randomMove` function that occurs when `performRandomMoves=true`
402 | * @default RandomMoveOptions
403 | */
404 | randomMove?: RandomMoveOptions
405 | /**
406 | * Default options for the `move` function
407 | * @default MoveOptions
408 | */
409 | move?: MoveOptions
410 | /**
411 | * Default options for the `moveTo` function
412 | * @default MoveToOptions
413 | */
414 | moveTo?: MoveToOptions
415 | /**
416 | * Default options for the `click` function
417 | * @default ClickOptions
418 | */
419 | click?: ClickOptions
420 | /**
421 | * Default options for the `scrollIntoView`, `scrollTo`, and `scroll` functions
422 | * @default ScrollIntoViewOptions
423 | */
424 | scroll?: ScrollOptions & ScrollIntoViewOptions
425 | /**
426 | * Default options for the `getElement` function
427 | * @default GetElementOptions
428 | */
429 | getElement?: GetElementOptions
430 | } = {}
431 | ): GhostCursor => {
432 | // this is kind of arbitrary, not a big fan but it seems to work
433 | const OVERSHOOT_SPREAD = 10
434 | const OVERSHOOT_RADIUS = 120
435 | let previous: Vector = start
436 |
437 | // Initial state: mouse is not moving
438 | let moving: boolean = false
439 |
440 | // Move the mouse over a number of vectors
441 | const tracePath = async (
442 | vectors: Iterable,
443 | abortOnMove: boolean = false
444 | ): Promise => {
445 | const cdpClient = getCDPClient(page)
446 |
447 | for (const v of vectors) {
448 | try {
449 | // In case this is called from random mouse movements and the users wants to move the mouse, abort
450 | if (abortOnMove && moving) {
451 | return
452 | }
453 |
454 | const dispatchParams: Protocol.Input.DispatchMouseEventRequest = {
455 | type: 'mouseMoved',
456 | x: v.x,
457 | y: v.y
458 | }
459 |
460 | if ('timestamp' in v) dispatchParams.timestamp = v.timestamp
461 |
462 | await cdpClient.send('Input.dispatchMouseEvent', dispatchParams)
463 |
464 | previous = v
465 | } catch (error) {
466 | // Exit function if the browser is no longer connected
467 | if (!page.browser().isConnected()) return
468 |
469 | log('Warning: could not move mouse, error message:', error)
470 | }
471 | }
472 | }
473 | // Start random mouse movements. Function recursively calls itself
474 | const randomMove = async (options?: RandomMoveOptions): Promise => {
475 | const optionsResolved = {
476 | moveDelay: 2000,
477 | randomizeMoveDelay: true,
478 | ...defaultOptions?.randomMove,
479 | ...options
480 | } satisfies RandomMoveOptions
481 |
482 | try {
483 | if (!moving) {
484 | const rand = await getRandomPagePoint(page)
485 | await tracePath(path(previous, rand, optionsResolved), true)
486 | previous = rand
487 | }
488 | await delay(optionsResolved.moveDelay * (optionsResolved.randomizeMoveDelay ? Math.random() : 1))
489 | randomMove(options).then(
490 | (_) => { },
491 | (_) => { }
492 | ) // fire and forget, recursive function
493 | } catch (_) {
494 | log('Warning: stopping random mouse movements')
495 | }
496 | }
497 |
498 | const actions: GhostCursor = {
499 | toggleRandomMove (random: boolean): void {
500 | moving = !random
501 | },
502 |
503 | getLocation (): Vector {
504 | return previous
505 | },
506 |
507 | async click (
508 | selector?: string | ElementHandle,
509 | options?: ClickOptions
510 | ): Promise {
511 | const optionsResolved = {
512 | moveDelay: 2000,
513 | hesitate: 0,
514 | waitForClick: 0,
515 | randomizeMoveDelay: true,
516 | button: 'left',
517 | clickCount: 1,
518 | ...defaultOptions?.click,
519 | ...options
520 | } satisfies ClickOptions
521 |
522 | const wasRandom = !moving
523 | actions.toggleRandomMove(false)
524 |
525 | if (selector !== undefined) {
526 | await actions.move(selector, {
527 | ...optionsResolved,
528 | // apply moveDelay after click, but not after actual move
529 | moveDelay: 0
530 | })
531 | }
532 |
533 | try {
534 | await delay(optionsResolved.hesitate)
535 |
536 | const cdpClient = getCDPClient(page)
537 | const dispatchParams: Omit = {
538 | x: previous.x,
539 | y: previous.y,
540 | button: optionsResolved.button,
541 | clickCount: optionsResolved.clickCount
542 | }
543 | await cdpClient.send('Input.dispatchMouseEvent', { ...dispatchParams, type: 'mousePressed' })
544 | await delay(optionsResolved.waitForClick)
545 | await cdpClient.send('Input.dispatchMouseEvent', { ...dispatchParams, type: 'mouseReleased' })
546 | } catch (error) {
547 | log('Warning: could not click mouse, error message:', error)
548 | }
549 |
550 | await delay(optionsResolved.moveDelay * (optionsResolved.randomizeMoveDelay ? Math.random() : 1))
551 |
552 | actions.toggleRandomMove(wasRandom)
553 | },
554 |
555 | async move (
556 | selector: string | ElementHandle,
557 | options?: MoveOptions
558 | ): Promise {
559 | const optionsResolved = {
560 | moveDelay: 0,
561 | maxTries: 10,
562 | overshootThreshold: 500,
563 | randomizeMoveDelay: true,
564 | ...defaultOptions?.move,
565 | ...options
566 | } satisfies MoveOptions
567 |
568 | const wasRandom = !moving
569 |
570 | const go = async (iteration: number): Promise => {
571 | if (iteration > (optionsResolved.maxTries)) {
572 | throw Error('Could not mouse-over element within enough tries')
573 | }
574 |
575 | actions.toggleRandomMove(false)
576 |
577 | const elem = await this.getElement(selector, optionsResolved)
578 |
579 | // Make sure the object is in view
580 | await this.scrollIntoView(elem, optionsResolved)
581 |
582 | const box = await boundingBoxWithFallback(page, elem)
583 | const { height, width } = box
584 | const destination = (optionsResolved.destination !== undefined)
585 | ? add(box, optionsResolved.destination)
586 | : getRandomBoxPoint(box, optionsResolved)
587 | const dimensions = { height, width }
588 | const overshooting = shouldOvershoot(
589 | previous,
590 | destination,
591 | optionsResolved.overshootThreshold
592 | )
593 | const to = overshooting
594 | ? overshoot(destination, OVERSHOOT_RADIUS)
595 | : destination
596 |
597 | await tracePath(path(previous, to, optionsResolved))
598 |
599 | if (overshooting) {
600 | const correction = path(to, { ...dimensions, ...destination }, {
601 | ...optionsResolved,
602 | spreadOverride: OVERSHOOT_SPREAD
603 | })
604 |
605 | await tracePath(correction)
606 | }
607 |
608 | previous = destination
609 |
610 | actions.toggleRandomMove(true)
611 |
612 | const newBoundingBox = await boundingBoxWithFallback(page, elem)
613 |
614 | // It's possible that the element that is being moved towards
615 | // has moved to a different location by the time
616 | // the the time the mouseover animation finishes
617 | if (!intersectsElement(to, newBoundingBox)) {
618 | return await go(iteration + 1)
619 | }
620 | }
621 | await go(0)
622 |
623 | actions.toggleRandomMove(wasRandom)
624 |
625 | await delay(optionsResolved.moveDelay * (optionsResolved.randomizeMoveDelay ? Math.random() : 1))
626 | },
627 |
628 | async moveTo (destination: Vector, options?: MoveToOptions): Promise {
629 | const optionsResolved = {
630 | moveDelay: 0,
631 | randomizeMoveDelay: true,
632 | ...defaultOptions?.moveTo,
633 | ...options
634 | } satisfies MoveToOptions
635 |
636 | const wasRandom = !moving
637 | actions.toggleRandomMove(false)
638 | await tracePath(path(previous, destination, optionsResolved))
639 | actions.toggleRandomMove(wasRandom)
640 |
641 | await delay(optionsResolved.moveDelay * (optionsResolved.randomizeMoveDelay ? Math.random() : 1))
642 | },
643 |
644 | async scrollIntoView (selector: string | ElementHandle, options?: ScrollIntoViewOptions): Promise {
645 | const optionsResolved = {
646 | scrollDelay: 200,
647 | scrollSpeed: 100,
648 | inViewportMargin: 0,
649 | ...defaultOptions?.scroll,
650 | ...options
651 | } satisfies ScrollIntoViewOptions
652 |
653 | const scrollSpeed = clamp(optionsResolved.scrollSpeed, 1, 100)
654 |
655 | const elem = await this.getElement(selector, optionsResolved)
656 |
657 | const {
658 | viewportWidth,
659 | viewportHeight,
660 | docHeight,
661 | docWidth,
662 | scrollPositionTop,
663 | scrollPositionLeft
664 | } = await page.evaluate(() => (
665 | {
666 | viewportWidth: document.body.clientWidth,
667 | viewportHeight: document.body.clientHeight,
668 | docHeight: document.body.scrollHeight,
669 | docWidth: document.body.scrollWidth,
670 | scrollPositionTop: window.scrollY,
671 | scrollPositionLeft: window.scrollX
672 | }
673 | ))
674 |
675 | const elemBoundingBox = await boundingBoxWithFallback(page, elem) // is relative to viewport
676 | const elemBox = {
677 | top: elemBoundingBox.y,
678 | left: elemBoundingBox.x,
679 | bottom: elemBoundingBox.y + elemBoundingBox.height,
680 | right: elemBoundingBox.x + elemBoundingBox.width
681 | }
682 |
683 | // Add margin around the element
684 | const marginedBox = {
685 | top: elemBox.top - optionsResolved.inViewportMargin,
686 | left: elemBox.left - optionsResolved.inViewportMargin,
687 | bottom: elemBox.bottom + optionsResolved.inViewportMargin,
688 | right: elemBox.right + optionsResolved.inViewportMargin
689 | }
690 |
691 | // Get position relative to the whole document
692 | const marginedBoxRelativeToDoc = {
693 | top: marginedBox.top + scrollPositionTop,
694 | left: marginedBox.left + scrollPositionLeft,
695 | bottom: marginedBox.bottom + scrollPositionTop,
696 | right: marginedBox.right + scrollPositionLeft
697 | }
698 |
699 | // Convert back to being relative to the viewport-- though if box with margin added goes outside
700 | // the document, restrict to being *within* the document.
701 | // This makes it so that when element is on the edge of window scroll, isInViewport=true even after
702 | // margin was added.
703 | const targetBox = {
704 | top: Math.max(marginedBoxRelativeToDoc.top, 0) - scrollPositionTop,
705 | left: Math.max(marginedBoxRelativeToDoc.left, 0) - scrollPositionLeft,
706 | bottom: Math.min(marginedBoxRelativeToDoc.bottom, docHeight) - scrollPositionTop,
707 | right: Math.min(marginedBoxRelativeToDoc.right, docWidth) - scrollPositionLeft
708 | }
709 |
710 | const { top, left, bottom, right } = targetBox
711 |
712 | const isInViewport = top >= 0 &&
713 | left >= 0 &&
714 | bottom <= viewportHeight &&
715 | right <= viewportWidth
716 |
717 | if (isInViewport) return
718 |
719 | const manuallyScroll = async (): Promise => {
720 | let deltaY: number = 0
721 | let deltaX: number = 0
722 |
723 | if (top < 0) {
724 | deltaY = top // Scroll up
725 | } else if (bottom > viewportHeight) {
726 | deltaY = bottom - viewportHeight // Scroll down
727 | }
728 |
729 | if (left < 0) {
730 | deltaX = left // Scroll left
731 | } else if (right > viewportWidth) {
732 | deltaX = right - viewportWidth// Scroll right
733 | }
734 |
735 | await this.scroll({ x: deltaX, y: deltaY }, optionsResolved)
736 | }
737 |
738 | try {
739 | const cdpClient = getCDPClient(page)
740 |
741 | if (scrollSpeed === 100 && optionsResolved.inViewportMargin <= 0) {
742 | try {
743 | const { objectId } = elem.remoteObject()
744 | if (objectId === undefined) throw new Error()
745 | await cdpClient.send('DOM.scrollIntoViewIfNeeded', { objectId })
746 | } catch {
747 | await manuallyScroll()
748 | }
749 | } else {
750 | await manuallyScroll()
751 | }
752 | } catch (e) {
753 | // use regular JS scroll method as a fallback
754 | log('Falling back to JS scroll method', e)
755 | await elem.evaluate((e) => e.scrollIntoView({
756 | block: 'center',
757 | behavior: scrollSpeed < 90 ? 'smooth' : undefined
758 | }))
759 | }
760 | },
761 |
762 | async scroll (delta: Partial, options?: ScrollOptions) {
763 | const optionsResolved = {
764 | scrollDelay: 200,
765 | scrollSpeed: 100,
766 | ...defaultOptions?.scroll,
767 | ...options
768 | } satisfies ScrollOptions
769 |
770 | const scrollSpeed = clamp(optionsResolved.scrollSpeed, 1, 100)
771 |
772 | const cdpClient = getCDPClient(page)
773 |
774 | let deltaX = delta.x ?? 0
775 | let deltaY = delta.y ?? 0
776 | const xDirection = deltaX < 0 ? -1 : 1
777 | const yDirection = deltaY < 0 ? -1 : 1
778 |
779 | deltaX = Math.abs(deltaX)
780 | deltaY = Math.abs(deltaY)
781 |
782 | const largerDistanceDir = deltaX > deltaY ? 'x' : 'y'
783 | const [largerDistance, shorterDistance] = largerDistanceDir === 'x' ? [deltaX, deltaY] : [deltaY, deltaX]
784 |
785 | // When scrollSpeed under 90, pixels moved each scroll is equal to the scrollSpeed. 1 is as slow as we can get (without adding a delay), and 90 is pretty fast.
786 | // Above 90 though, scale all the way to the full distance so that scrollSpeed=100 results in only 1 scroll action.
787 | const EXP_SCALE_START = 90
788 | const largerDistanceScrollStep = scrollSpeed < EXP_SCALE_START
789 | ? scrollSpeed
790 | : scale(scrollSpeed, [EXP_SCALE_START, 100], [EXP_SCALE_START, largerDistance])
791 |
792 | const numSteps = Math.floor(largerDistance / largerDistanceScrollStep)
793 | const largerDistanceRemainder = largerDistance % largerDistanceScrollStep
794 | const shorterDistanceScrollStep = Math.floor(shorterDistance / numSteps)
795 | const shorterDistanceRemainder = shorterDistance % numSteps
796 |
797 | for (let i = 0; i < numSteps; i++) {
798 | let longerDistanceDelta = largerDistanceScrollStep
799 | let shorterDistanceDelta = shorterDistanceScrollStep
800 | if (i === numSteps - 1) {
801 | longerDistanceDelta += largerDistanceRemainder
802 | shorterDistanceDelta += shorterDistanceRemainder
803 | }
804 | let [deltaX, deltaY] = largerDistanceDir === 'x'
805 | ? [longerDistanceDelta, shorterDistanceDelta]
806 | : [shorterDistanceDelta, longerDistanceDelta]
807 | deltaX = deltaX * xDirection
808 | deltaY = deltaY * yDirection
809 |
810 | await cdpClient.send('Input.dispatchMouseEvent', {
811 | type: 'mouseWheel',
812 | deltaX,
813 | deltaY,
814 | x: previous.x,
815 | y: previous.y
816 | } satisfies Protocol.Input.DispatchMouseEventRequest)
817 | }
818 |
819 | await delay(optionsResolved.scrollDelay)
820 | },
821 |
822 | async scrollTo (destination: ScrollToDestination, options?: ScrollOptions) {
823 | const optionsResolved = {
824 | scrollDelay: 200,
825 | scrollSpeed: 100,
826 | ...defaultOptions?.scroll,
827 | ...options
828 | } satisfies ScrollOptions
829 |
830 | const {
831 | docHeight,
832 | docWidth,
833 | scrollPositionTop,
834 | scrollPositionLeft
835 | } = await page.evaluate(() => (
836 | {
837 | docHeight: document.body.scrollHeight,
838 | docWidth: document.body.scrollWidth,
839 | scrollPositionTop: window.scrollY,
840 | scrollPositionLeft: window.scrollX
841 | }
842 | ))
843 |
844 | const to = ((): Partial => {
845 | switch (destination) {
846 | case 'top':
847 | return { y: 0 }
848 | case 'bottom':
849 | return { y: docHeight }
850 | case 'left':
851 | return { x: 0 }
852 | case 'right':
853 | return { x: docWidth }
854 | default:
855 | return destination
856 | }
857 | })()
858 |
859 | await this.scroll({
860 | y: to.y !== undefined ? to.y - scrollPositionTop : 0,
861 | x: to.x !== undefined ? to.x - scrollPositionLeft : 0
862 | }, optionsResolved)
863 | },
864 |
865 | async getElement (selector: string | ElementHandle, options?: GetElementOptions): Promise> {
866 | const optionsResolved = {
867 | ...defaultOptions?.getElement,
868 | ...options
869 | } satisfies GetElementOptions
870 |
871 | let elem: ElementHandle | null = null
872 | if (typeof selector === 'string') {
873 | if (selector.startsWith('//') || selector.startsWith('(//')) {
874 | selector = `xpath/.${selector}`
875 | if (optionsResolved.waitForSelector !== undefined) {
876 | await page.waitForSelector(selector, { timeout: optionsResolved.waitForSelector })
877 | }
878 | const [handle] = await page.$$(selector)
879 | elem = handle.asElement() as ElementHandle | null
880 | } else {
881 | if (optionsResolved.waitForSelector !== undefined) {
882 | await page.waitForSelector(selector, { timeout: optionsResolved.waitForSelector })
883 | }
884 | elem = await page.$(selector)
885 | }
886 | if (elem === null) {
887 | throw new Error(
888 | `Could not find element with selector "${selector}", make sure you're waiting for the elements by specifying "waitForSelector"`
889 | )
890 | }
891 | } else {
892 | // ElementHandle
893 | elem = selector
894 | }
895 | return elem
896 | }
897 | }
898 |
899 | // Start random mouse movements. Do not await the promise but return immediately
900 | if (performRandomMoves) {
901 | randomMove().then(
902 | (_) => { },
903 | (_) => { }
904 | )
905 | }
906 |
907 | return actions
908 | }
909 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig",
3 | "exclude": [
4 | "node_modules",
5 | "**/__test__/**/*",
6 | "**/__debug__/**/*"
7 | ]
8 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | "skipLibCheck": true,
8 | "lib": ["es2015", "dom"],
9 | "outDir": "./lib" /* Redirect output structure to the directory. */,
10 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
11 | "declaration": true,
12 | // "composite": true, /* Enable project compilation */
13 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
14 | "removeComments": false /* Keep comments-- want JSDocs in types */,
15 | // "noEmit": true, /* Do not emit outputs. */
16 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
17 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,
18 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
19 | /* Strict Type-Checking Options */
20 | "strict": true /* Enable all strict type-checking options. */,
21 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
22 | // "strictNullChecks": true, /* Enable strict null checks. */
23 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
24 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
25 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
26 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
27 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
28 | /* Additional Checks */
29 | // "noUnusedLocals": true, /* Report errors on unused locals. */
30 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
31 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
32 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
33 | /* Module Resolution Options */
34 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
35 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
36 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
37 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
38 | // "typeRoots": [], /* List of folders to include type definitions from. */
39 | // "types": [], /* Type declaration files to be included in compilation. */
40 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
41 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
42 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
43 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
44 | /* Source Map Options */
45 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
46 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
47 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
48 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
49 | /* Experimental Options */
50 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
51 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
52 | /* Advanced Options */
53 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
54 | },
55 | "include": ["src/**/*"],
56 | "exclude": ["node_modules"],
57 | "parserOptions": {
58 | "project": ["./tsconfig.json"]
59 | },
60 | }
--------------------------------------------------------------------------------