> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cursory: generate real mouse acitivty for Playwright
- URL: https://roundproxies.com/blog/cursory/
- Published: 2025-09-23T22:04:36.000Z
- Updated: 2026-09-23T13:04:51.000Z
- Description: Learn how to use Cursory, a Python library for human-like mouse trajectories, to improve UX research and QA in safe sandboxes.
- Author: Marius Bernard
- Tags: #dated-68cfc7fe4fa498a2c6ee150a

`page.mouse.move(x, y)` draws a straight line at constant speed. So does Selenium's `move_to_element`. Any script that watches pointer events on the page can flag that in one sample window, because no hand on a mouse has ever produced it.

Cursory replaces that straight line with a path pulled from a database of recorded human movements, morphed onto your start and end points, with the timing of the original recording attached.

I've been using it since 1.0 came out last September, and version 2.0 (released September 8, 2026) added the parameters that make it usable in a real Playwright loop: `directness`, `seed`, and a configurable sample `frequency`.

This guide covers how to use Cursory from `pip install` to a working `HumanMouse` class for Playwright, including the one playback mistake that quietly throws away the timing data you installed the library for.

If you want the theory of behavioral detection first, read [how to bypass bot detection](https://roundproxies.com/blog/bypass-bot-detection/) and come back.

## What is Cursory?

Cursory is a Python library that generates human-realistic mouse trajectories with timings. Give it a start point and an end point and it returns a list of `(x, y)` samples plus a millisecond timestamp for each one, built from a database of movements recorded from real people. Use it whenever an automated browser needs to move a cursor without drawing a straight line.

In practice, Cursory gives you:

- A path between two points that curves, overshoots, and corrects the way a hand does
- A timestamp per sample, so speed ramps up and tapers off instead of staying constant
- A different path every call, or an identical one when you pass a `seed`
- Control over how direct the path is (`directness`) and how many samples per second it contains (`frequency`)
- Nothing else. It has no browser binding and no click or scroll helper. It produces numbers; you decide what to do with them.

That last point is the design. Cursory works with Playwright, Selenium, Puppeteer via the [TypeScript port](https://github.com/JWriter20/cursory-js), pyautogui, or a canvas in a test harness, because it never touches any of them.

Under the hood it runs six steps, per the [project README](https://github.com/Vinyzu/cursory):

1. Find the closest recorded human path for your distance and angle, with some randomization so the same request doesn't always pick the same recording.
2. Morph it onto your exact endpoints.
3. Jitter and knot it so the path can't be matched by hashing.
4. Resample at your frequency, using the recording's own interval pattern for the timings.
5. Jitter again, because resampling smooths things out.
6. Morph one last time so the endpoints land exactly.

The recordings come from real people. The README credits the [SapiMouse dataset](https://ieeexplore.ieee.org/document/9465583) by Antal, Fejer, and Buza, among others. The whole database ships inside the wheel, which is why a library with one public function is a 660 KB download.

Vinyzu, the author, also maintains [Patchright](https://roundproxies.com/blog/patchright/) and [Botright](https://roundproxies.com/blog/botright/), and Cursory is the mouse layer those projects grew out of.

## Cursory vs. other human-mouse libraries

The alternatives you'll run into are [HumanCursor](https://roundproxies.com/blog/humancursor/) for Selenium, [ghost-cursor](https://roundproxies.com/blog/ghost-cursor/) for Puppeteer, and the Bezier curve everyone writes themselves at 2 a.m.

|                       | Cursory                       | HumanCursor         | ghost-cursor                   | Hand-rolled Bezier |
| --------------------- | ----------------------------- | ------------------- | ------------------------------ | ------------------ |
| Path source           | Recorded human movements      | Generated curves    | Generated curves + Fitts's law | Generated curve    |
| Timings included      | Yes, per sample               | Duration-based      | Yes                            | Whatever you write |
| Browser binding       | None (you wire it)            | Selenium, pyautogui | Puppeteer                      | None               |
| Language              | Python 3.11+ (TS port exists) | Python              | JavaScript                     | Any                |
| Reproducible output   | seed parameter                | No                  | No                             | If you build it    |
| Clicks, drags, scroll | No                            | Yes                 | Click and move                 | No                 |

The tradeoff is real. HumanCursor gives you `cursor.click_on(element)` and you're done in one line. Cursory gives you two arrays and a job.

In exchange, the velocity profile comes from a person's wrist rather than an easing function, and you get to decide exactly how each event hits the browser.

Choose Cursory if you're already on Playwright or async Python, if you need repeatable paths for tests, or if you've had a curve-based library flagged and want a different generator.

Choose HumanCursor if you're on Selenium and want to ship today.

## How to install Cursory

You need Python 3.11 or newer for 2.0.0\. The package pulls in NumPy, which is where the morphing and resampling math lives.

```bash
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install cursory playwright
playwright install chromium
pip show cursory | grep Version                     # expect 2.0.0 or newer

```

That last line matters. If your interpreter is older than 3.11, pip will quietly resolve an earlier release, and the keyword arguments later in this guide won't exist.

Verify the import and generate one path:

```python
from cursory import generate_trajectory

points, timings = generate_trajectory(target_start=(100, 100), target_end=(640, 420))

print(len(points), "samples over", timings[-1], "ms")
print("first:", points[0], "last:", points[-1])

```

The first and last points are exactly what you passed in. That's a guarantee from the final morph step, and it's what lets you chain moves without the cursor drifting off target.

## Cursory basic concepts

Everything in the library comes through one function. Here it is with every parameter spelled out at its default:

```python
points, timings = generate_trajectory(
    target_start=(100, 100),
    target_end=(640, 420),
    frequency=60,            # samples per second
    frequency_randomizer=1,  # max jitter added to each sample time, in ms
    directness=0.65,         # 0 prefers wandering recordings, 1 prefers straight ones
    seed=None,               # any int makes the output reproducible
)

```

### points

A list of `(x, y)` tuples of floats. Coordinates are in whatever unit you passed in. Playwright's mouse works in CSS pixels relative to the viewport, so pass viewport coordinates and you'll get viewport coordinates back.

### timings

A list of integers, one per point, in milliseconds **from the start of the movement**. It begins at 0, never decreases, and the final value is the total duration.

This trips people up, including a previous version of this article. They are timestamps, not delays between points.

If you `sleep(timings[i])` in a loop, a 600 ms move at 60 Hz (about 36 samples) becomes an 11-second crawl, because you've summed a cumulative series.

Compute the gap yourself: `timings[i] - timings[i-1]`.

### frequency

Samples per second. The default of 60 matches how often a browser on a 60 Hz display delivers `mousemove` events.

Chrome coalesces pointer events to the frame rate anyway, so sending 200 samples per second mostly means the browser merges them down to the 60 it can render.

### frequency\_randomizer

The largest jitter, in milliseconds, added to each sample time. The default of 1 keeps the intervals from being perfectly regular without making them noisy.

### directness

Where in the database Cursory looks for a match. At 1 it prefers the straight, efficient recordings; at 0 it prefers the wandering ones.

It shifts the whole distribution rather than filtering it, so either kind stays possible at any setting. 0.65 is the default and is a sensible cursor for someone who knows where the button is.

### seed

Any integer. The same seed with the same endpoints and parameters returns the same path, byte for byte. Without a seed, each call draws fresh OS entropy. There's also an `rng` keyword if you'd rather pass your own NumPy `Generator`.

A zero-length move (start equals end) returns a single point at timing 0 and consumes no randomness. Out-of-range values (`frequency <= 0`, negative `frequency_randomizer`, `directness` outside 0 to 1) raise `ValueError`.

## Your first Cursory trajectory

Before wiring anything into a browser, look at what the library produces. Five paths between the same endpoints, plus their speed curves:

```python
import matplotlib.pyplot as plt
from cursory import generate_trajectory

fig, (ax_path, ax_speed) = plt.subplots(1, 2, figsize=(11, 4))

for _ in range(5):
    pts, ts = generate_trajectory(target_start=(80, 60), target_end=(900, 520))
    xs, ys = zip(*pts)
    ax_path.plot(xs, ys, linewidth=1)
    # speed between consecutive samples, in px per ms
    speeds = [((xs[i] - xs[i-1])**2 + (ys[i] - ys[i-1])**2) ** 0.5 / max(ts[i] - ts[i-1], 1)
              for i in range(1, len(pts))]
    ax_speed.plot(ts[1:], speeds, linewidth=1)

ax_path.invert_yaxis()                     # screen coordinates grow downward
ax_path.set_title("five paths, same endpoints")
ax_speed.set_title("speed (px/ms) over time (ms)")
plt.savefig("cursory-five-paths.png", dpi=150)

```

![Five Cursory paths between the same endpoints, and their bell-shaped speed curves](https://claude.ai/chat/cursory-five-paths-speed-profile.webp)

Two things to notice. Every path is different even though the endpoints are identical, which is the anti-hashing work paying off.

And every speed curve is a lopsided bell: slow start, fast middle, long taper with a small wobble near the target.

That wobble is the correction phase a real hand makes, and it's what a `steps=30` linear interpolation in Playwright can never produce.

## How to use Cursory with Playwright

Cursory needs three things from you: a starting position (Playwright won't tell you where its cursor is, so you track it), a way to send each sample to the browser, and a clock to pace them.

We'll build a `HumanMouse` class that handles all three, then fix the version most people write first.

### Step 1: track the cursor position

Playwright's mouse has a position internally but doesn't expose it. Keep your own, starting at `(0, 0)` because that's where a fresh page's pointer sits.

```python
# human_mouse.py
import asyncio
import random
from cursory import generate_trajectory

class HumanMouse:
    def __init__(self, page, start=(0, 0)):
        self.page = page
        self.pos = start        # Playwright never reports this, so we own it
        self._cdp = None        # created lazily; Chromium only

```

If anything else moves the mouse (a `locator.click()`, a `page.mouse.move()` you forgot about), `self.pos` is now wrong and the next trajectory will start from the wrong place. Route every pointer action through this class or accept the occasional teleport.

### Step 2: the naive replay (and why it drifts)

The obvious loop awaits `page.mouse.move()` for each sample, then sleeps until the next timestamp is due:

```python
    async def move_naive(self, x, y):
        points, timings = generate_trajectory(target_start=self.pos, target_end=(x, y))
        loop = asyncio.get_running_loop()
        t0 = loop.time()
        for (px, py), t in zip(points, timings):
            await self.page.mouse.move(px, py)
            # sleep until this sample is due, measured from t0 so lag can't accumulate
            await asyncio.sleep(max(0.0, t0 + t / 1000 - loop.time()))
        self.pos = (x, y)

```

This works, in the sense that the cursor ends up at `(x, y)` along a curved path. It also plays back slow.

Each `await page.mouse.move()` waits for the browser to acknowledge the event, and Chromium acknowledges on its rendering cadence.

Measured against headless Chromium 140 by the [cursory-js author](https://github.com/JWriter20/cursory-js#driving-a-real-cursor), that's a median of 16.67 ms per awaited move, which is one full 60 Hz frame.

At 60 samples per second, that's your entire budget, and the Python-to-driver round trip adds more on top.

The sleep line can't save you because it's already at zero. The trajectory stretches, the speed curve flattens, and the timing data becomes decoration.

### Step 3: fire-and-forget playback over CDP

The fix is to dispatch each `mousemove` without awaiting the acknowledgement, and await only the last sample so the cursor has settled before anything clicks.

```python
    async def replay(self, points, timings):
        if self._cdp is None:
            self._cdp = await self.page.context.new_cdp_session(self.page)
        loop = asyncio.get_running_loop()
        t0 = loop.time()
        last = len(points) - 1
        for i, ((px, py), t) in enumerate(zip(points, timings)):
            await asyncio.sleep(max(0.0, t0 + t / 1000 - loop.time()))
            if i == last:
                # awaited on purpose: settles the cursor AND updates Playwright's
                # own position bookkeeping, which raw CDP dispatches bypass
                await self.page.mouse.move(px, py)
                break
            task = asyncio.ensure_future(self._cdp.send(       # don't wait for the ack
                "Input.dispatchMouseEvent", {"type": "mouseMoved", "x": px, "y": py}))
            task.add_done_callback(_swallow)

```

And the small helper so an unawaited task that fails doesn't spam your logs:

```python
def _swallow(task):
    if not task.cancelled():
        task.exception()    # retrieve it so asyncio doesn't warn; ignore it

```

Now `move_to` is a two-liner over `replay`:

```python
    async def move_to(self, x, y):
        if (x, y) == self.pos:
            return
        points, timings = generate_trajectory(target_start=self.pos, target_end=(x, y))
        await self.replay(points, timings)
        self.pos = (x, y)

```

The browser will coalesce a few events when they arrive faster than it renders. That's fine. A physical mouse polling at 125 Hz on a 60 Hz display has the same thing happen to it.

`new_cdp_session` only exists on Chromium. For Firefox or WebKit, keep `move_naive` and accept the stretch, or lower `frequency` to 30 so each awaited move has two frames of headroom.

### Step 4: click like a person

`page.mouse.click()` presses and releases in the same instant. Real presses last a few dozen milliseconds, and there's a pause after arriving before the press starts.

I use 50 to 180 ms to settle and 60 to 140 ms held down; the exact band matters less than the fact that it varies.

```python
    async def click(self, x, y, button="left"):
        await self.move_to(x, y)
        await asyncio.sleep(random.uniform(0.05, 0.18))   # settle before pressing
        await self.page.mouse.down(button=button)
        await asyncio.sleep(random.uniform(0.06, 0.14))   # hold
        await self.page.mouse.up(button=button)

```

`page.mouse.down()` and `up()` fire at the position Playwright thinks the cursor is at. Raw CDP dispatches don't update that bookkeeping, which is why `replay()` sends the final sample through `page.mouse.move()`.

Skip that and every click lands wherever Playwright last moved the mouse itself, which is usually `(0, 0)`.

### Step 5: click an element without hitting dead center

`locator.click()` aims at the exact center of the bounding box, every time. People don't. Pick a point in the middle 40 percent of the box and let it wander.

```python
    async def click_element(self, locator):
        await locator.scroll_into_view_if_needed()
        box = await locator.bounding_box()      # viewport-relative CSS pixels
        if box is None:
            raise RuntimeError("element has no bounding box (hidden or detached)")
        x = box["x"] + box["width"]  * random.uniform(0.3, 0.7)
        y = box["y"] + box["height"] * random.uniform(0.3, 0.7)
        await self.click(x, y)

```

The `scroll_into_view_if_needed()` call is there because `bounding_box()` returns viewport coordinates, and if the element is below the fold you'd generate a beautiful trajectory to a point off-screen.

### Step 6: run it

A complete script that opens a page, moves to the first link the human way, and clicks it:

```python
# demo.py
import asyncio
from playwright.async_api import async_playwright
from human_mouse import HumanMouse

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        page = await browser.new_page(viewport={"width": 1280, "height": 800})
        await page.goto("https://example.com")
        mouse = HumanMouse(page)
        await mouse.move_to(400, 300)              # wander in first
        await mouse.click_element(page.locator("a").first)
        await page.wait_for_load_state()
        print("landed on", page.url)
        await browser.close()

asyncio.run(main())

```

Run it with `headless=False` the first time and watch the cursor. If it looks like a person who knows roughly where the link is, you've got it.

If it looks like a person who's had four coffees, lower `frequency_randomizer` or raise `directness`.

You now have a pointer that moves like a person. Pair it with [human-like typing in Playwright](https://roundproxies.com/blog/human-typing-playwright/) and the two loudest behavioral signals are handled.

## Why awaiting every mouse move wrecks the timing

This is the part no other Cursory guide covers, and it's the difference between using the library and defeating its purpose.

A Cursory trajectory at 60 Hz has a sample every 16.7 ms. Anything in your loop that takes 16.7 ms per iteration doubles the playback time.

Awaiting a mouse event is one of those things, because the browser only confirms input on its frame boundary. Dropping from `page.mouse.move()` to raw `cdp.send()` and awaiting that costs the same frame.

Not awaiting is the fix.

Don't take the 16.67 ms number on faith. It came from one machine and one Chromium build. Measure your own setup:

```python
import time
from cursory import generate_trajectory

async def measure_drift(mouse, runs=20):
    overruns = []
    for i in range(runs):
        target = (200 + (i * 37) % 900, 150 + (i * 53) % 500)   # scatter the targets
        points, timings = generate_trajectory(target_start=mouse.pos, target_end=target)
        t0 = time.perf_counter()
        await mouse.replay(points, timings)
        mouse.pos = target
        actual_ms = (time.perf_counter() - t0) * 1000
        overruns.append(actual_ms / timings[-1] - 1)     # 0.0 means perfect playback
    print(f"mean overrun {100 * sum(overruns) / len(overruns):.1f}%, "
          f"worst {100 * max(overruns):.1f}%")

```

Call it once with `replay` pointing at the fire-and-forget version, then swap in a version that awaits every send and call it again.

The cursory-js author measured the awaited version overrunning on 44 percent of moves and the unawaited version landing within 0.05 percent of the intended duration.

If your unawaited numbers are much worse than that, something else in your event loop is blocking, and it's usually a synchronous `time.sleep` or a blocking log handler.

One more thing the harness shows: the first move after `new_cdp_session` is slower than the rest. Warm the session with a throwaway `move_to` before anything that's being scored.

## Tuning directness, frequency, and seeds

The defaults are fine for a general-purpose cursor. Three situations call for changing them.

**Regression tests.** Pass a `seed` and the path is identical on every run, so a test that fails because a drag ended two pixels short fails the same way tomorrow. Derive the seed from the test name so different tests still get different paths.

```python
import zlib

def seed_for(test_name: str) -> int:
    return zlib.crc32(test_name.encode())     # stable, cheap, fits in an int

points, timings = generate_trajectory(
    target_start=(50, 50), target_end=(700, 400), seed=seed_for("drag_slider_to_end"),
)

```

Never use a fixed seed in anything that runs against a real site. Identical paths across sessions is exactly the kind of pattern the jitter step exists to prevent.

**High-refresh emulation.** If your browser fingerprint claims a 144 Hz display, a 60 Hz mouse stream is a small inconsistency. Set `frequency=144`. Sample count scales linearly, so a 600 ms move goes from about 36 samples to about 86, and the browser will drop the ones it can't render.

**Task-specific directness.** A user hunting for a small link scans; a user clicking the same Submit button for the tenth time doesn't. Pull `directness` toward 0.4 for the first and toward 0.85 for the second. Don't go to either extreme: `directness=1` doesn't make paths straight, it makes the straightest recordings more likely, and the difference is visible if you plot it.

## Common errors and how to fix them

### `ModuleNotFoundError: No module named 'cursory'`

**What it means:** the interpreter running your script isn't the one you installed into. Nine times out of ten it's a virtual environment that isn't activated, or an editor pointed at the system Python.

**How to fix it:** `python -c "import sys; print(sys.executable)"` and compare with `pip --version`. If they differ, use `python -m pip install cursory` so both resolve to the same interpreter.

### `TypeError: generate_trajectory() got an unexpected keyword argument 'directness'`

**What it means:** you're on a release older than 2.0.0\. Pip does this silently when the interpreter is below 3.11 and can't satisfy the newer package's requirement.

**How to fix it:** `python --version`, then upgrade to 3.11 or newer and `pip install --upgrade cursory`. Confirm with `pip show cursory`.

### `ValueError` on generate

**What it means:** one of the numeric parameters is out of range. `frequency` must be positive, `frequency_randomizer` can't be negative, and `directness` must be between 0 and 1 inclusive.

**How to fix it:** if you're computing `directness` from something else, clamp it: `directness=min(1.0, max(0.0, value))`.

### Every move takes ten times longer than it should

**What it means:** you're sleeping for `timings[i]` instead of `timings[i] - timings[i-1]`. The list is cumulative timestamps, and summing them grows quadratically with sample count.

**How to fix it:** pace against a fixed start time as in `replay()` above. `asyncio.sleep(max(0, t0 + t/1000 - loop.time()))` is self-correcting; per-gap sleeps accumulate lag.

### `new_cdp_session` raises on Firefox or WebKit

**What it means:** the error text will mention Chromium, because CDP is a Chromium protocol and the other two engines don't speak it.

**How to fix it:** branch on `page.context.browser.browser_type.name` and fall back to `move_naive` for non-Chromium engines. Lower `frequency` to 30 there so the awaited moves have headroom.

### The cursor teleports before a trajectory starts

**What it means:** something moved the mouse outside your `HumanMouse` and `self.pos` is stale. `locator.click()`, `locator.hover()`, and `page.mouse.move()` all do this.

**How to fix it:** grep your code for those calls and route them through the class. If you can't, set `mouse.pos` manually after each one.

### General debugging tips

- Plot before you drive. Ten paths on a matplotlib axis will show a bad parameter faster than watching a browser will.
- Run `measure_drift()` whenever you change the loop, the browser version, or the machine. Playback timing is environmental.
- Log `timings[-1]` per move. A move that's consistently under 150 ms or over 2,000 ms is a sign your coordinates are wrong, not that the library is.

## Cursory best practices

### 1\. Start every trajectory from where the cursor is

Cursory's endpoints are exact, so the only way to get a discontinuity is to lie about the start. Track position religiously.

### 2\. Wander before you aim

A user who arrives on a page doesn't click the first thing they see.

One or two idle moves to nowhere in particular, then the target, reads better than a single perfect approach. Two short moves cost you well under a second.

### 3\. Let the press duration and landing point vary

Both are in the code above. The temptation is to tune them to fixed "good" values. Fixed values are the pattern.

### 4\. Keep the rest of the fingerprint consistent

Human mouse movement doesn't help if the browser announces itself through `navigator.webdriver`, a mismatched TLS fingerprint, or a datacenter IP with a bad reputation.

Behavioral signals get scored alongside network and browser signals, and a residential or ISP proxy from a provider like Roundproxies is the piece of that stack that no library can supply.

Handle the browser side with [Patchright](https://roundproxies.com/blog/patchright/) or [Camoufox](https://roundproxies.com/blog/camoufox/).

### 5\. Respect the site

Rate-limit yourself, honor `robots.txt` where it applies, and don't point this at systems you don't have permission to test. The library's README says the same thing in bold.

## Cursory FAQ

### Does Cursory work with Selenium?

Yes, because it doesn't know Selenium exists. Generate the trajectory, then replay it with `ActionChains(driver).move_by_offset(dx, dy)` per sample, computing `dx, dy` from consecutive points and pausing for the gap in `timings`.

Selenium's round trip per action is slower than Playwright's, so expect more stretch; measure it with the same harness.

### Can Cursory scroll or drag?

No scrolling. For a drag, call `page.mouse.down()`, then `move_to()`, then `page.mouse.up()`; the trajectory between is the same as any other move.

For wheel scrolling you're on your own, and constant-delta `page.mouse.wheel()` calls in a loop are a pattern of their own.

### Can detection systems tell the paths are generated?

The README says it's theoretically possible but would need an infeasible amount of compute, and that the real risk is a bad trajectory (a straight line, a teleport, a perfectly regular interval) rather than a subtle one.

That matches my experience: the losses come from playback mistakes and stale positions, not from the generator.

### Is Cursory free for commercial use?

It's GPL-3.0-or-later. The README says commercial use is allowed as long as source, licence, and copyright are made available.

The TypeScript port's README states all Cursory versions were later dual-licensed under LGPL as well; check the LICENSE file in the repo before bundling it into anything closed-source.

### Is there a JavaScript version?

[cursory-js](https://github.com/JWriter20/cursory-js) is a TypeScript port with the same database and algorithms. Its parity tests show the same seed produces the same path in both languages to within about 1.4e-12 px, so a trajectory generated in Python can be replayed in Node and vice versa.

### How fast is generation?

Fast enough that you don't need the caching some older guides suggest. Generation is a nearest-neighbor lookup plus a few NumPy transforms; the browser round trips in playback dwarf it. Cache only if you've profiled and found otherwise.

## Wrapping up

Cursory turns two coordinates into a recorded human path with timing attached, and the whole API is one function.

The work is on the playback side: track your own cursor position, pace samples against a fixed start time, and dispatch moves over CDP without awaiting each one so the timing survives contact with the browser.

Start by plotting a few trajectories, then run `measure_drift()` on your machine before trusting any loop. From there, the `HumanMouse` class above is a drop-in for every `page.mouse` call you have.