For AI agents: a documentation index is available at /llms.txt
Skip to main content

BAP Python SDK

Browser Automation Protocol (BAP) is the Python SDK for Browserless, distributed as bap-py. Every class, method, option, and response type is generated from the GraphQL schema, so you write page.goto() and page.click() instead of hand-writing GraphQL documents. This page covers when to reach for BAP, how it talks to Browserless, and which parts of Playwright's API do and don't carry over.

Prerequisites

When to use BAP

Use BAP when you want Browserless's automation platform (managed stealth, residential proxies, CAPTCHA solving, live debugging URLs) but prefer writing Python over GraphQL. Options and response fields are generated straight from the schema, so a mismatched keyword argument fails at call time instead of silently sending the wrong GraphQL variable.

If you want to send GraphQL documents directly, from another language or through the interactive IDE, use the GraphQL API instead.

If you already have Playwright or Selenium code you don't want to rewrite, connect it to Browsers as a Service (BaaS) over CDP. BAP is not a drop-in replacement for Playwright. See Differences from Playwright.

If you need a single one-off task such as a screenshot or a PDF, use the REST APIs.

How it works

BAP does not connect over Chrome DevTools Protocol. Each Page method builds a GraphQL mutation, sends it as one JSON frame over a WebSocket, and awaits the response. Operations are queued and run serially, matching the server-side concurrency model.

That architecture is why BAP is fast over the wire: a CDP session sends thousands of small messages for a single interaction, while BAP sends one frame per method call. It's also why the method set is a subset of Playwright's. Page exposes what the BAP schema can express, and nothing that needs a live CDP session.

Each call to browser.new_page() or the browser.page() context manager opens its own WebSocket connection. Nothing connects at Browserless.connect() time.

The package ships two API surfaces with matching class and method names: bap for asyncio applications, and bap.sync_api for synchronous code. The sync surface isn't a separate transport, it runs the same async implementation on a background event-loop thread and blocks the calling thread until each call resolves. Importing bap.sync_api is fine even inside an asyncio application. The restriction is calling its methods from the thread that already owns a running event loop, since blocking that thread would deadlock the loop it's supposed to drive.

Install and run

python -m pip install bap-py

See Getting started with the BAP Python SDK for a runnable first script, the endpoint to use per browser, and the full list of connection options.

Common tasks

These examples continue from the quickstart, where page is open inside a connected Browserless session. They use the sync API. For async, import bap instead of bap.sync_api and add await. Method names and options are identical.

Capture a screenshot or PDF

page.goto("https://example.com")

# Returns bytes. `path` also writes to disk.
page.screenshot(path="screenshot.png")

page.screenshot(
path="full.webp",
type="webp",
full_page=True,
quality=80,
)

page.pdf(path="page.pdf", format="a4", print_background=True)

Extract content

html(), eval_on_selector(), and eval_on_selector_all() have no direct Playwright equivalent since they don't run your code in the page. They run server-side, so you get structured results back without a round trip per element.

page.goto("https://example.com")

# Text content of a single selector.
heading = page.eval_on_selector("h1")

# Structured properties for every match, in one request.
items = page.eval_on_selector_all("ul li")
for item in items:
print(item["innerText"])

# Full HTML, optionally scoped to a selector.
html_response = page.html(selector="main")
print(html_response["html"])

Fill out a form

page.goto("https://example.com/login")

# A delay range makes typing look human to bot detection.
page.type("#username", "user@example.com")
page.type("#password", "secret", delay=[100, 200])
page.click("#submit")

page.wait_for_navigation(wait_until="networkIdle")

Block requests and route through a proxy

Call these before goto(), since they configure the session rather than act on the current page.

page = browser.new_page()

# Skipping images and stylesheets cuts page load time on scrape-only runs.
page.reject(type=["image", "stylesheet"], operator="or")

# BAP runs on Browserless, so the session already pins to one stable exit IP by
# default. Pass sticky=False to rotate the IP per request instead. Anti-bot
# systems (Akamai, DataDome) bind their sensor to the egress IP, so a
# mid-session rotation can re-trigger the block.
page.proxy(country="US", state="California", sticky=True)

page.goto("https://example.com")

Solve a CAPTCHA

page.goto("https://example.com/protected")

result = page.solve(type="cloudflare", timeout=30000)
print(result["solved"])

Listen to page events

Attaching the first console, request, or response listener opens a GraphQL subscription lazily; removing the last one closes it. Use on, once, and off as you would in Playwright.

page.on("console", lambda msg: print(f"[{msg['type']}] {msg['text']}"))
page.on("request", lambda req: print(f"{req['method']} {req['url']}"))

# Response bodies are not fetched by default, since streaming them is expensive.
page.on("response", lambda res: print(f"{res['status']} {res['url']}"))

# Non-fatal streaming and handshake failures surface here instead of raising.
page.on("error", lambda err: print(err))

page.goto("https://example.com")

Handle errors

BAP raises typed errors, so you can tell a dead socket apart from a slow page. ConnectionError and TimeoutError share names with Python's builtin exceptions, so import them explicitly rather than relying on except clauses written for the builtins.

from bap import BrowserQLError, ConnectionError, TimeoutError

try:
with browser.page() as page:
page.goto("https://example.com", timeout=5000)
except TimeoutError:
print("operation timed out")
except ConnectionError:
print("websocket connection failed")
except BrowserQLError as error:
print("BAP returned errors:", error)

Differences from Playwright

BAP borrows Playwright's method names, but some shared methods behave differently:

MethodPlaywrightBAP
eval_on_selector(selector, expression)Runs expression against the matched element and returns its resultReturns the text content of the matched selector. No expression argument
eval_on_selector_all(selector, expression)Runs expression against every matched elementDelegates to map_selector and returns a list of MapSelectorResponse dicts
evaluate(expression, arg)Passes a serialized arg and returns the deserialized resultAccepts only an expression string, always returns str | None, and takes no arg
wait_for_selector(selector, state=...)Accepts a state predicate (attached, visible, hidden, detached)Accepts visible/hidden booleans, no attached/detached states
Typing into a fieldfill() sets the value instantly; press_sequentially() types key by keytype() is the only entry point, with a per-character delay range for human-like typing

These Playwright APIs have no BAP equivalent, because they need a live CDP session:

  • Browser contexts: no new_context(). Browser.new_page() opens directly against the BAP endpoint
  • Input devices: no page.keyboard or page.mouse
  • Frames: no page.frames, page.main_frame, or frame targeting
  • Function exposure: no expose_function()
  • Generic interception: no page.route(). Use fulfill(), reject(), request(), and response() instead, which match filters server-side rather than running a Python callback per request
  • Tracing and accessibility: no page.context.tracing or accessibility snapshots

BAP adds methods Playwright has no equivalent for: html(), markdown(), map_selector(), reject(), proxy(), solve(), solve_image_captcha(), live_url(), reconnect(), switch_to_window(), stop_session_recording(), preferences(), and load_secret().

API reference

The reference is generated from the public exports of bap-py, covering the async and sync browser APIs, every generated option and response type, and public errors.

FAQ & Troubleshooting

Why does add_script_tag fail with a syntax error?

Inline content must be a single line. The Browserless server rejects multi-line content. Host the script and pass url instead, or collapse the source to one line first, which only works when it has no newline-sensitive syntax such as // comments or multi-line template literals.

Can I reuse my existing Playwright script?

Not without changes. BAP covers the subset of Playwright that the BAP schema exposes, so anything using page.mouse, page.keyboard, browser contexts, frames, or page.route() needs rewriting. See Differences from Playwright. If you want to run existing scripts unchanged, use BaaS over CDP instead.

Why does evaluate() return a string when my function returns an object?

evaluate() always resolves to str | None, and it takes no arg. Serialize inside the page with JSON.stringify() and parse the result yourself.

Why is eval_on_selector_all() returning dicts instead of my mapped values?

BAP's eval_on_selector_all() delegates to map_selector() rather than running an expression in the page, so it returns a list of MapSelectorResponse dicts with element properties such as innerText and innerHTML. Read the key you need off each result.

Next steps