Skip to main content

surfsky

Python SDK for Surfsky, a cloud-based antidetect browser.

Python 3.12+ License: MIT

  • Talks plain CDP, with none of the traces Playwright, Puppeteer or Selenium leave behind: no Runtime.enable, no injected scripts, no extra globals.
  • Clicks, scrolls and keystrokes go through Surfsky's server-side human emulation, with real mouse paths and per-key timing. The page gets trusted events.
  • Residential and mobile proxies, 100M+ IPs, picked by country, region or city. Or bring your own.
  • Fingerprints from a pool of 2.5M+ real devices. Persistent profiles keep the same identity between runs.
  • A browser pool that keeps every slot in your plan busy.
  • Nothing to install. Browsers run in Surfsky's cloud. Playwright-style API, sync and async clients, fully typed.

API reference for the service itself: 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 and use it:

import asyncio

from surfsky import AsyncSurfsky, SharedProxy


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=SharedProxy(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.pages has every open tab, popups included, and new_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
        if not browser.data.get("logged_in"):
            await log_in(browser)
            browser.data["logged_in"] = True
        ...
        if looks_blocked:
            browser.retire()                 # next lease gets a fresh identity

A browser keeps its fingerprint, proxy and cookies between leases until you retire it.

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 drive it. The SDK then handles profiles, proxies and 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 leave automation traces the SDK goes out of its way to avoid, Runtime.enable above all. Use one of the patched forks that strip those traces. They're 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=. Bring your own, or use Surfsky's built-in pools:

  • Premium: clean residential and mobile IPs, targeted by country, region or city. 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 the available countries, regions and cities, and your quota.

Profiles and the REST API

Profiles, proxies, fingerprints, extensions and account limits are all typed calls on the client. Profiles are the ones you'll use most: a profile is a saved identity, and 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.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for surfsky 0.0.1
File Size Uploaded
surfsky-0.0.1.tar.gz 78.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for surfsky 0.0.1
File Interpreter ABI Platform
surfsky-0.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 122.4 kB

Release files / surfsky-0.0.1.tar.gz

Download URL surfsky-0.0.1.tar.gz
Size 78.7 kB
Tags Source
SHA-256 checksum
How to use checksums
419c300a69696c2467bed3f4bf1c5d191e7ba61fd6b5e48f019851958664bcb4
BLAKE2b-256 checksum
How to use checksums
b6afe015ef7709351799ac1d9a03ed9355e2b34319c56933f16f3ebc6829650b
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.1-py3-none-any.whl

Download URL surfsky-0.0.1-py3-none-any.whl
Size 43.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a626a0f91e13f506f11d8618460ae08d5e5cad242e2a95e23509adb874f6c6aa
BLAKE2b-256 checksum
How to use checksums
29c58aa36368a758c039897fdf29128a158d82b6e51a40af8b357a5a8b8f15b5
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 history Release notifications | RSS feed

0.0.10

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

This release

0.0.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page