Playwright + Scrapeless Scraping Browser: Capture Live WebSocket Data Over CDP
Expert Network Defense Engineer
A live price ticker, a sportsbook's odds board, and a real-time dashboard all share one trait: the number on screen never came from a page reload. The browser opened a single WebSocket connection once, and the server has been pushing every update down that same socket as a frame ever since — no repeated requests, no fresh HTML to parse. A tool that only reads the DOM or fetches HTML never sees that traffic, because the data lives in frames, not markup.
Connect Playwright to the Scrapeless Scraping Browser over wss://browser.scrapeless.com/api/v2/browser, and the CDP session underneath gives you two separate ways to read those frames as they arrive: Playwright's own WebSocket event API, and the raw Network.webSocketFrameReceived event from the Chrome DevTools Protocol itself. This guide connects to that cloud browser, opens a WebSocket to a public, no-auth market-data stream, and captures the frames both ways, with every code path run against the live endpoint.
Why WebSocket Traffic Needs a Different Capture Path
page.query_selector_all() and similar calls read whatever the DOM contains at the moment you call them. A socket-fed widget mutates its own display node by node as frames arrive, so a single DOM read only catches whichever value happened to land last. Reading the stream itself — the frames, not the rendered pixels — is the only way to see every update a ticker, an odds feed, or a live dashboard actually received.
The Chrome DevTools Protocol exposes that stream directly. Its Network domain fires an event for every frame a page's WebSocket sends or receives, independent of what the page does with that frame afterward. The Scrapeless Scraping Browser is a cloud Chromium session reachable only over CDP — Puppeteer, Playwright, and other CDP clients connect to it, but there is no Selenium/WebDriver endpoint to drive it with instead. Playwright happens to expose two layers of that same CDP event: a page-level websocket event with its own frame callbacks, and, one level down, direct access to the CDP session so you can subscribe to Network.webSocketFrameReceived yourself.
Prerequisites
You need Python 3.9 or newer, the playwright package, and a Scrapeless API key from the free plan at app.scrapeless.com. No local Chrome binary is required — connect_over_cdp reaches a browser that already exists in Scrapeless's cloud, so playwright install chromium is optional here.
The example below opens a WebSocket directly against Binance's public spot market-data stream, documented at Binance's WebSocket market streams reference. It needs no API key and no account — it is public, unauthenticated market data. Both examples subscribe to the 24-hour ticker stream, which pushes one update per second, and the in-page script closes the socket itself as soon as the fifth frame arrives, so neither run holds a connection open longer than it takes to prove the capture works.
Install
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="your_scrapeless_api_key"
Connect Over CDP
Reuse the same URL-builder pattern any Playwright-to-Scraping-Browser script uses: three query parameters on one WSS endpoint.
python
import os
from urllib.parse import urlencode
API_KEY = os.environ["SCRAPELESS_API_KEY"]
def scraping_browser_url(proxy_country="DE", session_ttl=60):
params = urlencode({"token": API_KEY, "sessionTTL": session_ttl, "proxyCountry": proxy_country})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
proxyCountry matters here for a reason specific to this target: a connection routed through a United States exit IP fails the handshake against Binance's spot stream — the socket opens and then closes immediately with WebSocket close code 1006. Routing through Germany, or most other non-US regions, completes the handshake normally and frames start arriving. The examples below pin proxyCountry to "DE" for that reason; swap it for whichever region your own target actually serves.
Capture Frames With Playwright's WebSocket Events
Playwright fires a websocket event on the Page object whenever the page opens a socket, whether that socket comes from your own page.evaluate() call or from the target site's own JavaScript. The WebSocket object it hands you fires framereceived for every incoming frame and framesent for every outgoing one.
python
import json
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
FRAME_LIMIT = 5
def scraping_browser_url(proxy_country="DE", session_ttl=60):
params = urlencode({"token": API_KEY, "sessionTTL": session_ttl, "proxyCountry": proxy_country})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
frames = []
def handle_websocket(ws):
print("websocket opened:", ws.url)
ws.on("framereceived", lambda payload: frames.append(payload))
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(scraping_browser_url())
page = browser.new_page()
page.on("websocket", handle_websocket)
page.goto("about:blank")
page.evaluate(
f"""() => {{
window.__frameCount = 0;
const ws = new WebSocket("wss://stream.binance.com:9443/ws/btcusdt@ticker");
ws.onmessage = () => {{
window.__frameCount += 1;
if (window.__frameCount >= {FRAME_LIMIT}) {{ ws.close(); }}
}};
window.__ws = ws;
}}"""
)
page.wait_for_function("window.__ws && window.__ws.readyState === 3", timeout=20000)
page.wait_for_timeout(300)
browser.close()
print(f"captured {len(frames)} frames via page.on('websocket')")
print(json.dumps(json.loads(frames[0]), indent=2))
Five real ticker updates come back, each one a complete JSON message:
text
websocket opened: wss://stream.binance.com:9443/ws/btcusdt@ticker
captured 5 frames via page.on('websocket')
{
"e": "24hrTicker",
"E": 1785154788015,
"s": "BTCUSDT",
"p": "516.30000000",
"P": "0.800",
"w": "65153.97639938",
"x": "64558.24000000",
"c": "65074.53000000",
"Q": "0.00022000",
"b": "65074.53000000",
"B": "0.00376000",
"a": "65074.54000000",
"A": "6.01063000",
"o": "64558.23000000",
"h": "65744.60000000",
"l": "64414.00000000",
"v": "12430.45583000",
"q": "809893625.78133730",
"O": 1785068388012,
"C": 1785154788012,
"F": 6534229591,
"L": 6536075962,
"n": 1846372
}
The in-page onmessage handler counts frames itself and calls ws.close() the instant the fifth one arrives, so the cap is enforced at the source rather than by polling from Python — a stream that pushes updates faster than once a second would otherwise let several extra frames land before a Python-side check catches up. page.wait_for_function then blocks until readyState reports closed. about:blank plus page.evaluate is enough here because the target is the socket, not a page's markup; page.on("websocket") fires the same way when a real page opens the connection through its own script instead.
Capture Raw Frames From the CDP Network Domain
Playwright's framereceived event already decodes the frame for you. Going one layer lower, through the CDP session directly, exposes the same event the DevTools Network panel uses — including the opcode that says whether a frame is text or binary.
python
import base64
import json
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
FRAME_LIMIT = 5
def scraping_browser_url(proxy_country="DE", session_ttl=60):
params = urlencode({"token": API_KEY, "sessionTTL": session_ttl, "proxyCountry": proxy_country})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
cdp_frames = []
def on_frame(event):
frame = event["response"]
payload = frame["payloadData"] if frame["opcode"] == 1 else base64.b64decode(frame["payloadData"])
cdp_frames.append((frame["opcode"], payload))
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(scraping_browser_url())
page = browser.new_page()
cdp = page.context.new_cdp_session(page)
cdp.send("Network.enable")
cdp.on("Network.webSocketFrameReceived", on_frame)
page.goto("about:blank")
page.evaluate(
f"""() => {{
window.__frameCount = 0;
const ws = new WebSocket("wss://stream.binance.com:9443/ws/btcusdt@ticker");
ws.onmessage = () => {{
window.__frameCount += 1;
if (window.__frameCount >= {FRAME_LIMIT}) {{ ws.close(); }}
}};
window.__ws = ws;
}}"""
)
page.wait_for_function("window.__ws && window.__ws.readyState === 3", timeout=20000)
page.wait_for_timeout(300)
browser.close()
print(f"captured {len(cdp_frames)} raw CDP frames")
opcode, payload = cdp_frames[0]
print(f"opcode={opcode}")
print(json.dumps(json.loads(payload), indent=2))
The opcode confirms these are text frames, and the payload is the identical ticker schema from the other capture path:
text
captured 5 raw CDP frames
opcode=1
{
"e": "24hrTicker",
"E": 1785154852015,
"s": "BTCUSDT",
"p": "525.37000000",
"P": "0.814",
"w": "65154.69931666",
"x": "64529.61000000",
"c": "65054.99000000",
"Q": "0.00430000",
"b": "65054.99000000",
"B": "4.56302000",
"a": "65055.00000000",
"A": "1.44666000",
"o": "64529.62000000",
"h": "65744.60000000",
"l": "64414.00000000",
"v": "12421.62669000",
"q": "809327352.01071850",
"O": 1785068452012,
"C": 1785154852012,
"F": 6534232202,
"L": 6536078668,
"n": 1846467
}
page.context.new_cdp_session(page) opens a session whose CDP session interface exposes send() for protocol commands and on() for protocol events — Network.enable turns frame reporting on, and every subsequent Network.webSocketFrameReceived event fires with the same requestId, timestamp, and response shape the DevTools Network tab reads. Opcode 1 marks a text frame under the WebSocket protocol's own frame format, so the payload is already a UTF-8 string; any other opcode needs the base64.b64decode() step, because the protocol carries binary frames as base64 to keep the whole event JSON-safe.
What You Get Back
Both paths return the same ticker payload for this stream, because both are reading the same underlying frames — one through Playwright's abstraction, one straight from the protocol.
| Field | Type | Meaning |
|---|---|---|
e |
string | Event type ("24hrTicker" for this stream) |
E |
integer | Event time, epoch milliseconds |
s |
string | Trading symbol |
c |
string | Last traded price |
o |
string | Price 24 hours ago |
h / l |
string | 24-hour high / low |
v |
string | 24-hour base-asset volume |
b / a |
string | Best current bid / ask price |
n |
integer | Number of trades in the 24-hour window |
A trade stream on the same connection (btcusdt@trade instead of btcusdt@ticker) returns one message per executed trade instead of a rolling summary — smaller payloads, with p (price), q (quantity), and t (trade ID) in place of the ticker fields above, arriving far more often than once a second on an active pair. The capture code does not change; only the stream name in the WebSocket URL does.
Get your API key on the free plan: app.scrapeless.com
Reading Frames From a Real Page Instead of a Direct Socket
Every example above opens the WebSocket itself, from an otherwise blank page, because that keeps the target small and public. A live odds board or an exchange dashboard opens its own socket the same way — from its own bundled JavaScript, as soon as the page loads — and page.on("websocket") fires identically either way. Attach the same handler before calling page.goto() on the real target, and frames arrive as the page's own script receives them; nothing about the capture logic changes because the socket happens to belong to the page instead of to your page.evaluate() call. What does change is discovery — open the target's own Network panel once, locate the socket URL and frame shape by hand, then translate that into a framereceived handler instead of guessing at a stream name in advance.
Conclusion
A WebSocket connection carries data that a DOM read or an HTTP fetch never sees, because the server keeps pushing frames down one open socket instead of answering discrete requests. Playwright's page.on("websocket") event and the raw CDP Network.webSocketFrameReceived event both read that same stream — one through a convenience wrapper, one straight from the protocol — and both worked identically against a real public market-data stream over the Scrapeless Scraping Browser's CDP connection. Pin proxyCountry to a region your target actually serves, cap how many frames you capture before closing the socket, and the rest of the script is the handful of Playwright calls you already know. Read the current session and egress limits on the Scraping Browser product page and check plan limits on the pricing page. For the connection mechanics this guide builds on, the Chrome DevTools Protocol explainer walks through what CDP exposes beyond the Network domain.
Join our community to claim a free plan and compare notes with other developers building browser automation: Discord · Telegram.
FAQ
Q: Do I need Selenium or WebDriver to capture WebSocket frames this way?
No. The Scrapeless Scraping Browser is reachable only over the Chrome DevTools Protocol, so any client that speaks CDP — Playwright here, or Puppeteer — can connect and read frame events. There is no WebDriver endpoint, so Selenium cannot drive this connection.
Q: What is the difference between page.on("websocket") and the raw CDP Network.webSocketFrameReceived event?
Playwright's websocket event and its framereceived callback already decode a frame to a string, which covers text-based JSON streams like the one in this guide. The CDP event underneath adds the opcode field, so you can tell a text frame from a binary one before deciding how to decode it.
Q: How do I read frames the page sends, not just the ones it receives?
Playwright's WebSocket object fires framesent alongside framereceived, and the CDP session fires the matching Network.webSocketFrameSent event with the same requestId, timestamp, and response shape as the received variant.
Q: What happens with binary WebSocket frames?
A binary frame arrives through CDP with opcode set to something other than 1, and payloadData holds base64-encoded bytes instead of a UTF-8 string — decode it with base64.b64decode() before parsing whatever binary format the target uses. Playwright's own framereceived event passes binary frames through as bytes rather than str.
Q: Why did the connection fail with close code 1006 when proxyCountry was "US"?
A close code of 1006 means the connection ended abnormally, without a proper close frame — in this case, the handshake to Binance's stream never completed when the exit IP was in the United States. Routing the same request through a different proxyCountry value completed the handshake and started delivering frames.
Q: Can I capture WebSocket traffic from a page instead of opening the socket myself?
Yes. Attach the page.on("websocket") handler, or enable the CDP Network domain, before calling page.goto() on the real target, and the same events fire for any socket the page's own script opens — nothing about the capture code changes.
Q: How long can the WebSocket stay open?
The sessionTTL query parameter bounds the browser session in seconds. A short value is enough for a bounded capture like the one in this guide; a longer one keeps the session — and any open sockets — alive for a longer-running stream.
Q: Is it safe to run this against any WebSocket endpoint I find?
Only against public, unauthenticated endpoints you are permitted to read, and only at a volume the endpoint's own documentation allows. The example here targets Binance's documented public market-data stream, needs no key, and closes the connection after five frames rather than holding it open indefinitely.
Q: Does the frame schema stay the same across different stream types?
No — the ticker stream in this guide returns a rolling 24-hour summary once a second, while a trade stream on the same connection returns one smaller message per executed trade, often several times a second. The capture mechanism is identical either way; only the JSON shape inside each frame changes with the stream you subscribe to.
At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.



