surfsky
Python SDK for Surfsky, a cloud-based antidetect browser.
- Raw CDP, without the automation traces stock Playwright, Puppeteer or Selenium leave in the page.
- Input goes through our human-emulation framework: real timing, typing, cursor movement.
- Residential and mobile proxies, 100M+ IPs, with geo, ASN and sticky-session targeting. Or use your own.
- Real browser fingerprints from a pool of 2.5M+ devices, not synthetic ones.
- Browsers run in the cloud. Playwright-style API, sync and async, fully typed.
REST API docs for the service: https://docs.surfsky.io/api-reference
Installation
uv add surfsky
or, with pip:
pip install surfsky
Requires Python 3.12 or newer.
Quick start
Sign up at surfsky.io. Once you're logged in, the dashboard shows your API token and base URL. Put them in the environment:
export SURFSKY_API_TOKEN=...
export SURFSKY_API_BASE_URL=...
Then start a browser:
import asyncio
from surfsky import AsyncSurfsky, PremiumProxy
async def main():
# or AsyncSurfsky(api_token="...", base_url="...") instead of env vars
async with AsyncSurfsky() as client:
# starts a session, stops it on exit so it doesn't keep billing
async with client.browser(proxy=PremiumProxy(country="us")) as browser:
await browser.goto("https://duckduckgo.com", wait_until="domcontentloaded")
await browser.type('[name="q"]', "surfsky cloud browser")
await browser.click("#searchbox_homepage button[type=submit]")
print(await browser.wait_for_url("?q="))
asyncio.run(main())
More examples in examples/.
Browser automation
The API sticks to Playwright's names and semantics where it can: click,
fill, hover, wait_for_selector, inner_text, select_option,
keyboard.press, mouse.move and so on. Input goes through Surfsky's
human emulation. Reads use plain CDP
and run no JavaScript in the page.
When you need JavaScript:
await browser.evaluate("document.title")
await browser.evaluate("(a, b) => a + b", 1, 2)
Scripts run in an isolated world the page can't see. Pass isolated=False to
use the page's own context.
Also useful:
-
client.browser(block_resources={"image", "font", "media"})skips those downloads and saves proxy traffic. -
Grab the JSON a page fetches instead of parsing its HTML:
await browser.capture_responses("/api/search") await browser.goto(url) data = (await browser.wait_for_response("/api/search")).json()
-
Work with several pages at once.
browser.pageshas every open tab, popups included, andnew_page()adds one:await browser.new_page() await browser.pages[1].goto("https://example.com") print(await browser.pages[0].title(), await browser.pages[1].title())
Every method is listed in the API reference.
Running multiple browsers
client.map runs a function over a list of items in parallel, one browser per
item, and collects the results:
async def title(browser, url):
await browser.goto(url)
return await browser.title()
for o in await client.map(title, urls):
print(o.item, o.value if o.ok else o.error)
Errors land in o.error instead of raising, so one bad page doesn't stop the
run. By default the pool uses every browser your plan allows
(concurrency="auto"). Pass concurrency=5 to cap it.
If you need more control, write your own loop on top of the pool:
async with client.browsers() as pool:
async with pool.lease() as browser: # waits for a free browser
await browser.goto("https://example.com")
print(await browser.title())
A browser keeps its fingerprint, proxy and cookies between leases.
browser.data carries your own state across them, browser.retire() swaps in
a fresh identity.
Using Playwright or Puppeteer
You don't have to use the SDK's browser API. client.session starts a browser
and gives you its WebSocket URL, so Playwright, Puppeteer or any other CDP
client can connect to it. The SDK still handles profiles, proxies and the
session lifecycle:
from playwright.sync_api import sync_playwright
from surfsky import Surfsky
with Surfsky() as client, client.session() as session, sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(session.connect_url)
page = browser.contexts[0].pages[0]
page.goto("https://example.com")
Don't use stock Playwright or Puppeteer: both put back the traces this SDK
avoids, Runtime.enable above all. The patched forks strip them and are
drop-in replacements.
Selenium works too (enable_chromedriver=True, see
examples/selenium_connect.py) but isn't
recommended: chromedriver is easy to detect.
Proxies
Every session start accepts proxy=. Use your own, or one of Surfsky's
built-in pools:
- Premium: clean residential and mobile IPs, targeted by country, region, city, ASN or coordinates, with sticky sessions. Use it for production.
- Shared: a pool for testing. Don't rely on it against sites that matter.
from surfsky import PremiumProxy, ProxyCycle, ProxyGeo, ProxyTemplate, SharedProxy
proxy = PremiumProxy(country="us", region="ny", type="mobile") # Surfsky premium
proxy = SharedProxy(country="us") # Surfsky shared, for tests
proxy = ProxyGeo(country="de") # premium if set up, else shared
proxy = "socks5://user:pass@host:1080" # your own
proxy = ProxyCycle(my_proxies) # round-robin over your own list
proxy = ProxyTemplate("http://user-sessid-{session}:pw@gate.example.com:7000")
client.proxies lists available countries, regions and cities, plus your quota.
Profiles and the REST API
Profiles, proxies, fingerprints, extensions and account limits are all typed calls on the client. You'll mostly use profiles: a profile is a saved identity, so every session started on it gets the same fingerprint, proxy and cookies.
from surfsky import AsyncSurfsky, Fingerprint, PremiumProxy
async with AsyncSurfsky() as client:
profile = await client.profiles.create(
title="account-1",
fingerprint=Fingerprint(os="win", os_arch="x86", os_version="11"),
proxy=PremiumProxy(country="us"),
)
async with client.browser(profile_uuid=profile.uuid) as browser:
await browser.goto("https://example.com/login")
... # log in once, the cookies stay with the profile
Next time, client.browser(profile_uuid=...) brings back the same identity,
still logged in. client.profiles.iter_all() lists your profiles and
delete(uuid) removes one.
The sync client, Surfsky, has the same REST calls without the browser API.
For endpoints the SDK doesn't cover, client.request(method, path, ...) sends
the request and returns the raw httpx.Response.
API reference
Every browser method is async. Browser is a Page plus the connection: page
methods on it act on the session's first tab. Waits take timeout in seconds,
default 30, and raise BrowserTimeoutError.
Client
AsyncSurfsky(api_token=None, base_url=None, timeout=30, max_retries=3, backoff_factor=0.5).
Surfsky is the sync client, REST only.
| Method | Description |
|---|---|
session(profile_uuid=None, **options) |
Start a session, stop it on exit. Yields Session with internal_uuid and connect_url. |
browser(profile_uuid=None, block_resources=None, block_urls=None, **options) |
Start a session and connect a Browser. Stops on exit. |
browsers(concurrency="auto", block_resources=None, block_urls=None, **options) |
A BrowserPool. Exiting it stops every browser. |
map(handler, items, **pool_options) |
browsers() and pool.map() in one call. |
with_options(timeout=None, max_retries=None, headers=None) |
Copy with overrides. Same connection pool. |
request(method, path, json=None, params=None, ...) |
Raw call. Returns httpx.Response, never raises on status. |
Session options: fingerprint, proxy, browser_settings
(inactive_kill_timeout, cache_enabled, cache_key), enable_chromedriver,
extensions (up to 5 uuids), proxy_blacklist, domain_routes, cookies.
fingerprint and cookies apply to one-time sessions only.
Pool
| Member | Description |
|---|---|
pool.lease() |
Async context manager. Yields a live browser and hands it back on exit. Waits while all are busy. |
pool.map(handler, items) |
handler(browser, item) per item, capacity at a time. Returns PoolOutcome list in input order: item, index, value, error, ok. Raise StopRun to end early. |
pool.capacity |
Max live browsers. "auto" is the plan's limit, SURFSKY_MAX_BROWSERS overrides it. |
browser.data |
Per-browser dict. Survives leases. |
browser.use_count |
Leases so far, current included. |
browser.retire() |
Replace this browser with a fresh identity after the lease. |
browser.internal_uuid |
Session id. |
browser.connected |
Socket is up. |
The plan limit counts browsers started elsewhere with the same token. lease()
waits for one of its own and raises RateLimitError only if it has none.
Navigation
| Method | Description |
|---|---|
goto(url, wait_until="load", timeout=30) |
Navigate. wait_until: commit, domcontentloaded, load, networkidle. Follows redirects. |
reload(wait_until="load", timeout=30) |
Reload. |
go_back(timeout=30), go_forward(timeout=30) |
Returns the new URL, None at the end of history. |
wait_for_load_state(state="load", timeout=30) |
Wait for the current document to reach state. |
wait_for_url(fragment, timeout=30) |
Wait until the URL contains fragment. Returns the URL. |
status |
HTTP status of the current document. Set even when goto raises. |
Reading
| Method | Description |
|---|---|
url(), title() |
Current URL and title. |
content() |
Full HTML. |
outer_html(selector) |
HTML of the first match, None if none. |
inner_text(selector), all_inner_texts(selector) |
Rendered text of the first match, or of every match. Runs script in the isolated world. |
get_attribute(selector, name) |
None if missing. |
count(selector) |
Number of matches. |
is_visible(selector) |
First match has a bounding box. |
wait_for_selector(selector, visible=True, timeout=30) |
Wait for the element, visible by default. |
screenshot(selector=None, full_page=False, format="png", quality=None) |
Bytes. Viewport, one element or the full page. format: png, jpeg, webp. |
Input
Server-side human emulation. The first CSS match is used. click, dblclick
and hover also take wait_for_visible, scroll_into_view, pre_delay,
post_delay, timeout.
| Method | Description |
|---|---|
click(selector, button=None, click_count=None, modifiers=None) |
button: left, right, middle. modifiers: Alt, Control, Meta, Shift. Waits up to 30s for the element. |
dblclick(selector, ...) |
Double-click. |
hover(selector) |
Move the mouse over it. |
type(selector, text) |
Click, then type after the existing text. |
fill(selector, text) |
Select the existing text, then type over it. |
select_option(selector, value=None, label=None) |
Pick an <option> by value or label. Returns the value. |
scroll(delta_x=None, delta_y=None, duration=None) |
Animated scroll. |
scroll_into_view(selector, behavior=None), scroll_to(x=None, y=None, behavior=None) |
behavior: smooth, instant. |
keyboard.type(text), keyboard.press(key, modifiers=None, delay=None) |
Keys to the focused element. press("Enter") doesn't submit forms, click the button. |
mouse.move(x, y), mouse.click(x, y), mouse.down(x, y), mouse.up(x, y), mouse.wheel(delta_x, delta_y), mouse.drag(start_x=, start_y=, end_x=, end_y=) |
Viewport coordinates. |
Script
| Method | Description |
|---|---|
evaluate(expression, *args, isolated=True, await_promise=True) |
Run JS. A function is called with args as JSON, anything else is an expression. Isolated world by default. |
wait_for_function(expression, *args, isolated=True, timeout=30) |
Poll until truthy. Returns the value. |
send(method, params=None) |
Raw page-level CDP command. |
browser.cdp |
Raw browser-level client: send, post, on. |
Cookies and storage
| Method | Description |
|---|---|
cookies() |
All cookies, httpOnly included, as Cookie models. |
set_cookies(cookies) |
Cookie models or dicts. |
clear_cookies() |
Remove every cookie. |
local_storage(), set_local_storage(values) |
Current origin, as a dict. |
session_storage(), set_session_storage(values) |
Same for sessionStorage. |
Network
| Method | Description |
|---|---|
capture_responses(*fragments) |
Record responses whose URL contains a fragment. Call before navigating. |
wait_for_response(fragment, timeout=30) |
First captured match. CapturedResponse: url, status, headers, body, text, json(). |
responses |
Everything captured, oldest first. |
stop_capturing() |
Drop captures, stop recording. |
Dialogs
page.on_dialog = handler(kind, message). kind: alert, confirm, prompt,
beforeunload. Return True to accept, False to dismiss, a string to answer
a prompt, None for the default. Default: dismiss, except beforeunload is
accepted.
Pages
| Member | Description |
|---|---|
browser.pages |
Every open page. The browser's own first, newest last. |
browser.new_page() |
Blank page in a new window. |
browser.wait_for_page(action, timeout=30) |
Await action (a click) and return the page it opened. |
page.close() |
Close the tab. On the browser itself: close the connection. |
page.closed |
True once gone. Commands then raise PageClosedError. |
page.bring_to_front() |
Make it the visible tab. Screenshots of hidden tabs hang. |
page.target_id |
CDP target id. |
REST
Same on both clients.
| Namespace | Methods |
|---|---|
client.profiles |
start_one_time(**options), start(uuid, **options), stop(session), stop_all(), list_active(), create(title=, fingerprint=, description=, proxy=, cookies=, storage_options=), get(uuid), update(uuid, **fields), delete(uuid), delete_many(uuids), list_page(page=, page_len=, ordering=), iter_all(page_len=100, ordering="created"), export_cookies(uuid, export_format="json"), import_cookies(uuid, cookies), scrape(session, url, screenshot=, wait=, wait_until=, wait_for=, human_actions=) |
client.proxies |
countries(), regions(country), cities(country, region), quota(), premium_stats(), shared_countries(), shared_quota(), shared_stats(). The first four need a premium provider on the account. |
client.fingerprints |
renderers(os, os_arch), screens(os, os_arch), device_models(os=, os_arch=, os_version=, device_type=) |
client.extensions |
upload(file, name) (path, bytes or stream, zip up to 100 MB), list_all(), get(uuid), update(uuid, name=), delete(uuid) |
client.account |
session_limits(), browser_limits(), max_browsers() |
Errors
All subclasses of SurfskyError. HTTP: APIError subclasses named after the
status (NotFoundError, RateLimitError, ...). Browser: CDPError,
BrowserTimeoutError, PageClosedError. Idempotent requests retry on 429, 5xx
and connection errors. POST and PATCH retry on 429 only, so a lost reply can't
start a second billed session.
Examples
More examples in examples/. Install the extras first:
uv sync --group examples
Development
uv sync --all-extras
uv run ruff check . && uv run ty check && uv run pytest
Live tests start real sessions and bill your account:
SURFSKY_LIVE_TESTS=1 SURFSKY_API_TOKEN=... uv run pytest tests/test_live_concurrency.py
License
MIT
Release files for surfsky 0.0.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| surfsky-0.0.5.tar.gz | 80.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| surfsky-0.0.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 124.6 kB
Release files / surfsky-0.0.5.tar.gz
| Download URL | surfsky-0.0.5.tar.gz |
|---|---|
| Size | 80.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6d02433aec69273b3fade0e83e4df238746d62913f5c3ac17866bc75010fbeef
|
|
BLAKE2b-256 checksum How to use checksums |
f73dcfc62790f8beafde9301d9c1aa1b2b76188247fd1be43fe1c8919eb6fbca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / surfsky-0.0.5-py3-none-any.whl
| Download URL | surfsky-0.0.5-py3-none-any.whl |
|---|---|
| Size | 44.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2501f5ee3e266d8cea4cee89e09f5e76e3983ea84e8778829f34eaea9127f2b8
|
|
BLAKE2b-256 checksum How to use checksums |
4dfb018236970217b10a719ed66dcf49ca1e9c94a2e368705320392b5c87a5ea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|