├── .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 | ![ghost-cursor in action](https://cdn.discordapp.com/attachments/418699380833648644/664110683054538772/acc_gen.gif) 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 | ![](https://mamamoo.xetera.dev/😽🤵👲🧦👵.png) 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 | } --------------------------------------------------------------------------------