Migrate from Playwright or Puppeteer
Attach Playwright or Puppeteer code to a Scalebrowser profile over CDP, remove the stealth plugin, and move strict targets to direct CDP with human input.
Playwright and Puppeteer can both attach to a Chromium that is already running, and a Scalebrowser profile is one. The first step keeps your script and changes only where the browser comes from. Patchright takes the same call with the same arguments as Playwright.
Start a profile and attach
Start the profile through the daemon, then hand its cdp_ws to your tool. The address is new at every start, so read it from the start response each time. Direct CDP describes the call and the fields it returns.
import os
from playwright.sync_api import sync_playwright
from scalebrowser import ScalebrowserClient
sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token=os.environ["SCALEBROWSER_TOKEN"])
PROFILE_ID = "prf_7d2a…" # an existing profile; sb.create_profile(...) makes a new one
started = sb.start_profile(PROFILE_ID)
try:
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(started.cdp_ws, no_defaults=True)
context = browser.contexts[0] # the profile's own context
page = context.pages[0] if context.pages else context.new_page()
page.goto("https://example.com")
print(page.title())
browser.close() # disconnects; the browser keeps running
finally:
sb.stop_profile(PROFILE_ID)puppeteer-core is enough for Puppeteer: it attaches and never downloads a browser of its own.
Five things that change when you attach
- Use the existing context, not a new one.
browser.contexts[0]is the profile: its cookies, its storage, its logins.new_context()creates a separate incognito context that is thrown away when you disconnect, so nothing done there reaches the profile. - Tell your tool to leave the context alone. Without
no_defaults=True(Python) ornoDefaults: true(Node), available since Playwright 1.60, Playwright points downloads at a temporary folder and turns on focus and media emulation for the default context. Puppeteer withoutdefaultViewport: nullgives every page an 800 by 600 viewport that no longer matches the window. - Launch options do not apply.
proxy,args,headlessanduser_data_dirbelong to a launch, and nothing is launched. Store the proxy on the profile (Proxies & exits) and passheadlessto the start call. - Closing disconnects.
browser.close()in Playwright andbrowser.disconnect()in Puppeteer leave the browser running. Stop the profile through the daemon, or it keeps holding one of your plan's browsers. - The address is new every start. Never cache
cdp_wsacross a stop and a start.
Remove the stealth plugin
playwright-stealth in Python and playwright-extra with puppeteer-extra-plugin-stealth in Node patch browser properties with scripts. On a profile they do harm:
- Their values replace the profile's. Both ship fixed defaults: languages
en-US, platformWin32, a WebGL vendorIntel Inc.with the rendererIntel Iris OpenGL Engine, and a rewritten user agent. A profile reports the GPU and display of the machine it runs on, and the machine's own rendering can be compared against what the plugin claims. - They mostly miss the attached context. Both hook the creation of new contexts and pages, so the default context of an attached profile is not patched unless you apply the plugin to it yourself (read from their source, September 2026).
The playwright-stealth README says it plainly: "Don't expect this to bypass anything but the simplest of bot detection methods." Delete the import and the call; the profile needs neither.
What the page still sees
Attaching changes the browser, not how your script types and clicks. Three habits of these tools are visible to the page, measured on our own bench with every tool driving the same form:
| Tool | What the page receives |
|---|---|
Playwright fill | The text arrives in one piece, with no key events at all |
Playwright and Puppeteer type | Capital letters with no Shift key pressed |
| Puppeteer clicks | A pointerdown with pressure 0 while a button is held, where the specification requires 0.5 |
You can see what your own tool leaves behind on /ai-agent-check, without an account.
Move strict targets to the SDK driver
For a site that grades input, drive the page with the SDK instead. It speaks CDP directly, never calls Runtime.enable, and sends every click and keystroke through the daemon's human input layer. A selector becomes a box, and the click lands inside it:
import os
from scalebrowser import ScalebrowserClient
sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token=os.environ["SCALEBROWSER_TOKEN"])
BOX = """(() => {
const r = document.querySelector('#email').getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
})()"""
with sb.launch("prf_7d2a…") as page:
page.navigate("https://example.com/login")
box = page.evaluate(BOX, isolated=True)
page.humanize_click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.humanize_type("name@example.com")isolated: true reads the box from an isolated world, so the page's own scripts never see the query. launch starts the profile and stops it when the block ends.
If an AI agent is doing the driving rather than your code, skip the script entirely: the MCP server gives the agent the page as a text map and the same input layer behind every action.
Next
- Direct CDP: the endpoint, and why the SDKs speak CDP themselves.
- Profiles & personas: what a profile carries instead of the options you used to pass.
- Verification challenges: what gets through, measured per challenge type.