跳到主要内容

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 launchapi/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 转发给外部,支持自定义 handlerapi/src/plugins/browser-socket/browser-socket.ts

主线走一遍(高层,不进代码): agent 发 POST /v1/sessions → 路由把参数交给 SessionService.startSession → 会话层若有代理就先起代理服务器,再把所有选项拼成一份 BrowserLauncherOptions,交给 CDPService.launch → 浏览器层判断能否复用现有 Chrome(配置相近就复用,否则关旧的开新的),启动时注入指纹、挂请求拦截、装扩展 → 返回一个带 websocketUrl 的会话详情 → agent 拿这个 URL 用 Puppeteer 连上去,后续每个 CDP 命令都经 WebSocket 插件转发到那台 Chrome。

一个关键的顶层事实:同一时刻只有一个「活跃会话」SessionServiceactiveSession 是单数(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:93 startSession。其中代理是惰性起的——只有传了 proxyUrlnew ProxyServerlisten():

// 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:196)。传布尔是「全都拦」,传对象是「精细控制」。
    • persist: true 或显式 userDataDir 会让会话数据写到仓库内固定目录,否则用系统临时目录(session.service.ts:178)——这决定了状态是不是真落盘。
    • 移动设备(deviceConfig.device === "mobile")会把尺寸抬到最小 508×1074(session.service.ts:156)。

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:557 launchInternal。复用分支里会 refreshPrimaryPage()(开新页、关旧页)并在有 sessionContext 时重新注入(cdp.service.ts:591)。

  • 关键细节/坑: 整个 launchInternal 被 60 秒超时和 RetryManager 包着(cdp.service.ts:548: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:1188 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:1376 injectSessionContext为什么 localStorage 要等导航时才灌:localStorage 是按 origin 隔离的,只有页面真的到了那个 origin,写进去才有意义——所以它按 origin 分组、监听导航、命中才注入。

3.4 反检测:让这台被自动化控制的 Chrome 看起来像真人

  • 它要解决的小问题: 网站会检测「你是不是机器人」。Puppeteer 驱动的 Chrome 有一堆破绽(navigator.webdriver、缺失的指纹、--enable-automation 痕迹、可疑的请求头)。

  • 手段拆成几层(各司其职):

做什么位置
启动参数去掉 --enable-automation,关掉一大票会暴露/干扰的 Chrome featurecdp.service.ts:154(ignoreDefaultArgs)、:826 staticDefaultArgs
指纹生成fingerprint-generator 造一套一致的 UA/屏幕/OS 指纹cdp.service.ts:688 附近
指纹注入把 UA、UA-metadata、请求头灌进页面(CDP Emulation.setUserAgentOverride)cdp.service.ts:1429 injectFingerprintSafely
请求头修补拦截时删掉 accept-language(headless 特征)cdp.service.ts:397
  • 一个很实在的工程细节(值得学): 指纹数据集常常落后于最新 Chrome 版本,如果硬指定「最新版 + 很窄的屏幕范围」,可能一个匹配样本都没有,getFingerprint() 直接抛异常。Steel 的对策是逐级放宽——先试最严的选项(最好的隐匿性),不行就放宽浏览器版本,再不行就去掉屏幕约束:
// api/src/services/cdp/cdp.service.ts:716 # 真实源码节选,注释即设计说明
const fallbackOptions = [
fingerprintOptions, // 最严
{ ...fingerprintOptions, browsers: [{ name: "chrome" }] }, // 放宽版本
{ ...fingerprintOptions, browsers: [{ name: "chrome" }], screen: undefined }, // 再去掉屏幕约束
];
// 依次尝试,第一个成功的就用;全失败才抛

这是「优先最优、失败降级而非硬崩」的典型模式。

3.5 请求拦截:省流量、屏广告、堵安全洞——都在一个钩子里

  • 它要解决的小问题: 页面动辄拉几 MB 图片/视频;有些 host 想直接屏蔽;还有 file:// 这种绝不能让被控浏览器碰的协议。

  • 思路/直觉: 把所有「这个请求要不要放行」的判断集中到一个请求拦截函数里,按优先级依次裁决:广告 → 屏蔽 host/模式 → 省带宽资源类型 → 安全兜底。

  • 判定顺序(命中即拦):

请求进来 handlePageRequest
│ 删掉 accept-language 头(反检测)
├─ 是广告且 blockAds? ──► abort
├─ host 被屏蔽 / URL 命中模式? ──► abort
├─ 是图/媒体/样式且对应开关开? ──► abort
├─ 是 file:// ? ──► 关页 + 结束会话(安全违规)
└─ 否则 ──► continue(带修补后的头)

真实实现见 api/src/services/cdp/cdp.service.ts:394 handlePageRequest。注意 file:// 那条不是简单 abort,而是直接终止整个会话(ShutdownReason.SECURITY_VIOLATION,cdp.service.ts:445)——把访问本地文件系统当成红线。


4. 巧妙之处(可借鉴的技术)

  • 「相似配置就复用实例」把冷启动省掉。 不是无脑每次重启,而是用 isSimilarConfig 决定复用还是重建(cdp.service.ts:565)。给「按需拉起昂贵资源」的服务一个可抄的范式。

  • 错误分级三件套:executeCritical / executeOptional / executeBestEffort 启动流程里每一步都明确标注「失败该致命还是该降级」(如文件清理失败只记日志继续,配置校验失败则抛)。这让一个几百行的启动函数依然可控——引用密集地出现在 launchInternal(cdp.service.ts:640:647:665)。

  • 指纹生成的渐进放宽。 面对「上游数据集滞后」这种外部不可控问题,用 fallback 数组逐级降要求而非硬失败(cdp.service.ts:716)。工程上很实用的容错思路。

  • localStorage 按 origin + 导航时机注入。 认清「存储有 origin 边界」这个 web 本质,用 framenavigated 在正确时机灌数据(cdp.service.ts:1408),而不是盲目一次性写。

  • CDP WebSocket 透明转发 + 可插拔 handler。 WS 插件先让自定义 handler 匹配,没匹配上才回落到把原生 CDP 转发给 Chrome(browser-socket.ts:52 起、cdp.service.ts:1103 proxyWebSocket)。既能让外部 Puppeteer 直连,又能拦一层做定制。


5. 边界与局限(诚实)

  • 单实例、单活跃会话。 SessionService.activeSession 是单数、CDPService 只管一个 browserInstance。这是「一台服务器 = 一台浏览器」的自托管模型;要多租户并发得自己在上层编排多个实例(Steel Cloud 做的正是这层,不在本仓库)。

  • 代理内的 Node 抓取有意留空。 handleScrape 里注释明说:走代理时只用浏览器导航,「proxy-aware Node fetch 需要 HTTP agent,超出当前范围」(api/src/modules/actions/actions.controller.ts:52)。

  • file:// 直接判为安全违规并杀会话(cdp.service.ts:442)——这是有意的护栏,但也意味着任何本地文件访问需求都被堵死。

  • 指纹强绑 Chrome/desktop/linux 假设。 默认指纹选项写死了 operatingSystems: ["linux"]browsers: chrome(cdp.service.ts:690 附近);跨这些假设的场景需要自己传 fingerprintuserAgent 覆盖。

  • 状态导出依赖 userDataDir getBrowserState 若没有 userDataDir 直接返回空对象(cdp.service.ts:1195)——不 persist 的会话拿不到落盘那部分存储。


6. 横向对比(同 shelf 的 browser-agents)

Steel 和同货架的其它浏览器项目定位明显不同,值得对照着看:

项目定位关键差异
Steel Browser(本章)浏览器基础设施 API:管会话/代理/指纹/抓取不含「决定点哪」的 agent 逻辑,只提供干净的浏览器和 CDP 通道
agent-browser给 agent 的自动化 CLI:无障碍树 + @e 引用点按关注「怎么让 agent 稳定地操作元素」,更偏动作落地
browseros面向浏览的 agent 环境侧重端到端浏览体验
browser-use-web-uibrowser-use 的 Web UI侧重可视化编排与交互

一句话:Steel 是「浏览器的供给侧」——别人负责教 agent 怎么点、看、想,Steel 负责把那台又干净又隐匿又带状态的浏览器递到手上。二者常常是搭配关系,而非竞争。


7. 代码地图(导航索引)

按符号名 grep 比按行号更抗上游漂移;下表列出关键文件与真实符号。

主题文件路径符号名
服务入口(建 server / 监听)api/src/index.tssetupServer / startServer
主插件(串联所有子插件与路由)api/src/steel-browser-plugin.tssteelBrowserPlugin
会话编排(开会话总入口)api/src/services/session.service.tsSessionService.startSession
结束会话api/src/services/session.service.tsSessionService.endSession
浏览器核心服务api/src/services/cdp/cdp.service.tsCDPService
启动 / 复用判定api/src/services/cdp/cdp.service.tslaunchInternal(isSimilarConfig 判复用)
启动的重试包装api/src/services/cdp/cdp.service.tslaunch(RetryManager)
请求拦截(广告/带宽/安全)api/src/services/cdp/cdp.service.tshandlePageRequest
状态导出api/src/services/cdp/cdp.service.tsgetBrowserState / getExistingPageSessionData
状态注入(cookie/localStorage)api/src/services/cdp/cdp.service.tsinjectSessionContext
指纹注入api/src/services/cdp/cdp.service.tsinjectFingerprintSafely
CDP WebSocket 转发api/src/services/cdp/cdp.service.tsproxyWebSocket
会话路由api/src/modules/sessions/sessions.routes.ts(POST /v1/sessions 等)
会话控制器api/src/modules/sessions/sessions.controller.tshandleLaunchBrowserSession
抓取动作(scrape/screenshot/pdf)api/src/modules/actions/actions.controller.tshandleScrape / handleScreenshot / handlePDF
插件生命周期基类api/src/services/cdp/plugins/core/base-plugin.tsBasePlugin / ShutdownReason
WebSocket 服务插件api/src/plugins/browser-socket/browser-socket.tsbrowserWebSocket
抓取工具(清洗/转换)api/src/utils/scrape/cleanHtml / jsonToMarkdown / getDefuddleContent / safeGoTo