跳到正文

托管浏览器

只需一次 API 调用,就能在我们的服务器上启动一个 Clearcote 浏览器,并通过 Chrome DevTools Protocol 从 Playwright、Puppeteer 或任意 CDP 客户端驱动它。你无需自行安装或运行任何东西。流量默认经住宅 IP 出网,按 GB 从预付余额中扣费。

  • 住宅 IP,而非数据中心 IP。网站看到的是来自普通家用宽带运营商的真实家庭网络连接,而不是主机托管或云服务的地址。
  • 真实硬件,而非 VPS。浏览器运行在我们自有的独立物理服务器上,而不是共享的云虚拟机。

免费连接 GitHub 即可免费获得 €5 流量。无需信用卡。

一次性赠送,每个 GitHub 账号限领一次。GitHub 账号须注册满 30 天。

领取 €5

价格

  • 每 GB €1.00,含住宅代理。默认情况下,每个会话都通过住宅 IP 访问互联网:来自普通家用宽带运营商的真实家庭连接,而不是数据中心或主机托管地址。€1.00 买的就是这部分流量;代理不另外收费。
  • 流量按浏览器与互联网之间的传输量计算,上传和下载合并计(1 GB = 109 字节)。参见哪些算作流量。
  • 时长、会话数和 CDP 消息均不收费。
  • 预付费:在仪表盘中充值。启动一个浏览器至少需要 €0.50 的余额;每个会话的上限是启动时余额所能支付的额度,由你正在运行的所有浏览器共同分摊(如果你自己设置的 maxGb 更低,则以它为准)。余额归零时,正在运行的浏览器会被停止(用量大约每 15 秒上报一次,因此最后一次上报可能让余额略低于零)。

哪些算作流量

浏览器发往网站或从网站接收的每一个字节都按线路上的实际传输量计算,与代理服务商的计量方式相同。对一个典型页面来说,这意味着:

  • 计入:页面本身及其加载的所有内容:脚本、样式表、图片、字体、视频、API 调用、广告和跟踪器、WebSocket,以及请求头、cookie 和每个连接的加密(TLS)开销。你等待期间页面仍在持续加载,所以后台轮询和统计分析也会计入。
  • 不计入:你的代码与浏览器之间的 CDP 连接(命令、返回结果、截图、PDF、你读取的页面内容)、仪表盘中的实时画面,以及浏览器根本没有去获取的内容(你拦截的请求、它直接从自身缓存读取的文件)。

粗略来说,一个轻量的纯文本页面远不到 1 MB,一个典型的新闻或电商页面为 2 到 5 MB,而重型单页应用或任何带视频的页面则在 10 MB 以上。按每 GB €1.00 计算,1,000 个 3 MB 的页面约为 3 GB。你自己的会话会显示真实数据:仪表盘列出每个会话的流量及按字节数排名前 20 的站点,而 maxGb 可以为会话设定上限,防止失控的页面耗光你的余额。

减少流量

页面的大部分体积通常都是脚本用不到的东西。按节省幅度从大到小排列:

  1. 拦截图片、媒体和字体。它们往往占页面的一半甚至更多。在浏览器内按 URL 模式拦截,这些请求就根本不会发出,也就不会计费——而且浏览器会保留缓存:
javascript
// Playwright: block by URL pattern over CDP (keeps the browser cache on)
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.setBlockedURLs", {
  urls: ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.svg", "*.woff", "*.woff2", "*.ttf", "*.mp4", "*.webm"],
});

// Puppeteer: the same, through its CDP session
const client = await page.createCDPSession();
await client.send("Network.enable");
await client.send("Network.setBlockedURLs", { urls: ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.woff2"] });

不要仅仅为了丢弃请求而使用 page.route() / context.route() 或 Puppeteer 的请求拦截:它们会关闭浏览器缓存,导致每个页面都要重新下载脚本和样式,花费比省下的图片还多。只有在需要修改请求时才使用它们。adblock 选项也能替你拦截广告和跟踪器。

  1. 拦截广告、统计分析和跟踪器。中止发往你不需要的第三方域名的请求。仪表盘中该会话的热门站点列表会告诉你哪些站点花费最多。
  2. 不要多等。waitUntil: "networkidle" 会等待页面加载的所有内容,包括广告。优先使用 "domcontentloaded",然后只等待你真正需要的那一个元素。
  3. 用完立即关闭浏览器。打开的页面会在后台持续轮询。调低 idleTimeoutSec,让被遗忘的会话自动关闭。
  4. 复用一个浏览器访问多个页面。有了缓存,同一站点各页面共用的脚本和样式只需下载一次,而不是每个页面都下载一遍。请在同一个会话中导航,而不是为每个 URL 新开一个会话。
  5. 能调站点的 API 就调 API。浏览器建立起可用的会话后,在页面内用 fetch() 获取你需要的 JSON,数据量只是重新加载页面的一小部分。
  6. 设置上限。为每个会话设置 maxGb,这样意外过重的页面会被停止,而不会耗光你的余额。

拦截对大多数站点都有效,但少数站点会检查图片或字体是否真的加载了。如果某个站点在开启拦截后表现异常,就针对该站点重新放行这类资源。

想先看看实际效果?Playground 让你直接在仪表盘里用云端浏览器运行脚本,实时画面、控制台输出和截图并排显示。

1. 获取 API 密钥

在“API keys”页面创建一个。密钥以 cc_live_ 开头,作为 bearer token 发送。请妥善保管:任何持有它的人都能花掉你的余额。

2. 启动浏览器并连接

POST /api/v1/browsers 会返回一个 connectUrl:指向该浏览器的一次性 WebSocket URL。请在两分钟内连接;它不能重复使用。

javascript
// Node.js + Playwright
import { chromium } from "playwright";

const res = await fetch("https://www.clearcotelabs.com/api/v1/browsers", {
  method: "POST",
  headers: { authorization: "Bearer cc_live_...", "content-type": "application/json" },
  body: JSON.stringify({ identity: "account-1", country: "us" }),
});
const { connectUrl, id, error } = await res.json();
if (error) throw new Error(error);

const browser = await chromium.connectOverCDP(connectUrl);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.newPage());
await page.goto("https://example.com");
await browser.close(); // ends the session
javascript
// Puppeteer: the same connectUrl
const browser = await puppeteer.connect({ browserWSEndpoint: connectUrl, defaultViewport: null });
python
# Python + Playwright
import requests
from playwright.sync_api import sync_playwright

r = requests.post("https://www.clearcotelabs.com/api/v1/browsers",
                  headers={"authorization": "Bearer cc_live_..."},
                  json={"identity": "account-1", "country": "de"}).json()
with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(r["connectUrl"])
    page = browser.contexts[0].new_page()
    page.goto("https://example.com")
    browser.close()

创建调用返回 201,响应中包含你的代码连接浏览器和了解价格所需的全部信息:

json
{
  "id": "bs_…",                       // use it with GET / DELETE /api/v1/browsers/<id>
  "connectUrl": "wss://…/v1/connect/bs_…?token=…",
  "expiresAt": "2026-09-24T10:02:00.000Z", // connect before this (two minutes)
  "worker": "w_…",
  "pricing": { "eurPerGb": 1, "eurPerHour": 0 },
  "limits": { "maxSeconds": 14400, "idleSeconds": 300 } // plus maxBytes when capped
}

选项

以下选项均为可选,作为创建调用的 JSON 请求体发送。

字段类型含义
identitystringOne label per account you run: the same device fingerprint AND the same residential IP, for as long as that IP stays online.
fingerprintstringDevice seed only (no IP pinning). The same seed gives the same device profile every time; with lightStealth on it picks from a small set of metadata profiles.
lightStealthbooleanDefault true: varies only the metadata axes the host can back up. Set false to turn it off.
platformwindows | macos | linux | androidOperating system the persona presents.
brandChrome | Edge | Opera | VivaldiBrowser brand the persona presents.
timezoneIANA namee.g. America/New_York. Use geoip instead to follow the exit IP.
localestringAccept-Language, e.g. en-US,en.
geoipbooleanTimezone and language follow the exit IP. Default true unless you set timezone or locale yourself.
proxy"managed" | { server, username?, password? }Omitted = managed residential pool. Or your own proxy: http://, socks5:// or socks5h:// (rules below).
country2-letter codeManaged pool: exit country, e.g. us, de, gb.
stateregion codeManaged pool: exit state/region, e.g. ca, ny. Needs country.
citycity nameManaged pool: exit city, e.g. "los angeles". Needs state.
proxySessionstringManaged pool: sticky label. The same label returns the same exit IP later (kept for 24 hours).
timeoutSecnumberHard limit on the session length, in seconds (10 up to the account maximum below).
idleTimeoutSecnumberEnd the session after this long without a CDP command (10–1800).
maxGbnumberStop the session after this much traffic (0.001–1000).
headlessbooleanDefault true.
keepAlivebooleanDefault false. Keep the browser running when your client disconnects, until you end it (DELETE, or the CDP command Browser.close) or a limit does; reconnect with POST /api/v1/browsers/<id>/connect.
versionstringRun a specific Clearcote release, e.g. "152.0.7977.82-r21" or "r21". Omitted = the current release. See "Pinning a release".
profile"name" | { name, persist? }Load a saved profile (cookies + site storage). With persist: true, save it back when the session ends. See "Profiles".
urlhttp(s) URLOpened in the first tab before you connect: you find it already loading.
adblockbooleanRefuse known ad and tracker hosts before they load, so they are never billed. Default false.
notestringYour label for the session (at most 256 characters). Shown in the dashboard; filter by it in the list.
workerstringPlace the session on the same server as an earlier one (its worker). 503 if that server is full.

默认隐匿设置与身份

每个会话都以推荐设置页面中的配置为起点:开启 lightStealth、使用一个种子(seed),时区和语言跟随出口 IP。在 lightStealth(默认)模式下,种子会从一小组设备配置中选出一个,这些配置在 CPU 核心数、内存和像素比上各不相同;canvas、WebGL 和音频则保持会话所在机器本身的值。设置 lightStealth: false 可获得完整的、按种子生成的身份画像(persona):canvas 和 WebGL 的像素读回会带上由种子派生、按站点区分的噪声,GPU 字符串、屏幕和音频设置(采样率、延迟)也都跟随身份画像。如果不指定 identity 或 fingerprint,每个会话都会得到一个随机种子和一个新 IP。

传入 identity: "account-42",之后的会话就会以同一个设备配置回来,并且只要该住宅 IP 仍然在线,就使用同一个 IP——这正是已登录账号所期望的。身份只在你的账号内有效:其他客户即使使用相同的标签,也会得到他们自己的种子和 IP。身份本身不会保留 cookie;如需保留,请使用 Profile。

Profile:只需登录一次

Profile 会以一个名称保存会话的 cookie、localStorage 和 IndexedDB,之后使用该名称的会话启动时就已处于登录状态。传入 profile: { name: "shop-account", persist: true } 会加载它,并在会话结束时保存回去;只传 profile: "shop-account" 则以只读方式加载。使用新名称的第一个会话从空白状态开始,并创建该 Profile。

javascript
// Every run: the same body. The first one starts signed out; sign in, then close the browser
// and the session saves the cookies and site storage. Every later run starts signed in.
const res = await fetch("https://www.clearcotelabs.com/api/v1/browsers", {
  method: "POST",
  headers: { authorization: "Bearer cc_live_...", "content-type": "application/json" },
  body: JSON.stringify({ profile: { name: "shop-account", persist: true }, country: "de" }),
});
  • 同一设备,同一 IP。Profile 自带身份(profile:<name>),因此站点看到的是同一个指纹;只要该住宅 IP 仍在线,看到的也是同一个 IP,就像一位回头客。如需另作选择,可自行传入 identity 或 fingerprint,并在多次运行之间保持国家不变。
  • 同一时间只有一个写入者。同一 Profile 只允许一个正在运行的会话写入;第二个带 persist: true 的会话会收到 409 PROFILE_IN_USE。只读会话可以同时运行,看到的是最后一次保存的状态。
  • 在会话结束时保存,无论是你关闭浏览器、断开连接、停止会话,还是因达到某项限制而结束。如果浏览器崩溃,会保留上一次保存的状态,而不会被不完整的状态覆盖。会话 cookie(没有过期时间的 cookie)会被丢弃,就像真实浏览器重启时会丢弃它们一样。
  • 压缩后最多约 3.5 MB。如果某个站点的 IndexedDB 使其超出这一大小,IndexedDB 将不予保存;cookie 和 localStorage 仍会保存。
  • 私有。Profile 只属于你(其他客户的“shop-account”是另一个 Profile),加密存储,并且只会交给运行你会话的服务器。用 GET /api/v1/browsers/profiles 列出,用 DELETE /api/v1/browsers/profiles/<name> 删除,也可以使用仪表盘。

出口 IP:轮换、固定与地理定向

  • 默认:每个浏览器会话都有自己的住宅出口 IP,并在整个会话期间保持不变。
  • 固定(sticky):传入相同的 proxySession 标签(例如你管理的每个账号各用一个),即可在之后的会话中拿回同一个出口 IP。标签只在你的账号内有效。住宅 IP 在其对端节点在线期间一直可用,通常为数小时;节点下线后,你会从同一网络中获得另一个 IP。
  • 位置:country,还可以进一步指定 state 和 city。定位越精确,可用的 IP 池就越小。
  • 自有代理:proxy: { server: "http://host:port", username, password },或 socks5://host:port(由我们这一侧解析域名),或 socks5h://host:port(由你的代理解析域名)。请求会被严格校验:必须显式指定端口,凭据须放在 username / password 中(user:pass@host 形式的 URL 会返回 400;每项最多 255 字节);country、state、city 和 proxySession 会被直接拒绝而不是忽略,因为它们描述的是托管代理池。代理本身的地址必须是公网地址。流量按同样的方式计费。

时区和语言默认跟随出口 IP(geoip)。传入 timezone / locale 可自行指定,传入 geoip: false 则关闭此功能。

固定版本

会话运行的是当前的 Clearcote 版本。要运行旧版本,请传入 version:可以是完整版本号("152.0.7977.82-r21")、仅重建编号("r21")、Chromium 版本号或主版本号("152" 会选择该主版本的最新构建),或 "latest"。这些版本与 SDK 的 version 选项下载的是同一批,因此固定到同一版本的托管会话和本地运行使用的是同一个构建。

javascript
const res = await fetch("https://www.clearcotelabs.com/api/v1/browsers", {
  method: "POST",
  headers: { authorization: "Bearer cc_live_...", "content-type": "application/json" },
  body: JSON.stringify({ identity: "account-1", version: "152.0.7977.82-r21" }),
});
const { connectUrl, engine, warnings } = await res.json();
// engine   -> { version: "152.0.7977.82", revision: "r21", pinned: true }
// warnings -> [ "This session runs 152.0.7977.82-r21, older than the current ... " ]
  • 检查 warnings。旧版本不具备后续版本新增的内容。在它之后才加入的选项和修复可能缺失,或者被静默忽略而不是报错拒绝,因此在当前版本上有效的设置,在固定的旧版本上可能悄无声息地不起任何作用。
  • 无论是否固定版本,响应中的 engine 都会说明会话运行的是哪个版本。
  • 指定不存在的版本会返回 400,错误码为 UNKNOWN_VERSION,错误信息中会列出可选的版本。
  • 如果某个版本我们的服务器还没有用过,该版本上的第一个会话在获取构建期间,启动时间最多可能多出一分钟。之后该版本上的会话启动速度与其他会话一样快。

起始页与广告拦截

  • url 会在你连接之前就在第一个标签页中打开一个页面,这样你的脚本接入时,页面已经在加载了。
  • adblock: true 会在请求发出之前拒绝发往知名广告、广告验证和统计分析主机的请求,因此这些请求不会计费。该列表刻意保持保守(标签管理器、授权同意工具、登录 SDK 和 CAPTCHA 都不受影响),但少数网站会察觉到广告缺失;在这类场景下请保持关闭。

实时画面:观看、接管、分享

在仪表盘中点击某个会话,即可查看它的流量去往了哪些站点,并实时观看。点击“Take control”后,你可以亲自在其中点击、输入、滚动、粘贴和导航,例如完成登录,或者处理脚本无法通过的检查。你的脚本在此期间始终保持连接,所以你操作时请先暂停脚本。人工输入也算作活动,因此你正在操作的会话不会因空闲而被关闭。“Share”会生成一个无需账号、任何人都能打开的链接,可设为仅观看或允许控制,有效期为 15 分钟到 4 小时,且最长不超过会话结束的时间。

通过 API:

bash
# a live-view WebSocket for a running session (open it within 60 s)
# binary messages are JPEG frames, text messages are {"url","title","tabs"}
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/<id>/live

# with control: the answer says "interactive": true when it was granted
curl -H "authorization: Bearer cc_live_..." "https://www.clearcotelabs.com/api/v1/browsers/<id>/live?control=1"

# a share link: control optional, 1 to 240 minutes (default 30)
curl -X POST -H "authorization: Bearer cc_live_..." -H "content-type: application/json" -d '{"control": false, "minutes": 60}' https://www.clearcotelabs.com/api/v1/browsers/<id>/share

在控制模式下,通过同一个 WebSocket 发送 JSON 文本消息。坐标是你所看到画面的比例值(0 到 1);其他内容一律忽略。

消息作用
{"t":"mouse","e":"down","x":0.5,"y":0.3,"b":"left","n":1,"m":0}按下(down)、松开(up)或移动(move);n 为点击次数,m 为修饰键(Alt 1、Ctrl 2、Meta 4、Shift 8)。
{"t":"wheel","x":0.5,"y":0.5,"dx":0,"dy":400}在某一点按像素滚动。
{"t":"key","e":"down","key":"a","code":"KeyA","kc":65,"text":"a"}按键按下或抬起,与键盘实际发送的一致。
{"t":"text","text":"pasted text"}像键盘输入一样插入文本(最多 5000 个字符)。
{"t":"nav","a":"back"}、forward、reload 或 {"t":"nav","a":"go","url":"example.com"}历史前进/后退、重新加载,或打开一个 http(s) 地址。

GET /api/v1/browsers/<id> 的返回中包含 traffic:该会话按字节数排名前 20 的站点。

管理会话

bash
# one session: status, traffic, seconds, cost so far
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/<id>

# stop it (a running browser closes within about 15 seconds)
curl -X DELETE -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/<id>

# balance + your 20 most recent sessions
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers

# filtered by status and note text, up to 100; page back with before=<a createdAt you got>
curl -H "authorization: Bearer cc_live_..." "https://www.clearcotelabs.com/api/v1/browsers?status=active,ended&note=shop-de&limit=50"

# label a session (null clears it)
curl -X PATCH -H "authorization: Bearer cc_live_..." -H "content-type: application/json" -d '{"note": "shop-de nightly"}' https://www.clearcotelabs.com/api/v1/browsers/<id>

创建会话时给它加上 note,之后就能在列表和仪表盘中再次找到它。若要在之前某个会话所在的同一台服务器上启动新会话(缓存已预热、同一台机器),请传入那个会话的 worker;该服务器已满时你会收到 503,而不会被分配到另一台服务器。

当你关闭浏览器或断开连接,或者达到下文的某项限制时,会话也会结束。对于还没有人连接过的会话,停止请求会立即结束它;正在运行的浏览器则会在一个上报周期(约 15 秒)内由其所在服务器关闭。GET 返回:

json
{
  "id": "bs_…",
  "status": "active",                 // see the table below
  "proxy": "managed",                 // or "custom"
  "createdAt": "…", "startedAt": "…", "endedAt": null,
  "endReason": null,                  // set once ended, e.g. "user", "balance", "launch_failed"
  "stopRequested": false,
  "usage": { "bytesUp": 120334, "bytesDown": 4812009, "gb": 0.0049, "seconds": 41 },
  "traffic": [ { "site": "example.com", "bytesUp": 20400, "bytesDown": 3100000 }, … ], // top 20 sites
  "costEur": 0.0050,
  "pricing": { "eurPerGb": 1, "eurPerHour": 0 }
}
status含义
pendingCreated; nobody has connected yet. Counts towards the concurrency limit until it starts or expires.
activeA browser is running and reporting usage.
lostNo usage report for 5 minutes. Billed up to the last report; a late report puts it back to active.
endedClosed: you disconnected, stopped it, or a limit or the balance ended it. endReason says which.
expiredNobody connected within two minutes of creating it. Never billed.

列表调用 GET /api/v1/browsers 返回 { balanceEur, sessions: [...] },其中的会话对象与上面相同,按从新到旧排列。

保持浏览器运行与重新连接

默认情况下,客户端断开连接时会话就会结束。如果启动时设置 keepAlive: true,浏览器则会继续运行,并保留其标签页、cookie 和出口 IP,这样之后的脚本(或者在崩溃、合上笔记本之后重新运行的同一个脚本)就能从上次中断的地方继续:

bash
# a new single-use connect URL for a running keepAlive session (connect within two minutes)
curl -X POST -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/<id>/connect
  • 保持运行的方法是断开连接:Playwright 的 browser.close()(通过 connectOverCDP 连接时它只会断开连接)、Puppeteer 的 browser.disconnect(),或者直接结束你的进程。
  • 结束会话可以调用 DELETE /api/v1/browsers/<id>,或发送 CDP 命令 Browser.close:Puppeteer 的 browser.close() 会发送它;在 Playwright 中则用 await (await browser.newBrowserCDPSession()).send("Browser.close")。在此之前,它会一直占用你并发上限中的一个名额。
  • 同一时间只允许一个客户端:已有其他客户端接入时,重新连接会被拒绝并返回 409;对启动时未设置 keepAlive 的会话重新连接也是如此。
  • 无人连接期间,各项限制依然有效:idleTimeoutSec(如果你打算稍后回来继续使用这个浏览器,可以调高它,最大 1800)、timeoutSec、maxGb 以及你的余额。保持打开的页面会继续产生后台流量,和其他流量一样计费。

限制

  • 每个账号最多同时有 24 个浏览器处于运行或启动状态。
  • 会话最长持续 4 小时。
  • 连续 5 分钟没有任何 CDP 命令的会话会被关闭(可通过 idleTimeoutSec 修改)。
  • 每个会话都从一个全新的浏览器 Profile 开始,并在会话结束时删除;除非你使用命名的 Profile,它会在会话之间保留 cookie 和站点存储。
  • 出于安全考虑,浏览器不能打开本地文件(file://)、不能从服务器上传文件、不能访问私有或内部网络,也不能通过 25 端口发送邮件。
  • 可以通过 Playwright 上传文件:setInputFiles() 会从你的机器发送文件(最大 50 MB)。Puppeteer 的 uploadFile() 会被拒绝。下载的文件保留在我们的服务器上,并随会话一起删除;如需保留某个文件,请在页面内获取它并返回其内容。
  • 托管浏览器无法加载 Chrome 扩展。
  • 浏览器不会转发控制台消息或页面错误,因此 page.on("console") 不会收到任何内容。请在页面内收集你需要的信息,再用 evaluate 读回。

错误

状态码code含义
400—The body is not JSON, or an option is invalid; the message says which.
400UNKNOWN_VERSIONNo release matches version; the message lists the ones you can pick.
401—Missing, malformed or revoked API key.
402INSUFFICIENT_BALANCEBalance below the minimum. Top up in the dashboard.
404NOT_FOUNDNo session with that id on your account.
409NOT_RUNNINGLive view or a reconnect asked for before the browser started or after it ended.
409NOT_KEEPALIVEThis session cannot be reconnected. Start it with keepAlive: true.
409PROFILE_IN_USEAnother session is already saving to that profile. Stop it, or open the profile with persist: false.
429CONCURRENCY_LIMITToo many browsers running or starting at once. Close one first.
429—More than 60 create calls in a minute from one address. Slow down.
503NO_CAPACITYNo free browser slot right now. Retry after a few seconds.
503NO_WORKERThe server running that session is not reachable at the moment.
503NOT_CONFIGUREDHosted browsers are not configured on this server.
503NOT_AVAILABLENotes or profiles are not enabled on this server yet.

错误以 JSON 形式返回:{ "error": "...", "code": "..." }。如果 WebSocket 连接本身被拒绝,请创建一个新会话:连接 URL 是一次性的,两分钟后过期。被拒绝的 WebSocket 升级请求会返回一个 HTTP 状态码和一个 JSON error:URL 已被使用、会话已取消、会话启动时未设置 keepAlive 或已有客户端连接时,返回 409;URL 已过期时,返回 401。

哪些错误该重试

  • 带退避重试:503 NO_CAPACITY 和 503 NO_WORKER(依次等待 1、2、4… 秒并加入一些随机抖动,重试几次后放弃),以及不带 code 的 429(按地址的速率限制)。
  • 带退避重试少数几次:其他 5xx 响应,以及在你的脚本开始运行之前就被拒绝的连接(需使用新会话:旧的连接 URL 已经作废)。
  • 切勿循环重试:400(修正请求)、401、402(充值)、429 CONCURRENCY_LIMIT(先关闭一个浏览器)和 409 PROFILE_IN_USE。这些都需要人来处理;重试只会白白消耗请求。

最佳实践

  1. 只连接,不启动。用 connectOverCDP 或 puppeteer.connect 连接 connectUrl;chromium.launch() 则会在你自己的机器上启动一个浏览器。
  2. 用现成的。使用 browser.contexts()[0] 及其第一个页面,而不是新建 context:新 context 不带 Profile 的 cookie 和存储,而且 Playwright 会给它一个模拟的 1280×720 视口,与浏览器窗口不一致。
  3. 在创建时设定身份画像,而不是在脚本里。国家、时区和语言应该放在创建调用中。在脚本里覆盖 user agent、视口或 navigator 属性,恰恰会制造出检测所要寻找的那种不一致。
  4. CDP 钩子范围要尽量小。宽泛的监听器、对每个请求的拦截以及 init 脚本,本身就是自动化的指纹。原版 Playwright 和 Puppeteer 可以直接使用:引擎会让 Runtime.enable 的副作用不触及页面本身,因此像 Patchright 这样的修补版驱动是可选项,而非必需。
  5. 一个会话,多个页面。启动浏览器是最慢的环节;请在同一个浏览器内导航。用 Profile 登录一次即可,不必每次运行都登录。
  6. 务必停止。在 finally 中关闭浏览器,并根据任务设置 maxGb 和 idleTimeoutSec,这样即使代码有 bug,也不会让浏览器一直用你的余额运行下去。
  7. 先观察,再加代码。当某个站点表现异常时,先在实时画面中观察(或接管控制),再考虑添加等待和变通方案。

框架

任何通过 CDP 连接 Chrome 的工具都可以使用 connectUrl。它是一次性的,因此会自行重连的框架每次连接都需要一个新会话。浏览器在你连接时启动,通常只需几秒;无需事先轮询任何状态。

javascript
// Patchright (optional; a Playwright fork): npm i patchright
import { chromium } from "patchright";
const browser = await chromium.connectOverCDP(connectUrl);
const page = browser.contexts()[0].pages()[0];
python
# Browser Use
from browser_use import Agent, Browser
agent = Agent(task="Find the cheapest flight to Lisbon next Friday", llm=llm, browser=Browser(cdp_url=connect_url))
await agent.run()

# Crawl4AI
from crawl4ai import AsyncWebCrawler, BrowserConfig
config = BrowserConfig(browser_mode="custom", cdp_url=connect_url, use_managed_browser=True)
async with AsyncWebCrawler(config=config) as crawler:
    result = await crawler.arun("https://example.com")
javascript
// Stagehand v4
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({ browser: await localBrowser.connect({ cdpUrl: connectUrl }) });

一个可以直接上手的辅助函数

一个函数搞定:按上文的重试规则创建会话、连接,并始终停止会话:

typescript
// clearcote-hosted.ts
import { chromium, type Browser } from "playwright"; // or "patchright"

const API = "https://www.clearcotelabs.com/api/v1/browsers";
const AUTH = { authorization: "Bearer " + process.env.CLEARCOTE_API_KEY };
const RETRY_CODES = new Set(["NO_CAPACITY", "NO_WORKER"]);

export async function createSession(options: Record<string, unknown> = {}, attempts = 5) {
  for (let i = 0; ; i++) {
    const res = await fetch(API, {
      method: "POST",
      headers: { ...AUTH, "content-type": "application/json" },
      body: JSON.stringify(options),
    });
    const body = await res.json().catch(() => ({}));
    if (res.ok) return body as { id: string; connectUrl: string; worker: string };
    const retry = RETRY_CODES.has(body.code) || (res.status === 429 && !body.code) || [500, 502, 504].includes(res.status);
    if (!retry || i + 1 >= attempts) throw new Error([res.status, body.code, body.error].filter(Boolean).join(" "));
    await new Promise((r) => setTimeout(r, Math.min(15_000, 1000 * 2 ** i) * (0.5 + Math.random())));
  }
}

export async function withBrowser<T>(options: Record<string, unknown>, work: (browser: Browser) => Promise<T>) {
  const session = await createSession(options);
  try {
    const browser = await chromium.connectOverCDP(session.connectUrl);
    try {
      return await work(browser);
    } finally {
      await browser.close().catch(() => {});
    }
  } finally {
    // Ends the session if closing the browser did not (a no-op otherwise).
    await fetch(API + "/" + session.id, { method: "DELETE", headers: AUTH }).catch(() => {});
  }
}

// await withBrowser({ profile: { name: "shop", persist: true }, country: "de" }, async (browser) => {
//   const page = browser.contexts()[0].pages()[0];
//   await page.goto("https://example.com");
// });

Playground

仪表盘中的 Playground 会在一个这样的托管浏览器上运行脚本,旁边同时显示实时画面、控制台和截图;下方的“Use in your code”面板则给出与上文代码相同的会话选项。它的 page 是一个直接通过 CDP 通信的小型辅助对象,并不是 Playwright,因此 Playground 脚本只是一份需要你转写的草稿,而不是可以直接粘贴使用的文件:

辅助函数作用
page.goto(url, { timeout? })导航并等待 load 事件。
page.click(sel) · page.type(sel, text) · page.press(key)真实的鼠标和键盘输入,会先将元素滚动到可见区域。
page.evaluate(fn, ...args)在页面中运行一个函数,并取回其 JSON 结果。
page.waitForSelector(sel, { timeout? }) · page.waitForNavigation()等待某个元素出现,或等待下一次页面加载。
page.scroll(px) · page.screenshot({ fullPage? })用鼠标滚轮滚动;截取一张 JPEG,显示在“Screenshots”标签页中。
page.title() · page.url() · page.content()文档标题、地址和 HTML。
log(...values) · sleep(ms)输出到控制台(对象会格式化打印);暂停。
cdp(method, params)向浏览器发送一条原始 CDP 命令(Target.*、Browser.*、Storage.*)。
page.cdp(method, params)向页面发送一条原始 CDP 命令(Page.*、Runtime.*、DOM.*、Network.*)。

报错时会指出出错的脚本行。“Share”会复制一个链接,脚本及其会话选项都放在链接的 URL 中,因此我们这边不存储任何内容;打开链接的人会用自己的余额运行它。