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

zendriver + Scrapeless:通过 CDP 驱动远程浏览器

Sophia Martinez
Sophia Martinez

Specialist in Anti-Bot Strategies

07-Aug-2026

TL;DR:

  • zendriver 是 nodriver 的一个社区分支,通过原始 CDP 驱动 Chrome,像 nodriver 一样,它用于启动浏览器本身。
  • Browser.start() 有一个 connect_existing 路径,但发现是通过 HTTPApi 进行的,它硬编码了一个包含主机和端口的普通 HTTP——安全的 WebSocket 端点携带令牌无法以这种方式表达。
  • 真正的障碍在于标签地址。zendriver 每个标签都是由主机、端口和目标 ID 构建的,而远程端点没有这样的路由。
  • 一个 CDP URL 代表一个浏览器:对同一端点的第二个连接会获得一个不同的远程浏览器,并且看不到第一个浏览器的任何目标。
  • 关键在于平面会话复用 —— Target.attachToTarget(flatten=True) 返回一个 sessionId,将其戳印在外发帧上,使 zendriver 的 Tab 辅助函数能够访问远程页面。
  • 通过附加会话,实际提取返回了一个完全渲染的页面和 20 个带有正确标题和价格的产品卡片。
  • Scrapeless 免费计划 开始,驱动一个不需要自己启动的浏览器。

Scrapeless Scraping Browser 是一个云浏览器,您可以连接而不是启动。它监听一个安全的 WebSocket CDP 端点,这与 zendriver 所使用的协议相同——因此在理论上这两个应该在一条线上连接。

它们没有。 Playwright 有 connect_over_cdp。 Puppeteer 有 connect。 zendriver 没有相应的实现,对此的请求自 2025 年 4 月起在跟踪器中挂起,作为 一个请求具体此事的开放问题,尚未得到回应。官方的原始 CDP 教程涵盖了 page.send() 和事件处理程序,完全没有提到远程浏览器。

本指南解释了实际阻碍的原因——这不是连接——然后仅使用库本身的原语附加一个远程浏览器。

如何启动浏览器

zendriver 是 nodriver 的一个分支,旨在合并未合并的错误修复,并重新开放项目以进行贡献。从架构上讲,这两者是相同的理念:直接通过 Chrome DevTools 协议 驱动 Chrome,而不经过 WebDriver 层,从而去除了基于驱动程序的堆栈暴露的自动化面。这个分支添加了标准 asyncio.run() 入口、Docker 支持和 cookie 持久性。

正常的入口点启动一个浏览器:

python Copy
import asyncio

import zendriver as zd


async def main():
    browser = await zd.start(headless=True, no_sandbox=True)
    page = await browser.get("https://books.toscrape.com/")
    await page.wait_for("h3 a", timeout=20)
    await page.sleep(2)
    print("cards:", len(await page.select_all("article.product_pod")))
    await browser.stop()


asyncio.run(main())
text Copy
cards: 20

当进程作为 root 运行时,需要 no_sandbox=True,在容器内部是正常情况;在桌面上使用它。 sleep 调用有其存在的理由,理由在后文中有涵盖,这个理由同样适用于远程路径。

这会在运行脚本的任何机器上生成一个 Chrome 子进程。结果集中每个 zendriver 指南都是这样工作的:驱动一个脚本启动的浏览器,并通过 browser_args 进行调整——一个 Chromium --proxy-server 开关是常见的例子。它们没有附加到一个它没有生成的浏览器上。

先决条件

  • Python 3.10 或更高版本。
  • 从仪表板获取的 Scrapeless API 密钥,以 SCRAPELESS_KEY 的形式导出。
  • 本地安装的 Chrome 或 Chromium,仅在您想要运行上面的本地启动块时需要。远程路径不需要浏览器二进制文件。

安装

bash Copy
pip install "zendriver==0.15.5"
bash Copy
export SCRAPELESS_KEY="your_api_key_here"

connect_existing 实际功能

阅读 Browser.start() 给人一种远程连接已经得到支持的印象。当 config.hostconfig.port 都被设置时,它会翻转一个标志,完全跳过启动进程。已安装的包将向您展示:

python Copy
import inspect
import textwrap

from zendriver.core.browser import Browser

source = inspect.getsource(Browser.start).splitlines()
start = next(i for i, line in enumerate(source) if "connect_existing = False" in line)
print(textwrap.dedent("\n".join(source[start:start + 6])))
text Copy
connect_existing = False
if self.config.host is not None and self.config.port is not None:
    connect_existing = True
else:
    self.config.host = "127.0.0.1"
    self.config.port = util.free_port()

这是真的——它确实连接到一个它没有启动的浏览器。限制在于接下来发生的事情。发现通过 HTTPApi 进行,它采用主机和端口对并将其插入固定地址模板中,然后使用 /json/versionurllib 获取浏览器的 WebSocket URL。您可以在不离开 Python 的情况下查看该地址的结构:

python Copy
from zendriver.core.browser import HTTPApi

api = HTTPApi(("example-host", 9222))
print("scheme:", api.api.split("://")[0])
print("room for a query string:", "?" in api.api)
print("room for a path:", api.api.count("/") > 2)
text Copy
scheme: http
room for a query string: False
room for a path: False

在云端点会同时出现三件事情的问题。方案固定为普通 http。地址是一个主机和端口,所以没有地方可以放置路径、查询字符串或身份验证令牌。而且,托管端点通常不对公共互联网公开 CDP HTTP 发现路由——在 Scrapeless 主机上请求 /json/version/json/list 都返回 HTTP 403
所以 connect_existing 是您自己启动的 Chrome 使用 --remote-debugging-port,在您控制的主机和端口上。这并不是远程附加功能。

真正的障碍是标签 URL

假设发现问题得到解决。连接仍然不可用,而原因就在大多数人查看的层面之下。

zendriver 通过将来自相同主机和端口的字符串构建连接,与目标 ID 插值在 /devtools/page/<target_id> 路径中。这在 browser.py 的两个不同位置完成,您可以从安装的包中确认:

python Copy
import inspect

from zendriver.core import browser

source = inspect.getsource(browser)
print("tab addresses built from host and port:", source.count("{self.config.host}:{self.config.port}"))
print("devtools path template present:", "/devtools/" in source)
text Copy
tab addresses built from host and port: 2
devtools path template present: True

本地 Chrome 提供该路由。云浏览器则不然——它暴露一个端点,并且每个目标的 DevTools 路径不是其公共表面的一部分。针对实时会话尝试了四种 plausible 形状:带令牌、没有令牌、在 /api/v2/browser 前缀下和作为 /page/ 路由。所有四种方式都被 HTTP 404 拒绝。

这就是为什么无法通过找到正确的 URL 来解决问题的原因。没有每个标签的 URL 可以找到。

一个 CDP URL,一个浏览器

下一个直觉是为标签打开第二个连接。这也不可行,并且以一种安静到足以浪费一个下午的方式失败。

python Copy
import asyncio
import os
from urllib.parse import urlencode

import zendriver as zd
from zendriver import cdp


def cdp_url():
    return "wss://browser.scrapeless.com/api/v2/browser?" + urlencode(
        {"token": os.environ["SCRAPELESS_KEY"], "sessionTTL": 300, "proxyCountry": "US"}
    )


async def main():
    a = zd.Connection(cdp_url())
    b = zd.Connection(cdp_url())

    tid = await a.send(cdp.target.create_target("https://books.toscrape.com/"))
    a_ids = {str(t.target_id) for t in await a.send(cdp.target.get_targets())}
    b_ids = {str(t.target_id) for t in await b.send(cdp.target.get_targets())}

    print("A sees its own target:", str(tid) in a_ids)
    print("B sees it:", str(tid) in b_ids)
    print("shared target ids:", len(a_ids & b_ids))

    await a.aclose()
    await b.aclose()


asyncio.run(main())
text Copy
A sees its own target: True
B sees it: False
shared target ids: 0

每个与端点的连接都是它自己的浏览器。这两会话没有共享任何内容——既不是您刚刚创建的目标,也不是一个目标 ID。基于“每个标签一个插座”的任何构建都在悄悄地驱动着一个与其认为不同的浏览器。

不过,注意到什么是有效的。zd.Connection(cdp_url()) 连接并响应 CDP 命令。传输从来不是问题。

准备好驱动一个您不必启动的浏览器吗?创建一个免费的 Scrapeless 账户并将 zendriver 指向它。

附加一个平坦会话

一切必须通过一个连接传输,这就是 CDP Target.attachToTarget 方法 的用途。设置 flatten 后,它返回一个 sessionId,任何承载该 sessionId 的帧都被路由到附加的目标,而不是浏览器。

zendriver 不使用它。每个外发帧都是通过一个属性序列化的,Transaction.message

python Copy
import inspect

from zendriver.core.connection import Transaction

body = inspect.getsource(Transaction.message.fget)
print(body.strip().splitlines()[-1].strip())
print("sessionId present:", "sessionId" in body)
text Copy
return json.dumps({"method": self.method, "params": self.params, "id": self.id})
sessionId present: False

没有 sessionId 字段,所以命令总是落在浏览器上。添加一个是整个集成:附加会话,然后将其印加到外发帧上。

python Copy
import asyncio
import json
import os
from urllib.parse import urlencode

import zendriver as zd
from zendriver import cdp

CDP_URL = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode(
    {"token": os.environ["SCRAPELESS_KEY"], "sessionTTL": 300, "proxyCountry": "US"}
)


class ScrapelessTab(zd.Tab):
    """A zendriver Tab bound to a remote browser over a single websocket."""

    def __init__(self, url):
        super().__init__(url, target=None)
        self._session_id = None

    async def open_page(self, url):
        tid = await self.send(cdp.target.create_target(url))
        infos = await self.send(cdp.target.get_targets())
        self._target = next(t for t in infos if str(t.target_id) == str(tid))
        self._session_id = await self.send(
            cdp.target.attach_to_target(tid, flatten=True)
        )

        websocket, session_id = self.websocket, self._session_id
        original_send = websocket.send

        async def send_with_session(message, *args, **kwargs):
            frame = json.loads(message)
            frame.setdefault("sessionId", str(session_id))
            return await original_send(json.dumps(frame), *args, **kwargs)

        websocket.send = send_with_session
        return self


async def main():
    tab = ScrapelessTab(CDP_URL)
    await tab.open_page("https://books.toscrape.com/")
    print("session attached:", bool(tab._session_id))
    await tab.aclose()


asyncio.run(main())
text Copy
session attached: True

故意子类化 Tab 而不是 ConnectionTab 已经承载了每个页面助手,并扩展了 Connection,因此一个对象持有一个插座、一个监听器和一个响应映射。将 target=None 传递给构造函数是可以的,因为真实的目标在存在后分配。回复以发送时的同一 id 到达,因此 zendriver 的现有分发未受影响地解析它们。

进行真实提取

附加会话后,普通 API 可以工作。两个时间细节首先咬合,且两者都与远程浏览器无关——它们在本地启动的浏览器上表现相同。

get_content() 立即发送其命令,而不是轮询。太早调用它会返回空文档骨架,39 个字符,根本没有错误——这看起来像是断开的连接,而不是未渲染的页面。

wait_for() 确实轮询,但一旦其选择器匹配 一次,就会返回。在一个尚在加载标记的页面上,这比看起来要弱的保证:在 wait_for("h3 a") 返回后立即选择,在一次本地运行中返回了 9 张卡片,而下一次仅返回 4 张,直到 DOM 稳定后返回 20 张。在计数之前,请给页面一点时间。

python Copy
    await tab.wait_for("h3 a", timeout=20)
    await tab.sleep(2)

    html = await tab.get_content()
    print("fully rendered page:", len(html) > 40000)
    print("catalogue marker present:", "All products" in html)

    cards = await tab.select_all("article.product_pod")
    print("product cards:", len(cards))

    for card in cards[:5]:
        link = await card.query_selector("h3 a")
        price = await card.query_selector("p.price_color")
        print(f"  {link.attrs.get('title')} — {price.text}")
text Copy
fully rendered page: True
catalogue marker present: True
product cards: 20
  A Light in the Attic — £51.77
  Tipping the Velvet — £53.74
  Soumission — £50.10
  Sharp Objects — £47.82
  Sapiens: A Brief History of Humankind — £54.23

选择器和元素查询的行为与对本地浏览器的一样,因为从 zendriver 的角度来看,除了帧放在哪个插座下,其他没有改变。proxyCountry 在请求需要从特定国家或地区出口时采用两字母代码,而 sessionTTL 则界定了远程会话保持打开的时间。

结论

zendriver 没有 connect_over_cdp,原因并不是缺少 WebSocket 传输——zd.Connection 在第一次尝试时就与远程端点对话。障碍在于标签连接是通过主机和端口字符串构建的,而云浏览器没有每个标签的路由可以指向。

平坦会话填补了这一空白。一个连接,一个 Target.attachToTarget 调用,以及一个印加到外发帧上的 sessionId 将库本身的 Tab 转变为对远程页面的句柄,所有选择器助手保持完整。
当像这样的设置出现问题时,通常有两个检查可以解决。如果结果看起来是空而不是错误的,请在读取内容之前等待选择器 — get_content() 不会等待。如果选项卡看起来已经打开但里面从未找到任何内容,请确认您没有打开第二个连接,因为那是一个不同的浏览器。

有关协议的背景,请参见 Chrome DevTools Protocol 是什么nodriver 和 Patchright 作为驱动级隐形工具的比较。详细计划信息请参见 Scrapeless 定价页面,会话参数在 Scrapeless 文档 中。

常见问题解答

问:zendriver 有 connect_over_cdp 方法吗?

没有。没有等同于 Playwright 的 connect_over_cdp 或 Puppeteer 的 connectconnect_existingBrowser.start() 内部的行为接近,但目标是一个本地可达的主机和端口,而不是带有令牌的远程 WebSocket URL。

问:为什么将主机和端口设置为远程端点不起作用?

因为 HTTPApi 将主机和端口插值到一个固定的纯 HTTP 地址中并从中获取 /json/version。带有查询字符串的安全 WebSocket URL 不能表示为主机和端口,托管的端点通常不会公开这些发现路由。

问:我可以在一个远程浏览器上打开多个选项卡吗?

可以,但它们必须共享连接。每个目标调用一次 Target.attachToTarget 并将匹配的 sessionId 打上该选项卡的框架。打开另一个连接到端点会给您一个独立的浏览器。

问:分支的反检测补丁仍然适用于远程浏览器吗?

这些补丁会影响浏览器的启动和配置方式,因此它们属于 zendriver 启动的进程。当您附加到一个您没有启动的浏览器时,其配置是提供者设置的,而 zendriver 仅作为协议客户端。

问:为什么 get_content() 返回一个几乎为空的文档?

因为它不等待。它立即发出命令,因此尚未完成渲染的页面返回的是空文档骨架 — 39 个字符 — 没有错误。首先在 wait_for() 上等待选择器。

问:远程路径是否需要在本地安装 Chrome?

不需要。您的机器上没有启动任何东西,因此不需要浏览器二进制文件。您只需要一个来运行本地 zd.start() 示例。

问:sessionTTL 控制什么?

远程浏览器会话保持打开的时间,以秒为单位。将其设置超过您运行所需的时间;会话在连接关闭或窗口过期时结束,以先到者为准。

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

最受欢迎的文章

目录