🎯 一款可定制、具备反检测功能的云浏览器,由自主研发的 Chromium驱动,专为网页爬虫AI 代理设计。👉立即试用
返回博客

剧作家 + 无痕爬虫浏览器:通过 CDP 捕获实时 WebSocket 数据

Michael Lee
Michael Lee

Expert Network Defense Engineer

29-Jul-2026

一个实时价格行情发报器、一个体育博彩的赔率板和一个实时仪表盘都有一个共同点:屏幕上的数字从未通过页面重载产生。浏览器只打开了一次 WebSocket 连接,服务器自那时起就通过同一个套接字不断推送每次更新——没有重复请求,没有新的 HTML 需要解析。一个仅仅读取 DOM 或获取 HTML 的工具永远无法看到这些流量,因为数据存在于帧中,而不是标记中。

通过 wss://browser.scrapeless.com/api/v2/browser 将 Playwright 连接到 Scrapeless Scraping Browser,底层的 CDP 会话为您提供两种不同的方式来读取到达的帧:Playwright 自己的 WebSocket 事件 API,以及来自 Chrome DevTools 协议的原始 Network.webSocketFrameReceived 事件。本指南连接到该云浏览器,打开一个公共的、无认证的市场数据流的 WebSocket,并以两种方式捕获帧,运行每条代码路径以验证实时端点的有效性。

为什么 WebSocket 流量需要不同的捕获路径

page.query_selector_all() 及类似调用在您调用它们时读取 DOM 当前包含的内容。通过套接字提供的组件在帧到达时逐个节点地变更自身的显示,因此只进行一次 DOM 读取只能捕获最后一个落下的值。直接读取流本身——帧,而不是渲染的像素——是查看行情、赔率数据或实时仪表盘实际接收到的每个更新的唯一方法。

Chrome DevTools 协议直接暴露该流。其 网络域 为页面的 WebSocket 发送或接收的每个帧都触发事件,而与页面后续对该帧做什么无关。Scrapeless Scraping Browser 是一个仅可通过 CDP 访问的云 Chromium 会话——Puppeteer、Playwright 和其他 CDP 客户端都可以连接到它,但没有其他的 Selenium/WebDriver 端点可供使用。Playwright 恰好暴露了该 CDP 事件的两个层次:一个页面级的 websocket 事件,带有自己的帧回调,以及一个层级下,直接访问 CDP 会话,以便您可以自己订阅 Network.webSocketFrameReceived

前提条件

您需要 Python 3.9 或更新版本,playwright 包,以及来自 app.scrapeless.com 免费计划的 Scrapeless API 密钥。无需本地 Chrome 二进制文件——connect_over_cdp 可以访问已经存在于 Scrapeless 云中的浏览器,因此此处 playwright install chromium 可选。

下面的示例直接打开针对 Binance 的公共现货市场数据流的 WebSocket,相关文档位于 Binance 的 WebSocket 市场流参考。它不需要 API 密钥,也不需要帐户——该数据是公共的、未经认证的市场数据。两个示例都订阅了 24 小时行情流,每秒推送一次更新,页面内脚本在第五个帧到达时立即关闭套接字,因此两个运行的连接都不会保持打开超过证明捕获有效所需的时间。

安装

bash Copy
pip install playwright
bash Copy
export SCRAPELESS_API_KEY="your_scrapeless_api_key"

通过 CDP 连接

重用任何 Playwright-to-Scraping-Browser 脚本使用的相同 URL 构建模式:在一个 WSS 端点上的三个查询参数。

python Copy
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 在这里很重要,这个目标有其特定原因:通过美国出口 IP 路由的连接无法与 Binance 的现货流完成握手——套接字打开后立即以 WebSocket 关闭代码 1006 关闭。通过德国或大多数其他非美地区路由则正常完成握手,帧开始到达。以下示例将 proxyCountry 固定为 "DE",原因就是如此;将其替换为您自己目标实际服务的地区。

使用 Playwright 的 WebSocket 事件捕获帧

每当页面打开套接字时,Playwright 在 Page 对象上触发 websocket 事件,无论该套接字是来源于您自己的 page.evaluate() 调用还是目标网站的 JavaScript。它提供给您的 WebSocket 对象为每个传入的帧触发 framereceived,并为每个传出的帧触发 framesent

python Copy
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})

返回 f"wss://browser.scrapeless.com/api/v2/browser?{params}"

frames = []

def handle_websocket(ws):
print("websocket 已打开:", 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)

Copy
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"通过 page.on('websocket') 捕获了 {len(frames)} 帧")
print(json.dumps(json.loads(frames[0]), indent=2))

五个真实的报价更新返回,每个都是一个完整的 JSON 消息:

text Copy
websocket 已打开: wss://stream.binance.com:9443/ws/btcusdt@ticker
通过 page.on('websocket') 捕获了 5 帧
{
  "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
}

页面中的 onmessage 处理程序自行统计帧,并在第五个到达时立即调用 ws.close(),因此限制在源头执行,而不是通过 Python 进行轮询。如果流的更新速度超过每秒一次,Python 端的检查将会滞后,导致会有额外帧到达。page.wait_for_function 然后阻塞,直到 readyState 报告为关闭。about:blank 加上 page.evaluate 在这里就足够了,因为目标是套接字,而不是页面的标记;当一个真实页面通过它自己的脚本打开连接时,page.on("websocket") 也会以相同的方式触发。

从 CDP 网络域捕获原始帧

Playwright 的 framereceived 事件已经为你解码了帧。通过直接访问 CDP 会话,能够暴露 DevTools 网络面板使用的相同事件——包括 opcode,指示一个帧是文本还是二进制。

python Copy
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"捕获了 {len(cdp_frames)} 个原始 CDP 帧")
opcode, payload = cdp_frames[0]
print(f"opcode={opcode}")
print(json.dumps(json.loads(payload), indent=2))

该操作码确认这些是文本帧,负载是来自其他捕获路径的相同报价模式:

text Copy
捕获了 5 个原始 CDP 帧
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) 打开一个会话,其 CDP 会话接口 公开了 send() 用于协议命令和 on() 用于协议事件 — Network.enable 会打开帧报告,每个后续的 Network.webSocketFrameReceived 事件会以相同的 requestIdtimestampresponse 形状触发,正是开发者工具网络选项卡读取的格式。操作码 1WebSocket 协议 的帧格式中标记为文本帧,因此有效载荷已经是一个 UTF-8 字符串;任何其他操作码都需要 base64.b64decode() 步骤,因为协议以 base64 形式传输二进制帧,以保持整个事件的 JSON 安全。

返回的内容

这两个路径返回相同的此流的报价有效载荷,因为两者读取的是相同的底层帧 — 一个通过 Playwright 的抽象,另一个直接从协议。

字段 类型 说明
e 字符串 事件类型(对于此流是 "24hrTicker"
E 整数 事件时间,纪元毫秒
s 字符串 交易标志
c 字符串 最近交易价格
o 字符串 24小时前的价格
h / l 字符串 24小时高 / 低
v 字符串 24小时基础资产交易量
b / a 字符串 当前最佳买入 / 卖出价格
n 整数 24小时窗口内交易次数

在同一连接上的交易流(btcusdt@trade 而非 btcusdt@ticker)返回每个执行的交易一条消息,而非滚动摘要 — 较小的有效载荷,p(价格)、q(数量)和 t(交易 ID)取代上面的报价字段,活跃交易对的到达频率远远超过每秒一次。捕获代码不会改变;只有 WebSocket URL 中的流名称会变化。

在免费计划上获取你的 API 密钥:app.scrapeless.com

从真实页面读取帧,而不是直接从套接字

上述每个示例都从一个空白页面打开 WebSocket,因为这样可以保持目标小且公开。现场赔率板或交易所仪表板以相同的方式打开自己的套接字 — 从其自己的捆绑 JavaScript 中,在页面加载后立即 — 并且 page.on("websocket") 无论是哪种方式都会以相同方式触发。在对真实目标调用 page.goto() 之前,附加相同的处理程序,帧会在页面自己的脚本接收它们时到达;捕获逻辑没有改变,因为套接字属于页面,而不是你的 page.evaluate() 调用。改变的是发现 — 打开目标的网络面板一次,手动定位套接字 URL 和帧形状,然后将其转换为 framereceived 处理程序,而不是提前猜测流名称。

结论

WebSocket 连接承载了 DOM 读取或 HTTP 获取永远无法看到的数据,因为服务器不断通过一个开放的套接字推送帧,而不是响应离散请求。Playwright 的 page.on("websocket") 事件和原始 CDP Network.webSocketFrameReceived 事件都读取相同的流 — 一个通过便利包装器,另一个直接从协议 — 并且在 Scrapeless Scraping Browser 的 CDP 连接上对真实公共市场数据流的工作是相同的。将 proxyCountry 固定到你的目标实际服务的区域,在关闭套接字之前限制你捕获的帧数量,其余的脚本就是你已经知道的少数 Playwright 调用。查看当前会话和出口限制的 Scraping Browser 产品页面,并在 定价页面 检查计划限制。关于本指南所建立的连接机制,可以参考 Chrome DevTools Protocol 解释,了解 CDP 在网络领域之外所暴露的内容。

加入我们的社区以索取免费计划,并与其他构建浏览器自动化的开发者分享经验:Discord · Telegram

常见问题

问:我需要 Selenium 或 WebDriver 来以这种方式捕获 WebSocket 帧吗?

不需要。Scrapeless Scraping Browser 只能通过 Chrome DevTools Protocol 访问,因此任何能讲 CDP 的客户端 — 这里是 Playwright,或者 Puppeteer — 都可以连接并读取帧事件。没有 WebDriver 端点,因此 Selenium 无法驱动此连接。

问:page.on("websocket") 和原始 CDP Network.webSocketFrameReceived 事件有什么区别?
剧作家的 websocket 事件及其 framereceived 回调已经将帧解码为字符串,这覆盖了本指南中的基于文本的 JSON 流。底层的 CDP 事件添加了 opcode 字段,因此您可以在决定如何解码之前区分文本帧和二进制帧。

问:如何读取页面发送的帧,而不仅仅是接收的帧?

Playwright 的 WebSocket 对象在 framereceived 旁边触发 framesent,CDP 会话以相同的 requestIdtimestampresponse 形状触发匹配的 Network.webSocketFrameSent 事件。

问:二进制 WebSocket 帧有什么情况?

一个二进制帧通过 CDP 到达,opcode 被设置为除 1 以外的值,payloadData 包含的是 base64 编码的字节,而不是 UTF-8 字符串 — 在解析目标使用的任何二进制格式之前,请使用 base64.b64decode() 解码。Playwright 自己的 framereceived 事件将二进制帧以 bytes 的形式传递,而不是 str

问:当 proxyCountry 为 "US" 时,为什么连接以关闭代码 1006 失败?

关闭代码 1006 表示连接异常结束,没有适当的关闭帧 — 在这种情况下,当退出 IP 在美国时,与 Binance 流的握手从未完成。使用不同的 proxyCountry 值路由相同的请求完成了握手并开始发送帧。

问:我可以从页面捕获 WebSocket 流量,而不是自己打开套接字吗?

可以。在对真实目标调用 page.goto() 之前,附加 page.on("websocket") 处理程序,或启用 CDP 的 Network 域,页面自己的脚本打开的任何套接字都会触发相同的事件 — 捕获代码没有变化。

问:WebSocket 可以保持打开多长时间?

sessionTTL 查询参数以秒限制浏览器会话。较短的值足以进行如本指南中所述的有限捕获;较长的值可以保持会话 — 以及所有打开的套接字 — 在较长时间运行的流中保持活跃。

问:在我找到的任何 WebSocket 端点上运行这个安全吗?

只能在您被允许读取的公共、未身份验证的端点上运行,并且只能在端点自身文档允许的数量内。此处的示例以 Binance 的公开市场数据流为目标,不需要密钥,并在五个帧后关闭连接,而不是无限期保持连接。

问:不同流类型之间帧的架构是否相同?

不 — 本指南中的行情流每秒返回一次 24 小时滚动摘要,而同一连接上的交易流则为每个执行的交易返回一个较小的消息,通常每秒几次。无论如何,捕获机制都是相同的;只有您订阅的流中每个帧内的 JSON 形状会发生变化。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录