Cursory: generate real mouse acitivty for Playwright

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 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, 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:

  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 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 and 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 for Selenium, 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.

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:

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:

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:

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

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.

# 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:

    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, 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.

    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:

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:

    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.

    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.

    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:

# 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 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:

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.

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 or 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 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.