Skip to main content

surfsky

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

Python 3.12+ License: MIT

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

selector is CSS, or XPath when it starts with //, .. or xpath=. XPath covers the DOM reads below and screenshot(selector=); inner_text, all_inner_texts, select_option and the input methods take CSS only.

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

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.7
File Size Uploaded
surfsky-0.0.7.tar.gz 81.1 kB Details

Built distribution (wheel)

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

Total release size: 125.7 kB

Release files / surfsky-0.0.7.tar.gz

Download URL surfsky-0.0.7.tar.gz
Size 81.1 kB
Tags Source
SHA-256 checksum
How to use checksums
bd26241181353c8449bc41467b1e8e06fd60e443da51e53178973128176f4dfa
BLAKE2b-256 checksum
How to use checksums
768af8d42ca1fa7a8399a775ee205f753bbf28c4043a0379547360d11a523ae4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","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.7-py3-none-any.whl

Download URL surfsky-0.0.7-py3-none-any.whl
Size 44.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
594831b9bf75918f7e1f5ceb092f675f72744086438a148cdf89e4d35a65d4e9
BLAKE2b-256 checksum
How to use checksums
f55fbc44dd208095562b63103badbf9bd2620127459e7412ffbc45d7c3d29838
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","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

This release

0.0.7 This release

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.1

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