Your cursor movements are giving your scraper away.
When Puppeteer clicks a button, the cursor teleports there instantly. No movement, no path, no hesitation. Real users do not interact with pages that way, and anti-bot systems that watch behavioral signals know the difference. A cursor that jumps directly to coordinates and fires a click without any travel is one of the cleaner signals that automated traffic is happening.
Ghost Cursor is a Node.js library that fixes this by replacing instant cursor jumps with natural bezier curves. It generates a large number of intermediate points between the cursor's current position and the target element, moves through them with slight randomization, and produces movement that looks far more like a human hand guiding a mouse.
In this tutorial you will learn how to set up Ghost Cursor, use every method and option it provides, tune the movement for better stealth, and understand exactly where its limits are.
What is Ghost Cursor?
Ghost Cursor is an open-source Node.js library that injects human-like cursor behavior into Puppeteer. Where standard Puppeteer mouse events jump directly to coordinates, Ghost Cursor calculates a bezier curve between the starting point and the destination, places hundreds of intermediate positions along that curve, and moves through them with small natural variations.
It is important to be clear about what Ghost Cursor is and is not. It is a behavioral tool focused specifically on cursor movement. It is not a stealth library. It does not patch browser fingerprints, modify request headers, rotate IPs, or solve CAPTCHAs. It addresses one detection vector and leaves the rest for you to handle separately.
Setup and installation
Create a project folder and install the dependencies:
mkdir ghost-cursor-project
cd ghost-cursor-project
npm init --y
npm install ghost-cursor puppeteerCreating and using the Cursor
Ghost Cursor injects cursor events into a Puppeteer page instance. Here is a basic example that navigates to an e-commerce test site and clicks the next page button:
// npm install ghost-cursor puppeteer
const puppeteer = require('puppeteer');
const { createCursor } = require('ghost-cursor');
const scraper = async (url) => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// attach the cursor to the page instance
const cursor = createCursor(page);
await page.goto(url);
const selector = '.next';
await page.waitForSelector(selector);
// move to the next button along a natural curve and click
await cursor.click(selector);
const title = await page.title();
console.log(title);
await browser.close();
};
scraper('https://www.scrapingcourse.com/ecommerce/');# Output
Ecommerce Test Site to Learn Web Scraping - Page 2 - ScrapingCourse.comThe click worked and the page advanced. To watch the cursor movement visually, add installMouseHelper and run in non-headless mode:
const puppeteer = require('puppeteer');
const { createCursor, installMouseHelper } = require('ghost-cursor');
const scraper = async (url) => {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
// renders a visible cursor dot inside the browser window
await installMouseHelper(page);
const cursor = createCursor(page);
await page.goto(url);
const selector = '.next';
await page.waitForSelector(selector);
await cursor.click(selector);
const title = await page.title();
console.log('Page Title:', title);
await browser.close();
};
scraper('https://www.scrapingcourse.com/ecommerce/');The cursor visibly travels from its starting position to the button along a curved path. The default movement is fairly smooth but slightly linear. You can make it less predictable by passing a starting coordinate vector and enabling random moves as the second and third arguments to createCursor:
// more randomized movement
const cursor = createCursor(page, { x: 114.78, y: 97.52 }, true);The third argument true enables performRandomMoves, which adds spontaneous movement between actions to simulate idle browsing behavior.
Click event options
The click method accepts a set of options that let you tune the timing and behavior of each click to look more natural:
await cursor.click(selector, {
hesitate: 1000, // pause before clicking (ms)
waitForClick: 200, // hold the click down before releasing (ms)
moveDelay: 3000, // wait after the move before clicking (ms)
randomizeMoveDelay: true, // randomize moveDelay between 0 and its value
button: 'left', // 'left' or 'right' click
clickCount: 1, // number of clicks, default is 1
});Using hesitate and randomizeMoveDelay together produces the most natural click behavior. A real user does not always click the moment their cursor arrives. There is usually a small pause, a slight hover, and then the click.
Here is the full script with randomized movement and tuned click timing:
// npm install ghost-cursor puppeteer
const puppeteer = require('puppeteer');
const { createCursor, installMouseHelper } = require('ghost-cursor');
const scraper = async (url) => {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await installMouseHelper(page);
// start from a natural position with random movement enabled
const cursor = createCursor(page, { x: 114.78, y: 97.52 }, true);
await page.goto(url);
const selector = '.next';
await page.waitForSelector(selector);
await cursor.click(selector, {
hesitate: 1000,
waitForClick: 200,
moveDelay: 3000,
randomizeMoveDelay: true,
});
const title = await page.title();
console.log('Page Title:', title);
await browser.close();
};
scraper('https://www.scrapingcourse.com/ecommerce/');Other cursor methods
move(selector, coordinates, options)
Moves the cursor to an element without clicking. Accepts additional positioning and timing options:
| Option | Description |
|---|---|
paddingPercentage | Sets cursor padding position within the element (percentage) |
destination | Cursor destination relative to the element's top-left corner |
maxTries | Maximum hover attempts before giving up (default 0) |
moveSpeed | Cursor speed, randomized by default |
overshootThreshold | How far the cursor can travel past the target before correcting (pixels) |
await cursor.move(
selector,
{ x: 278, y: 300 },
{
paddingPercentage: 80,
moveSpeed: 40,
maxTries: 5,
overshootThreshold: 20,
}
);moveTo(coordinates, options)
Moves the cursor directly to a given (x, y) coordinate. Accepts moveSpeed, moveDelay, and randomizeMoveDelay:
await cursor.moveTo(
{ x: 278, y: 300 },
{
moveSpeed: 40,
moveDelay: 1000,
randomizeMoveDelay: true,
}
);scrollIntoView(selector, options)
Scrolls the page until the target element is in the viewport without moving the cursor over it directly. Useful before interacting with elements below the fold:
| Option | Description |
|---|---|
scrollSpeed | Speed of the scroll, between 0 and 100 |
scrollDelay | Pause before scrolling (ms) |
inViewportMargin | Margin around the element to determine if it is in view |
await cursor.scrollIntoView(selector, {
scrollSpeed: 50,
scrollDelay: 2000,
inViewportMargin: 20,
});
await cursor.click(selector);scrollTo(direction)
Scrolls to a named page position. Accepts top, bottom, left, or right:
await cursor.scrollTo('bottom');scroll(coordinates, options)
Scrolls until the given coordinates are in the viewport. Accepts scrollDelay and scrollSpeed:
await cursor.scroll(
{ x: 200.78, y: 300.25 },
{ scrollDelay: 2000, scrollSpeed: 10 }
);Tips for using Ghost Cursor
- Wait for elements before interacting. Use
page.waitForSelector()before any cursor action. If the element is not yet in the DOM when Ghost Cursor tries to find it, the action will fail. - Scroll before clicking elements below the fold. Use
scrollIntoViewto bring an element into the viewport before clicking it. Ghost Cursor cannot reliably click elements it cannot see. - Give infinite scroll and load-more buttons time to load. After each scroll action, add a delay to allow new elements to render before continuing.
- Close the browser only after all actions are complete. Do not close the browser mid-flow. Wait until all scraping logic has finished before calling
browser.close().
The limitations of Ghost Cursor
Ghost Cursor does what it says. Cursor movement is more human. Behavioral detection that specifically watches mouse paths has a harder time flagging sessions using it. For sites that weight this signal heavily, it makes a real difference.
The limits become visible when you push further.
It is not a stealth library. Ghost Cursor does not patch browser fingerprints, modify request headers, change TLS signatures, or rotate IP addresses. Anti-bot systems check all of these. Making cursor movement human does not touch any of them.
CAPTCHAs remain unsolvable. This is not just a question of stealth. Ghost Cursor cannot physically access the position of a shadow DOM element. CAPTCHA checkboxes, including Cloudflare Turnstile, live inside a shadow DOM. The cursor gets stuck above the checkbox because it cannot find the element's coordinates inside the shadow tree. Even if it could click the checkbox, the underlying Puppeteer session still leaks automation signals that the anti-bot would catch.
Here is what that failure looks like in code:
const puppeteer = require('puppeteer');
const { createCursor, installMouseHelper } = require('ghost-cursor');
const scraper = async (url) => {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await installMouseHelper(page);
const cursor = createCursor(page);
await page.goto(url);
// wait for page to load
await new Promise((resolve) => setTimeout(resolve, 10000));
// attempt to move to the CAPTCHA checkbox coordinates
await cursor.moveTo(
{ x: 68, y: 270 },
{ moveSpeed: 30, moveDelay: 1000 }
);
await new Promise((resolve) => setTimeout(resolve, 4000));
// attempt to click
await cursor.click();
await page.screenshot({ path: 'captcha-attempt.png' });
await browser.close();
};
scraper('https://www.scrapingcourse.com/antibot-challenge');The cursor moves to the coordinates and stops above the CAPTCHA. It cannot access the element inside the shadow DOM. The click fires at empty space. The challenge never resolves.
Pairing it with Puppeteer Extra does not fully close the gaps. Even with stealth plugins added on top, Puppeteer still leaks signals through its JS execution environment, missing browser APIs, and other fingerprint characteristics that those plugins do not fully patch.
Getting past what Ghost Cursor cannot handle
Mouse movement covers one detection vector. When you are scraping a site with real anti-bot protection, the CAPTCHA, the fingerprint check, the IP reputation filter, and the JavaScript challenge are all still running in parallel. Ghost Cursor does not touch any of them.
The alternative is to move all of that handling out of your local setup and into a managed service that maintains it as a full stack.
Spidra handles this at the API level. Every request runs in a real browser with residential proxy rotation across 50 countries, CAPTCHA solving, full browser fingerprinting, and automatic handling of Cloudflare, DataDome, and PerimeterX. You do not configure any of it. You describe what you want and get back clean structured data.
Here is the same anti-bot challenge page that stopped Ghost Cursor, using Spidra's Node.js SDK:
npm install spidra-jsimport { SpidraClient } from 'spidra-js';
const spidra = new SpidraClient({ apiKey: process.env.SPIDRA_API_KEY });
const job = await spidra.scrape.run({
urls: [{ url: 'https://www.scrapingcourse.com/antibot-challenge/' }],
prompt: 'Extract the main heading',
useProxy: true,
proxyCountry: 'us',
});
console.log(job.result.content);
// { "heading": "You bypassed the Antibot challenge! :D" }No browser to launch. No cursor to configure. No CAPTCHA solver to wire up. No proxy to authenticate.
For the original e-commerce scraping task:
const job = await spidra.scrape.run({
urls: [{ url: 'https://www.scrapingcourse.com/ecommerce/' }],
prompt: 'Extract all product names and prices',
output: 'json',
});
console.log(job.result.content);[
{ "name": "Abominable Hoodie", "price": "$69.00" },
{ "name": "Artemis Running Short", "price": "$45.00" }
]Same data. No selectors. No parsing. No browser overhead.
For pages that need scrolling or clicking before content appears, Spidra's browser actions replace what you would have built with Ghost Cursor:
const job = await spidra.scrape.run({
urls: [{
url: 'https://www.scrapingcourse.com/ecommerce/',
actions: [
{ type: 'click', value: 'Accept cookies' },
{ type: 'scroll', to: '60%' },
{ type: 'click', value: 'Load more products' },
]
}],
prompt: 'Extract all product names and prices',
output: 'json',
});Proxy usage is billed against your bandwidth quota separately so there is no credit multiplier when anti-bot bypass is needed.
Ghost Cursor vs. Spidra
| Ghost Cursor | Spidra | |
|---|---|---|
| Human-like cursor movement | Yes, bezier curves with options | Handled at infrastructure level |
| Shadow DOM access | No, cannot access shadow elements | Not applicable |
| CAPTCHA solving | No | Built in, automatic |
| Cloudflare bypass | No | Built in, automatic |
| DataDome / PerimeterX bypass | No | Built in, automatic |
| Proxy rotation | Manual setup required | Built in, 50 countries |
| Browser fingerprinting | Not patched | Full stack, maintained |
| Structured JSON output | You write the parser | AI extraction, optional schema |
| Maintenance as anti-bots evolve | Manual | Handled by Spidra |
| Language support | Node.js | Node.js, Python, Go, PHP, Ruby, and more |
| Best for | Behavioral evasion on light targets | Protected sites, production pipelines |
Conclusion
Ghost Cursor is a well-built tool for the specific problem it solves. Natural cursor movement is a real detection vector and the library handles it cleanly with a flexible set of options for tuning speed, timing, and randomization. For lighter scraping tasks on sites without aggressive protection, it adds meaningful stealth with very little setup.
The shadow DOM limitation is the clearest sign of where it stops. CAPTCHA checkboxes live inside shadow DOM trees, and Ghost Cursor cannot find them. Even before that, the underlying Puppeteer session still carries fingerprint signals that any real anti-bot system will catch before the cursor movement question even comes up.
When your target is actually trying to stop you, the stack of tools you need to assemble around Ghost Cursor grows quickly. Spidra handles that full stack automatically through a single endpoint so you can focus on the data rather than the infrastructure.
Get started free at spidra.io. No credit card required.
