sleight
Drive any CDP browser like a human. Bezier trajectories with real hand tremor, typing rhythm modelled on keystroke-dynamics research, and exclusive leasing for browser instance pools.
Python ≥ 3.11 · one runtime dependency (websocket-client) · MIT
中文 README · 📖 Wiki — full documentation
pip install sleight # core: one dependency
pip install "sleight[xpath]" # + lxml, for parse(xpath=True)
pip install "sleight[ui]" # + fastapi/uvicorn, for `sleight ui`
pip install "sleight[redis]" # + redis, cross-process leasing
sleight browser install fingerprint-chromium # optional: an anti-detect kernel
30 seconds
from sleight import connect, Text
with connect("http://127.0.0.1:9222") as s: # opens its own tab, closes it on exit
s.open("https://example.com", wait=Text("Example Domain"))
print(s.title(), len(s.content()))
With a browser pool that has real profiles behind it:
from sleight.providers import CloakBrowserManager
mgr = CloakBrowserManager("http://127.0.0.1:19000", token="…")
with mgr.lease() as inst: # exclusive lease, released on exit
with inst.session(human=True) as s: # every action gets a human trajectory
s.open("https://example.com")
s.click("#login")
s.type("#email", "user@example.com")
s.click("#submit", human=False) # …except this one, speed matters here
See what the page actually loaded — the library gives you structured data, you decide what to print:
with s.capture_resources(types={"Script", "Stylesheet"}) as capture:
s.open(url, wait=Load())
s.pump_events(10) # the async batch that arrives after `load`
for r in capture.snapshot():
print(r.resource_type, r.status, r.url)
Target one specific profile — by id, by name, or by tag. A name that matches nothing fails immediately with the visible names listed, rather than blocking until timeout:
with mgr.lease(instance_id="5edcc28a-…") as inst: ...
with mgr.lease(name="Win-US-02") as inst: ...
with mgr.lease(where=lambda i: "us" in i.tags) as inst: ...
handles = pool.lease_many(4, names=NAMES, timeout=60) # rolls back on partial failure
Drag a slider — the buttons mask stays down for the whole haul, the trajectory overshoots and comes back, and there is a pause before the release, because releasing the instant you arrive is the most reliable machine tell there is:
s.drag("#captcha-knob", by=(212, 0), human=CAREFUL)
s.drag_and_drop("#card", "#done-column") # HTML5 native drag, or a JS one — both
Rotate the exit IP. The tunnel hands out addresses per TCP connection and Chrome
reuses keep-alive sockets, so a whole run pins to one IP. A fresh browser context is
the only thing that reliably breaks that — clearing cache, unique query strings, and
emulateNetworkConditions all do nothing (why):
with inst.context() as ctx, ctx.session() as s: # own socket pool → new exit
print(ctx.exit_ip())
s.open(url)
Stop paying for bytes you throw away, and shed the tracking cookie afterwards:
with s.block(types=["Image", "Media", "Font"]) as blocked:
s.open(url)
print(blocked.by_type) # {'Image': 34, 'Font': 6}
report = s.clear_site_data("https://example.com")
print(report.cookies) # ('datadome',) — what actually went away
Three providers' worth of instances, one logical pool:
from sleight import Pool
from sleight.providers import CloakBrowserManager, Plain
pool = Pool([
CloakBrowserManager("http://10.0.0.1:9000", token=T1, name="hk"),
CloakBrowserManager("http://10.0.0.2:9000", token=T2, name="sg"),
Plain("http://127.0.0.1:9222", name="local"),
])
with pool.lease(where=lambda i: "us" in i.tags) as inst:
...
Driving it from an LLM
Compress the page into something a model can read, and get a stable ref back for every
interactable element. Clicking a ref goes through the same human input chain — real
isTrusted events, both hit tests, everything:
snap = s.snapshot()
print(snap.text())
# RootWebArea "Checkout"
# heading "Your order"
# textbox "Card number" [e1]
# button "Pay now" [e2]
s.type(snap.ref("e1"), "4242 4242 4242 4242")
s.click(snap.ref("e2"))
Refs are keyed by (loaderId, backendNodeId): the same node keeps the same ref across
snapshots, and every ref dies the moment the page navigates (StaleRef) rather than
silently pointing at whatever now sits in that slot. Same-process iframes are merged in,
so an element inside an iframe gets a ref like any other.
Pull the article and the usual fields out of the rendered DOM — no lxml, no Node:
doc = s.extract_document()
doc.title, doc.byline, doc.text[:80], doc.json_ld, doc.low_quality
Reading a lot of elements? Take one static snapshot and query it in memory instead of paying a CDP round-trip per element (~18× faster on a 50-row table):
dom = s.parse() # one fetch, then pure Python
names = [e.text for e in dom.query_all("tr.row td.name")]
deep = s.parse(pierce_shadow=True) # open shadow DOM inlined too
Launch a local browser yourself — any Chromium build, including an anti-detect one. Same seed, same fingerprint, every run:
from sleight import launch
with launch("fingerprint-chromium", fingerprint=42) as s:
s.open("https://example.com")
And the whole thing is an MCP server, so a model can drive it directly:
SLEIGHT_CDP_URL=http://127.0.0.1:9222 sleight-mcp
It speaks JSON-RPC over stdio with no extra dependencies and exposes four tools —
browser_session / browser_observe (snapshot + find) / browser_act (by ref or by
coordinate, for canvas and icon-only UIs) / browser_extract. Raw CDP and eval stay
hidden unless you opt in.
Getting a fleet to drive
The hard part of running CloakBrowser is not the driving — it is the deployment, the extension rollout, and the "why does this profile behave differently" archaeology. So the same package ships a CLI for it. Local docker or a remote host over SSH is the same code path, only the runner differs:
sleight hosts add hk-01 --ssh deploy@10.0.0.12 --dir /srv/cloakbrowser-manager --sudo
sleight deploy --host hk-01
sleight deployments add hk-01 second --dir /srv/cbm-2 --port 9001 # same box, second manager
sleight ext push ./plugins/bypass-paywalls --host hk-01 # MV3 check + permissions
sleight ext apply --host hk-01 # every profile, then restart
sleight ext verify --host hk-01 # did the browser really load it
sleight ui # the same, in a browser
Hosts, the managers on each of them, and a deploy/backup/upgrade audit trail live in a
local SQLite database (~/.sleight/sleight.db) that the CLI and the web UI share.
sleight ui walks you through it: connect a host (with a real connection test before
anything is saved), pick a sizing template, preflight, deploy. Every option carries a
one-line explanation of what breaks if you get it wrong plus a recommended value —
defined once on the backend, rendered by both the CLI (sleight templates) and the UI.
Deploys are idempotent, --dry-run prints the exact bytes it would write, and the
things you must not do are refused rather than documented: no latest tag, no
down -v, no silently rotating an in-use AUTH_TOKEN, no second manager on the same
/data. The engine is stdlib-only (SSH is the system ssh binary); only
sleight ui needs pip install "sleight[ui]".
Why this exists
Fingerprint-level anti-detection is a solved problem — CloakBrowser patches Chromium at the source level, Camoufox patches Firefox. They fix what the browser looks like. Nothing fixes how it moves.
- Playwright and Puppeteer teleport the mouse.
mouse.move(steps=N)interpolates a straight line at constant speed — zero jitter, zero acceleration. That is itself a signature. - The browser will not fill in the trajectory for you. Even with a humanize feature
enabled browser-side, an external CDP client produces zero
mousemoveevents between press and release. Measured, not assumed. - The good trajectory work lives in JavaScript (
ghost-cursor). Python ports are thinly maintained. - Crawlee for Python's
BrowserPooldoes not support remote browsers.
sleight fills exactly that gap: Python + remote CDP + human behaviour + instance leasing.
Relationship to Playwright
Not a replacement — a complement. sleight is a driver layer, not a framework. It deliberately does not do downloads, video, tracing, or a full locator DSL. When you need those, use Playwright.
The interesting part is that you can use both: sleight's human module is
sans-io — it emits (method, params, sleep_after)
tuples and never touches a socket — so it drives a Playwright CDPSession just as
happily as sleight's own transport.
What makes the motion credible
| sleight | typical automation | |
|---|---|---|
| Path shape | cubic Bezier, control points offset to one side | straight line |
| Micro-motion | WindMouse wind term (correlated tremor) | none, or white noise |
| Point count | Fitts's law — far small targets take longer | fixed steps=N |
| Landing | truncated Gaussian inside the box | dead centre |
| Coordinates | integers | floats used as "jitter" |
| Overshoot | past the target then back, distance-scaled | exact arrival |
| Typing | per-character events, interval by digraph class | one insertText |
| Scrolling | repeated small mouseWheel deltas |
one scrollTo |
| Dragging | buttons mask held the whole way, slider-grade overshoot, pause before release | teleport, or release on arrival |
Parameters are not invented. They come from the WindMouse physical model, ghost-cursor's Fitts-law point budgeting, and published keystroke-dynamics measurements (alternating-hand digraphs average 114 ms, same-hand-different-finger 131 ms, same-finger slowest and most variable).
Scope
Does: navigation, reload and history · typed wait conditions · rendered-DOM reads ·
CSS queries · human mouse / keyboard / wheel / drag · element screenshots · forms
(select_option, upload_file) · isolated browser contexts for exit-IP rotation ·
origin-scoped site-data clearing · request blocking via the Fetch domain ·
exit_ip() · structured network-resource capture · instance discovery across providers ·
cooperative exclusive leasing with TTL renewal (in-memory, or Redis-backed across
processes) · idempotent recovery · deploying and operating CloakBrowser Manager over
local docker or SSH, extensions included.
Also does (the LLM-facing layer, see below): iframe / OOPIF / Shadow DOM piercing · accessibility snapshots with stable refs · main-content and metadata extraction · a four-tool agent gateway and an MCP server · launching a local browser binary.
Does not: scheduling and queues · fingerprint spoofing (that is the browser's job —
sleight drives one, see LocalLauncher) · strict fencing · WebDriver BiDi · Firefox.
The deploy layer lives in its own subpackage and is never imported by import sleight,
so the driver stays a one-dependency library.
Known limits
Measured, not assumed. Each of these cost someone a day to find out:
| No extensions inside a browser context | Target.createBrowserContext makes an off-the-record context, and Chrome does not enable extensions there. Same profile, same URL: chrome-extension://<id>/… opens in the default context and returns ERR_BLOCKED_BY_CLIENT in a fresh one. So rotating the exit IP and using a plugin are mutually exclusive — if your run depends on one, rotate by leasing different profiles with different upstream proxies instead. |
reload() on a redirecting URL can return early |
One redirect is two document commits, and the intermediate one may fire its own DOMContentLoaded. Measured 3/8 on http:// → https://, 0/8 without the redirect. Not specific to sleight. Wait on something page-specific (Selector, Text) when it matters. |
block() only bites while sleight is talking to the browser |
Paused requests need the event pump, which runs inside open / wait / pump_events / every call. A plain time.sleep() stalls them. |
Transport belongs to the thread that created it |
Enforced, not documented-and-hoped: cross-thread use raises. Lease one instance per thread. Pool and the lease table are shared on purpose. |
set_viewport() does not change screen.*, and clear_viewport() may not resize anything |
The override is render-layer; screen dimensions are a profile fingerprint field fixed at launch. Clearing the override only guarantees no override — measured on Chromium 146 + Xvnc, the window bounced back on half the attempts and stayed at the overridden size on the other half. Set the size you want; do not rely on restoring. |
Roadmap
Ordered by what actually blocks work, not by size.
iframe / frame support— shipped.s.frames(),s.frame(sel),s.frame_element(iframe, sel), ands.snapshot()merges child frames — both same-process and cross-origin OOPIFs (each via its own CDP session, with the parent-page offset applied) — so elements inside any iframe get refs you can click directly. The DataDome-style slider inside an iframe is reachable now.- Context vs. lightweight-instance resource numbers — memory, CPU, and time-to-ready for instance with proxy+plugin / bare instance / N contexts in one instance. Nobody should redesign their concurrency around contexts without this table, so the API stays an opt-in dimension until the numbers exist.
launch_args_effective—get_profile()returns the configured launch args; the effective command line lives on the Manager side. Diagnosing proxy problems currently means readingchrome://version.
Status
0.x — alpha, the API will move. Every release documents its breaking changes in its
git tag. Releases are published from that tag by
.github/workflows/publish.yml via PyPI Trusted
Publishing — no token is stored in this repository.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sleight-0.5.0.tar.gz.
File metadata
- Download URL: sleight-0.5.0.tar.gz
- Upload date:
- Size: 394.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d827b297c4c5457644a53c74d1b663ddf4c13f9f9a8ad41754cf74d930db3b9
|
|
| MD5 |
4c2c3e46a4413e57dc787348f1cfee68
|
|
| BLAKE2b-256 |
099df6598db3233687468adf362a5e90f9ef01179d57d11db867dd17a4ff2167
|
Provenance
The following attestation bundles were made for sleight-0.5.0.tar.gz:
Publisher:
publish.yml on yuanqimanong/sleight
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sleight-0.5.0.tar.gz -
Subject digest:
8d827b297c4c5457644a53c74d1b663ddf4c13f9f9a8ad41754cf74d930db3b9 - Sigstore transparency entry: 2426865816
- Sigstore integration time:
-
Permalink:
yuanqimanong/sleight@15fa4c9765c68f18e1920b0fe39479cdf577d1c5 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/yuanqimanong
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@15fa4c9765c68f18e1920b0fe39479cdf577d1c5 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sleight-0.5.0-py3-none-any.whl.
File metadata
- Download URL: sleight-0.5.0-py3-none-any.whl
- Upload date:
- Size: 290.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9eeb899f11d9b53c72c93c604bccc37d0bd65d31495a38088b15da912f97cc12
|
|
| MD5 |
5bb2f7dc457c9ed8e5236f2a80b7d755
|
|
| BLAKE2b-256 |
12f050177bf76f30c707699ac534a15271bdbd4b8bc3e25d3692296ba85da733
|
Provenance
The following attestation bundles were made for sleight-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on yuanqimanong/sleight
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sleight-0.5.0-py3-none-any.whl -
Subject digest:
9eeb899f11d9b53c72c93c604bccc37d0bd65d31495a38088b15da912f97cc12 - Sigstore transparency entry: 2426865978
- Sigstore integration time:
-
Permalink:
yuanqimanong/sleight@15fa4c9765c68f18e1920b0fe39479cdf577d1c5 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/yuanqimanong
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@15fa4c9765c68f18e1920b0fe39479cdf577d1c5 -
Trigger Event:
push
-
Statement type: