Add SuperRPA integration guide, simple extension and standalone replay page
Some checks failed
Tests / Tests (push) Has been cancelled
ESLint Check / ESLint Check and Report Upload (push) Has been cancelled
Prettier Check / Format Check (push) Has been cancelled
Prettier Check / Format Code (push) Has been cancelled
ESLint Check / Build Base for Bundle Size Comparison (push) Has been cancelled
Some checks failed
Tests / Tests (push) Has been cancelled
ESLint Check / ESLint Check and Report Upload (push) Has been cancelled
Prettier Check / Format Check (push) Has been cancelled
Prettier Check / Format Code (push) Has been cancelled
ESLint Check / Build Base for Bundle Size Comparison (push) Has been cancelled
- docs/integration/superrpa-integration.zh_CN.md: complete integration guide - rrweb-simple-ext/: minimal Chrome extension for page recording - replay.html: standalone drag-and-drop replay viewer - CLAUDE.md: project instructions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
rrweb is a web session recording and replay library. It records user interactions on web pages (DOM mutations, mouse movements, input events, etc.) and can replay them faithfully. The project is a TypeScript monorepo managed by Yarn workspaces and Turborepo.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (use yarn, NOT npm)
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Build all packages
|
||||||
|
yarn build:all
|
||||||
|
|
||||||
|
# Watch mode for all packages (auto-rebuild on changes)
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# Run all tests across the monorepo
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run tests for a specific package (run from package directory)
|
||||||
|
cd packages/rrweb && yarn test # builds then runs tests
|
||||||
|
cd packages/rrweb && yarn retest # runs tests without rebuild (faster)
|
||||||
|
cd packages/rrweb-snapshot && yarn test
|
||||||
|
|
||||||
|
# Watch mode tests for a specific package
|
||||||
|
cd packages/rrweb && yarn test:watch
|
||||||
|
|
||||||
|
# Update test snapshots
|
||||||
|
cd packages/rrweb && yarn test:update # or retest:update for no rebuild
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Format
|
||||||
|
yarn format
|
||||||
|
|
||||||
|
# Type check all packages
|
||||||
|
yarn check-types
|
||||||
|
|
||||||
|
# REPL tool (interactive testing in browser)
|
||||||
|
yarn repl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Package Dependency Graph
|
||||||
|
|
||||||
|
The core data flow is: **snapshot** captures DOM → **record** observes mutations → **replay** reconstructs events.
|
||||||
|
|
||||||
|
```
|
||||||
|
@rrweb/types (shared type definitions, no deps)
|
||||||
|
@rrweb/utils (shared utilities)
|
||||||
|
rrweb-snapshot (DOM serialization/deserialization, depends on types/utils)
|
||||||
|
rrdom / rrdom-nodejs (virtual DOM for Node.js environments)
|
||||||
|
rrweb (main package: record + replay, depends on all above)
|
||||||
|
├── src/record/ (DOM mutation observers, event capture, canvas recording)
|
||||||
|
└── src/replay/ (event replay engine, timer, canvas replay, style injection)
|
||||||
|
rrweb-player (Svelte-based player UI)
|
||||||
|
@rrweb/all (convenience package combining record + replay)
|
||||||
|
@rrweb/packer (pack/unpack utilities for rrweb event data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
|
||||||
|
Plugins live in `packages/plugins/` and follow a record/replay pair pattern:
|
||||||
|
- `rrweb-plugin-console-record` / `rrweb-plugin-console-replay` — console logging
|
||||||
|
- `rrweb-plugin-canvas-webrtc-record` / `rrweb-plugin-canvas-webrtc-replay` — canvas via WebRTC
|
||||||
|
- `rrweb-plugin-sequential-id-record` / `rrweb-plugin-sequential-id-replay` — sequential IDs
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
- **Turbo** orchestrates builds via `turbo.json` with task dependency graph
|
||||||
|
- Each package uses **Vite** in library mode (`vite.config.default.ts` shared config)
|
||||||
|
- Output formats: ES modules (`.js`), CommonJS (`.cjs`), UMD (`.umd.cjs`), plus TypeScript declarations (`.d.ts` / `.d.cts`)
|
||||||
|
- `DISABLE_WORKER_INLINING=true` env var disables worker inlining for Chrome extension builds
|
||||||
|
- Test runner is **Vitest** (some older packages still use Jest)
|
||||||
|
- Tests in `packages/rrweb` use **Puppeteer** for browser-based integration tests; set `PUPPETEER_HEADLESS=true` for headless mode
|
||||||
|
|
||||||
|
### Key Source Paths
|
||||||
|
|
||||||
|
- `packages/rrweb/src/record/` — recording logic (mutation observer, iframe/shadow-dom managers, canvas observers)
|
||||||
|
- `packages/rrweb/src/replay/` — replay engine (timer, state machine via @xstate/fsm, canvas replay, style injection)
|
||||||
|
- `packages/rrweb/src/entries/record.ts` / `replay.ts` — separate entry points for record-only or replay-only bundles
|
||||||
|
- `packages/rrweb-snapshot/src/snapshot.ts` — DOM → serializable data structure
|
||||||
|
- `packages/rrweb-snapshot/src/rebuild.ts` — serialized data → DOM reconstruction
|
||||||
|
- `packages/types/src/` — shared event types (`eventWithTime`, `EventType`, `IncrementalSource`)
|
||||||
|
|
||||||
|
### ESLint Configuration
|
||||||
|
|
||||||
|
- Uses `@typescript-eslint` with type-checked rules
|
||||||
|
- `camelCase` rule allows `rr_`, `legacy_`, `UNSAFE_`, `__rrweb_` prefixed identifiers
|
||||||
|
- TSDoc syntax is enforced via `eslint-plugin-tsdoc`
|
||||||
|
- Browser compatibility checked via `eslint-plugin-compat`
|
||||||
819
docs/integration/superrpa-integration.zh_CN.md
Normal file
819
docs/integration/superrpa-integration.zh_CN.md
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
# 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, // <!DOCTYPE ...>
|
||||||
|
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
|
||||||
|
<link rel="stylesheet" href="replay/dist/style.css">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
206
replay.html
Normal file
206
replay.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>rrweb Replay</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: sans-serif; background: #1a1a2e; color: #eee; }
|
||||||
|
#dropzone {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
z-index: 999999; background: #1a1a2e;
|
||||||
|
}
|
||||||
|
#dropzone h2 { font-size: 22px; margin-bottom: 8px; }
|
||||||
|
#dropzone p { color: #aaa; margin-bottom: 20px; }
|
||||||
|
#dropzone .hint { font-size: 13px; color: #666; margin-top: 12px; }
|
||||||
|
#fileInput { display: none; }
|
||||||
|
.open-btn { padding: 12px 28px; border: 2px dashed #4CAF50; border-radius: 8px; background: transparent; color: #4CAF50; font-size: 16px; cursor: pointer; }
|
||||||
|
.open-btn:hover { background: #4CAF50; color: white; }
|
||||||
|
#controls {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 99999;
|
||||||
|
background: #16213e; padding: 10px 16px; display: none; align-items: center; gap: 12px;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
#controls button { padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; color: white; font-size: 13px; }
|
||||||
|
#controls button:hover { opacity: 0.85; }
|
||||||
|
.btn-play { background: #4CAF50; }
|
||||||
|
.btn-pause { background: #ff9800; }
|
||||||
|
.btn-speed { background: #2196F3; }
|
||||||
|
.btn-open { background: #9C27B0; }
|
||||||
|
#timeline { flex: 1; height: 6px; -webkit-appearance: none; appearance: none; background: #0f3460; border-radius: 3px; outline: none; }
|
||||||
|
#timeline::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #e94560; cursor: pointer; }
|
||||||
|
#info { font-size: 12px; color: #aaa; min-width: 120px; text-align: right; }
|
||||||
|
#replay-container { margin-top: 50px; }
|
||||||
|
</style>
|
||||||
|
<!-- Embed replay CSS inline so no external files needed -->
|
||||||
|
<script src="packages/rrweb/dist/rrweb.umd.min.cjs"></script>
|
||||||
|
<link rel="stylesheet" href="packages/rrweb/dist/style.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Drop zone: show when no events loaded -->
|
||||||
|
<div id="dropzone">
|
||||||
|
<h2>rrweb Replay</h2>
|
||||||
|
<p>Drag & drop a recording JSON file here</p>
|
||||||
|
<button class="open-btn" onclick="document.getElementById('fileInput').click()">Choose File</button>
|
||||||
|
<input type="file" id="fileInput" accept=".json">
|
||||||
|
<div class="hint">Or paste JSON via Ctrl+V</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player controls -->
|
||||||
|
<div id="controls">
|
||||||
|
<button class="btn-open" id="openBtn">Open</button>
|
||||||
|
<button class="btn-play" id="playBtn">Play</button>
|
||||||
|
<button class="btn-pause" id="pauseBtn">Pause</button>
|
||||||
|
<input type="range" id="timeline" min="0" max="100" value="0">
|
||||||
|
<span id="info">0 / 0s</span>
|
||||||
|
<button class="btn-speed" id="speedBtn">1x</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="replay-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const dropzone = document.getElementById('dropzone');
|
||||||
|
const controls = document.getElementById('controls');
|
||||||
|
const container = document.getElementById('replay-container');
|
||||||
|
const playBtn = document.getElementById('playBtn');
|
||||||
|
const pauseBtn = document.getElementById('pauseBtn');
|
||||||
|
const timeline = document.getElementById('timeline');
|
||||||
|
const infoEl = document.getElementById('info');
|
||||||
|
const speedBtn = document.getElementById('speedBtn');
|
||||||
|
const openBtn = document.getElementById('openBtn');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
|
let replayer = null;
|
||||||
|
let playing = false;
|
||||||
|
let totalMs = 0;
|
||||||
|
let startMs = 0;
|
||||||
|
const speeds = [1, 2, 4, 8];
|
||||||
|
let speedIdx = 0;
|
||||||
|
|
||||||
|
function formatTime(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
return m > 0 ? m + ':' + String(s % 60).padStart(2, '0') : s + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInfo(currentMs) {
|
||||||
|
infoEl.textContent = formatTime(currentMs) + ' / ' + formatTime(totalMs);
|
||||||
|
timeline.value = Math.round((currentMs / totalMs) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReplay(events) {
|
||||||
|
if (replayer) replayer.destroy();
|
||||||
|
container.innerHTML = '';
|
||||||
|
dropzone.style.display = 'none';
|
||||||
|
controls.style.display = 'flex';
|
||||||
|
playing = false;
|
||||||
|
playBtn.textContent = 'Play';
|
||||||
|
|
||||||
|
replayer = new rrweb.Replayer(events, {
|
||||||
|
root: container,
|
||||||
|
speed: speeds[speedIdx],
|
||||||
|
skipInactive: true,
|
||||||
|
showWarning: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = replayer.getMetaData();
|
||||||
|
totalMs = meta.totalTime;
|
||||||
|
startMs = meta.startTime;
|
||||||
|
updateInfo(0);
|
||||||
|
|
||||||
|
replayer.on('finish', () => {
|
||||||
|
playing = false;
|
||||||
|
playBtn.textContent = 'Play';
|
||||||
|
updateInfo(totalMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
try {
|
||||||
|
const events = JSON.parse(e.target.result);
|
||||||
|
if (!Array.isArray(events) || events.length === 0) {
|
||||||
|
alert('Invalid or empty events file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startReplay(events);
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to parse JSON: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File input
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files[0]) handleFile(e.target.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open button
|
||||||
|
openBtn.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
// Drag & drop
|
||||||
|
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||||
|
document.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paste JSON
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
const text = e.clipboardData.getData('text');
|
||||||
|
try {
|
||||||
|
const events = JSON.parse(text);
|
||||||
|
if (Array.isArray(events) && events.length > 0) startReplay(events);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playback controls
|
||||||
|
playBtn.addEventListener('click', () => {
|
||||||
|
if (!playing && replayer) {
|
||||||
|
replayer.play();
|
||||||
|
playing = true;
|
||||||
|
playBtn.textContent = 'Playing...';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pauseBtn.addEventListener('click', () => {
|
||||||
|
if (replayer) {
|
||||||
|
replayer.pause();
|
||||||
|
playing = false;
|
||||||
|
playBtn.textContent = 'Play';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
speedBtn.addEventListener('click', () => {
|
||||||
|
speedIdx = (speedIdx + 1) % speeds.length;
|
||||||
|
if (replayer) replayer.setConfig({ speed: speeds[speedIdx] });
|
||||||
|
speedBtn.textContent = speeds[speedIdx] + 'x';
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('input', () => {
|
||||||
|
if (!replayer) return;
|
||||||
|
const pct = parseInt(timeline.value) / 100;
|
||||||
|
const targetMs = totalMs * pct;
|
||||||
|
replayer.pause();
|
||||||
|
playing = false;
|
||||||
|
playBtn.textContent = 'Play';
|
||||||
|
replayer.play(targetMs);
|
||||||
|
setTimeout(() => { replayer.pause(); updateInfo(targetMs); }, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeline animation
|
||||||
|
function tick() {
|
||||||
|
if (playing && replayer) {
|
||||||
|
const ct = replayer.getCurrentTime();
|
||||||
|
updateInfo(ct);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
rrweb-simple-ext/background.js
Normal file
25
rrweb-simple-ext/background.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// background.js - service worker
|
||||||
|
const events = [];
|
||||||
|
let recording = false;
|
||||||
|
let activeTabId = null;
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
if (msg.action === 'startRecording') {
|
||||||
|
events.length = 0;
|
||||||
|
recording = true;
|
||||||
|
activeTabId = msg.tabId;
|
||||||
|
chrome.tabs.sendMessage(msg.tabId, { action: 'startRecord' });
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
} else if (msg.action === 'stopRecording') {
|
||||||
|
recording = false;
|
||||||
|
chrome.tabs.sendMessage(activeTabId, { action: 'stopRecord' });
|
||||||
|
sendResponse({ ok: true, eventCount: events.length });
|
||||||
|
} else if (msg.action === 'getEvents') {
|
||||||
|
sendResponse({ events: events });
|
||||||
|
} else if (msg.action === 'event') {
|
||||||
|
events.push(msg.data);
|
||||||
|
} else if (msg.action === 'getStatus') {
|
||||||
|
sendResponse({ recording, eventCount: events.length });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
46
rrweb-simple-ext/content.js
Normal file
46
rrweb-simple-ext/content.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// content.js - content script (runs in content script world)
|
||||||
|
(function() {
|
||||||
|
// Inject the record library + inject script into page
|
||||||
|
function loadScripts(callback) {
|
||||||
|
// Load record.umd.cjs first
|
||||||
|
const script1 = document.createElement('script');
|
||||||
|
script1.src = chrome.runtime.getURL('record.umd.cjs');
|
||||||
|
script1.onload = function() {
|
||||||
|
script1.remove();
|
||||||
|
// Then load inject.js
|
||||||
|
const script2 = document.createElement('script');
|
||||||
|
script2.src = chrome.runtime.getURL('inject.js');
|
||||||
|
script2.onload = function() {
|
||||||
|
script2.remove();
|
||||||
|
};
|
||||||
|
(document.head || document.documentElement).appendChild(script2);
|
||||||
|
};
|
||||||
|
(document.head || document.documentElement).appendChild(script1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from inject.js (page context)
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.source !== window) return;
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || !data.__rrweb_action) return;
|
||||||
|
|
||||||
|
if (data.__rrweb_action === 'event') {
|
||||||
|
// Forward event to background
|
||||||
|
chrome.runtime.sendMessage({ action: 'event', data: data.__rrweb_data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages from background
|
||||||
|
chrome.runtime.onMessage.addListener(function(msg, sender, sendResponse) {
|
||||||
|
if (msg.action === 'startRecord') {
|
||||||
|
loadScripts();
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
} else if (msg.action === 'stopRecord') {
|
||||||
|
window.postMessage({ __rrweb_action: 'stop' }, '*');
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
}
|
||||||
|
// Don't return true for unrelated messages
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[rrweb] Content script loaded');
|
||||||
|
})();
|
||||||
30
rrweb-simple-ext/inject.js
Normal file
30
rrweb-simple-ext/inject.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// inject.js - runs in page context, auto-starts recording
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let stopFn = null;
|
||||||
|
|
||||||
|
// Auto-start recording
|
||||||
|
stopFn = rrwebRecord.record({
|
||||||
|
emit: function(event) {
|
||||||
|
window.postMessage({
|
||||||
|
__rrweb_action: 'event',
|
||||||
|
__rrweb_data: event
|
||||||
|
}, '*');
|
||||||
|
},
|
||||||
|
recordCrossOriginIframes: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for stop command
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.source !== window) return;
|
||||||
|
if (event.data && event.data.__rrweb_action === 'stop') {
|
||||||
|
if (stopFn) {
|
||||||
|
stopFn();
|
||||||
|
stopFn = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[rrweb] Recording started');
|
||||||
|
})();
|
||||||
20
rrweb-simple-ext/manifest.json
Normal file
20
rrweb-simple-ext/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "rrweb Simple Recorder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Simple rrweb page recorder",
|
||||||
|
"permissions": ["activeTab", "storage", "scripting"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "rrweb Recorder",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["record.umd.cjs", "inject.js"],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
rrweb-simple-ext/popup.html
Normal file
34
rrweb-simple-ext/popup.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { width: 300px; padding: 16px; font-family: sans-serif; }
|
||||||
|
button { padding: 8px 16px; margin: 4px; cursor: pointer; border: none; border-radius: 4px; color: white; }
|
||||||
|
.start { background: #4CAF50; }
|
||||||
|
.start:hover { background: #45a049; }
|
||||||
|
.stop { background: #f44336; }
|
||||||
|
.stop:hover { background: #da190b; }
|
||||||
|
.view { background: #2196F3; }
|
||||||
|
.view:hover { background: #0b7dda; }
|
||||||
|
#status { margin-top: 12px; padding: 8px; background: #f5f5f5; border-radius: 4px; font-size: 13px; }
|
||||||
|
#events { margin-top: 8px; max-height: 200px; overflow-y: auto; font-size: 11px; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h3>rrweb Simple Recorder</h3>
|
||||||
|
<div>
|
||||||
|
<button id="startBtn" class="start">Start Recording</button>
|
||||||
|
<button id="stopBtn" class="stop disabled" disabled>Stop Recording</button>
|
||||||
|
</div>
|
||||||
|
<div id="status">Ready</div>
|
||||||
|
<div>
|
||||||
|
<button id="viewBtn" class="view disabled" disabled>View Events</button>
|
||||||
|
<button id="downloadBtn" class="view disabled" disabled>Download JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="events"></div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
93
rrweb-simple-ext/popup.js
Normal file
93
rrweb-simple-ext/popup.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
|
const viewBtn = document.getElementById('viewBtn');
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
const eventsDiv = document.getElementById('events');
|
||||||
|
|
||||||
|
function updateStatus(text) {
|
||||||
|
statusDiv.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRecording(isRecording) {
|
||||||
|
startBtn.disabled = isRecording;
|
||||||
|
startBtn.classList.toggle('disabled', isRecording);
|
||||||
|
stopBtn.disabled = !isRecording;
|
||||||
|
stopBtn.classList.toggle('disabled', !isRecording);
|
||||||
|
}
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
// First inject content script programmatically
|
||||||
|
try {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
files: ['content.js']
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
// Might already be injected, that's ok
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({ action: 'startRecording', tabId: tab.id }, (resp) => {
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
setRecording(true);
|
||||||
|
updateStatus('Recording...');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stopBtn.addEventListener('click', () => {
|
||||||
|
chrome.runtime.sendMessage({ action: 'stopRecording' }, (resp) => {
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
setRecording(false);
|
||||||
|
updateStatus('Stopped. Captured ' + resp.eventCount + ' events.');
|
||||||
|
viewBtn.disabled = false;
|
||||||
|
viewBtn.classList.remove('disabled');
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
downloadBtn.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
viewBtn.addEventListener('click', () => {
|
||||||
|
chrome.runtime.sendMessage({ action: 'getEvents' }, (resp) => {
|
||||||
|
if (resp && resp.events) {
|
||||||
|
const summary = resp.events.map((e, i) =>
|
||||||
|
`${i}: type=${e.type} ts=${e.timestamp}`
|
||||||
|
).join('\n');
|
||||||
|
eventsDiv.textContent = `Total: ${resp.events.length} events\n\n${summary}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBtn.addEventListener('click', () => {
|
||||||
|
chrome.runtime.sendMessage({ action: 'getEvents' }, (resp) => {
|
||||||
|
if (resp && resp.events) {
|
||||||
|
const blob = new Blob([JSON.stringify(resp.events, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'rrweb-events.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check current status on open
|
||||||
|
chrome.runtime.sendMessage({ action: 'getStatus' }, (resp) => {
|
||||||
|
if (resp) {
|
||||||
|
setRecording(resp.recording);
|
||||||
|
updateStatus(resp.recording
|
||||||
|
? 'Recording... (' + resp.eventCount + ' events)'
|
||||||
|
: 'Ready');
|
||||||
|
if (resp.eventCount > 0) {
|
||||||
|
viewBtn.disabled = false;
|
||||||
|
viewBtn.classList.remove('disabled');
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
downloadBtn.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
12732
rrweb-simple-ext/record.umd.cjs
Normal file
12732
rrweb-simple-ext/record.umd.cjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user