Obscura 架构解析——8 crate 分层、V8 隔离与 CDP 调度
Obscura 的 8 个 crate 分层设计、单 V8 Isolate 架构、DOM 树实现、CDP 调度与生命周期管理的完整介绍与使用说明。
从命令到字节的完整路径
当 Puppeteer 调用 page.goto('https://example.com') 时,Obscura 内部发生了什么?本文沿着这条路径,介绍 8 个 crate 各自承担的角色,以及为什么架构是这样切分的。理解架构不是为了读源码,而是为了在排障、调优、扩展时知道该看哪一层。
8 个 crate 一览
obscura-cli CLI 入口:fetch / serve / scrape / mcp
obscura-cdp CDP WebSocket 服务:消息路由、Domain 处理器
obscura-browser Page 类型:导航、生命周期、上下文管理
obscura-js V8 运行时:deno_core、bootstrap.js、Rust ops
obscura-dom DOM 树:html5ever 解析、选择器、序列化
obscura-net HTTP 客户端:reqwest、wreq stealth 客户端、Cookie 轴、Tracker Blocklist
obscura-mcp MCP 协议服务器:30+ 个工具
obscura 嵌入库 API:Browser / Page / Element / CookieStore跨 crate 调用原则:上层调用下层,不横向依赖。这条规则决定了你排障时的方向——网络层的异常不会因为 DOM 层的修改而消失,反之亦然。
请求流全景
CDP 客户端发送一条 Page.navigate 命令的完整路径:
CDP Client (Puppeteer)
│ WebSocket frame
▼
obscura-cdp/server.rs 接受连接,按 sessionId 路由
│
▼
obscura-cdp/dispatch.rs 方法路由,获取 v8_lock
│
▼
obscura-cdp/domains/page.rs Page.navigate 处理器
│
▼
obscura-browser/page.rs 执行 navigate_with_wait
│
├──► obscura-net/client.rs HTTP 请求
│
├──► obscura-dom/tree.rs 解析 HTML 构建 DOM 树
│
└──► obscura-js/runtime.rs 执行内联脚本
│
└──► bootstrap.js + ops.rs DOM 绑定调度器通过 WebSocket 将 CDP 事件(Network.requestWillBeSent、Page.frameNavigated、Page.lifecycleEvent)发送回客户端。这套事件链就是 Puppeteer/Playwright 判断"页面加载完成"的依据——客户端在收到匹配的 lifecycleEvent 后才会 resolve goto。
单 V8 Isolate 设计
所有 Page 共享一个 V8 Isolate。这是 Obscura 最重要的架构决策,也是它内存占用低的根本原因——一个 Chrome 进程会为每个标签页分配独立的渲染进程和 Isolate,而 Obscura 用一个 Isolate 服务所有页面。
// obscura_js::v8_lock::global() 是一个 tokio::sync::Mutex
let _guard = obscura_js::v8_lock::global().lock().await;
page.evaluate(expr).await;V8 Isolate 是单线程的,因此所有需要执行 JavaScript 的操作都必须先获取这个全局锁:
Runtime.evaluate、callFunctionOnPage.navigate(需要运行页面脚本)DOM操作(Node 插入、属性查询)
并发细节:dispatch.rs 通过 process_with_interception 将长操作(navigation、eval)spawn 到 tokio LocalSet,释放调度器继续处理其他 CDP 消息。这就是为什么 Target.createTarget 从多个并发客户端调用时能够立即返回:每个 newPage 立即返回,实际导航在后台任务中执行。
这个设计对你的使用方式有直接影响:
- 并发页面要控制数量。虽然
newPage立即返回,但所有页面的 JS 执行串行通过同一把锁。开 50 个 Page 同时跑重 JS,等于 50 个任务排队抢一把锁。 - CPU 密集型 JS 会拖累其他页面。一个页面跑死循环(虽然会被 watchdog 终止),在终止前会阻塞所有其他页面的 JS 执行。
- 要真正并行就用多进程。
obscura serve --workers N起多个进程,每个进程一个独立 Isolate,这才是水平扩展的正确方式。
DOM 树实现
Obscura 使用 html5ever 解析 HTML,通过自定义 tree_sink 构建 DOM 树,选择器引擎用的是 Servo 的 selectors crate。
// obscura-dom/src/tree.rs — 核心数据结构
struct Node {
id: NodeId,
parent: Option<NodeId>,
children: Vec<NodeId>,
node_type: NodeType, // Element / Text / Comment / Document
}
struct Tree {
nodes: Vec<Node>,
root: NodeId,
}几个对使用有影响的设计特性:
- 循环引用守卫:
append_child和insert_before拒绝将祖先节点插入为子节点。这类树操作守卫确保 DOM 树操作不会产生环,树遍历(如descendants())始终能在有限步内完成。 - descendants() 长度上限:作为额外兜底,树遍历带有一个硬编码长度上限,即便遇到异常结构也不会无限递归。
- 选择器引擎:使用
selectorscrate(Servo 的 CSS 选择器实现),支持标准的 CSS 选择器语法,所以你通过 CDP 的querySelector或 CLI 的--selector用的选择器和浏览器里写的一致。
CDP 调度架构
obscura-cdp 是 WebSocket 服务端,核心组件是 server.rs(接受连接)、dispatch.rs(按 sessionId 路由消息)、domains/*.rs(每个 CDP Domain 一个处理器)。
Session 模型:每个 CDP 客户端连接对应一个或多个 Target。Session ID 格式为 "{targetId}-session"。调度器根据传入帧的 sessionId 将消息路由到正确的 Page。
Target 创建:Target.createTarget 创建新的 Page。关闭 WebSocket 会 detach 所有 session,但 Page 继续运行——这一点和 Chrome 不同,断连不会销毁页面状态。
长操作处理:process_with_interception 是调度器中的核心机制——它把需要长时间运行的操作(navigation、JS eval)spawn 到独立任务,同时释放当前任务继续处理其他 CDP 消息。这就是为什么单个慢页面的导航不会阻塞其他 session 的命令。
生命周期状态机
Page 的生命周期由 obscura-browser/lifecycle.rs 管理:
init → commit → domcontentloaded → load → networkidle2 → networkidle0init:用户发起导航commit:服务器返回响应首字节domcontentloaded:DOM 解析完成load:所有同步资源加载完成networkidle2:500ms 内活跃连接 ≤ 2networkidle0:500ms 内无活跃连接
Page.navigate 的 waitUntil 参数对应上述事件。Puppeteer 的 goto 在客户端等待匹配的 Page.lifecycleEvent。
理解这条链对调试 goto 超时很有用:如果 goto 卡住,本质是某个 lifecycleEvent 一直没到。用 obscura serve --verbose 看日志里的事件序列,能快速定位是网络层(load 不来)还是页面层(networkidle 一直被长轮询打破)。
Storage 持久化
--storage-dir 参数启用持久化存储:
<storage-dir>/
├── cookies.json # Cookie Jar 序列化
└── localStorage/
├── <origin-1>.json # 每个源的 localStorage
└── <origin-2>.json启动时读取,每次导航和优雅关闭时写入。多身份场景的做法是起多个 obscura serve 进程,每个用不同的 --storage-dir 和端口,互不干扰。
Stealth 集成
--stealth 是全局 CLI 参数(不是子命令参数),作用于所有子命令:
obscura fetch https://example.16yun.cn --stealthobscura serve --stealthobscura scrape url1 url2 --stealthobscura mcp --stealth
scrape 命令通过 OBSCURA_STEALTH 环境变量将 stealth 设置传递给每个 Worker 进程。
Stealth 模式在 obscura-net 层将默认的 reqwest 客户端替换为 wreq 客户端,同时在 obscura-js 层的 bootstrap.js 中应用 JS API 伪装。这意味着 Stealth 的开关只影响两个 crate,对其他层透明——这也是分层设计带来的好处。
为什么是 8 个 crate
分层设计不是为了好看,而是有实际的工程收益:
- 单一职责:每个 crate 对应一个明确的能力域,新开发者可以快速定位代码。改 DOM 不用翻网络层,改 CDP 调度不用碰 V8。
- 编译缓存:修改某个 crate 不需要重新编译整个 V8。V8 的源码编译是最耗时的部分(首次约 5 分钟),分层让日常迭代的增量编译控制在秒级。
- 测试边界:每个 crate 可以独立测试,DOM 测试不需要网络,CDP 测试不需要 V8。这让单元测试跑得快、定位准。
- 可选依赖:Stealth 功能只在
obscura-net层,不影响核心。不需要反检测的用户用默认 rustls 构建,不引入 BoringSSL/cmake 依赖。
这套分层也决定了扩展 Obscura 的切入点:加 CDP 方法在 obscura-cdp/domains/ 下加处理器;加 Web API 在 bootstrap.js 加 JS shim 配合 Rust op;改网络行为在 obscura-net。每一类扩展都有明确的归属层,不需要跨层改动。
总结
Obscura 的架构核心是一系列"以性能为硬约束"的设计决策:单 V8 Isolate 避免线程同步开销、Rust 直接操作 V8 C API 避免桥接层、DOM 树直接构建在 Rust 中避免序列化开销。8 crate 分层让每一次修改、测试、扩展都有明确的边界。理解这套架构,是为了在排障时知道看哪一层、在调优时知道动哪个旋钮、在扩展时知道从哪里下手。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。