数据截至 (上游 commit 5880b48c1af1)
Steel Browser — 架构与原理
30 秒导读: Steel Browser 是一个自托管的「浏览器即服务」。它把一整台真实的 Chrome 包在一个 Fastify HTTP 服务里,对外只暴露干净的 REST + WebSocket 接口。AI agent 不必自己去装 Chrome、管 cookie、配代理、躲反爬检测——只要调
POST /v1/sessions开一个会话,然后用 Puppeteer/Playwright 连上返回的地址去操作页面就行。核心价值是帮 agent 把浏览器基础设施的脏活全干了:会话生命周期、跨请求的状态(cookie/localStorage)、IP 轮换、指纹伪装、按需省流量、以及把页面一键转成 Markdown/PDF/截图。
1. 这是什么(零基础也能懂)
-
一句话定义: 一个开源的浏览器 API 服务器——你把它跑起来(Docker 一条命令),它内部拉起一台 Chrome,然后给你一组 HTTP 接口去开会话、抓页面、截图;需要精细操作时,你用 Puppeteer 之类的工具连上它。
-
解决什么问题 / 给谁用: 假设你在写一个「会自己上网」的 AI agent——它要登录网站、点按钮、填表单、读结果。你很快会撞上一堆和 AI 无关的苦活:Chrome 怎么装、会话怎么保活、登录态(cookie)怎么跨请求保住、被网站当机器人封了怎么办、代理 IP 怎么轮换、页面几 MB 的 HTML 怎么塞进模型上下文。Steel 把这些基础设施统一收进一个服务,让你专注写 agent 逻辑。
-
它能做什么(功能):
- 管理浏览器会话:开、关、查、复用同一个 Chrome 实例。
- 跨请求保住浏览器状态:cookie、localStorage、sessionStorage、IndexedDB。
- 内置代理链管理,支持 IP 轮换。
- 反检测:随机化指纹、隐藏自动化痕迹、屏蔽广告。
- 省带宽:按需拦掉图片/媒体/样式表,或按 host/URL 模式屏蔽。
- 抓取工具:一个接口把页面转成 Markdown、可读正文(readability)、截图或 PDF。
- 实时调试:WebSocket 转发原生 CDP,配一个 UI 看/调会话。
-
用起来什么样: 最小闭环是两步——先起服务,再开会话:
# 1) 起服务(内置 Chrome + API + UI)
docker run -p 3000:3000 -p 9223:9223 ghcr.io/steel-dev/steel-browser
// 2) 开一个会话,然后用 Puppeteer 连上去操作 # 示意,基于 README 用法
import Steel from "steel-sdk";
const client = new Steel({ baseURL: "http://localhost:3000" });
const session = await client.sessions.create(); // POST /v1/sessions
// session 里带 websocketUrl,拿它给 puppeteer.connect() 即可驱动这台 Chrome
你也可以完全不碰 Puppeteer,直接用「快捷动作」接 口一把梭:POST /v1/scrape 传一个 URL,拿回该页的 Markdown + 元数据 + 链接。
- 一句话直觉/类比: 把它想成**「浏览器界的数据库连接池」**——你不自己
new Chrome(),而是向一个常驻服务「借」一个已经配好代理、指纹、反检测的浏览器会话,用完还回去;服务替你管好实例的生老病死。
本节不涉及底层实现,目标只是让你知道「这是干嘛的」。
2. 顶层全景(它大概怎么转)
怎么读下面这张图: 从左到右是「谁调它 → 服务内部三层 → 真实 Chrome」;控制流由 REST/WS 请求触发,最终都落到底层那个 CDPService 上,由它经 CDP 驱动 Chrome。
AI agent / SDK / 浏览器
│
REST + WebSocket 请求
│
┌───────────▼─────────────────── ──────────────────────────┐
│ Fastify 服务 (api/src/index.ts) │
│ │
│ ① 路由层 /v1/sessions /v1/scrape /v1/cdp /v1/files │
│ │ (modules/*/*.routes.ts) │
│ ▼ │
│ ② 会话层 SessionService │
│ │ 开/关会话、装代理、拼装 launch 配置 │
│ ▼ │
│ ③ 浏览器层 CDPService ◄── 插件 (PluginManager) │
│ │ launch / 复用实例 / 请求拦截 / 指纹注入 │
└───────┼──────────────────────────────────────────────────┘
│ Puppeteer-core + CDP
▼
真实 Chrome 进程 ──(WS 端点)──► agent 直接连它做精细操作
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| Fastify 入口 | 建 server、注册插件与路由、监听端口 | api/src/index.ts |
| 主插件 | 把所有子插件和路由串起来,fp 封装 | api/src/steel-browser-plugin.ts:59 steelBrowserPlugin |
| 路由层 | 各领域的 REST 端点(会话/动作/CDP/文件/日志) | api/src/modules/*/**.routes.ts |
SessionService | 会话生命周期:开一个会话 = 装代理 + 拼配置 + 调 CDP launch | api/src/services/session.service.ts:58 |
CDPService | 核心:管 Chrome 进程、CDP、请求拦截、指纹、状态导出 | api/src/services/cdp/cdp.service.ts:83 |
PluginManager / BasePlugin | 生命周期钩子扩展点(页面创建、导航、关闭…) | api/src/services/cdp/plugins/core/base-plugin.ts:27 |
| WebSocket 插件 | 把原生 CDP WS 转发给外部,支持自定义 handler | api/src/plugins/browser-socket/browser-socket.ts |
主线走一遍(高层,不进代码): agent 发 POST /v1/sessions → 路由把参数交给 SessionService.startSession → 会话层若有代理就先起代理服务器,再把所有选项拼成一份 BrowserLauncherOptions,交给 CDPService.launch → 浏览器层判断能否复用现有 Chrome(配置相近就复用,否则关旧的开新的),启动时注入指纹、挂请求拦截、装扩展 → 返回一个带 websocketUrl 的会话详情 → agent 拿这个 URL 用 Puppeteer 连上去,后续每个 CDP 命令都经 WebSocket 插件转发到那台 Chrome。
一个关键的顶层事实:同一时刻只有一个「活跃会话」。SessionService 里 activeSession 是单数(api/src/services/session.service.ts:67),CDPService 也维护单个 browserInstance——这个服务是「一台服务器管一台浏览器」的模型,不是多租户会话池。
3. 核心原理(逐个机制,由浅入深)
下面挑五个最能代表 Steel 价值的机制,从「最外层的会话」一路讲到「最底层的请求拦截」。
3.1 会话生命周期:一次 POST /v1/sessions 到底发生了什么
-
它要解决的小问题: agent 说「给我一个浏览器」,服务得把「代理 + 指纹 + 尺寸 + 状态注入 + 启动」这一长串编排好,还得保证同一台服务器上前一个会话干净退出。
-
思路/直觉: 把「会话」这个业务概念(有 id、有代理、有 cookie)和「浏览器实例」这个技术资源分开。
SessionService负责前者的编排,真正的启动委托给CDPService。 -
主流程(拼装 → 启动):
startSession里先尽早异步去解析时区,再规整尺寸/带宽选项、按需起代理,最后把一切塞进BrowserLauncherOptions:真实实现见
api/src/services/session.service.ts:93startSession。其中代理是惰性起的——只有传了proxyUrl才new ProxyServer并listen():
// api/src/services/session.service.ts:210 附近 # 真实源码节选
if (proxyUrl) {
this.activeSession.proxyServer = await this.proxyFactory(proxyUrl, normalizedOptimize);
await this.activeSession.proxyServer.listen();
}
- 关键细节/坑:
optimizeBandwidth: true会被规整成一组具体开关(blockImages/blockMedia/blockStylesheets全开),见normalizeOptimizeBandwidth(session.service.ts:198)。传布尔是「全都拦」,传对象是「精细控制」。persist: true或显式userDataDir会让会话数据写到仓库内固定目录,否则用系统临时目录(session.service.ts:180)——这决定了状态是不是真落盘。- 移动设备(
deviceConfig.device === "mobile")会把尺寸抬到最小 508×1074(session.service.ts:158)。
3.2 实例复用:凭什么不每次都重启 Chrome
-
它要解决的小问题: 冷启动一台 Chrome 很慢。如果新会话的配置和当前跑着的这台差不多,直接复用能省掉整个启动开销。
-
思路/直觉: 启动前先问一句「现有实例的配置和这次请求相似吗?」相似就只刷新页面 + 重注状态,不相似才关旧开新。
-
判定与分支:
launchInternal开头就做这个决策:
// api/src/services/cdp/cdp.service.ts:565 # 真实源码节选
const shouldReuseInstance =
this.browserInstance &&
(await isSimilarConfig(this.launchConfig, config || this.defaultLaunchConfig));
if (shouldReuseInstance) {
// 复用:只刷新 primaryPage + 重新注入 sessionContext,不重启进程
} else if (this.browserInstance) {
// 不相似:先 shutdown(RELAUNCH) 关掉旧的,再走新启动
}
完整逻辑见 api/src/services/cdp/cdp.service.ts:555 launchInternal。复用分支里会 refreshPrimaryPage()(开新页、关旧页)并在有 sessionContext 时重新注入(cdp.service.ts:589)。
- 关键细节/坑: 整个
launchInternal被 60 秒超时和RetryManager包着(cdp.service.ts:546、:560),启动失败会走清理 + 重试;每一步用executeCritical / executeOptional / executeBestEffort区分「失败即致命」和「失败可降级」——这套错误分级是它启动稳的关键。
3.3 跨请求的状态:cookie / localStorage 怎么「存」和「灌」
-
它要解决的小问题: agent 这一步登录了,下一步(甚至下一个会话)还想是登录态。浏览器状态得能导出成数据、也能灌回新浏览器。
-
思路/直觉: 提供一对镜像操作——dump(把当前浏览器的全套存储抽出来)和 inject(把一份存储写回新浏览器)。
-
导出(dump):
getBrowserState把 cookie、来自 userDataDir 的存储、以及各打开页面的实时存储并行抽取再合并:
// api/src/services/cdp/cdp.service.ts:1204 # 真实源码节选
const [cookieData, sessionData, storageData] = await Promise.all([
this.getCookies(),
this.chromeSessionService.getSessionData(userDataDir),
this.getExistingPageSessionData(), // 遍历每个 http(s) 页,提 localStorage/sessionStorage/IndexedDB
]);
完整见 api/src/services/cdp/cdp.service.ts:1186 getBrowserState。对外是 GET(会话上下文接口)返回一个 SessionData。
- 注入(inject): 反过来,
injectSessionContext用 CDP 的Network.setCookies灌 cookie,再给页面挂framenavigated监听,在导航到对应 origin 时把 localStorage 写回:
// api/src/services/cdp/cdp.service.ts:1390 # 真实源码节选
if (context.cookies?.length) {
await client.send("Network.setCookies", { cookies: context.cookies.map(...) });
}
page.on("framenavigated", (frame) => handleFrameNavigated(frame, storageByOrigin, this.logger));
完整见 api/src/services/cdp/cdp.service.ts:1375 injectSessionContext。为什么 localStorage 要等导航时才灌:localStorage 是按 origin 隔离的,只有页面真的到了那个 origin,写进去才有意义——所以它按 origin 分组、监听导航、命中才注入。
3.4 反检测:让这台被自动化控制的 Chrome 看起来像真人
-
它要解决的小问题: 网站会检测「你是不是机器人」。Puppeteer 驱动的 Chrome 有一堆破绽(
navigator.webdriver、缺失的指纹、--enable-automation痕迹、可疑的请求头)。 -
手段拆成几层(各司其职):
| 层 | 做什么 | 位置 |
|---|---|---|
| 启动参数 | 去掉 --enable-automation,关掉一大票会暴露/干扰的 Chrome feature | cdp.service.ts:154(ignoreDefaultArgs)、:826 staticDefaultArgs |
| 指纹生成 | 用 fingerprint-generator 造一套一致的 UA/屏幕/OS 指纹 | cdp.service.ts:686 附近 |
| 指纹注入 | 把 UA、UA-metadata、请求头灌进页面(CDP Emulation.setUserAgentOverride) | cdp.service.ts:1428 injectFingerprintSafely |
| 请求头过滤 | 指纹注入前先剥掉 accept/accept-encoding/sec-fetch-* 等一批头(filterHeaders)再 setExtraHTTPHeaders | browser.ts:148、cdp.service.ts:1445 |
- 一个很实在的工程细节(值得学): 指纹数据集常常落后于最新 Chrome 版本,如果硬指定「最新版 + 很窄的屏幕范围」,可能一个匹配样本都没有,
getFingerprint()直接抛异常。Steel 的对策是逐级放宽——先试最严的选项(最好的隐匿性),不行就放宽浏览器版本,再不行就去掉屏幕约束:
// api/src/services/cdp/cdp.service.ts:716 # 真实源码节选,注释即设计说明
const fallbackOptions = [
fingerprintOptions, // 最严
{ ...fingerprintOptions, browsers: [{ name: "chrome" }] }, // 放宽版本
{ ...fingerprintOptions, browsers: [{ name: "chrome" }], screen: undefined }, // 再去掉屏幕约束
];
// 依次尝试,第一个成功的就用;全失败才抛
这是「优先最优、失败降级而非硬崩」的典型模式。