A toolkit for human-like behaviour in Playwright — making automation look like a real person rather than a script. Works with both the async and sync Playwright APIs.
The first behaviour is scrolling: instead of a robotic
element.scroll_into_view_if_needed() jump, it spins the real mouse wheel
with an accelerate → cruise → decelerate motion profile, breaks long scrolls into
human-sized segments, pauses to "read", and occasionally scrolls back up to
re-read — the way a person actually browses. More behaviours (mouse movement,
cursor visualisation, …) will land alongside it under the same API.
Extracted and cleaned up from a production AdSense automation project.
uv add humanwright # or: pip install humanwright
playwright install chromium # once, if you haven't alreadyfrom playwright.async_api import async_playwright
from humanwright.async_api import scroll_into_view, scroll_to_bottom
async with async_playwright() as p:
page = await (await p.chromium.launch()).new_page()
await page.goto("https://example.com")
await scroll_to_bottom(page, speed="medium") # wheel down the page
await scroll_into_view(page, page.locator("#cta")) # bring an element upfrom playwright.sync_api import sync_playwright
from humanwright.sync_api import scroll_into_view, scroll_to_bottom
with sync_playwright() as p:
page = p.chromium.launch().new_page()
page.goto("https://example.com")
scroll_to_bottom(page, speed="medium")
scroll_into_view(page, page.locator("#cta"))Both humanwright.async_api and humanwright.sync_api expose the same names
(the async versions are coroutines):
| Function | Purpose |
|---|---|
scroll_by(page, distance, *, speed, config) |
One continuous eased scroll of distance px (negative = up). |
scroll_by_steps(page, distance, *, speed, config, back_read, avg_segment_height) |
Segmented scroll with reading pauses + optional re-read. Returns True if the bottom was hit. |
scroll_into_view(page, element, *, speed, config) |
Wheel an element (Locator or ElementHandle) into the viewport. |
scroll_to_bottom(page, *, speed, config, max_rounds) |
Step down until the page bottom is reached. |
scroll_height(page) |
scrollHeight - innerHeight. |
is_at_bottom(page, config) |
Within bottom_threshold px of the bottom? |
viewport_size(page) |
{"width", "height"} of the viewport. |
speed is one of "faster", "fast", "medium", "slow", or "random"
(default — picks one of fast/medium/slow per call).
Every behaviour is configurable. The defaults reproduce the original project's (deliberately slow, "really reading") behaviour — you'll usually want shorter pauses:
from humanwright import ScrollConfig
cfg = ScrollConfig(
pause=(0.4, 1.2), # reading pause between segments (default 5-20s!)
back_read_probability=0.05, # how often to scroll back up and re-read
segment_height_range=(300, 600),
)
await scroll_into_view(page, locator, config=cfg)For reproducible motion (e.g. in tests) inject a seeded RNG:
import random
cfg = ScrollConfig(rng=random.Random(42))generate_swipe_path()produces a stream of(wheel_delta, delay)ticks whose cumulative motion lands on the target, accelerating then easing off.segment_distances()chops a long scroll into slightly-varied chunks that sum exactly to the target.- The async/sync executors replay those ticks through
page.mouse.wheel, adding reading pauses and the occasional back-scroll.
The algorithm is pure Python in humanwright._core (no browser), so it's unit
tested without launching Chromium.
This extraction fixes two bugs in the source helper:
- the segment-execution loop was nested inside the sum-reconciliation loop, causing duplicated scrolling;
- a fractional
distancecould make the reconciliationwhileloop spin forever (integer segments can never sum to a float). Targets are now truncated to int.
This project is managed with uv.
uv sync --extra dev # create .venv and install everything
uv run pytest # core algorithm tests (no browser needed)
uv run ruff check .
uv run mypy src
uv build # build wheel + sdistMIT