# rrweb 与 SuperRPA 集成适配指南 本文档面向 SuperRPA(魔改版 Chromium)项目开发团队,说明如何将 rrweb 录制/回放能力集成到浏览器中。 --- ## 1. rrweb 概述 rrweb 是一个 Web 页面操作录制与回放库。它通过以下机制工作: 1. **全量快照 (Full Snapshot)**:将当前页面 DOM 树序列化为一个带唯一 ID 的 JSON 数据结构 2. **增量快照 (Incremental Snapshot)**:通过 MutationObserver 和各种事件监听器,持续捕获 DOM 变更和用户交互 3. **回放 (Replay)**:从全量快照重建 DOM,然后按时间戳顺序重放增量事件 所有录制产物都是纯 JSON 数据(`eventWithTime[]`),可序列化传输和持久存储。 ### 录制覆盖的用户操作 | 类别 | 具体内容 | |------|----------| | DOM 变更 | 节点增删、属性变化、文本内容变化 | | 鼠标操作 | 移动、点击、双击、右键、悬停 | | 键盘输入 | 输入框文本变化、复选框/单选框状态 | | 滚动 | 页面滚动、元素内滚动 | | 触摸 | TouchStart、TouchMove、TouchEnd | | Canvas | 2D 绑定调用记录、WebGL 调用记录 | | 媒体 | 音视频播放、暂停、跳转、音量变化 | | 样式 | CSS 规则增删改、行内样式变化 | | 字体 | 动态加载的字体 | | 文本选择 | 选区变化 | | Shadow DOM | Shadow DOM 内的变更 | | 自定义元素 | Custom Element 注册 | | 跨域 iframe | 通过 postMessage 桥接录制 | --- ## 2. 构建产物 ### 2.1 构建 ```bash # 在 rrweb 项目根目录 yarn install yarn build:all ``` ### 2.2 关键产物文件 | 文件 | 用途 | 大小参考 | |------|------|----------| | `packages/rrweb/dist/rrweb.umd.cjs` | 全量包(录制+回放),UMD 格式 | 较大 | | `packages/rrweb/dist/rrweb.js` | 全量包,ESM 格式 | 较大 | | `packages/record/dist/record.umd.cjs` | **仅录制**,UMD 格式(推荐用于注入) | ~40KB gzip | | `packages/record/dist/record.js` | 仅录制,ESM 格式 | ~40KB gzip | | `packages/replay/dist/replay.js` | 仅回放,ESM 格式 | ~50KB gzip | | `packages/replay/dist/style.css` | 回放必需的 CSS 样式 | — | | `packages/rrweb/dist/style.css` | 全量包回放样式 | — | **建议**:SuperRPA 注入录制脚本时,使用 `@rrweb/record`(仅录制)以减小注入体积。回放端使用 `@rrweb/replay` 或 `rrweb-player`。 ### 2.3 UMD 全局变量名 注入后,rrweb 的 API 会挂载到全局变量上: - `rrweb.umd.cjs` → `window.rrweb`(包含 `record`、`Replayer`) - `record.umd.cjs` → `window.rrwebRecord`(仅包含 `record`) --- ## 3. 集成方案 SuperRPA 作为魔改版 Chromium,有以下几种集成方式: ### 方案 A:通过 CDP 注入(推荐,改动最小) 利用 Chrome DevTools Protocol 的 `Page.addScriptToEvaluateOnNewDocument` 在每个页面加载前注入录制脚本。 ``` ┌─────────────────────────────────────────────┐ │ SuperRPA (Chromium) │ │ │ │ ┌──────────┐ CDP ┌───────────────┐ │ │ │ 控制层 │ ────────→ │ 渲染进程 │ │ │ │ (Browser │ │ │ │ │ │ Process)│ │ Web Page │ │ │ │ │ │ ┌──────────┐ │ │ │ │ 收集事件 │ ←──────── │ │ rrweb │ │ │ │ │ 存储/转发 │ postMsg │ │ record() │ │ │ │ └──────────┘ │ └──────────┘ │ │ │ └───────────────┘ │ └─────────────────────────────────────────────┘ ``` **步骤:** 1. 读取录制脚本文件内容 2. 在页面导航前通过 CDP 注入 ```cpp // C++ 侧伪代码(SuperRPA Browser Process) std::string record_script = ReadFile("record.umd.cjs"); std::string injection_code = record_script + R"( (function() { window.__rrweb_events = []; window.__rrweb_stop = rrwebRecord.record({ emit: function(event) { // 方式1:存到全局变量,由 CDP 定时读取 window.__rrweb_events.push(event); // 方式2:通过 postMessage 发给 content script window.postMessage({ type: '__rrweb_event', event: event }, '*'); } }); // 通知控制层录制已就绪 window.postMessage({ type: '__rrweb_ready' }, '*'); })(); )"; // 通过 CDP 注入 cdp_session->Send("Page.addScriptToEvaluateOnNewDocument", { {"source", injection_code} }); ``` ```python # Python 侧伪代码(如果用 Puppeteer/Playwright 驱动) import json cdp = await page.context().new_cdp_session(page) await cdp.send("Page.addScriptToEvaluateOnNewDocument", { "source": record_script + """ window.__rrweb_events = []; window.__rrweb_stop = rrwebRecord.record({ emit(event) { window.__rrweb_events.push(event); } }); """ }) # 导航到目标页面 await page.goto("https://example.com") # 定时采集事件 events = await page.evaluate("window.__rrweb_events.splice(0)") # 停止录制 await page.evaluate("window.__rrweb_stop()") ``` ### 方案 B:Chromium 源码层注入(改动较大,性能最优) 在 Chromium 的 `ContentRendererClient::RenderFrameCreated` 或 `RenderFrameImpl::DidCreateDocumentElement` 中直接注入。 ```cpp // content/renderer/render_frame_impl.cc 或对应的 SuperRPA 扩展点 void SuperRPARenderFrameObserver::DidCreateDocumentElement() { auto& web_frame = render_frame_->GetWebFrame(); std::string script = Load rrweb_record_script + R"( window.__rrweb_events = []; window.__rrweb_stop = rrwebRecord.record({ emit(event) { window.__rrweb_events.push(event); } }); )"; web_frame.ExecuteScript( WebScriptSource(WebString::FromUTF8(script))); } ``` ### 方案 C:Chrome Extension 方式 rrweb 项目已包含一个浏览器扩展示例(`packages/web-extension/`),可直接参考或改造。 --- ## 4. 核心 API ### 4.1 录制 ```js // 开始录制 const stopFn = rrwebRecord.record({ emit(event) { // event: eventWithTime 类型,每次用户操作或 DOM 变更触发 // 必须保证所有 event 按 timestamp 顺序存储 sendToBackend(event); }, }); // 停止录制 stopFn(); ``` ### 4.2 回放 ```js // 创建回放器 const replayer = new rrweb.Replayer(events, { root: document.getElementById('container'), // 回放容器 DOM speed: 1, }); // 获取元数据 const meta = replayer.getMetaData(); // meta = { startTime: number, endTime: number, totalTime: number } // 播放控制 replayer.play(); // 从头播放 replayer.play(5000); // 从 5000ms 处播放 replayer.pause(); // 暂停在当前位置 replayer.pause(3000); // 暂停在 3000ms 处 // 配置变更 replayer.setConfig({ speed: 2 }); // 修改播放速度 // 销毁 replayer.destroy(); ``` ### 4.3 事件监听 ```js replayer.on('finish', () => { /* 回放结束 */ }); replayer.on('resize', (dimension) => { /* 视口大小变化 */ }); ``` --- ## 5. 事件数据结构 ### 5.1 顶层结构 每个事件的顶层结构为 `eventWithTime`: ```ts { timestamp: number; // Unix 时间戳(毫秒),相对于页面加载的偏移量 type: EventType; // 事件类型(数值枚举) data: object; // 事件数据,结构因 type 不同而异 } ``` ### 5.2 EventType 枚举 ```ts enum EventType { DomContentLoaded = 0, // DOM 加载完成 Load = 1, // 页面完全加载 FullSnapshot = 2, // 全量 DOM 快照 IncrementalSnapshot = 3, // 增量快照(最重要的类型) Meta = 4, // 页面元信息(URL、视口尺寸) Custom = 5, // 自定义事件 Plugin = 6, // 插件事件 } ``` ### 5.3 一个完整的录制会话包含的事件序列 ``` 1. Meta → { href: "页面URL", width: 1920, height: 1080 } 2. FullSnapshot → 完整序列化的 DOM 树 3. IncrementalSnapshot × N → 各种增量变更事件 ...(可选)DomContentLoaded / Load ``` **关键点**:每个会话必须以一个 `Meta` 事件 + 一个 `FullSnapshot` 事件开头,后续才有意义。 ### 5.4 IncrementalSnapshot 的 data.source 枚举 ```ts enum IncrementalSource { Mutation = 0, // DOM 变更(增删改节点/属性/文本) MouseMove = 1, // 鼠标移动 MouseInteraction = 2, // 鼠标交互(点击、双击、右键、聚焦等) Scroll = 3, // 滚动 ViewportResize = 4, // 视口尺寸变化 Input = 5, // 输入框值变化 TouchMove = 6, // 触摸移动 MediaInteraction = 7, // 媒体播放/暂停/跳转 StyleSheetRule = 8, // CSS 规则变更 CanvasMutation = 9, // Canvas 绑定调用 Font = 10, // 字体加载 Log = 11, // 控制台日志(需插件) Drag = 12, // 拖拽 StyleDeclaration = 13, // 行内样式声明变更 Selection = 14, // 文本选区变化 AdoptedStyleSheet = 15, // Adopted Stylesheet CustomElement = 16, // 自定义元素注册 } ``` ### 5.5 常见事件数据示例 **Meta 事件**: ```json { "type": 4, "data": { "href": "https://example.com/page", "width": 1920, "height": 1080 }, "timestamp": 1712600000000 } ``` **全量快照**: ```json { "type": 2, "data": { "node": { "type": 0, "id": 1, "childNodes": [ { "type": 2, "tagName": "html", "id": 2, "attributes": { "lang": "zh" }, "childNodes": [...] } ] }, "initialOffset": { "top": 0, "left": 0 } }, "timestamp": 1712600000001 } ``` **鼠标点击**: ```json { "type": 3, "data": { "source": 2, "type": 2, "id": 42, "x": 320, "y": 150 }, "timestamp": 1712600002500 } ``` **输入框变化**: ```json { "type": 3, "data": { "source": 5, "id": 15, "text": "用户输入的内容", "isChecked": false }, "timestamp": 1712600003000 } ``` **DOM 变更**: ```json { "type": 3, "data": { "source": 0, "texts": [], "attributes": [{ "id": 7, "attributes": { "class": "active" } }], "removes": [], "adds": [ { "parentId": 5, "nextId": null, "node": { "type": 2, "tagName": "div", "id": 99, "attributes": {}, "childNodes": [] } } ] }, "timestamp": 1712600004000 } ``` **滚动**: ```json { "type": 3, "data": { "source": 3, "id": 1, "x": 0, "y": 500 }, "timestamp": 1712600005000 } ``` ### 5.6 序列化节点类型 全量快照和增量快照中的新增节点使用以下类型: ```ts enum NodeType { Document = 0, // 文档节点 DocumentType = 1, // Element = 2, // HTML 元素 Text = 3, // 文本节点 CDATA = 4, // CDATA 节点 Comment = 5, // 注释节点 } ``` 每个序列化节点都有唯一的 `id`(自增数字),用于增量快照中引用和定位。 --- ## 6. 录制配置详解 ```js rrwebRecord.record({ // 必填:事件回调 emit(event) { // 每个 event 都必须保存,不可丢弃 // events 必须按 timestamp 有序 }, // ---- 隐私保护 ---- // 遮挡所有输入框内容(记录为 *) maskAllInputs: false, // 细粒度控制哪些输入类型被遮挡 maskInputOptions: { password: true, text: false, email: false, // 支持所有 input type }, // 自定义遮挡函数 maskInputFn: (text) => text.replace(/./g, '*'), // 通过 CSS 选择器遮挡元素内的文本 maskTextSelector: '.sensitive-data', // 自定义文本遮挡函数 maskTextFn: (text) => '***', // CSS 类名方式遮挡文本(默认 'rr-mask') maskTextClass: 'rr-mask', // ---- 屏蔽/忽略 ---- // 不录制该 CSS 类名的元素及其子树(显示为灰色色块) blockClass: 'rr-block', blockSelector: '.no-record-area', // 完全忽略该元素(不录制,回放时也不出现) ignoreClass: 'rr-ignore', ignoreSelector: '.ignore-me', // ---- 采样策略(影响性能和存储体积的关键配置)---- sampling: { // 鼠标移动:false=不录制,数字=节流间隔(ms) mousemove: 50, // 鼠标交互:false=不录制,true=全部录制,或细粒度控制 mouseInteraction: { MouseUp: true, MouseDown: true, Click: true, ContextMenu: false, DblClick: true, Focus: true, Blur: true, TouchStart: true, TouchEnd: true, }, // 滚动节流间隔(ms) scroll: 150, // 媒体交互节流间隔(ms) media: 800, // 输入事件:'all'=记录每次按键,'last'=只记录最终值 input: 'last', // Canvas:'all'=记录每次API调用,数字=每秒截图次数(1-60) canvas: 'all', }, // ---- 高级功能 ---- recordCanvas: false, // 是否录制 Canvas recordCrossOriginIframes: false, // 是否录制跨域 iframe recordAfter: 'DOMContentLoaded', // 何时开始录制: 'DOMContentLoaded' | 'load' inlineStylesheet: true, // 是否内联外部样式表 inlineImages: false, // 是否内联图片为 base64 collectFonts: false, // 是否收集动态加载的字体 // 定期生成全量快照(防止增量快照链过长导致回放失败) checkoutEveryNth: 100, // 每 N 个增量事件生成一次 checkoutEveryNms: 60000, // 每 N 毫秒生成一次 // 数据压缩(需配合 @rrweb/packer) // packFn: pack, // 错误处理 errorHandler: (error) => { console.error('rrweb error:', error); // return true 可跳过该错误继续录制 }, // 插件 plugins: [ // console 录制插件 // getRecordConsolePlugin(), ], }); ``` --- ## 7. SuperRPA 集成注意事项 ### 7.1 注入时机 录制脚本必须在页面的 DOM 构建之前或 `DOMContentLoaded` 之前注入。使用 `Page.addScriptToEvaluateOnNewDocument` 可以保证在页面任何脚本执行前注入。 如果注入太晚,会丢失页面初始状态的快照。 ### 7.2 事件采集 在 SuperRPA 中,需要将录制事件从渲染进程传递到控制层。推荐方式: | 方式 | 适用场景 | 注意事项 | |------|----------|----------| | `Runtime.evaluate` 定时轮询 `window.__rrweb_events` | 简单场景 | 高频轮询有性能开销 | | CDP `Runtime.binding` | 高性能 | 需要在 CDP session 上创建 binding | | `window.postMessage` + Content Script | 扩展模式 | 需要 content script 中转 | | V8 Inspector 自定义消息 | 深度集成 | 改动较大 | 推荐 **CDP `Runtime.binding`** 方案: ```js // 注入脚本 window.__rrweb_events = []; rrwebRecord.record({ emit(event) { // 通过 CDP binding 发送 if (typeof window.__rrwebSend === 'function') { window.__rrwebSend(JSON.stringify(event)); } // 同时存入数组作为备份 window.__rrweb_events.push(event); } }); ``` ```python # 控制端 await cdp.send("Runtime.addBinding", {"name": "__rrwebSend"}) cdp.on("Runtime.bindingCalled", lambda params: handle_event( json.loads(params["payload"]) )) ``` ### 7.3 会话管理 一个「录制会话」对应一次页面导航(navigation)。需要注意: 1. **单页应用 (SPA)**:页面内路由切换不会触发新的导航,rrweb 会持续录制整个 SPA 会话 2. **多页导航**:每次导航需要重新注入录制脚本并开始新会话 3. **跨域 iframe**:每个跨域 iframe 也需要单独注入录制脚本,rrweb 会通过 `postMessage` 桥接 4. **事件合并**:一个完整会话的所有事件必须按 `timestamp` 有序存储 ### 7.4 性能影响 | 配置 | 性能影响 | 存储体积 | |------|----------|----------| | 默认配置 | 中等 | 中等 | | `sampling.mousemove = false` | 低 | 小 | | `sampling.input = 'last'` | 低 | 小 | | `recordCanvas = true` | 高 | 大 | | `recordCanvas = true` + `sampling.canvas = 10` | 中 | 中 | | `inlineStylesheet = true` | 中 | 大(取决于页面样式复杂度) | | `checkoutEveryNms = 30000` | 略高 | 略大(但提高回放鲁棒性) | **建议基线配置**: ```js { sampling: { mousemove: 50, scroll: 150, input: 'last', media: 800, }, recordCanvas: false, checkoutEveryNms: 300000, // 每 5 分钟一次全量快照 } ``` ### 7.5 存储优化 1. **使用 packFn 压缩**: ```js import { pack } from '@rrweb/packer'; record({ emit(event) {}, packFn: pack }); ``` 2. **服务端批量压缩**:采集原始事件,服务端用 gzip/zstd 压缩效果更好 3. **定期全量快照**:通过 `checkoutEveryNms` 避免增量链过长 4. **跨会话去重**:相同样式表、相同页面的全量快照可以做去重 ### 7.6 回放端集成 回放可以在 SuperRPA 内的专用标签页中展示,或在外部系统中: ```js // 在空白页面中创建回放器 const replayer = new rrweb.Replayer(events, { root: document.getElementById('replay-container'), speed: 1, skipInactive: true, // 跳过无操作的静默期 inactivePeriodThreshold: 5000, // 超过 5 秒无操作则跳过 showWarning: true, insertStyleRules: [ // 注入自定义样式(如鼠标光标样式) 'div.replayer-mouse { cursor: pointer; }' ], }); // 监听事件 replayer.on('finish', () => console.log('回放结束')); replayer.on('resize', (d) => console.log('视口变化', d)); // 播放控制 replayer.play(); replayer.pause(); replayer.setConfig({ speed: 4 }); ``` 回放需要加载 CSS 样式: ```html ``` --- ## 8. 录制事件中提取 RPA 可用信息 如果 SuperRPA 不仅需要视觉回放,还需要从事件中提取 **可复用的操作信息**(如元素定位、操作类型),可以从以下字段获取: ### 8.1 鼠标点击事件 → 点击坐标 + 目标元素 ```json { "type": 3, "data": { "source": 2, "type": 2, // MouseInteractions.Click = 2 "id": 42, // 序列化节点 ID "x": 320, "y": 150 // 相对于视口的坐标 } } ``` - `id` 对应全量快照中的节点 ID,可反查到 `tagName`、`attributes`(含 `class`、`id` 等) - `x`、`y` 为点击坐标 ### 8.2 输入事件 → 输入值 + 目标元素 ```json { "type": 3, "data": { "source": 5, "id": 15, "text": "用户输入", "isChecked": false } } ``` ### 8.3 滚动事件 → 滚动位置 ```json { "type": 3, "data": { "source": 3, "id": 1, "x": 0, "y": 500 } } ``` ### 8.4 从全量快照反查元素定位信息 全量快照中包含完整的 DOM 树,每个节点有 `id`、`tagName`、`attributes`。可以通过 ID 反查来构造 CSS 选择器或 XPath。 ```js // 从全量快照中构建 ID → 节点映射 function buildNodeMap(fullSnapshot) { const map = {}; function walk(node) { map[node.id] = node; if (node.childNodes) node.childNodes.forEach(walk); } walk(fullSnapshot.data.node); return map; } ``` ### 8.5 MouseInteractions 枚举 ```ts enum MouseInteractions { MouseUp = 0, MouseDown = 1, Click = 2, ContextMenu = 3, DblClick = 4, Focus = 5, Blur = 6, TouchStart = 7, TouchMove_Departed = 8, TouchEnd = 9, TouchCancel = 10, } ``` --- ## 9. 完整注入示例 以下是一个可直接使用的注入脚本模板: ```js // === rrweb-recorder-inject.js === // 此文件应包含 record.umd.cjs 的完整内容 // 然后附加以下启动代码 (function() { 'use strict'; var SESSION_ID = '__SESSION_ID_PLACEHOLDER__'; // 由控制层替换 var events = []; function sendEvent(event) { // 使用 CDP binding(如果可用) if (typeof window.__rrwebSendEvent === 'function') { try { window.__rrwebSendEvent(JSON.stringify({ sessionId: SESSION_ID, event: event })); } catch (e) { // fallback events.push(event); } } else { events.push(event); } } // 开始录制 var stopFn = rrwebRecord.record({ emit: sendEvent, maskAllInputs: false, recordCanvas: false, recordCrossOriginIframes: true, recordAfter: 'DOMContentLoaded', sampling: { mousemove: 50, scroll: 150, input: 'last', media: 800, }, checkoutEveryNms: 300000, }); // 暴露控制接口 window.__rrweb = { stop: function() { if (stopFn) stopFn(); }, getEvents: function() { return events; }, drainEvents: function() { var result = events.slice(); events.length = 0; return result; }, getSessionId: function() { return SESSION_ID; } }; // 通知就绪 if (typeof window.__rrwebReady === 'function') { window.__rrwebReady(SESSION_ID); } })(); ``` --- ## 10. 故障排查 | 问题 | 可能原因 | 解决方案 | |------|----------|----------| | 回放白屏 | 缺少 Meta + FullSnapshot 事件 | 确保从页面加载开始就注入了录制脚本 | | 回放缺少样式 | `inlineStylesheet` 未开启或外部样式表加载失败 | 设置 `inlineStylesheet: true` | | 回放中元素错位 | 视口尺寸与录制时不一致 | 使用 Meta 事件中的 width/height 创建同尺寸回放容器 | | 录制数据过大 | Canvas 动画或频繁 DOM 更新 | 配置 `sampling`、`blockClass`,或关闭 `recordCanvas` | | 跨域 iframe 无录制 | 未对 iframe 注入脚本 | 开启 `recordCrossOriginIframes: true` 并确保 iframe 中也注入了脚本 | | 回放报错 "node not found" | 增量快照链中丢失了事件 | 确保所有事件都被保存,使用 `checkoutEveryNms` 定期全量快照 | | 注入后页面行为异常 | rrweb 脚本与页面脚本冲突 | 使用 `ignoreSelector` 排除 rrweb 自身的 UI 元素 | --- ## 11. 参考链接 - rrweb 官方文档:`docs/` 目录下 - 事件数据结构定义:`packages/types/src/index.ts` - 录制逻辑:`packages/rrweb/src/record/`、`packages/record/src/` - 回放逻辑:`packages/rrweb/src/replay/`、`packages/replay/src/` - 浏览器扩展示例:`packages/web-extension/` - DOM 序列化设计:`docs/serialization.md` - 增量快照设计:`docs/observer.md` - 回放沙箱设计:`docs/sandbox.md`