1.77.6: SERP sorry-重定向按验证码语义(不换号+15min冷却);浏览池保温释放跳过+60min持有上限;速率-门壳实验与监控工具
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
bundles/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="veilbrowser">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="vb-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#12101f"/>
|
||||||
|
<stop offset="1" stop-color="#1e1240"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="vb-shield" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7c3aed"/>
|
||||||
|
<stop offset="0.5" stop-color="#5b21b6"/>
|
||||||
|
<stop offset="1" stop-color="#3730a3"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="vb-v" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#e0e7ff"/>
|
||||||
|
<stop offset="1" stop-color="#a5b4fc"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="vb-hi" cx="0.3" cy="0.2" r="1">
|
||||||
|
<stop offset="0" stop-color="#ffffff" stop-opacity="0.22"/>
|
||||||
|
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="116" fill="url(#vb-bg)"/>
|
||||||
|
<path d="M256 84 L 402 132 V 266 C 402 352 342 412 256 442 C 170 412 110 352 110 266 V 132 Z"
|
||||||
|
fill="url(#vb-shield)"/>
|
||||||
|
<path d="M256 84 L 402 132 V 266 C 402 352 342 412 256 442 C 170 412 110 352 110 266 V 132 Z"
|
||||||
|
fill="url(#vb-hi)"/>
|
||||||
|
<path d="M196 196 L 256 330 L 316 196" fill="none" stroke="url(#vb-v)"
|
||||||
|
stroke-width="46" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Submodule
+1
Submodule admin added at 1c6a5d891a
@@ -0,0 +1,483 @@
|
|||||||
|
# ProfileMax:基于 CRIU 的 Chromium 浏览器快照实现方案
|
||||||
|
|
||||||
|
- 文档类型:实现方案 / PoC 计划
|
||||||
|
- 状态:已验证(2026-10-20 本机 PoC + node 端到端集成测试通过,实测数据见第 12 节)
|
||||||
|
- 目标路径:`/home/bigcode/data/dev/bun/SerpMax/docs/01_CRIU_BROWSER_SNAPSHOT.md`
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
ProfileMax 需要保存已启动的 Chromium 浏览器运行现场。示例:浏览器已经打开 Google 并完成搜索,创建快照后结束原浏览器;后续恢复时尽可能直接回到原搜索结果页面,避免再次启动浏览器、打开网站、重新执行页面初始化,从而降低重复作业的启动延迟和 CPU 峰值。
|
||||||
|
|
||||||
|
本方案采用 Linux + Chromium + CRIU,先验证技术可行性,再开发快照管理层。
|
||||||
|
|
||||||
|
### 1.1 必须实现
|
||||||
|
|
||||||
|
- 对 Chromium 完整相关进程树执行 checkpoint / restore。
|
||||||
|
- 恢复页面当前的内存状态,重点验证 DOM、JavaScript 堆、滚动位置和页面交互。
|
||||||
|
- 快照成功后可以结束原进程;恢复时不依赖原 Chromium 进程仍然存在。
|
||||||
|
- 保存快照元数据,校验浏览器版本、启动参数和运行环境。
|
||||||
|
- 记录创建耗时、恢复耗时、快照大小、内存占用和 CPU 峰值。
|
||||||
|
- 恢复失败时能报告具体阶段和错误,后续可回退到普通浏览器启动流程。
|
||||||
|
|
||||||
|
### 1.2 明确不作为目标
|
||||||
|
|
||||||
|
- **不要求保持原 TCP、HTTP/2、WebSocket 等网络连接。** 恢复后允许浏览器和页面重新建立连接。
|
||||||
|
- 不保证 Google 或其他网站的服务端会话永远有效。
|
||||||
|
- 第一阶段不做跨主机迁移、不做快照分叉克隆、不开发通用虚拟机快照系统。
|
||||||
|
- 不自行实现进程内存快照引擎;使用 CRIU 完成底层检查点与恢复。
|
||||||
|
- 不修改 Chromium 源码,除非 PoC 证明现有接口无法满足必要条件。
|
||||||
|
|
||||||
|
> 网络连接不需要延续,不代表可以直接忽略进程持有的 socket。CRIU 在 dump 时仍必须能处理进程文件描述符及其内核资源。必须通过 PoC 验证连接相关资源的实际处理方式,不能假设不传输 TCP 就一定可以成功 dump。
|
||||||
|
|
||||||
|
## 2. 总体架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProfileMax API / Job Manager
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Browser Snapshot Manager
|
||||||
|
|
|
||||||
|
+-- Browser Runtime Manager
|
||||||
|
| +-- Chromium 启动与进程树识别
|
||||||
|
| +-- 固定 Profile / 启动参数 / 显示环境
|
||||||
|
|
|
||||||
|
+-- Checkpoint Adapter
|
||||||
|
| +-- CRIU pre-dump / dump / restore 能力探测
|
||||||
|
| +-- 命令执行、超时、日志和错误归一化
|
||||||
|
|
|
||||||
|
+-- Snapshot Store
|
||||||
|
| +-- CRIU image 目录
|
||||||
|
| +-- 元数据与环境指纹
|
||||||
|
| +-- 完整性检查
|
||||||
|
|
|
||||||
|
+-- Restore Validator
|
||||||
|
+-- 进程存活检查
|
||||||
|
+-- 浏览器 DevTools / 页面状态检查
|
||||||
|
+-- 页面交互与网络重连检查
|
||||||
|
```
|
||||||
|
|
||||||
|
建议管理层使用项目现有的 Bun/TypeScript 技术栈实现;CRIU 通过受控子进程调用。若当前项目已有统一任务执行或进程管理模块,应复用,不要另建重复框架。所有 CRIU 命令必须由服务端生成参数数组执行,禁止拼接用户输入到 shell 命令。
|
||||||
|
|
||||||
|
## 3. 关键技术约束
|
||||||
|
|
||||||
|
### 3.1 Chromium 是多进程程序
|
||||||
|
|
||||||
|
不能只对 Browser 主进程执行快照。需要识别并纳入同一检查点的相关进程,例如:
|
||||||
|
|
||||||
|
- Browser 主进程
|
||||||
|
- Renderer 进程
|
||||||
|
- GPU 进程
|
||||||
|
- Network Service / Utility 等子进程
|
||||||
|
- 与浏览器运行现场相关的其他子进程
|
||||||
|
|
||||||
|
PoC 需验证 CRIU 采用正确的进程树入口和权限设置,且恢复后进程关系及 IPC 可用。禁止仅凭一个 PID dump 就认为整个浏览器已被完整保存。
|
||||||
|
|
||||||
|
### 3.2 固定运行环境
|
||||||
|
|
||||||
|
第一阶段固定以下条件,减少变量:
|
||||||
|
|
||||||
|
- 同一台 Linux 主机、同一内核版本与 CRIU 版本。
|
||||||
|
- 同一 Chromium 二进制版本及启动参数。
|
||||||
|
- 同一用户、UID/GID、工作目录、环境变量和 Profile 路径。
|
||||||
|
- 相同的挂载点、字体、共享库、临时目录和必要设备。
|
||||||
|
- 固定显示模式;优先从无头 Chromium 或固定 Xvfb 环境中选定一种进行 PoC,不要在首轮同时测试多种模式。
|
||||||
|
- 固定代理配置。代理本身可以在恢复后重新连接,但其配置应保持一致。
|
||||||
|
|
||||||
|
快照元数据必须记录上述环境。若恢复环境与快照不兼容,应拒绝恢复并给出明确原因。
|
||||||
|
|
||||||
|
### 3.3 Chromium 沙箱与 GPU
|
||||||
|
|
||||||
|
Chromium sandbox、namespace、seccomp、GPU 设备、共享内存及文件描述符可能影响 CRIU 兼容性。
|
||||||
|
|
||||||
|
- 首轮先使用正常安全配置验证。
|
||||||
|
- 如果为了定位问题,需要临时关闭 GPU 或在隔离测试环境中调整沙箱,必须作为单独实验变量记录。
|
||||||
|
- 不得把 `--no-sandbox` 当作生产默认参数,也不得为了通过 PoC 而默默降低生产安全边界。
|
||||||
|
- 生产部署前必须明确支持矩阵,写明哪些 Chromium 参数和运行模式经过实测。
|
||||||
|
|
||||||
|
### 3.4 网络与 socket 处理
|
||||||
|
|
||||||
|
ProfileMax 不需要恢复原 TCP/WS 连接,但进程内仍可能保存 socket 文件描述符、epoll 状态及异步网络请求状态。
|
||||||
|
|
||||||
|
实施原则:
|
||||||
|
|
||||||
|
1. 不将网络连接连续性纳入快照成功标准。
|
||||||
|
2. PoC 分别测试 CRIU 默认行为,以及在 CRIU 支持和环境允许时对已建立 TCP 连接的处理行为。
|
||||||
|
3. 若 dump 因网络文件描述符失败,记录 CRIU 原始错误和对应 FD 类型,再选择可验证的处理方案;不要直接忽略错误。
|
||||||
|
4. 恢复后,允许应用层重建连接;对已经失效的请求和 WebSocket,页面自身必须能恢复或重新加载相关数据。
|
||||||
|
5. 如需在恢复后关闭陈旧连接,应采用可验证的浏览器/页面层策略;不要从外部任意关闭 Chromium 的 FD,这可能破坏进程内部状态。
|
||||||
|
|
||||||
|
### 3.5 页面状态边界
|
||||||
|
|
||||||
|
进程内存快照的目标是保留运行现场,但不能保证每个网站页面都能无缝续跑:
|
||||||
|
|
||||||
|
- 页面已渲染的 DOM 和 JavaScript 堆是重点验证项。
|
||||||
|
- 页面计时器、Promise、Service Worker、后台标签页、扩展进程和浏览器内部任务需要单独测试。
|
||||||
|
- 恢复后页面可能检测到网络断开、请求失败或登录状态失效。
|
||||||
|
- 页面现场恢复与网站业务状态恢复是两回事。服务端数据可能已经改变。
|
||||||
|
|
||||||
|
## 4. 快照目录与元数据
|
||||||
|
|
||||||
|
建议每个快照使用独立目录,CRIU image 文件和元数据分离管理:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/snapshots/
|
||||||
|
<snapshot-id>/
|
||||||
|
criu/
|
||||||
|
# CRIU 生成的 checkpoint image 文件
|
||||||
|
manifest.json
|
||||||
|
checkpoint.log
|
||||||
|
restore.log
|
||||||
|
```
|
||||||
|
|
||||||
|
`manifest.json` 建议字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"snapshotId": "uuid",
|
||||||
|
"createdAt": "ISO-8601",
|
||||||
|
"status": "ready",
|
||||||
|
"browser": {
|
||||||
|
"name": "chromium",
|
||||||
|
"version": "exact-version",
|
||||||
|
"executable": "/usr/bin/chromium",
|
||||||
|
"rootPid": 1234,
|
||||||
|
"launchArgsHash": "sha256"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"kernelRelease": "exact-release",
|
||||||
|
"criuVersion": "exact-version",
|
||||||
|
"uid": 1000,
|
||||||
|
"gid": 1000,
|
||||||
|
"profilePath": "/data/profiles/example",
|
||||||
|
"displayMode": "xvfb-or-headless",
|
||||||
|
"networkContinuityRequired": false
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"checkpointDurationMs": null,
|
||||||
|
"restoreDurationMs": null,
|
||||||
|
"snapshotBytes": null
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"processTreeRestored": false,
|
||||||
|
"pageStateVerified": false,
|
||||||
|
"interactive": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
上例仅为结构示意。实际实现必须从运行环境采集真实值,不能硬编码 PID、版本或路径。敏感代理凭据、Cookie 和页面数据不得写入普通日志或 manifest。
|
||||||
|
|
||||||
|
## 5. 生命周期与状态机
|
||||||
|
|
||||||
|
建议快照状态:
|
||||||
|
|
||||||
|
- `creating`:正在执行 checkpoint。
|
||||||
|
- `ready`:checkpoint 完成,元数据和文件校验通过。
|
||||||
|
- `restoring`:正在恢复。
|
||||||
|
- `restored`:进程恢复成功且基础检查通过。
|
||||||
|
- `failed`:创建或恢复失败。
|
||||||
|
- `deleting`:正在清理。
|
||||||
|
|
||||||
|
创建流程(浏览器关闭前自动快照):
|
||||||
|
|
||||||
|
1. ProfileMax 统一管理浏览器关闭入口。任务完成、任务管理器要求关闭或用户主动关闭时,先进入 `closing` 流程;不要等 Chromium 已经开始退出后才监听 `close` 事件。
|
||||||
|
2. 检查目标浏览器实例和所有权,并确认任务已结束、没有正在执行的关键操作。
|
||||||
|
3. 记录 Chromium 版本、进程树、启动参数、运行环境及当前作业 URL。
|
||||||
|
4. 创建临时快照目录并执行 CRIU checkpoint/dump。
|
||||||
|
5. 收集退出码、stdout/stderr、CRIU 日志和耗时;校验检查点文件完整并写入 manifest。
|
||||||
|
6. 校验通过后,原子地将快照标记为 `ready`,并将其关联到对应浏览器实例/任务。
|
||||||
|
7. 只有快照已可靠落盘并标记为 `ready` 后,才完成浏览器关闭流程。注意:常规 CRIU dump 通常会让被 dump 的进程停止/退出,因此应由 CRIU 的实际 dump 语义完成进程收尾,不能再无条件调用 Chromium 的正常关闭。
|
||||||
|
8. 如果快照失败,记录错误并按配置执行普通关闭;不要把不完整快照标记为 `ready`。若产品要求必须先保留一个仍运行的 Chromium 再正常关闭,需要单独验证 leave-running 模式与 Profile 文件一致性,不能直接把它当作安全默认方案。
|
||||||
|
|
||||||
|
关闭事件的实现要求:使用 ProfileMax 自己的统一关闭方法/生命周期钩子作为触发点;浏览器进程已经退出后的 `close`/`exit` 通知只用于清理和记录结果,不用于启动快照。
|
||||||
|
|
||||||
|
恢复流程:
|
||||||
|
|
||||||
|
1. 校验 manifest、快照文件、版本和运行环境。
|
||||||
|
2. 确保没有冲突的同一实例正在运行;首版不支持从同一快照并发恢复多个实例。
|
||||||
|
3. 准备恢复所需的用户、目录、挂载、网络命名空间和显示环境。
|
||||||
|
4. 执行 CRIU restore,保存日志和耗时。
|
||||||
|
5. 验证恢复后的进程树、浏览器调试接口(如果启用)、原页面状态及用户交互。
|
||||||
|
6. 对网络重连进行观察,但不要求旧连接继续存在。
|
||||||
|
7. 只有验证通过才标记为 `restored`;失败时保留诊断材料,避免自动覆盖原快照。
|
||||||
|
|
||||||
|
注意:CRIU 的具体命令行参数、权限要求和网络选项必须以部署机器上安装版本的 `criu --help`、官方文档和 PoC 结果为准。本文不预设某一组命令在所有内核/CRIU 版本上通用。
|
||||||
|
|
||||||
|
## 6. 模块接口建议
|
||||||
|
|
||||||
|
管理层可先实现以下内部接口,具体参数根据现有 ProfileMax 代码规范调整:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SnapshotStatus =
|
||||||
|
| "creating"
|
||||||
|
| "ready"
|
||||||
|
| "restoring"
|
||||||
|
| "restored"
|
||||||
|
| "failed"
|
||||||
|
| "deleting";
|
||||||
|
|
||||||
|
interface SnapshotManifest {
|
||||||
|
schemaVersion: number;
|
||||||
|
snapshotId: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: SnapshotStatus;
|
||||||
|
browser: {
|
||||||
|
name: "chromium";
|
||||||
|
version: string;
|
||||||
|
executable: string;
|
||||||
|
rootPid: number;
|
||||||
|
launchArgsHash: string;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
kernelRelease: string;
|
||||||
|
criuVersion: string;
|
||||||
|
uid: number;
|
||||||
|
gid: number;
|
||||||
|
profilePath: string;
|
||||||
|
displayMode: string;
|
||||||
|
networkContinuityRequired: false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckpointResult {
|
||||||
|
snapshotId: string;
|
||||||
|
durationMs: number;
|
||||||
|
snapshotBytes: number;
|
||||||
|
logPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoreResult {
|
||||||
|
snapshotId: string;
|
||||||
|
durationMs: number;
|
||||||
|
restoredPids: number[];
|
||||||
|
validationPassed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckpointAdapter {
|
||||||
|
checkCapabilities(): Promise<{
|
||||||
|
available: boolean;
|
||||||
|
criuVersion?: string;
|
||||||
|
reasons: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
checkpoint(browserRootPid: number, snapshotDir: string): Promise<CheckpointResult>;
|
||||||
|
|
||||||
|
restore(snapshotDir: string): Promise<RestoreResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这些接口是设计草案,不代表项目中已经存在对应模块。需要先查看 ProfileMax 当前目录结构和现有进程管理实现,再决定最终落点。
|
||||||
|
|
||||||
|
## 7. PoC 实施计划
|
||||||
|
|
||||||
|
### P0:环境检查
|
||||||
|
|
||||||
|
- 确认 Linux 发行版、内核版本、CRIU 版本及所需权限。
|
||||||
|
- 运行 CRIU 自检,并保存完整输出。
|
||||||
|
- 检查 Chromium 的实际进程树、命名空间、打开的 FD、共享内存和设备依赖。
|
||||||
|
- 记录当前 Chromium 版本、启动参数和运行模式。
|
||||||
|
|
||||||
|
交付物:环境报告、CRIU capability 报告、浏览器进程树样本。
|
||||||
|
|
||||||
|
### P1:最小 checkpoint / restore
|
||||||
|
|
||||||
|
- 启动 Chromium 并打开固定测试页面。
|
||||||
|
- 在页面内创建可验证的 JavaScript 状态标记,例如计数器、随机 token、滚动位置和 DOM 文本。
|
||||||
|
- 执行 checkpoint,确认原实例已按预期停止或退出。
|
||||||
|
- restore 后读取页面标记,验证其与 checkpoint 前一致。
|
||||||
|
- 检查页面是否可点击、输入和滚动。
|
||||||
|
|
||||||
|
交付物:可重复执行的测试脚本、CRIU 日志、成功/失败原因。
|
||||||
|
|
||||||
|
### P2:真实搜索页面
|
||||||
|
|
||||||
|
- 打开 Google 并执行固定关键词搜索,按正常流程完成测试任务。
|
||||||
|
- 任务完成、浏览器准备关闭时,由 ProfileMax 关闭流程触发快照;不使用定时快照,也不需要单独监听页面稳定事件。
|
||||||
|
- 快照完成并可靠落盘后,按 CRIU dump 的实际语义结束原进程,再执行恢复测试。
|
||||||
|
- 验证 URL、DOM、滚动位置和页面交互。
|
||||||
|
- 观察网络重连后的页面行为;不把旧 TCP/WS 保持连接作为成功条件。
|
||||||
|
|
||||||
|
交付物:真实网站验证记录。若网站行为不稳定,使用本地测试页面作为可重复基线,并将网站测试作为补充。
|
||||||
|
|
||||||
|
### P3:基准测试
|
||||||
|
|
||||||
|
至少重复 20 次,分别测量普通冷启动和快照恢复:
|
||||||
|
|
||||||
|
| 指标 | 冷启动 | CRIU 恢复 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 总耗时 P50 / P95 | 测量 | 测量 |
|
||||||
|
| CPU 峰值 | 测量 | 测量 |
|
||||||
|
| 恢复后可交互耗时 | 测量 | 测量 |
|
||||||
|
| 快照体积 | 不适用 | 测量 |
|
||||||
|
| 失败率 | 测量 | 测量 |
|
||||||
|
|
||||||
|
必须统一测试条件:相同主机、Chromium 版本、Profile、页面、代理和资源状态。单次结果不能作为结论。
|
||||||
|
|
||||||
|
### P4:管理器封装
|
||||||
|
|
||||||
|
只有 P1/P2 证明可行后再实施:
|
||||||
|
|
||||||
|
- snapshot create / restore / inspect / delete。
|
||||||
|
- 状态机和并发锁。
|
||||||
|
- 日志、错误分类、超时和取消。
|
||||||
|
- 快照容量限制及过期清理。
|
||||||
|
- 环境兼容性校验和失败降级。
|
||||||
|
- 权限隔离,避免普通用户对任意系统进程执行 dump/restore。
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
首阶段必须全部满足:
|
||||||
|
|
||||||
|
1. 能对目标 Chromium 进程树成功 checkpoint。
|
||||||
|
2. 原浏览器进程停止后,能够从快照恢复。
|
||||||
|
3. 恢复后原页面的验证标记、URL、主要 DOM 状态及滚动位置符合预期。
|
||||||
|
4. 页面可继续交互,且允许网络连接重新建立。
|
||||||
|
5. 失败时能够通过日志定位到具体 CRIU 错误或资源依赖。
|
||||||
|
6. 测得冷启动与恢复的耗时、CPU 峰值、快照大小和失败率。
|
||||||
|
|
||||||
|
性能目标在取得基线数据后设定,不预先承诺快照恢复一定比冷启动快。快照读取、内存恢复和页面恢复验证都可能消耗显著的磁盘 IO 与 CPU。
|
||||||
|
|
||||||
|
## 9. 安全与运维要求
|
||||||
|
|
||||||
|
- 快照可能包含 Cookie、认证令牌、页面内容及其他敏感内存数据,按敏感数据保护。
|
||||||
|
- 快照目录仅允许浏览器运行用户和快照管理服务访问,权限默认最小化。
|
||||||
|
- 不在日志中输出 Cookie、Authorization、代理密码或页面表单内容。
|
||||||
|
- 只允许管理器操作其登记的 Chromium 进程,不接受任意 PID 的外部请求。
|
||||||
|
- CRIU dump/restore 使用最小必要权限;如需 root 权限,限定服务边界并记录审计日志。
|
||||||
|
- 删除快照时清理完整目录,并记录清理结果。
|
||||||
|
- 版本、内核或启动参数不兼容时拒绝盲目恢复。
|
||||||
|
|
||||||
|
## 10. 参考资料
|
||||||
|
|
||||||
|
- CRIU:<https://github.com/checkpoint-restore/criu>
|
||||||
|
- CRIU 官方文档:<https://criu.org/>
|
||||||
|
- CRIU 使用手册:<https://criu.org/Basic_usage>
|
||||||
|
- Podman checkpoint/restore(用于参考容器集成方式):<https://docs.podman.io/>
|
||||||
|
|
||||||
|
## 11. 当前结论
|
||||||
|
|
||||||
|
采用“CRIU 底层 + ProfileMax 自有管理层”的路线。第一阶段只交付 PoC,不提前承诺 Chromium 全进程树一定可恢复。优先验证真实运行环境中的沙箱、GPU、共享资源和 socket 处理。TCP/WS 连续性不是验收目标;进程仍需正确处理网络相关 FD,恢复后允许应用重新连接。PoC 通过后再扩展为多实例快照管理。
|
||||||
|
|
||||||
|
## 12. 实测结论与落地记录(2026-10-20)
|
||||||
|
|
||||||
|
### 12.1 环境与实测数据
|
||||||
|
|
||||||
|
- 环境:Zorin OS 18.1(Ubuntu noble 基),内核 7.0.0-31-generic,veil-chromium 153.0.8010.52;
|
||||||
|
CRIU 4.2(源码编译,`/usr/local/bin/criu`,文件能力 `cap_sys_ptrace,cap_sys_admin,cap_net_admin+eip`,
|
||||||
|
非 root 可用,免改 ptrace_scope)。
|
||||||
|
- PoC(直启 chrome → baidu 搜索页 → dump → restore):
|
||||||
|
|
||||||
|
| 指标 | 实测值 |
|
||||||
|
|---|---|
|
||||||
|
| 冷启动(进程拉起 → CDP 就绪) | ≈ 0.6s |
|
||||||
|
| dump(9 进程树,RSS ≈ 1.13GB) | ≈ 1.3s,镜像 ≈ 354MB(397 个 .img) |
|
||||||
|
| restore(detached) | ≈ 0.36s |
|
||||||
|
| 现场校验 | URL/标题/JS 堆标记/滚动位置/搜索结果 DOM/localStorage 全部一致,`navigator.onLine=true` |
|
||||||
|
| 可交互 | 恢复后二次搜索成功(网络自动重连) |
|
||||||
|
|
||||||
|
- node 端到端(经 ProfileMax API:创建快照 Profile → 启动 → 搜索 → 停止自动 dump → 再启动):
|
||||||
|
停止含 dump ≈ 1.3s;再次启动 ≈ 0.4s 且 `restored:true`,CDP 端口原样恢复,现场校验同上全绿
|
||||||
|
(复测脚本 `node/scripts/criu-poc.ts`、`node/scripts/criu-e2e.ts`)。
|
||||||
|
|
||||||
|
### 12.2 关键结论(实测定论)
|
||||||
|
|
||||||
|
1. **沙箱与 CRIU 的结构性冲突**:criu 拒绝对「非根进程自建的嵌套 PID namespace」执行 dump
|
||||||
|
(`CLONE_SUBNS` 仅含 mount/net),而 chromium namespace 沙箱的 zygote/渲染进程恰在嵌套 pidns 中。
|
||||||
|
→ **快照模式 Profile 启动时显式追加 `--no-sandbox`**(browser.manager `snapshotSandboxArgs`);
|
||||||
|
生产节点为 root 容器,ProfileMax 本就强制 `--no-sandbox`,此处仅影响非 root 环境,非全局降级。
|
||||||
|
2. **仅支持 headless**:有头模式连接的 X11/Wayland socket 是树外 STREAM unix socket,
|
||||||
|
criu 的 `--ext-unix-sk` 仅支持 DGRAM 外部连接 → 有头实例停止时不做快照(回退常规关闭)。
|
||||||
|
3. **D-Bus 必须「干净失败」隔离**:chrome 对已建立后断开的 dbus 连接会 FATAL
|
||||||
|
("D-Bus connection was disconnected. Aborting.")。快照模式启动 env 将
|
||||||
|
`DBUS_SESSION_BUS_ADDRESS`/`DBUS_SYSTEM_BUS_ADDRESS` 指向 `unix:path=/nonexistent-bus`
|
||||||
|
(与生产容器无 bus 的现实一致);地址格式非法(如 `/dev/null`)会导致导航失败,不可用。
|
||||||
|
4. **crashpad handler 在树外**:chrome 会把 handler daemonize 到进程树外(subreaper 接管),
|
||||||
|
其 SEQPACKET socketpair 端为树外对端。已给 criu 打补丁:dump 允许外部 STREAM/SEQPACKET,
|
||||||
|
restore 侧对非 DGRAM 外部对端跳过 connect(以「未连接」语义恢复,客户端优雅降级);
|
||||||
|
补丁内容两处(criu/fsnotify.c 悬空 inotify watch 跳过 + criu/sk-unix.c 外部 socket 放行),
|
||||||
|
生产镜像需携带补丁版 criu。
|
||||||
|
5. **chromium 共享内存临时文件**(`.org.chromium.Chromium.*`,实测 4MB 起步)远超 criu 默认
|
||||||
|
1MB ghost 限额 → dump/restore 统一 `--ghost-limit=512MB`(env `CRIU_GHOST_LIMIT_MB` 可调)。
|
||||||
|
6. **dump 参数**:`--file-locks --tcp-close --ext-unix-sk --ghost-limit=N [--unprivileged]`;
|
||||||
|
`--tcp-close` 表示已建 TCP 连接按「关闭」落镜像(符合 1.2 节「不要求连接连续性」),
|
||||||
|
恢复后由页面/浏览器自行重连,实测 baidu 页面正常。
|
||||||
|
7. **启动方式约束**:快照 Profile 由本服务直启(setsid 保证 session leader + 显式
|
||||||
|
`--user-data-dir`),不经 playwright 管道(管道对端在树外无法 dump);CDP 经 connectOverCDP
|
||||||
|
挂接,停止时 playwright 句柄 close() 仅断连不杀进程。
|
||||||
|
8. **参数哈希与端口绑定**:dump 时将完整启动参数(含 `--user-data-dir` 打头)哈希入 manifest,
|
||||||
|
restore 前重算比对,任一不一致拒绝恢复回退冷启动;impersonate MITM 端口记入 manifest,
|
||||||
|
恢复时 `ensureImpersonate` 优先复用同端口(浏览器重连无缝),端口被占则拒绝恢复。
|
||||||
|
9. **陈旧 DevToolsActivePort**:dump 后该文件残留,直启前必须删除,否则 CDP 就绪探测
|
||||||
|
可能打在死端口上(实测踩坑)。
|
||||||
|
10. **criu 单次操作超时 180s**,dump/restore 失败不重试:回退常规关闭/冷启动,
|
||||||
|
失败快照保留 `failed` 状态与日志供诊断(manifest.json / dump.log / restore.log)。
|
||||||
|
|
||||||
|
### 12.3 落地范围(本版)
|
||||||
|
|
||||||
|
- Profile 新增 `snapshot.enabled` 配置(admin「快照模式」开关,缺省关闭;仅 veil + headless 生效);
|
||||||
|
- 停止浏览器时自动 checkpoint(存储快照抓取先于 dump),启动时优先 restore 并回写 CDP/条目;
|
||||||
|
- 节点 API:`GET/DELETE /profiles/:id/snapshot`;admin 中继同路径 GET/DELETE + PUT 快捷开关;
|
||||||
|
- admin 概览卡:快照(含大小)/ 自动CF挑战 / 自动挑战 reCAPTCHA 三开关同行;
|
||||||
|
- 快照目录:`<profileDir>/<profileId>/snapshot/`(manifest.json + criu/ 镜像 + dump/restore 日志),
|
||||||
|
随 Profile 删除一并清理;同一 Profile 仅保留最近一次快照。
|
||||||
|
|
||||||
|
## 13. 镜像压缩与 USER_DATA 清理(2026-10-21,node v0.38.0 / admin v1.55.0)
|
||||||
|
|
||||||
|
### 13.1 镜像压缩(zstd 后处理)
|
||||||
|
|
||||||
|
CRIU 4.x 核心已移除内建压缩选项(旧版 `-z/--compress` 不存在,`criu-image-streamer` 需额外组件),
|
||||||
|
采用「dump 后 zstd 压缩大镜像 / restore 前解压还原」的后处理方案(snapshot.manager):
|
||||||
|
|
||||||
|
- dump 成功后(进程已退出)对镜像目录中 >1MB 的 `*.img` 逐文件 `zstd -3 -T0` 压缩,
|
||||||
|
压缩成功才删原文件,单文件失败保留原样不阻塞;`SNAPSHOT_COMPRESS=off` 可整体关闭。
|
||||||
|
- restore 前把 `*.img.zst` 解压还原(criu 不识别压缩镜像),耗时记入 manifest
|
||||||
|
`metrics.decompressDurationMs`;压缩前/后体积分别记 `snapshotBytesRaw`/`snapshotBytes`。
|
||||||
|
- 实测(同环境同场景,baidu 搜索现场,两轮):
|
||||||
|
|
||||||
|
| 指标 | 压缩前(10-20 基线) | 压缩后(10-21 实测) |
|
||||||
|
|---|---|---|
|
||||||
|
| 快照镜像大小 | 308~312MB | **67.5~70.6MB(约 4.4x 缩小)** |
|
||||||
|
| dump 耗时 | ≈1.0~1.3s | ≈1.0~1.3s(不变) |
|
||||||
|
| 停止总耗时(含 dump+压缩) | ≈1.2~1.3s | ≈2.4s(压缩约 +1.1s,zstd -3 -T0) |
|
||||||
|
| restore(criu 自身) | ≈0.3s | ≈0.3s(不变) |
|
||||||
|
| restore 前解压 | — | ≈0.58s |
|
||||||
|
| 再次启动总耗时 | ≈0.43~0.58s | ≈1.11~1.16s(解压 +0.6s) |
|
||||||
|
| 现场校验(8 项) | 全绿 | 全绿(URL/标题/JS 标记/滚动/localStorage/结果数/在线/二次搜索) |
|
||||||
|
|
||||||
|
结论:以「每次停止 +≈1.1s、每次恢复 +≈0.6s」换「磁盘占用 -77%」,默认开启;
|
||||||
|
磁盘敏感场景收益显著,对恢复可用性无影响。
|
||||||
|
|
||||||
|
### 13.2 USER_DATA 静态缓存清理
|
||||||
|
|
||||||
|
- **自动清理(默认开启)**:浏览器停止时实测 USER_DATA 体积(关闭前采样)超过
|
||||||
|
`USERDATA_CLEAN_THRESHOLD_MB`(默认 100MB)→ 进程完全退出后软清理静态缓存
|
||||||
|
(cache.manager `autoCleanOnStop`);清理后体积才回传 admin(profiles.user_data_size)。
|
||||||
|
- **软清理范围(CACHE_DIRS)**:根级 + `Default/` 子目录双布局——HTTP Cache、Code Cache、
|
||||||
|
GPUCache、Dawn/GrShader/Graphite 缓存、Media Cache、Shared Dictionary、组件 crx 缓存等;
|
||||||
|
Cookie/LocalStorage/IndexedDB/Service Worker/登录态一律保留(实测主要占用
|
||||||
|
`Default/Cache` + `Default/Code Cache`,约占 USER_DATA 80%)。
|
||||||
|
- **手动清理**:节点 `POST /profiles/:id/cleanup`(admin 中继 `POST /api/profiles/:id/cleanup`,
|
||||||
|
Profile 详情页「编辑」与「复制」之间的「清理」按钮):软清理 + **删除现有 CRIU 快照**
|
||||||
|
(快照镜像含缓存页,restore 会原样还原缓存,不删等于没清;下次停止自动重建快照);
|
||||||
|
浏览器运行中节点拒绝(`CACHE_CLEAR_FAILED`,提示先停止)。返回 before/after/freedBytes
|
||||||
|
与 snapshotDeleted,前端 toast 展示释放体积。
|
||||||
|
- 验证:130MB 假缓存(Default/Cache)触发清理释放 120MB,Cookie/LocalStorage 保留;
|
||||||
|
50MB 未触发;E2E 全流程(含手动清理后快照 bytes=0)通过。
|
||||||
|
|
||||||
|
### 13.3 启动方式对比:正常冷启动 vs 快照恢复(到达同一现场)
|
||||||
|
|
||||||
|
口径:从 `POST /browser/start` 发起,到「baidu 同一关键词搜索结果页 DOM 已渲染」为止
|
||||||
|
(CDP 连接与现场确认均计入);基准脚本 `node/scripts/criu-bench-compare.ts`,两轮实测:
|
||||||
|
|
||||||
|
| 阶段 | 冷启动(非快照) | 快照恢复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 启动 → CDP 就绪 | 0.80~0.96s | 0.99~1.51s(含解压≈0.6s + restore≈0.3s) |
|
||||||
|
| 导航加载搜索结果 | 2.52~4.08s(随网络波动) | 0(现场已在,免导航) |
|
||||||
|
| 现场确认 | 0.08~0.11s | 0.04~0.32s |
|
||||||
|
| **到达同一现场总计** | **3.69s / 4.44s** | **1.18 / 1.40 / 1.49 / 1.59s(均值 ≈1.4s)** |
|
||||||
|
| 现场一致性 | — | 4/4 轮 URL/标题/结果数完全一致 |
|
||||||
|
| **加速比** | — | **2.86x / 2.88x(两轮稳定)** |
|
||||||
|
|
||||||
|
附:快照 Profile 自身冷启动(3.61s/5.14s)与普通 Profile 无差异,快照模式不拖累冷启动;
|
||||||
|
本组 dump 耗时 1.7~3.3s、快照 60.4~70.9MB(压缩后),dump 耗时随当时浏览器内存占用浮动。
|
||||||
+929
@@ -0,0 +1,929 @@
|
|||||||
|
# SerpMax API 文档
|
||||||
|
|
||||||
|
> 版本:2026-09-24(依据源码 `admin/src/server.ts` 与 `node/src/api/` 整理)
|
||||||
|
> 架构:**admin**(后台,默认端口 `5310`)与 **node**(Profile Manager 节点,默认端口 `3100`,容器宿主映射常见为 `3106`)。
|
||||||
|
> admin 的浏览器操作类接口大多是**中继**:admin 收到请求后按 `profile.node → nodes.base_url` 转发到对应节点,并自动附带 admin API Token 与节点密钥。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [1. 通用约定](#1-通用约定)
|
||||||
|
- [1.1 统一响应信封](#11-统一响应信封)
|
||||||
|
- [1.2 认证](#12-认证)
|
||||||
|
- [1.3 公共请求头](#13-公共请求头)
|
||||||
|
- [1.4 错误码表](#14-错误码表)
|
||||||
|
- [2. Admin API(端口 5310)](#2-admin-api-端口-5310)
|
||||||
|
- [2.1 认证 /api/auth/*](#21-认证-apiauth)
|
||||||
|
- [2.2 仪表盘 /api/dashboard](#22-仪表盘-apidashboard)
|
||||||
|
- [2.3 Profile 管理 /api/profiles](#23-profile-管理-apiprofiles)
|
||||||
|
- [2.4 浏览器操作中继(浏览器/VNC/打码)](#24-浏览器操作中继)
|
||||||
|
- [2.5 Cookie 管理(admin 本地 Jar)](#25-cookie-管理admin-本地-jar)
|
||||||
|
- [2.6 代理管理 /api/proxies](#26-代理管理-apiproxies)
|
||||||
|
- [2.7 节点管理 /api/nodes](#27-节点管理-apinodes)
|
||||||
|
- [2.8 节点代码包与自更新](#28-节点代码包与自更新)
|
||||||
|
- [2.9 日志 /api/logs](#29-日志-apilogs)
|
||||||
|
- [2.10 系统设置 /api/settings](#210-系统设置-apisettings)
|
||||||
|
- [2.11 事件上报 /api/events](#211-事件上报-apievents)
|
||||||
|
- [3. Node API(端口 3100)](#3-node-api-端口-3100)
|
||||||
|
- [3.1 Profile](#31-profile)
|
||||||
|
- [3.2 Lease(租约)](#32-lease租约)
|
||||||
|
- [3.3 浏览器生命周期](#33-浏览器生命周期)
|
||||||
|
- [3.4 浏览器操作(Action + SSE)](#34-浏览器操作action--sse)
|
||||||
|
- [3.5 HTTP 请求执行(fetch / browse / browser-fetch)](#35-http-请求执行)
|
||||||
|
- [3.6 Cookie(node 运行时)](#36-cookienode-运行时)
|
||||||
|
- [3.7 缓存管理](#37-缓存管理)
|
||||||
|
- [3.8 Schema 与 Execution](#38-schema-与-execution)
|
||||||
|
- [3.9 reCAPTCHA 打码](#39-recaptcha-打码)
|
||||||
|
- [3.10 VNC 与直连](#310-vnc-与直连)
|
||||||
|
- [3.11 系统 /health /ready /metrics 等](#311-系统)
|
||||||
|
- [4. 附录](#4-附录)
|
||||||
|
- [4.1 Browser Action 类型一览](#41-browser-action-类型一览)
|
||||||
|
- [4.2 SSE 事件格式](#42-sse-事件格式)
|
||||||
|
- [4.3 典型调用流程](#43-典型调用流程)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 通用约定
|
||||||
|
|
||||||
|
## 1.1 统一响应信封
|
||||||
|
|
||||||
|
所有 JSON 接口(除 `/metrics`、图片、SSE、脚本下载外)使用统一信封:
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
失败:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "PROFILE_NOT_FOUND",
|
||||||
|
"message": "Profile 不存在",
|
||||||
|
"requestId": "req_xxxxxxxxxxxx",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每个响应都带 `x-request-id` 响应头;请求可自带 `x-request-id` 头,否则自动生成。
|
||||||
|
- admin 的部分错误信封为 `{ success:false, error:{ code, message } }`(无 requestId 字段),语义一致。
|
||||||
|
|
||||||
|
## 1.2 认证
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
| 方式 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 会话 Cookie | `POST /api/auth/login` 后下发 `smax_session`(HttpOnly,默认 7 天) |
|
||||||
|
| API Token | `Authorization: Bearer smax_xxx...`(在 设置页 或 `GET /api/settings` 获取,可轮换) |
|
||||||
|
|
||||||
|
免会话的例外端点:
|
||||||
|
|
||||||
|
| 端点 | 凭证 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/nodes/register` | 无需鉴权(`key` 即身份,须与 provision 下发的密钥一致) |
|
||||||
|
| `POST /api/nodes/heartbeat` | 会话/API Token,**或**该节点自身的通信密钥(`x-node-key` / `Bearer <NODE_KEY>`) |
|
||||||
|
| `GET /node/install`、`GET /node/package` | query 中的节点密钥 `?key=` |
|
||||||
|
| `GET /node/version` | 公开 |
|
||||||
|
|
||||||
|
### Node API
|
||||||
|
|
||||||
|
- 配置了环境变量 `NODE_KEY` 后,所有路由要求携带:
|
||||||
|
- `x-node-key: <NODE_KEY>`,或
|
||||||
|
- `Authorization: Bearer <NODE_KEY>`
|
||||||
|
- 未配置 `NODE_KEY` 时全部放行(本机/内网模式)。
|
||||||
|
- 例外(在节点密钥之外**额外**支持一次性直连票据,供前端跨源直连):
|
||||||
|
- `POST /profiles/:id/browser/actions`、`GET /profiles/:id/browser/events`(SSE)、`GET /profiles/:id/vnc/ws`(WebSocket)
|
||||||
|
- 票据来源:`POST /profiles/:id/direct-ticket`(仅持节点密钥者可调)→ `{ ticket, expiresInMs }`,绑定 Profile、5 分钟有效;actions 用后即焚,events/VNC 在 TTL 内可复用。携带方式:`?ticket=` 或 `x-direct-ticket` 头。
|
||||||
|
|
||||||
|
## 1.3 公共请求头
|
||||||
|
|
||||||
|
| 头 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `x-request-id` | 可选。请求追踪 ID,缺省自动生成,原样回传于响应头与错误信封 |
|
||||||
|
| `idempotency-key` | 可选。`POST /profiles`(node)创建 Profile 幂等键 |
|
||||||
|
| `x-lease-id` | 可选。node 浏览器操作 / schema run 时声明 Lease 持有者(也可在 body 传 `leaseId`) |
|
||||||
|
|
||||||
|
跨源直连:node 对所有接口返回 CORS 头(`access-control-allow-origin: *`),`OPTIONS` 预检统一应答 204。
|
||||||
|
|
||||||
|
## 1.4 错误码表
|
||||||
|
|
||||||
|
node 侧规范错误码(`node/src/types/common.ts`,括号内为默认 HTTP 状态):
|
||||||
|
|
||||||
|
| 类别 | 错误码 |
|
||||||
|
|---|---|
|
||||||
|
| Profile | `PROFILE_NOT_FOUND`(404)、`PROFILE_BUSY`(409)、`PROFILE_INVALID`(400)、`PROFILE_CREATING`(409)、`PROFILE_DELETING`(409)、`PROFILE_DISK_LIMIT`(413) |
|
||||||
|
| Lease | `LEASE_NOT_FOUND`(404)、`LEASE_EXPIRED`(410)、`LEASE_CONFLICT`(409) |
|
||||||
|
| Browser | `BROWSER_NOT_RUNNING`(409)、`BROWSER_START_FAILED`(500)、`BROWSER_CRASHED`(500)、`BROWSER_TIMEOUT`(504)、`PROFILE_NOT_RUNNING`(409)、`BROWSER_SESSION_NOT_FOUND`(409) |
|
||||||
|
| Action | `INVALID_ACTION`(400)、`INVALID_ACTION_PARAMETER`(400)、`INVALID_SELECTOR`(400)、`ELEMENT_NOT_FOUND`(404)、`ELEMENT_NOT_VISIBLE`(400)、`ELEMENT_NOT_INTERACTABLE`(400)、`NAVIGATION_FAILED`(502)、`ACTION_TIMEOUT`(504)、`SCREENSHOT_FAILED`(500) |
|
||||||
|
| Schema | `SCHEMA_NOT_FOUND`(404)、`SCHEMA_INVALID`(400)、`SCHEMA_VERSION_NOT_FOUND`(404)、`SCHEMA_EXECUTION_FAILED`(500)、`SCHEMA_TIMEOUT`(504) |
|
||||||
|
| Fetch | `FETCH_FAILED`(502)、`FETCH_TIMEOUT`(504)、`PROXY_FAILED`(502) |
|
||||||
|
| Cache | `CACHE_CLEAR_FAILED`(500) |
|
||||||
|
| 通用 | `VALIDATION_FAILED`(400)、`UNAUTHORIZED`(401)、`NOT_FOUND`(404)、`CONFLICT`(409)、`INTERNAL_ERROR`(500) |
|
||||||
|
|
||||||
|
admin 侧常用错误码:`UNAUTHORIZED`(401)、`INVALID_CREDENTIALS`(401)、`VALIDATION_FAILED`(400)、`PROFILE_NOT_FOUND`(404)、`FINGERPRINT_CONFLICT`(409)、`PROXY_NOT_FOUND`(404)、`COOKIE_NOT_FOUND`(404)、`NODE_NOT_FOUND`(404)、`NODE_NOT_AVAILABLE`(503)、`NODE_RELAY_FAILED`(502)、`DIRECT_UNAVAILABLE`(502/503)、`PACKAGE_FAILED`(500)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Admin API(端口 5310)
|
||||||
|
|
||||||
|
## 2.1 认证 `/api/auth`
|
||||||
|
|
||||||
|
### POST /api/auth/login — 登录
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "username": "admin", "password": "******" } // username 缺省为 admin
|
||||||
|
// 200 → data
|
||||||
|
{ "username": "admin" }
|
||||||
|
// 失败 401 INVALID_CREDENTIALS
|
||||||
|
```
|
||||||
|
|
||||||
|
成功时 `Set-Cookie: smax_session=...; HttpOnly; SameSite=Lax`。
|
||||||
|
|
||||||
|
### POST /api/auth/logout — 登出 → `data: null`,清除 Cookie
|
||||||
|
|
||||||
|
### GET /api/auth/me — 当前登录状态
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "authenticated": true, "username": "admin" }
|
||||||
|
// 未登录也返回 200:{ "authenticated": false, "username": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 仪表盘 `/api/dashboard`
|
||||||
|
|
||||||
|
### GET /api/dashboard — 概览数据一次返回
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{
|
||||||
|
"statistics": {
|
||||||
|
"totalProfiles": 10, "runningProfiles": 3, "errorProfiles": 0,
|
||||||
|
"totalProxies": 8, "captcha": 12, "recaptcha": 5, "requests": 1200
|
||||||
|
},
|
||||||
|
"activity": [ { "id": "12345678", "name": "...", "status": "running",
|
||||||
|
"browserType": "browser", "proxyName": "...", "totalRequests": 100,
|
||||||
|
"lastUsedAt": "...", "updatedAt": "..." } ],
|
||||||
|
"captchaSeries": [ /* 近 7 天验证码统计 */ ],
|
||||||
|
"requestSeries": [ /* 近 7 天请求统计 */ ],
|
||||||
|
"recentLogs": [ /* 最近 12 条日志 */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.3 Profile 管理 `/api/profiles`
|
||||||
|
|
||||||
|
> `:id` 统一接受 **Profile UUID 或指纹数字**(6~8 位);节点侧运行 ID 一律为指纹数字。
|
||||||
|
|
||||||
|
### GET /api/profiles — 列表(联表统计)
|
||||||
|
|
||||||
|
Query:`q`(搜索)、`status`、`proxyId`、`node`、`sort`、`order`、`page`、`limit`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{
|
||||||
|
"total": 10, "page": 1, "limit": 20,
|
||||||
|
"items": [ {
|
||||||
|
"id": "12345678", "name": "...", "fingerprint": "12345678",
|
||||||
|
"node": "node-1", "status": "running", "browserType": "browser",
|
||||||
|
"browser": { /* 指纹快照 */ }, "tlsPreset": "...",
|
||||||
|
"proxyId": "uuid", "proxyName": "...", "proxyStatus": "connected", "proxyType": "socks5",
|
||||||
|
"statistics": {
|
||||||
|
"totalRequests": 0, "successfulRequests": 0, "failedRequests": 0,
|
||||||
|
"captchaCount": 0, "recaptchaCount": 0, "recaptchaSolved": 0, "lastCaptchaAt": null
|
||||||
|
},
|
||||||
|
"lastUsedAt": null, "createdAt": "2026-09-01T00:00:00.000Z"
|
||||||
|
} ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/profiles — 创建 Profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求(全部可选)
|
||||||
|
{
|
||||||
|
"name": "my-profile",
|
||||||
|
"fingerprint": "12345678", // 6~8 位数字(100000~99999999),缺省自动生成;冲突 → 409 FINGERPRINT_CONFLICT
|
||||||
|
"browserType": "browser", // 仅支持 "browser"
|
||||||
|
"proxyId": "uuid", // 绑定代理
|
||||||
|
"node": "node-1", // 指派运行节点(随分配自动注册节点)
|
||||||
|
"notes": "备注"
|
||||||
|
}
|
||||||
|
// 201 → data:Profile 详情(同 GET /api/profiles/:id 的 profile 部分)
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/profiles/nodes — 已有 Profile 的运行节点列表(去重,用于筛选)→ `data: ["node-1", ...]`
|
||||||
|
|
||||||
|
### GET /api/profiles/:id — 详情
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{
|
||||||
|
/* Profile 详情(指纹、代理、网络、统计等) */
|
||||||
|
"cookieCount": 12,
|
||||||
|
"captchaEvents": [ { "id": "1", "type": "recaptcha", "website": "...", "url": "...", "createdAt": "...", "metadata": {} } ],
|
||||||
|
"logs": [ { "id": "1", "time": "...", "level": "info", "type": "...", "message": "..." } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /api/profiles/:id — 编辑基础信息
|
||||||
|
|
||||||
|
请求体(字段均可选):`{ name?, browserType?, proxyId?(null 解绑), node?(空串回退 "local"), notes? }` → `data: Profile 详情`。
|
||||||
|
变更节点/代理会自动写系统日志,节点随分配自动注册。
|
||||||
|
|
||||||
|
### DELETE /api/profiles/:id — 删除 → `data: null`
|
||||||
|
|
||||||
|
### POST /api/profiles/:id/duplicate — 复制(克隆指纹)
|
||||||
|
|
||||||
|
请求体 `{ name? }`(缺省 `<原名>-copy`)→ `201`,data 为新 Profile 详情。
|
||||||
|
|
||||||
|
### POST /api/profiles/:id/start · /stop — 启动/停止(状态标记 running/stopped)
|
||||||
|
|
||||||
|
→ `data: Profile 详情`。(真实浏览器启停走 [2.4 浏览器操作中继](#24-浏览器操作中继)。)
|
||||||
|
|
||||||
|
### POST /api/profiles/:id/fetch — 通过 Profile 运行时执行请求(转发到节点)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"runtime": "fetch", // fetch | browser | page;缺省取 Profile 的 browserType
|
||||||
|
"url": "https://example.com",
|
||||||
|
"method": "GET", // GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS
|
||||||
|
"headers": {}, // 透传
|
||||||
|
"body": "...", // 透传(字符串)
|
||||||
|
"timeoutMs": 30000 // 1000~120000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
转发规则:`runtime=fetch` → 节点 `POST /profiles/:id/fetch`(指纹 UA/头序 + Cookie Jar + impersonate 代理链);`browser` / `page` → 节点 `POST /profiles/:id/browser/fetch`(真实浏览器渲染 / 页面上下文 fetch)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data(RunResult,归一化后)
|
||||||
|
{
|
||||||
|
"ok": true, "status": 200, "headers": {}, "bodyText": "<html>...",
|
||||||
|
"truncated": false,
|
||||||
|
"timingMs": 812, "runtime": "browser",
|
||||||
|
"captcha": { "type": "recaptcha", "kind": "cloudflare", "website": "...", "url": "..." } // 检出时才有
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`bodyText` 完整返回;仅当响应体超过节点 `FETCH_BODY_LIMIT`(默认 5MB,环境变量可调)时被截断,此时 `truncated: true`(非文本 content-type 时 bodyText 为 base64,`bodyBase64` 见节点侧对应接口)。响应命中 CF 挑战时节点自动回退浏览器过盾(见 3.5),此时 `runtime` 可能为 `browser-fallback` 且 `browserSolved: true`。
|
||||||
|
|
||||||
|
结果自动落库:请求统计、验证码事件。
|
||||||
|
|
||||||
|
## 2.4 浏览器操作中继
|
||||||
|
|
||||||
|
以下端点均需 admin 会话/API Token;admin 负责:解析 Profile → 定位节点(`nodes.base_url`)→ 自动**幂等导入** Profile 到节点(按指纹,节点侧缺失时以 admin 声明的指纹/代理创建)→ 附 `x-node-key` 转发。请求/响应与节点侧同名接口一致,细节见第 3 章对应小节。
|
||||||
|
|
||||||
|
| Admin 端点 | 方法 | 转发到节点 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/api/profiles/:id/browser-status` | GET | `GET /browser/status` | 浏览器/VNC 状态 |
|
||||||
|
| `/api/profiles/:id/browser-actions` | POST | `POST /browser/actions` | 执行动作批次(4MB 上限) |
|
||||||
|
| `/api/profiles/:id/browser-events?quality=` | GET | `GET /browser/events` | SSE 中继(state/screenshot/action/error) |
|
||||||
|
| `/api/profiles/:id/browser-restart` | POST | `POST /browser/restart` | 重启浏览器 |
|
||||||
|
| `/api/profiles/:id/browser-stop` | POST | `POST /browser/stop` | 关闭浏览器 |
|
||||||
|
| `/api/profiles/:id/browser-window` | POST | `POST /browser/window` | 新建窗口 |
|
||||||
|
| `/api/profiles/:id/cf-solver` | POST | `POST /cf-solver` | 自动 CF 挑战开关,`{ enabled: bool }` |
|
||||||
|
| `/api/profiles/:id/vnc/restart` | POST | `POST /vnc/restart` | 重启/恢复 x11vnc |
|
||||||
|
| `/api/profiles/:id/vnc/paste` | POST | `POST /vnc/paste` | VNC 粘贴,`{ text }`(1MB 上限) |
|
||||||
|
| `/api/profiles/:id/vnc/ws` | GET(WS) | `WS /vnc/ws` | noVNC WebSocket 中继(鉴权后 upgrade) |
|
||||||
|
| `/api/profiles/:id/browser-cookies` | GET / PUT | `GET·PUT /cookies` | 读取/覆盖导入节点浏览器 Cookie(8MB 上限) |
|
||||||
|
| `/api/profiles/:id/cookie-filter` | GET / PUT | `GET·PUT /cookie-filter` | Cookie 同步黑白名单 |
|
||||||
|
| `/api/profiles/:id/captcha/state` | GET | `GET /captcha/state` | 打码状态 |
|
||||||
|
| `/api/profiles/:id/captcha/events` | GET | `GET /captcha/events` | 打码状态 SSE |
|
||||||
|
| `/api/profiles/:id/captcha/image` | GET | `GET /captcha/image` | 主图(jpeg 原样透传) |
|
||||||
|
| `/api/profiles/:id/captcha/tile/:idx` | GET | `GET /captcha/tile/:idx` | 格子图 |
|
||||||
|
| `/api/profiles/:id/captcha/:action` | GET / POST | `GET·POST /captcha/:action` | `action ∈ start·adopt·click·verify·reload·action·autosolve·status·detach`,未知 action → 404 |
|
||||||
|
| `/api/profiles/:id/direct-session` | GET | `POST /direct-ticket` | 签发直连会话,见下 |
|
||||||
|
|
||||||
|
### GET /api/profiles/:id/direct-session — 直连会话
|
||||||
|
|
||||||
|
前端 VNC/浏览器操作直连节点的前置:admin 以节点密钥向节点索取一次性票据,密钥与 API Token 均不出服务端。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "nodeUrl": "https://node.example.com", "profileId": "12345678", "ticket": "dtk_...", "expiresInMs": 300000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 节点未配置公开地址(`nodes.public_url`)→ `503 DIRECT_UNAVAILABLE`,前端回退 admin 中继。
|
||||||
|
- `profileId` 须为指纹数字或 UUID,否则 `400 VALIDATION_FAILED`。
|
||||||
|
|
||||||
|
## 2.5 Cookie 管理(admin 本地 Jar)
|
||||||
|
|
||||||
|
admin 数据库中按 Profile 存储的 Cookie(与节点浏览器同步的"源头")。Cookie 形状:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "uuid", "profileId": "uuid", "name": "k", "value": "v", "domain": "example.com",
|
||||||
|
"path": "/", "expiresAt": "2026-12-31T00:00:00.000Z", "secure": false, "httpOnly": false,
|
||||||
|
"sameSite": "lax", "createdAt": "...", "updatedAt": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/profiles/:id/cookies` | 列出 Cookie → `data: Cookie[]` |
|
||||||
|
| `POST /api/profiles/:id/cookies` | 新增单条:`{ name, value, domain, path?, expiresAt?, secure?, httpOnly?, sameSite?(strict\|lax\|none) }` → `201` |
|
||||||
|
| `PUT /api/profiles/:id/cookies` | 批量导入:数组或 `{ cookies:[...] }`(按 name+domain+path upsert)→ `{ imported: n }` |
|
||||||
|
| `DELETE /api/profiles/:id/cookies` | 清空该 Profile → `{ cleared: n }` |
|
||||||
|
| `PUT /api/cookies/:id` | 编辑单条(部分更新) |
|
||||||
|
| `DELETE /api/cookies/:id` | 删除单条 → `data: null` |
|
||||||
|
|
||||||
|
## 2.6 代理管理 `/api/proxies`
|
||||||
|
|
||||||
|
代理形状(`serializeProxy`):`{ id, name, host, port, type, username, enabled, tags[], status, checkMessage, exitIp, exitCountry, exitCity, timingMs, createdAt, updatedAt }`。
|
||||||
|
|
||||||
|
### GET /api/proxies — 列表
|
||||||
|
|
||||||
|
Query:`q`、`tag`、`country`、`sort`、`order` → `data: Proxy[]`
|
||||||
|
|
||||||
|
### POST /api/proxies — 创建
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "p1", "host": "10.0.0.10", "port": 1080, "type": "socks5", "username": "", "password": "" }
|
||||||
|
// type 仅支持 socks5 | http | https,缺省 socks5 → 201
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/proxies/import — 批量导入
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "text": "socks5://user:pass@1.2.3.4:1080\n5.6.7.8:1080" }
|
||||||
|
// → data { created, skipped, failed: [ { line, reason } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
一行一个,支持 `protocol://user:pass@host:port` 与 `host:port`。
|
||||||
|
|
||||||
|
### GET /api/proxies/:id — 详情
|
||||||
|
|
||||||
|
### PUT /api/proxies/:id — 更新(部分字段;`password: ""` 表示清除;`tags` 为字符串数组)
|
||||||
|
|
||||||
|
### DELETE /api/proxies/:id — 删除 → `data: null`
|
||||||
|
|
||||||
|
### POST /api/proxies/:id/test — 连通性测试(真实走代理出口,geoip 归属)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "ok": true, "message": "...", "exitIp": "1.2.3.4", "exitCountry": "US", "exitCity": "...",
|
||||||
|
"timingMs": 320, "proxy": { /* 更新后的代理 */ } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/proxies/:id/toggle — 启用/禁用切换 → `data: Proxy`
|
||||||
|
|
||||||
|
## 2.7 节点管理 `/api/nodes`
|
||||||
|
|
||||||
|
节点形状(`serializeNode`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "node-1", "ip": "1.2.3.4", "country": "US", "status": "online", "cpu": "12%", "memory": "40%",
|
||||||
|
"baseUrl": "http://1.2.3.4:3100", "publicUrl": "https://node.example.com", "version": "20260924.1010",
|
||||||
|
"maxProfiles": null, "maxMemoryPct": null, "maxCpuPct": null, "hasKey": true,
|
||||||
|
"totalProfiles": 5, "runningProfiles": 2, "createdAt": "...", "updatedAt": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
节点不提供手动添加 —— 随 Profile 分配(`POST /api/profiles` 的 `node` 字段)或安装自注册自动创建。
|
||||||
|
|
||||||
|
### GET /api/nodes — 列表 → `data: Node[]`
|
||||||
|
|
||||||
|
### POST /api/nodes/provision — 预置节点(生成/轮换通信密钥)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 { "name": "node-2" }
|
||||||
|
// data
|
||||||
|
{ "name": "node-2", "key": "pmk_...", "installCmd": "wget -qO- https://<admin>/node/install?key=..&name=node-2 | bash" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/nodes/register — 节点安装完成后自注册(免会话)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "name": "node-2", "key": "pmk_...", "baseUrl": "http://1.2.3.4:3100", "ip": "1.2.3.4" }
|
||||||
|
// name 与 key 必填;key 须与 provision 下发的一致 → data: Node
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/nodes/heartbeat — 节点心跳上报(注册 + 指标更新)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "name": "node-1", "ip": "1.2.3.4", "country": "US", "status": "online",
|
||||||
|
"cpu": "12%", "memory": "40%", "baseUrl": "http://1.2.3.4:3100", "version": "20260924.1010" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 认证:admin 会话/API Token,**或**该节点自己的密钥(`x-node-key` / `Bearer`,且须与 `name` 匹配)。
|
||||||
|
- `status` 仅支持 `unknown|online|offline`;`version` 为节点代码版本(v0.4 起落库)。
|
||||||
|
|
||||||
|
### PUT /api/nodes/:name — 编辑节点
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ip": "...", "country": "US", "publicUrl": "node.example.com", // 无协议自动补 https://;空串清除(回退中继)
|
||||||
|
"status": "online", "maxProfiles": 20, "maxMemoryPct": 80, "maxCpuPct": 90 } // 配置项传 null 清除
|
||||||
|
// → data: Node
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /api/nodes/:name — 删除(节点上仍有 Profile 时拒绝)→ `data: null`
|
||||||
|
|
||||||
|
### POST /api/nodes/update-batch — 批量触发节点自更新
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 { "names": ["node-1", "node-2"] }
|
||||||
|
// data
|
||||||
|
{ "results": [ { "name": "node-1", "ok": true, "detail": "已触发" },
|
||||||
|
{ "name": "node-2", "ok": false, "detail": "节点未注册地址或无密钥" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
逐个串行中继 `POST {节点}/system/update`(15s 超时);节点后台拉包替换后自行退出,由容器/pm2 重启生效。
|
||||||
|
|
||||||
|
## 2.8 节点代码包与自更新
|
||||||
|
|
||||||
|
### GET /node/install?key=&name= — 下发一键安装脚本
|
||||||
|
|
||||||
|
返回 bash 脚本(`text/plain`):生成 docker-compose.yml → 拉镜像 → 启动容器 → 健康检查 → 自注册。支持环境变量覆盖:`NODE_KEY`、`NODE_NAME`、`IMAGE`、`INSTALL_DIR`,参数 `-n/--name -k/--key -i/--image -b/--base-url -d/--dir`。
|
||||||
|
|
||||||
|
### GET /node/package?key= — 下载节点代码包
|
||||||
|
|
||||||
|
- 凭节点密钥;`tar.gz`(src/ + package.json + bun.lock + tsconfig.json + version)。
|
||||||
|
- 响应头:`x-package-version: <版本>`、`content-disposition: attachment; filename="serpmax-node-<版本>.tgz"`。
|
||||||
|
- 无效 key → `401 UNAUTHORIZED`。
|
||||||
|
|
||||||
|
### GET /node/version — admin 侧节点代码版本(公开)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { "version": "20260924.1010" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 版本号 = 源码内容指纹,`YYYYMMDD.HHMM` 日期格式,缓存 30s。节点页用 `GET /node/version` 与各节点 `GET /system/version` 比对判断是否需要更新。
|
||||||
|
|
||||||
|
## 2.9 日志 `/api/logs`
|
||||||
|
|
||||||
|
### GET /api/logs — 日志列表
|
||||||
|
|
||||||
|
Query:`q`、`profileId`、`proxyId`、`level`、`type`、`from`、`to`、`sort`、`order`、`page`、`limit`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "total": 100, "page": 1, "limit": 20, "items": [ { "id": "1", "time": "...", "level": "info",
|
||||||
|
"type": "profile.started", "message": "...", "profileId": "uuid", "proxyId": null, "metadata": {} } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/logs/filters — 现存的级别与类型(筛选下拉)→ `data: { levels: [...], types: [...] }`
|
||||||
|
|
||||||
|
## 2.10 系统设置 `/api/settings`
|
||||||
|
|
||||||
|
### GET /api/settings — 当前设置
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "username": "admin", "apiToken": "smax_...",
|
||||||
|
"settings": { "theme": "system", "defaultRuntime": "fetch", "autoTestProxy": true,
|
||||||
|
"defaultTimeout": 30000, "systemName": "SerpMax Admin" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /api/settings — 更新(白名单键)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "theme": "dark", "defaultRuntime": "browser", "autoTestProxy": false, "defaultTimeout": 60000, "systemName": "..." }
|
||||||
|
// → data { changed: n };无可更新项 → 400
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/settings/password — 修改管理员密码
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "currentPassword": "...", "newPassword": "..." } // 新密码 ≥6 位;当前密码错 → 401 INVALID_CREDENTIALS
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/settings/api-token/rotate — 重置 API Token → `data: { apiToken: "smax_..." }`(旧 Token 立即失效)
|
||||||
|
|
||||||
|
## 2.11 事件上报 `/api/events`
|
||||||
|
|
||||||
|
节点(或任意持 Token 方)向 admin 上报事件;`profileId` 可为 UUID 或指纹数字(自动解析)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// POST /api/events
|
||||||
|
{
|
||||||
|
"event": "captcha.detected", // 见下表
|
||||||
|
"profileId": "12345678", // 按事件类型必填
|
||||||
|
"proxyId": "uuid",
|
||||||
|
"type": "recaptcha", "kind": "cloudflare", "website": "...", "url": "...",
|
||||||
|
"level": "info", "message": "...", "status": 200, "ok": true,
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
// data 按类别:{ "recorded": "captcha" | "captcha-solved" | "browser" | "proxy" | "request" | "log" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| event | 副作用 |
|
||||||
|
|---|---|
|
||||||
|
| `captcha.detected` / `recaptcha.detected` / `challenge.detected` | 记验证码事件 + 统计(`type=recaptcha` 判定:`type` 字段或事件名) |
|
||||||
|
| `captcha.solved` / `recaptcha.solved` | recaptcha 解题成功计数 |
|
||||||
|
| `browser.started` / `browser.stopped` / `browser.crashed` | Profile 状态 → running / stopped / error |
|
||||||
|
| `proxy.connected` / `proxy.failed` | 代理检测状态(必须带 `proxyId`) |
|
||||||
|
| `request.completed` | 请求统计(`ok` 优先,缺省按 `status` 2xx/3xx 判定) |
|
||||||
|
| 其他 | 仅按 `level`(debug/info/success/warn/error)记日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Node API(端口 3100)
|
||||||
|
|
||||||
|
> Profile Manager 节点:负责指纹浏览器(Chromium/CloakBrowser)生命周期、Lease、fetch/render、打码、VNC。
|
||||||
|
> 鉴权见 [1.2](#12-认证)。`data` 字段以下均指成功信封中的内容。
|
||||||
|
|
||||||
|
## 3.1 Profile
|
||||||
|
|
||||||
|
### POST /profiles — 创建(支持 Idempotency-Key 幂等)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求(全部可选;对象字段只校验"必须是对象",结构由指纹导入约定决定)
|
||||||
|
{
|
||||||
|
"id": "12345678", // 外部 ID(字母数字与 -_,≤64 位);admin 导入时用指纹数字
|
||||||
|
"name": "my-profile",
|
||||||
|
"fingerprint": { "import": { /* admin BrowserFingerprint:UA/画布/字体等,导入身份以声明值为准 */ } },
|
||||||
|
"network": { "tls": { "preset": "chrome-146" }, "proxy": { /* ... */ } },
|
||||||
|
"proxy": { "id": "...", "protocol": "socks5", "host": "1.2.3.4", "port": 1080, "username": "", "password": "" },
|
||||||
|
"browser": { /* 指纹快照(同 fingerprint.import) */ },
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
// 201 → data:Profile 对象
|
||||||
|
```
|
||||||
|
|
||||||
|
Profile 状态机:`READY → IN_USE → READY`,`→ DELETING`,`ERROR →(隐式恢复)READY`。
|
||||||
|
|
||||||
|
### GET /profiles — 列表 → `data: { profiles: [...], total: n }`
|
||||||
|
|
||||||
|
### GET /profiles/:id — 详情(含当前 Lease 概要)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "id": "...", "name": "...", "state": "READY", "updatedAt": "...", /* ... */
|
||||||
|
"lease": { "leaseId": "...", "consumerId": "...", "expiresAt": "..." } } // 有 Lease 时
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /profiles/:id?force=true|false — 删除
|
||||||
|
|
||||||
|
流程:停浏览器 → 清 Runtime → 删数据 → 删 Profile。`force=1/true` 强制(如忽略 Lease);→ `data: { deleted: true, profileId }`。
|
||||||
|
|
||||||
|
## 3.2 Lease(租约)
|
||||||
|
|
||||||
|
同一 Profile 同时只允许一个 Consumer;浏览器操作要求持有 Lease(部分接口无 Lease 时会短期自动接管)。
|
||||||
|
|
||||||
|
### POST /profiles/:id/lease — 获取
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "consumerId": "my-worker-1", "ttlMs": 60000 } // ttlMs ≥1000
|
||||||
|
// 201 → data
|
||||||
|
{ "leaseId": "ls_...", "profileId": "12345678", "expiresAt": "2026-09-24T10:00:00.000Z" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ 此端点特例:参数校验失败与「正在删除」时 **HTTP 状态码为 400/409,但信封仍是 `success:true`**,错误在 `data` 内 —— `data: { error: "VALIDATION_FAILED", message: "ttlMs 必须 ≥1000" }` 或 `data: { error: "PROFILE_DELETING" }`。
|
||||||
|
- ERROR 状态自动隐式恢复为 READY 后发放。
|
||||||
|
|
||||||
|
### POST /profiles/:id/lease/:leaseId/heartbeat — 续租
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 { "ttlMs": 60000 } // 可选,重新计时
|
||||||
|
// data { "leaseId": "...", "profileId": "...", "expiresAt": "...", "lastHeartbeat": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /profiles/:id/lease/:leaseId — 释放 → `data: { released: true, profileId, leaseId }`(Profile 回 READY,浏览器可能仍在运行)
|
||||||
|
|
||||||
|
## 3.3 浏览器生命周期
|
||||||
|
|
||||||
|
### POST /profiles/:id/browser/start — 启动浏览器(需 Lease)
|
||||||
|
|
||||||
|
请求头 `x-lease-id`(或 body `leaseId`)须匹配当前 Lease,否则 `409 LEASE_CONFLICT`。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "profileId": "12345678", "browserId": "...", "reused": false,
|
||||||
|
"cdp": { "endpoint": "http://127.0.0.1:9222", "wsEndpoint": "ws://...", "port": 9222 },
|
||||||
|
"processId": 1234 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /profiles/:id/browser/stop — 停止(不要求 Lease;验收流程 = 先释放 Lease 再 stop)→ `data: { profileId, status: "STOPPED" }`
|
||||||
|
|
||||||
|
### POST /profiles/:id/browser/restart — 重启
|
||||||
|
|
||||||
|
有 Lease 时校验持有者;无 Lease 时以 `admin:restart:<requestId>` 短期接管(120s),完成即释放。响应同 `start`。
|
||||||
|
|
||||||
|
### GET /profiles/:id/browser/status — 状态
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "profileId": "...", "status": "RUNNING", // RUNNING | STOPPED
|
||||||
|
"browserId": "...", "cdpEndpoint": "http://...", "processId": 1234, "startedAt": "...",
|
||||||
|
"vncPort": 6100, "vncDisplay": ":100", // VNC 启用时
|
||||||
|
"vncAvailable": true,
|
||||||
|
"idleCloseInMs": 118000 } // 空闲自动关闭剩余毫秒;未启用为 null
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /profiles/:id/browser/window — 新建空白窗口(浏览器须运行中)→ `data: { url: "about:blank", windows: 3 }`
|
||||||
|
|
||||||
|
### GET /profiles/:id/browser/cdp — CDP 端点 → `data: { profileId, cdp: { endpoint, wsEndpoint, port } }`;未运行 → `409 BROWSER_NOT_RUNNING`
|
||||||
|
|
||||||
|
### GET·POST /profiles/:id/cf-solver — 自动 CF 挑战开关
|
||||||
|
|
||||||
|
- `GET` → `data: { enabled: true }`(缺省启用,随节点进程保留)
|
||||||
|
- `POST` `{ "enabled": false }` → `data: { enabled: false }`
|
||||||
|
|
||||||
|
## 3.4 浏览器操作(Action + SSE)
|
||||||
|
|
||||||
|
> 设计:HTTP 负责控制、SSE 负责状态、截图负责视觉反馈;协议细节见 `node/docs/Browser-Control.md`。
|
||||||
|
|
||||||
|
### POST /profiles/:id/browser/actions — 执行动作批次(按序,任一失败即停)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{ "type": "goto", "url": "https://www.google.com/recaptcha/api2/demo?hl=en" },
|
||||||
|
{ "type": "fill", "selector": "#email", "text": "a@b.c" },
|
||||||
|
{ "type": "click", "selector": "button.submit" }, // 或 { "x": 100, "y": 200 } 坐标点击
|
||||||
|
{ "type": "press", "key": "Control+c" },
|
||||||
|
{ "type": "wait", "ms": 1000 } // 或 { "selector": "...", "ms": 5000 }
|
||||||
|
],
|
||||||
|
"screenshotQuality": 30, // 1-100,默认 30
|
||||||
|
"timeout": 30000 // 单动作超时,默认 30000,上限 120000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
支持 16 种 action:`goto · reload · back · forward · click · dblclick · hover · fill · type · press · select · check · uncheck · scroll · scrollIntoView · wait`(单次最多 30 个,详见 [附录 4.1](#41-browser-action-类型一览))。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "actionId": "act_...", "results": [ /* 每个动作的产出,按序 */ ], "clipboardText": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 浏览器未运行(如空闲自动关闭后)自动重启 —— "操作即连接"。
|
||||||
|
- Lease:已持有则复用;无 Lease 时短期接管(`admin:action:<requestId>`,批次期间持有,完成即释放)。
|
||||||
|
- 每个动作的进度经 SSE `action` 事件推送(`{ actionId, status: running|completed|failed }`)。
|
||||||
|
|
||||||
|
### GET /profiles/:id/browser/events?quality=30 — SSE 事件流
|
||||||
|
|
||||||
|
- 鉴权:节点密钥或直连票据(EventSource 无法自定义头,票据在 TTL 内可复用)。
|
||||||
|
- 事件类型:`state`(url/title/loading/viewport)、`screenshot`(jpeg base64,动态 FPS 调度)、`action`(动作进度)、`error`(错误)、`browser`(`{ status:"STOPPED", reason:"browser-closed" }`,浏览器关闭时终态)。
|
||||||
|
- 15s `: ping` 保活;连接期间自动持有 Lease 并心跳,断开释放。
|
||||||
|
- 浏览器随需启动;`quality` 控制截图质量。
|
||||||
|
|
||||||
|
## 3.5 HTTP 请求执行
|
||||||
|
|
||||||
|
### POST /profiles/:id/fetch — Profile 上下文 HTTP 请求(不走浏览器)
|
||||||
|
|
||||||
|
指纹 UA/头序 + Cookie Jar + impersonate-proxy(→socks5)链路。适配器两级:优先 **go-fetch**(Go 原生客户端,`GO_FETCH_BIN` 或节点根 `bin/go-fetch`;原生 CONNECT 隧道与 MITM 完全兼容),回落系统 curl。MITM 出站连接按 (上游, 域名) 池化复用(空闲 120s/寿命 10min,借出前 PING 探活),一次性 fetch 可复用浏览器焐热的同一条出站连接,连接层指纹与浏览器一致。
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "url": "https://httpbin.org/get", "method": "GET", "headers": { "x-demo": "1" },
|
||||||
|
"body": "...", "timeoutMs": 30000, "browserFallback": true, "rawEncoding": false } // timeoutMs 100~300000
|
||||||
|
// data:RunResult 形状(ok/status/headers/bodyText/timingMs/runtime:"fetch" 等)
|
||||||
|
// 响应体超过 FETCH_BODY_LIMIT(默认 5MB)时截断并置 truncated:true
|
||||||
|
```
|
||||||
|
|
||||||
|
**rawEncoding 压缩直通**(默认 false;v0.42.0 新增,serp-api 使用):请求强制 `accept-encoding: gzip`,
|
||||||
|
go-fetch 以 `--keep-encoding` 透传上游原始 gzip 字节——响应 `body` 为 base64、`bodyBase64:true`、
|
||||||
|
`bodyEncoding:"gzip"`,全链(Google→go-fetch→node→调用方)不解压不重压缩;上游未压缩(identity)时仍按文本返回。
|
||||||
|
仅 impersonate(go-fetch/curl)链路支持,其余链路自动降级为解压文本;此模式下不做 CAPTCHA 检测与 CF 浏览器回退。
|
||||||
|
|
||||||
|
**CF 挑战自动回退**(默认开启,仅 GET;`browserFallback:false` 关闭):响应被 Cloudflare/Turnstile 挑战拦截时,节点自动借同一 Profile 的浏览器过盾(CF 自动解题钩子生效)→ 吸收过盾 Cookie 后重试一次纯 HTTP;若站点免挑战放行浏览器、从不发 clearance(wethrift 类),则直接返回浏览器渲染内容,此时 `runtime:"browser-fallback"`、`browserSolved:true`、`status:200`。
|
||||||
|
|
||||||
|
### POST /profiles/:id/browser/fetch — 浏览器运行时请求
|
||||||
|
|
||||||
|
已运行直接 goto;未运行按需启动(复用真实指纹/代理)。两种模式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"url": "https://example.com/api/data",
|
||||||
|
"runtime": "page", // 缺省 = 整页导航(browser 模式)
|
||||||
|
"method": "GET", "headers": {}, "body": "...", // 仅 page 模式使用
|
||||||
|
"timeoutMs": 45000 // 钳制 5000~120000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `runtime:"page"`:导航到目标源(等 CF 解题放行)后在**页面 JS 里执行 fetch**,复用浏览器原生 TLS/连接池/Cookie/cf_clearance —— 适合 API 类请求。
|
||||||
|
- 默认(整页导航):返回渲染后 HTML + `title`。
|
||||||
|
- 响应含 `restarted: true|false`(本次是否触发了浏览器启动)、`finalUrl`、`timingMs`。
|
||||||
|
|
||||||
|
### POST /profiles/:id/browse — 浏览器渲染一次目标页面(同步返回)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 { "url": "https://example.com", "timeoutMs": 30000 } // timeoutMs 1000~120000
|
||||||
|
// data
|
||||||
|
{ "ok": true, "status": 200, "headers": {}, "bodyText": "<html>...", "truncated": false,
|
||||||
|
"timingMs": 900, "runtime": "chromium",
|
||||||
|
"captcha": { "type": "recaptcha", "kind": "...", "website": "...", "url": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- Lease:已持有则复用;否则临时获取(`admin:browse:<requestId>`),结束释放,并新开页渲染后关闭。
|
||||||
|
- 检出验证码时发布 `captcha.detected` / `challenge.detected` / `blocked.detected` 事件。
|
||||||
|
- 超时 → `504 FETCH_TIMEOUT`;证书/代理错误 → `502 PROXY_FAILED`。
|
||||||
|
|
||||||
|
## 3.6 Cookie(node 运行时)
|
||||||
|
|
||||||
|
操作**浏览器中的 Cookie**(运行中经 Playwright Context),并与 fetch 链路的 Cookie Jar 双向同步;浏览器未运行时仅操作 Jar。形状:`{ id, profileId, name, value, domain, path, expiresAt, secure, httpOnly, sameSite }`。
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /profiles/:id/cookies` | 浏览器运行中 → `context.cookies()`(过滤同步黑名单);未运行 → Jar |
|
||||||
|
| `PUT /profiles/:id/cookies` | 覆盖导入:数组或 `{cookies:[...]}`;运行中 addCookies + Jar,未运行仅 Jar → `{ imported, browser, deferred }`(`deferred:true` 表示浏览器侧留待下次启动) |
|
||||||
|
| `DELETE /profiles/:id/cookies` | 浏览器 clearCookies + 清 Jar → `{ cleared: true }` |
|
||||||
|
|
||||||
|
`expiresAt` 兼容 ISO 字符串与秒/毫秒时间戳;`sameSite ∈ strict|lax|none`。
|
||||||
|
|
||||||
|
### GET·PUT /profiles/:id/cookie-filter — Cookie 同步黑白名单
|
||||||
|
|
||||||
|
```json
|
||||||
|
// GET → data { "whitelist": [], "blacklist": ["ads.example.com"] } // 空 = 不限
|
||||||
|
// PUT 请求同形状 → data { whitelist, blacklist, pruned: 3 } // 立即剔除 Jar 中不允许同步的存量(浏览器 USER_DATA 不动)
|
||||||
|
```
|
||||||
|
|
||||||
|
只影响"浏览器 ↔ Jar / admin 视图"的同步方向,浏览器本地 Cookie 不变。
|
||||||
|
|
||||||
|
## 3.7 缓存管理
|
||||||
|
|
||||||
|
### GET /profiles/:id/cache — 磁盘用量
|
||||||
|
|
||||||
|
```json
|
||||||
|
// data
|
||||||
|
{ "profileId": "...", "totalBytes": 10485760, "cacheBytes": 8388608, "storageBytes": 2097152, "downloadsBytes": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /profiles/:id/cache/clear — 清理
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 { "mode": "soft" } // soft=清缓存目录(cache);hard=整目录重置(含 storage,浏览器须停止)
|
||||||
|
// soft → data { profileId, mode: "soft", bytesFreed: 8388608 }
|
||||||
|
// hard → data { profileId, mode: "hard", bytesFreed: null }
|
||||||
|
// 其他值 → 400 VALIDATION_FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.8 Schema 与 Execution
|
||||||
|
|
||||||
|
Schema = 声明式自动化流程(版本化,禁止覆盖只能递增)。
|
||||||
|
|
||||||
|
### POST /schemas — 创建新版本 → `201`,data:Schema 对象
|
||||||
|
|
||||||
|
### GET /schemas/:id — 读取
|
||||||
|
|
||||||
|
- `?version=N` 指定版本(缺省最新);`?versions=1` → `data: { id, versions: [...] }`
|
||||||
|
|
||||||
|
### DELETE /schemas/:id — 删除(全部版本;运行中的版本阻止删除)→ `data: { deleted: true, schemaId }`
|
||||||
|
|
||||||
|
### POST /profiles/:id/schema/:schemaId/run — 执行 Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{ "version": 3, "variables": { "keyword": "test" }, "leaseId": "ls_..." } // 均可选;leaseId 也可用 x-lease-id 头
|
||||||
|
// 202 → data
|
||||||
|
{ "executionId": "exec_...", "profileId": "...", "schemaId": "...", "version": 3, "status": "RUNNING" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema 内 `captcha.autoSolve` 声明会自动触发 reCAPTCHA 看门狗注入。
|
||||||
|
|
||||||
|
### GET /executions/:id — 查询执行记录(状态/步骤结果)
|
||||||
|
|
||||||
|
### POST /executions/:id/cancel — 取消 → `data: { executionId, cancelRequested: true|false }`
|
||||||
|
|
||||||
|
## 3.9 reCAPTCHA 打码
|
||||||
|
|
||||||
|
打码会话由显式端点(答题面板 / autosolve)创建;`state` 是只读端点,不会触发注入。
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/profiles/:id/captcha/state` | GET | 组合状态:手动 mapper 状态 + 自动求解器摘要;未连接会话 → `{ status:"absent", message, solver }` |
|
||||||
|
| `/profiles/:id/captcha/events` | GET | SSE:状态推送(连接即创建会话,15s ping) |
|
||||||
|
| `/profiles/:id/captcha/image` | GET | 主图 jpeg(无图 → 404 `no image`) |
|
||||||
|
| `/profiles/:id/captcha/tile/:idx` | GET | 第 idx 格子图 jpeg |
|
||||||
|
| `/profiles/:id/captcha/start` | POST | 点击 checkbox 拉起挑战 |
|
||||||
|
| `/profiles/:id/captcha/adopt` | POST | 采纳页面上已打开的挑战 |
|
||||||
|
| `/profiles/:id/captcha/click` | POST | `{ "index": 3 }` 点格子,或 `{ "nx": 0.5, "ny": 0.5 }` 归一化坐标 |
|
||||||
|
| `/profiles/:id/captcha/verify` | POST | 提交验证 |
|
||||||
|
| `/profiles/:id/captcha/reload` | POST | 换一题 |
|
||||||
|
| `/profiles/:id/captcha/action` | POST | `{ "kind": "next" \| "skip" \| "verify" }`(缺省 verify) |
|
||||||
|
| `/profiles/:id/captcha/autosolve` | POST | 触发自动求解(立即返回,结果轮询 status);页面无挑战 → `{ started:false, reason:"no-challenge" }`;求解中幂等忽略 |
|
||||||
|
| `/profiles/:id/captcha/status` | GET | 自动求解器状态:`{ solving, result, solvedToken }` |
|
||||||
|
| `/profiles/:id/captcha/detach` | POST | 丢弃会话 → `{ disposed: true }` |
|
||||||
|
|
||||||
|
## 3.10 VNC 与直连
|
||||||
|
|
||||||
|
### WS /profiles/:id/vnc/ws — noVNC WebSocket 桥(→ 本机 x11vnc TCP)
|
||||||
|
|
||||||
|
- 握手即升级;鉴权:节点密钥或一次性直连票据(`?ticket=`)。
|
||||||
|
- Query `cfAuto=1|0`:连接时设置「自动 CF 挑战」开关(缺省不动)。
|
||||||
|
- 浏览器未运行或 VNC 未启用 → 404 信封(升级前)。
|
||||||
|
- 客户端真实操作(RFB KeyEvent / 带按键 PointerEvent / ClientCutText)会重置浏览器空闲计时。
|
||||||
|
|
||||||
|
### POST /profiles/:id/direct-ticket — 签发直连票据(仅节点密钥持有者)
|
||||||
|
|
||||||
|
→ `data: { ticket: "dtk_...", expiresInMs: 300000 }`。票据绑定 Profile、一次性(actions)短时可复用(events/VNC)、5 分钟过期。
|
||||||
|
|
||||||
|
### POST /profiles/:id/vnc/paste — VNC 粘贴
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 { "text": "hello" }
|
||||||
|
// 纯 X 路径:xsel 写 CLIPBOARD + x11vnc 注入真实 Ctrl+V,落点为 VNC 当前焦点
|
||||||
|
// data { "pasted": 5, "mode": "x11" }
|
||||||
|
```
|
||||||
|
|
||||||
|
VNC 未启用 → `409 BROWSER_NOT_RUNNING`;缺 xsel → `500 INTERNAL_ERROR`。
|
||||||
|
|
||||||
|
### POST /profiles/:id/vnc/restart — VNC 重启/恢复
|
||||||
|
|
||||||
|
x11vnc 重启(色深切换清残留);实例丢失时自动整组重建(浏览器不动)。
|
||||||
|
→ `data: { restarted: true, recovered: false, port: 6100 }`;无法恢复 → `409 BROWSER_NOT_RUNNING`。
|
||||||
|
|
||||||
|
## 3.11 系统
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 / data |
|
||||||
|
|---|---|---|
|
||||||
|
| `/health` | GET | `{ status:"ok", uptimeMs }` |
|
||||||
|
| `/ready` | GET | 就绪检查:`{ ready, checks: { profileCache, runtimeManager, chromiumBinary, recoveryDone } }`;未就绪整体 503 |
|
||||||
|
| `/metrics` | GET | Prometheus 文本(`text/plain; version=0.0.4`),非 JSON 信封 |
|
||||||
|
| `/events?limit=&profileId=` | GET | 最近事件环形缓冲:`{ events: [...], total }`(调试) |
|
||||||
|
| `/browsers` | GET | `{ running: n }` 当前运行浏览器数(调试) |
|
||||||
|
| `/node/info` | GET | 节点自述(与心跳同构):`{ name, ip, country, status:"online", version, admin:{enabled,url,eventForward,heartbeatInterval}, profiles:{total,running}, browsersRunning }` |
|
||||||
|
| `/system/version` | GET | `{ version: "20260924.1010" }`(30s 缓存) |
|
||||||
|
| `/system/update` | POST | 自更新:立即 `{ started:true, from:"<旧版本>" }`;后台拉包(admin `/node/package`)→ 替换 /app → `bun install` → `process.exit(0)` → 容器 `restart: unless-stopped` 载入新代码。未配置 `ADMIN_URL` → `400 { started:false, reason:"ADMIN_URL 未配置" }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 附录
|
||||||
|
|
||||||
|
## 4.1 Browser Action 类型一览
|
||||||
|
|
||||||
|
单次请求最多 **30** 个 action;按数组顺序执行,任一失败即停止后续。前置校验快速失败:
|
||||||
|
|
||||||
|
| type | 必填/常用参数 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `goto` | `url`(http/https) | 导航 |
|
||||||
|
| `reload` | — | 刷新 |
|
||||||
|
| `back` / `forward` | — | 历史前进/后退 |
|
||||||
|
| `click` | `selector` 或 `x`+`y`(视口 CSS 像素) | 点击 |
|
||||||
|
| `dblclick` | `selector` | 双击 |
|
||||||
|
| `hover` | `selector` | 悬停 |
|
||||||
|
| `fill` | `selector`、`text` | 清空并填入 |
|
||||||
|
| `type` | `selector`、`text` | 逐键输入 |
|
||||||
|
| `press` | `key`(如 `Enter`、`Control+c`) | 按键;`Control+c` 的剪贴板结果经 `clipboardText` 返回 |
|
||||||
|
| `select` | `selector`、`value(s)` | 下拉选择 |
|
||||||
|
| `check` / `uncheck` | `selector` | 勾选/取消 |
|
||||||
|
| `scroll` | `x`/`y` 或 `selector` | 滚动 |
|
||||||
|
| `scrollIntoView` | `selector` | 滚动到元素 |
|
||||||
|
| `wait` | `ms` 或 `selector` | 等待时长/元素出现 |
|
||||||
|
|
||||||
|
请求级参数:`screenshotQuality`(1-100,默认 30)、`timeout`(单动作超时 ms,默认 30000,上限 120000)。
|
||||||
|
|
||||||
|
错误码:`INVALID_ACTION`(类型非法/超数)、`INVALID_ACTION_PARAMETER`(参数缺失)、`ELEMENT_NOT_FOUND`、`ELEMENT_NOT_VISIBLE`、`ACTION_TIMEOUT` 等,见 [1.4](#14-错误码表)。
|
||||||
|
|
||||||
|
## 4.2 SSE 事件格式
|
||||||
|
|
||||||
|
`GET /profiles/:id/browser/events` 每条事件:
|
||||||
|
|
||||||
|
```
|
||||||
|
event: state
|
||||||
|
data: {"url":"https://...","title":"...","loading":false,"viewport":{"w":1440,"h":900,"dpr":1}}
|
||||||
|
|
||||||
|
event: screenshot
|
||||||
|
data: {"jpeg":"<base64>","w":1440,"h":900}
|
||||||
|
|
||||||
|
event: action
|
||||||
|
data: {"actionId":"...","status":"completed"}
|
||||||
|
|
||||||
|
event: browser
|
||||||
|
data: {"status":"STOPPED","reason":"browser-closed"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 注释行 `: ping`(15s)保活;空闲超时 120s(服务端)。
|
||||||
|
- `GET /profiles/:id/captcha/events` 同为 SSE,`data` 为打码状态对象(带 `solver` 摘要)。
|
||||||
|
|
||||||
|
## 4.3 典型调用流程
|
||||||
|
|
||||||
|
**自动化消费方(节点直连):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://127.0.0.1:3100
|
||||||
|
K="x-node-key: $NODE_KEY"
|
||||||
|
|
||||||
|
# 1. 创建 Profile(幂等)
|
||||||
|
curl -s -X POST $BASE/profiles -H "$K" -H "idempotency-key: job-42" \
|
||||||
|
-H "content-type: application/json" -d '{"id":"12345678","name":"demo"}'
|
||||||
|
|
||||||
|
# 2. 取 Lease
|
||||||
|
curl -s -X POST $BASE/profiles/12345678/lease -H "$K" -H "content-type: application/json" \
|
||||||
|
-d '{"consumerId":"worker-1","ttlMs":60000}'
|
||||||
|
# → data.leaseId
|
||||||
|
|
||||||
|
# 3. 启动浏览器(拿 CDP)
|
||||||
|
curl -s -X POST $BASE/profiles/12345678/browser/start -H "$K" -H "x-lease-id: <leaseId>" -d '{}'
|
||||||
|
|
||||||
|
# 4. 执行动作
|
||||||
|
curl -s -X POST $BASE/profiles/12345678/browser/actions -H "$K" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"actions":[{"type":"goto","url":"https://example.com"}],"screenshotQuality":60}'
|
||||||
|
|
||||||
|
# 5. 释放 Lease、停浏览器
|
||||||
|
curl -s -X DELETE $BASE/profiles/12345678/lease/<leaseId> -H "$K"
|
||||||
|
curl -s -X POST $BASE/profiles/12345678/browser/stop -H "$K"
|
||||||
|
```
|
||||||
|
|
||||||
|
**admin 中继(前端):** 登录(`POST /api/auth/login`)→ `GET /api/profiles/:id/direct-session` 拿 `{ nodeUrl, ticket }` → 前端直连 `wss://<nodeUrl>/profiles/<指纹>/vnc/ws?ticket=<ticket>`(或全程走 admin 中继 `/api/profiles/:id/vnc/ws`、`/browser-actions`、`/browser-events`)。
|
||||||
|
|
||||||
|
**节点部署:** admin 节点页 `POST /api/nodes/provision` → 复制 `installCmd` 到目标机执行 → 脚本自动 docker compose 启动 + `POST /api/nodes/register` → 之后节点定时 `POST /api/nodes/heartbeat`;升级时 admin 调 `POST /api/nodes/update-batch`。
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# ProfileMax「发布」(Publishing) 功能扩展开发说明
|
||||||
|
|
||||||
|
> 目标:供 Zcode 在现有 SerpMax/ProfileMax 项目中直接扩展。新增一个「发布」一级菜单,页面内部使用 Ant Design Tabs(页签)管理子功能。此处“Tags”按页面 Tabs 理解;站点标签另用 tags 字段,不要混淆。
|
||||||
|
> 依据:`docs/审计-功能清单与技术栈.md`(2026-10-28),开发前以当前源码为准。
|
||||||
|
|
||||||
|
## 1. 定位和边界
|
||||||
|
|
||||||
|
- **只扩展现有 ProfileMax**:复用 Bun.serve、PostgreSQL/Bun.sql、React 19 + antd 6、Scripts、Tasks、54 个 MCP 工具、Profile/Proxy/Cookie、Node/Lease、Playwright、Fetch 和 WebDesk。
|
||||||
|
- **不做独立平台、不集成 LLM/Agent 服务**。Zcode/Codex/OpenCode 通过 MCP + Skills 负责网站学习、文章生成、脚本编写与失败修复。ProfileMax 负责确定性执行、持久化、校验、调度、监控和评分。
|
||||||
|
- 网站资源由用户 CSV/JSON/API 导入,不实现自动发现/爬取资源引擎;仅支持有授权或允许投稿的网站,遵守站点规则。
|
||||||
|
- V1 不增加消息队列、微服务、第二套调度器;优先扩展现有 tasks、scripts 和 task_runs。现有脚本/任务功能必须保持兼容。
|
||||||
|
|
||||||
|
## 2. UI:一个「发布」菜单 + 内部 Tabs
|
||||||
|
|
||||||
|
导航增加 **发布 (Publishing)** 一级菜单,点击后页面顶部用 Ant Design `Tabs` 切换:
|
||||||
|
|
||||||
|
| Tab | 最小功能 |
|
||||||
|
| --- | --- |
|
||||||
|
| 项目 | 项目 CRUD;官网、产品资料、目标 URL、关键词、内容规则、默认语言 |
|
||||||
|
| 站点 | CSV/JSON 导入、列表/标签/筛选、站点 URL、平台类型、允许发布的账号/Profile 关联、脚本版本、状态 |
|
||||||
|
| 文章 | 草稿导入/编辑(Markdown/HTML)、文章版本、审核状态、目标链接/锚文本;内容由外部 Agent 生成后保存 |
|
||||||
|
| 发布任务 | 创建发布任务、选择文章/站点/Profile、脚本、执行时间/并发、运行日志、失败诊断、手动重试 |
|
||||||
|
| 质量与评分 | 发布 URL、链接属性、链接存活检查、最近检测时间、站点评分/外链评分和历史结果 |
|
||||||
|
|
||||||
|
- 页面沿用当前布局、路由、权限、组件、暗色主题及 API 封装方式;图标使用 lucide-react,不使用 Emoji。
|
||||||
|
- Tabs 的选择应支持 URL 定位(刷新保留),权限统一使用新增 `publishing` 菜单权限,并校验关联 Profile 的所有权/分享权限。
|
||||||
|
- “发布”是一级菜单,其下使用 **Tabs 而非再添加五个左侧菜单**。站点本身的业务 `tags` 用于分类筛选,与 UI Tabs 是两种概念。
|
||||||
|
|
||||||
|
## 3. 执行流程
|
||||||
|
|
||||||
|
1. 用户/Agent 导入站点并与现有 Profile 关联,记录网站可发布权限、平台类型及标签。
|
||||||
|
2. 外部 Agent 通过已有 MCP/browser tools 学习站点,优先复用已有 `schema` 脚本,复杂页面才使用 `code`;第一次或少量测试后保存稳定脚本并绑定站点(或平台)。
|
||||||
|
3. 外部 Agent 根据项目资料生成有实际价值的文章,通过 MCP/REST 写入「文章」,标记 `draft/review/ready`。
|
||||||
|
4. Publishing 生成发布作业并交给**现有任务系统**执行。文章正文、标题、图片、链接作为每次运行的输入参数;不为每篇文章复制脚本。执行时使用已有 Profile Lease。
|
||||||
|
5. 将结构化输出(发布 URL、状态、错误码)与 task_run 关联,提交超时标记 `unknown`,先查是否已发布再重试,避免重复提交。
|
||||||
|
6. 失败时保存**脱敏**错误、失败步骤、截图/DOM 摘要;将修复状态标记 `needs_repair`,外部 Agent 通过 MCP 查询、测试并提交新脚本版本。V1 允许 Agent 主动轮询,不要求 ProfileMax 自动启动 Agent。
|
||||||
|
7. 发布后由后台定时进行 HTTP/HTML 检查;需要登录/JS 渲染才回退现有 Profile Fetch/Browser。定期计算评分,供站点筛选使用。
|
||||||
|
|
||||||
|
### 标准脚本 I/O(新增适配层,不破坏现有脚本 API)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublishInput {
|
||||||
|
siteId: string;
|
||||||
|
article: {
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
markdown?: string;
|
||||||
|
images?: string[];
|
||||||
|
};
|
||||||
|
targetLinks: Array<{ url: string; anchor: string }>;
|
||||||
|
mode: "draft" | "publish";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublishResult {
|
||||||
|
status: "draft" | "published" | "failed" | "unknown";
|
||||||
|
articleUrl?: string;
|
||||||
|
externalId?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
任务需要 `idempotencyKey`、脚本版本快照和稳定的 run ID;不能因重试发布重复文章。
|
||||||
|
|
||||||
|
## 4. 数据模型(尽量少表,沿用现有数据库)
|
||||||
|
|
||||||
|
新增前先检查已有数据结构,避免重复:
|
||||||
|
|
||||||
|
| 表 | 核心字段 |
|
||||||
|
| --- | --- |
|
||||||
|
| `publishing_projects` | id, owner_user_id, name, website, config(jsonb), created_at |
|
||||||
|
| `publishing_sites` | id, project_id, url, platform, tags(text[]), profile_id(nullable), script_id(nullable), status, metadata(jsonb) |
|
||||||
|
| `publishing_articles` | id, project_id, title, markdown, html, target_links(jsonb), version, status, metadata(jsonb) |
|
||||||
|
| `publishing_jobs` | id, project_id, site_id, article_id, profile_id, task_id(nullable), script_version, idempotency_key, status, result(jsonb) |
|
||||||
|
| `publishing_checks` | id, job_id, checked_at, page_status, link_status, rel(jsonb), detail(jsonb) |
|
||||||
|
|
||||||
|
- 与现有 `profiles`/`scripts`/`tasks`/`task_runs` 使用关联 ID,不复制 Cookie、Proxy 或完整执行日志。
|
||||||
|
- V1 脚本版本可增加 `script_versions` 通用表及稳定版本指针;禁止直接覆盖正在运行的版本。版本表应是通用 Scripts 能力,不放在 Publishing 业务表中。
|
||||||
|
- 评分先用查询/视图或定期计算,不必新建评分历史表;Site Score 建议结合主题相关性、成功率、链接保留率、成本;Backlink Score 包含链接存活、属性、内容相关性及可获得的引荐效果。未知数据标记 unknown,不编造指标。
|
||||||
|
- 迁移延续现有 PostgreSQL 迁移惯例;新增索引(owner/project/site/job/status/idempotency_key);新写接口校验访问权限、输入及关联实体归属。
|
||||||
|
|
||||||
|
## 5. REST / MCP / Skills
|
||||||
|
|
||||||
|
REST API 建议统一前缀 `/api/publishing`:
|
||||||
|
|
||||||
|
- `GET/POST /projects`、`GET/PATCH/DELETE /projects/:id`
|
||||||
|
- `GET/POST /sites`、`POST /sites/import`、`PATCH /sites/:id`
|
||||||
|
- `GET/POST /articles`、`PATCH /articles/:id`
|
||||||
|
- `GET/POST /jobs`、`GET /jobs/:id`、`POST /jobs/:id/retry`
|
||||||
|
- `POST /jobs/:id/verify`、`GET /jobs/:id/checks`、`GET /scores`
|
||||||
|
|
||||||
|
MCP **复用** `admin/src/api/mcp.ts` 的认证与工具框架;V1 只补充少量组合工具:
|
||||||
|
- `publishing_import_sites`
|
||||||
|
- `publishing_save_article`
|
||||||
|
- `publishing_create_job`
|
||||||
|
- `publishing_get_job`
|
||||||
|
- `publishing_verify_job`
|
||||||
|
- `publishing_get_repair_context`
|
||||||
|
|
||||||
|
脚本 CRUD/运行尽量调用现有 MCP,不重复暴露同名能力。新增 `skills/profilemax-publishing/SKILL.md` 及 references,写明:导入站点、学习站点、复用/生成脚本、测试、生成内容、创建任务、查询失败、修复测试、验证链接。Skills 是外部 Agent 的操作说明,不在服务端执行大模型。
|
||||||
|
|
||||||
|
## 6. 代码落点与实现要求
|
||||||
|
|
||||||
|
- 前端:`admin/frontend/pages/Publishing.tsx`;接入现有路由/导航;内部 Tabs 使用独立子组件,避免一个超大文件。
|
||||||
|
- API:`admin/src/api/routes/publishing.ts`;保持现有 `Bun.serve` 路由模式、身份与权限中间件。
|
||||||
|
- DB:`admin/src/repos/publishing*.ts` 与现有迁移文件。
|
||||||
|
- 业务:`admin/src/runtime/publishing/` 负责作业映射、定时监控和评分;**复用** `admin/src/runtime/taskScheduler.ts`,不要写第二套 scheduler。
|
||||||
|
- Node:优先不改节点;只有在脚本 I/O 或故障证据无法通过现有 Schema 执行器实现时,才最小修改 `node/src/core/schema/`。
|
||||||
|
- MCP:`admin/src/api/mcp.ts` 增加工具,继续使用同一 Bearer 认证与权限校验。
|
||||||
|
- 现有 `code` 脚本通过 `new Function` 在节点进程执行、没有沙箱:**不得**让 Agent 新生成的任意代码直接进入该运行通道。V1 优先 Schema;如果必须支持 Agent 生成 JS/TS,需先实现真正的低权限独立进程/容器隔离(不是 `node:vm`),并限制敏感文件、进程、网络及密钥访问。Agent 输出和网页 DOM 一律视为不可信。
|
||||||
|
- 站点/脚本修复只提交候选版本,经测试和审查再发布 stable;不能在生产中直接修改热脚本。
|
||||||
|
|
||||||
|
## 7. 开发顺序与验收
|
||||||
|
|
||||||
|
**阶段 A(最小可用)**:发布菜单+Tabs;项目/站点/文章 CRUD;导入站点/关联 Profile;MCP 读写;复用现有稳定脚本创建任务并显示结果。
|
||||||
|
|
||||||
|
**阶段 B(脚本复用)**:参数化脚本 I/O、通用版本管理、失败证据、待修复状态、外部 Agent 的学习/测试/修复 Skill。完成代码执行隔离前,不允许自动运行不可信 Agent 生成的 code 脚本。
|
||||||
|
|
||||||
|
**阶段 C(质量)**:定时外链验证、历史检查、站点评分及汇总报表。
|
||||||
|
|
||||||
|
必须验证:
|
||||||
|
1. 发布菜单仅一个一级入口,内部五个 Tabs 切换和刷新定位正常;多用户权限正确。
|
||||||
|
2. CSV/JSON 批量导入可去重,同项目站点可筛选、打标签、关联获授权的 Profile。
|
||||||
|
3. 已有 Schema/脚本可复用,文章不同但不复制脚本;运行成功返回文章 URL。
|
||||||
|
4. 失败、取消、超时、重复投递安全处理,重复任务不会导致重复发布。
|
||||||
|
5. Agent 离线时正常任务与质量监控照常运行;失败待修复可通过 MCP 领取和诊断。
|
||||||
|
6. HTTP 检测正常,动态/认证页面有回退方案;评分无数据时展示未知或样本数。
|
||||||
|
7. 原有 Profiles、Scripts、Tasks、SERP、MCP、WebDesk 功能均通过回归测试。
|
||||||
|
|
||||||
|
**Zcode 开发原则:先检查现有源码/接口与表结构,再最小增量改造;禁止重复造已有模块。本文是实施目标,不代表接口或表已经存在。**
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
# Chromium Extension Bind Mount 最小实现
|
||||||
|
|
||||||
|
> **[2026-10-01] 本方案已被「静默安装」Policy 取代,仅作历史设计存档。**
|
||||||
|
> 用户定版:不再坚持插件文件共享。插件改为每 Profile 静默安装
|
||||||
|
> (External Extensions + CRX),扩展文件自然落在各 Profile 内部,
|
||||||
|
> 且 install 事件每 Profile 只发一次、磁盘与装载语义均优于 bind mount。
|
||||||
|
> 实测结论与现行实现见 `extension-install-events-findings.md` §7 与
|
||||||
|
> `node/src/core/browser/extension.silent.ts`。
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
在 Linux + CloakBrowser/Chromium 环境中,让多个独立 Browser Profile 共用同一份第三方 Extension 文件,减少每个 `USER_DATA` 重复保存 Extension 代码。
|
||||||
|
|
||||||
|
目标结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
共享 Extension
|
||||||
|
/opt/serpmax/extensions/<extension-id>/<version>/
|
||||||
|
|
|
||||||
|
+---- bind mount ---- Profile 001/UserData/Default/Extensions/<id>/<version>
|
||||||
|
+---- bind mount ---- Profile 002/UserData/Default/Extensions/<id>/<version>
|
||||||
|
+---- bind mount ---- Profile 003/UserData/Default/Extensions/<id>/<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 Browser 仍然拥有独立的 `USER_DATA`、Cookie、Preferences、Extension State 等运行状态;只共享 Extension 代码目录。
|
||||||
|
|
||||||
|
本方案不修改 Chromium/CloakBrowser 源码。
|
||||||
|
|
||||||
|
## 2. 适用范围
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- Chromium/CloakBrowser
|
||||||
|
- 第三方 Extension,不修改 Extension 源码
|
||||||
|
- 多 Browser Profile
|
||||||
|
- Extension 代码版本固定
|
||||||
|
- 允许使用 root 或具备 mount 权限的服务执行 `mount --bind`
|
||||||
|
|
||||||
|
不使用:
|
||||||
|
|
||||||
|
- `--load-extension`
|
||||||
|
- CloakBrowser `extension_paths`
|
||||||
|
- 每个 Profile 复制一份 Extension 代码
|
||||||
|
|
||||||
|
## 3. 目录规划
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/serpmax/
|
||||||
|
├── extensions/
|
||||||
|
│ └── abcdefghijklmnopabcdefghijklmnop/
|
||||||
|
│ └── 1.0.0/
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ ├── background.js
|
||||||
|
│ ├── content.js
|
||||||
|
│ └── ...
|
||||||
|
└── profiles/
|
||||||
|
├── 001/
|
||||||
|
│ └── UserData/
|
||||||
|
│ └── Default/
|
||||||
|
│ └── Extensions/
|
||||||
|
│ └── abcdefghijklmnopabcdefghijklmnop/
|
||||||
|
│ └── 1.0.0/ # bind mount
|
||||||
|
└── 002/
|
||||||
|
└── UserData/
|
||||||
|
└── Default/
|
||||||
|
└── Extensions/
|
||||||
|
└── abcdefghijklmnopabcdefghijklmnop/
|
||||||
|
└── 1.0.0/ # bind mount
|
||||||
|
```
|
||||||
|
|
||||||
|
建议共享目录设置为只读,避免某个 Browser 修改共享 Extension 文件影响其它 Browser。
|
||||||
|
|
||||||
|
## 4. 前置条件
|
||||||
|
|
||||||
|
先准备一份完整的 Extension 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SHARED="/opt/serpmax/extensions/abcdefghijklmnopabcdefghijklmnop/1.0.0"
|
||||||
|
|
||||||
|
mkdir -p "$SHARED"
|
||||||
|
# 将已经确认可用的 Extension 文件放入 $SHARED
|
||||||
|
test -f "$SHARED/manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
检查版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat "$SHARED/manifest.json" | grep '"version"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Extension ID、目录名和版本目录必须与 Chromium 实际使用的 Extension 路径一致。
|
||||||
|
|
||||||
|
## 5. 为单个 Profile 创建 bind mount
|
||||||
|
|
||||||
|
假设:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXT_ID="abcdefghijklmnopabcdefghijklmnop"
|
||||||
|
VERSION="1.0.0"
|
||||||
|
PROFILE="/opt/serpmax/profiles/001/UserData"
|
||||||
|
|
||||||
|
SRC="/opt/serpmax/extensions/$EXT_ID/$VERSION"
|
||||||
|
DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION"
|
||||||
|
```
|
||||||
|
|
||||||
|
创建目标父目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p "$(dirname "$DST")"
|
||||||
|
```
|
||||||
|
|
||||||
|
目标目录第一次不能已经是普通文件目录。先确认:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -ld "$DST" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
如果是 Chromium 已经复制出来的普通目录,需要先停止 Browser,再删除该目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf "$DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
创建空目标目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p "$DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
执行 bind mount:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mount --bind "$SRC" "$DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
建议再设置只读:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mount -o remount,bind,ro "$DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mountpoint "$DST"
|
||||||
|
findmnt "$DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 多 Profile
|
||||||
|
|
||||||
|
每个 Profile 都执行一次:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for ID in 001 002 003 004; do
|
||||||
|
PROFILE="/opt/serpmax/profiles/$ID/UserData"
|
||||||
|
DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$DST")"
|
||||||
|
|
||||||
|
if mountpoint -q "$DST"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$DST"
|
||||||
|
mkdir -p "$DST"
|
||||||
|
|
||||||
|
mount --bind "$SRC" "$DST"
|
||||||
|
mount -o remount,bind,ro "$DST"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Profile 生命周期与启动顺序
|
||||||
|
|
||||||
|
Bind mount 的生命周期跟随 **Profile / USER_DATA**,不跟随 Browser 进程。
|
||||||
|
|
||||||
|
正确流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创建 Profile
|
||||||
|
|
|
||||||
|
+-- 创建 USER_DATA
|
||||||
|
|
|
||||||
|
+-- 创建 Extension bind mount
|
||||||
|
|
|
||||||
|
+-- 启动 CloakBrowser
|
||||||
|
|
|
||||||
|
+-- Browser 正常运行
|
||||||
|
|
|
||||||
|
+-- Browser 退出
|
||||||
|
| |
|
||||||
|
| +-- 保留 USER_DATA
|
||||||
|
| +-- 保留 bind mount
|
||||||
|
|
|
||||||
|
+-- 下次启动
|
||||||
|
| |
|
||||||
|
| +-- 直接复用已有 bind mount
|
||||||
|
|
|
||||||
|
+-- 删除 Profile
|
||||||
|
|
|
||||||
|
+-- 确认 Browser 已退出
|
||||||
|
+-- umount
|
||||||
|
+-- 删除 USER_DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
核心原则:
|
||||||
|
|
||||||
|
**先 mount,再启动 Browser;Browser 退出不卸载;只有删除 Profile 时才卸载。**
|
||||||
|
|
||||||
|
不要在 Browser 已经启动并加载 Extension 后动态替换目录。
|
||||||
|
|
||||||
|
## 8. 启动前校验
|
||||||
|
|
||||||
|
启动 Browser 前至少检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f "$SRC/manifest.json" || exit 1
|
||||||
|
mountpoint -q "$DST" || exit 1
|
||||||
|
test -f "$DST/manifest.json" || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
确认实际文件来自共享目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
readlink -f "$DST/manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
bind mount 下这里看到的是目标路径,但文件内容应与:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$SRC/manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
一致。
|
||||||
|
|
||||||
|
## 9. 删除 Profile 时卸载
|
||||||
|
|
||||||
|
Browser 退出后**不卸载** bind mount。
|
||||||
|
|
||||||
|
只要 Profile / USER_DATA 仍然存在,就保持 bind mount,这样下次启动 Browser 可以直接复用。
|
||||||
|
|
||||||
|
删除 Profile 时,必须先确认 Browser 已经完全退出,再执行卸载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if mountpoint -q "$DST"; then
|
||||||
|
umount "$DST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$PROFILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果存在多个 Extension,需要逐个卸载后再删除 USER_DATA:
|
||||||
|
|
||||||
|
```text
|
||||||
|
删除 Profile
|
||||||
|
|
|
||||||
|
+-- Browser 是否已退出?
|
||||||
|
|
|
||||||
|
+-- umount 所有 Extension bind mount
|
||||||
|
|
|
||||||
|
+-- 删除 USER_DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
**不要在 Browser 运行期间删除 USER_DATA,也不要直接 rm -rf 包含活动 mount point 的 USER_DATA。**
|
||||||
|
|
||||||
|
## 10. 关键实现:Profile Manager
|
||||||
|
|
||||||
|
SerpMax 后续可以只实现三个函数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mountExtension(profileId, extensionId, version)
|
||||||
|
unmountExtension(profileId, extensionId, version)
|
||||||
|
isExtensionMounted(profileId, extensionId, version)
|
||||||
|
```
|
||||||
|
|
||||||
|
伪代码:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mountExtension():
|
||||||
|
1. 定位共享 Extension
|
||||||
|
2. 检查 manifest.json
|
||||||
|
3. 定位 Profile Extension 目录
|
||||||
|
4. 检查 Browser 是否运行
|
||||||
|
5. 删除原来的版本目录
|
||||||
|
6. 创建空目录
|
||||||
|
7. mount --bind
|
||||||
|
8. remount,bind,ro
|
||||||
|
9. 校验 mount
|
||||||
|
10. 返回成功
|
||||||
|
|
||||||
|
unmountExtension():
|
||||||
|
1. 检查 mountpoint
|
||||||
|
2. 确认 Browser 已退出
|
||||||
|
3. umount
|
||||||
|
4. 删除 Profile 时再删除 USER_DATA 中对应目录
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 最小 Shell 实现
|
||||||
|
|
||||||
|
可以先使用下面脚本验证整个方案:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
EXT_ID="abcdefghijklmnopabcdefghijklmnop"
|
||||||
|
VERSION="1.0.0"
|
||||||
|
|
||||||
|
SRC="/opt/serpmax/extensions/$EXT_ID/$VERSION"
|
||||||
|
PROFILE="/opt/serpmax/profiles/001/UserData"
|
||||||
|
DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION"
|
||||||
|
|
||||||
|
test -f "$SRC/manifest.json"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$DST")"
|
||||||
|
|
||||||
|
if mountpoint -q "$DST"; then
|
||||||
|
echo "already mounted: $DST"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$DST" ]; then
|
||||||
|
rm -rf "$DST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DST"
|
||||||
|
|
||||||
|
mount --bind "$SRC" "$DST"
|
||||||
|
mount -o remount,bind,ro "$DST"
|
||||||
|
|
||||||
|
mountpoint "$DST"
|
||||||
|
echo "mounted: $SRC -> $DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 验证测试
|
||||||
|
|
||||||
|
### 12.1 Mount 是否成功
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt "$DST"
|
||||||
|
```
|
||||||
|
|
||||||
|
应该能看到 bind mount。
|
||||||
|
|
||||||
|
### 12.2 文件是否一致
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum "$SRC/manifest.json"
|
||||||
|
sha256sum "$DST/manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
两个 Hash 应一致。
|
||||||
|
|
||||||
|
### 12.3 多 Profile 是否共用文件
|
||||||
|
|
||||||
|
分别执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stat "$SRC/manifest.json"
|
||||||
|
stat "/opt/serpmax/profiles/001/UserData/Default/Extensions/$EXT_ID/$VERSION/manifest.json"
|
||||||
|
stat "/opt/serpmax/profiles/002/UserData/Default/Extensions/$EXT_ID/$VERSION/manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
确认所有 Profile 都指向同一个共享源目录。
|
||||||
|
|
||||||
|
### 12.4 Extension 生命周期测试
|
||||||
|
|
||||||
|
使用正常安装方式让 Chromium 识别 Extension,不使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--load-extension
|
||||||
|
extension_paths
|
||||||
|
```
|
||||||
|
|
||||||
|
然后测试:
|
||||||
|
|
||||||
|
1. 第一次启动 Profile。
|
||||||
|
2. 检查 Extension 是否正常运行。
|
||||||
|
3. 记录 `chrome.runtime.onInstalled`。
|
||||||
|
4. 关闭 Browser。
|
||||||
|
5. 再次启动同一个 Profile。
|
||||||
|
6. 确认不会因为每次启动都重新作为 command-line unpacked extension 加载而触发 `reason=install`。
|
||||||
|
|
||||||
|
注意:bind mount 本身不会改变 Chromium 的 Extension 生命周期。它只解决文件存储层共享问题。要避免 `--load-extension` 每次启动产生的 install 行为,必须使用正常的 Extension 安装/注册方式。
|
||||||
|
|
||||||
|
## 13. 重要限制
|
||||||
|
|
||||||
|
### 13.1 不要多个 Browser 同时写共享目录
|
||||||
|
|
||||||
|
多个 Browser 使用同一份 Extension 代码时,共享目录必须视为只读资源。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Extension Code 共享、只读
|
||||||
|
Extension State 每 Profile 独立
|
||||||
|
Cookies 每 Profile 独立
|
||||||
|
Preferences 每 Profile 独立
|
||||||
|
Secure Preferences 每 Profile 独立
|
||||||
|
Local Storage 每 Profile 独立
|
||||||
|
Service Worker State 每 Profile 独立
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 Extension 更新
|
||||||
|
|
||||||
|
不要直接覆盖正在使用的版本目录。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 新版本写入新的共享目录
|
||||||
|
2. 停止相关 Browser
|
||||||
|
3. 更新 Profile 的 bind mount
|
||||||
|
4. 创建新版本 bind mount
|
||||||
|
5. 启动 Browser
|
||||||
|
|
||||||
|
如果不需要立即切换版本,也可以等 Profile 下次启动前更新 mount。
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
extensions/<id>/1.0.0/
|
||||||
|
extensions/<id>/1.1.0/
|
||||||
|
```
|
||||||
|
|
||||||
|
不要让 Browser 运行期间直接修改:
|
||||||
|
|
||||||
|
```text
|
||||||
|
extensions/<id>/1.0.0/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.3 权限
|
||||||
|
|
||||||
|
执行 `mount --bind` 通常需要 root 或 CAP_SYS_ADMIN。
|
||||||
|
|
||||||
|
SerpMax 如果本身不是 root 运行,建议单独提供一个最小权限的 mount helper,而不是让整个应用拥有完整 root 权限。
|
||||||
|
|
||||||
|
## 14. 最小生产结构
|
||||||
|
|
||||||
|
第一阶段不需要复杂的 Mount Manager:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SerpMax
|
||||||
|
|
|
||||||
|
+-- Extension Registry
|
||||||
|
| |
|
||||||
|
| +-- extension-id
|
||||||
|
| +-- version
|
||||||
|
| +-- shared-path
|
||||||
|
|
|
||||||
|
+-- Profile Manager
|
||||||
|
| |
|
||||||
|
| +-- profile-id
|
||||||
|
| +-- user-data-path
|
||||||
|
|
|
||||||
|
+-- Mount Manager
|
||||||
|
|
|
||||||
|
+-- mount
|
||||||
|
+-- unmount
|
||||||
|
+-- status
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount Manager 只负责文件系统 Mount,不负责 Extension 生命周期、Cookie 或 Browser 状态。
|
||||||
|
|
||||||
|
## 15. 实施结论
|
||||||
|
|
||||||
|
本阶段只实现:
|
||||||
|
|
||||||
|
- 一个共享 Extension 目录
|
||||||
|
- 一个 Profile 一个 bind mount
|
||||||
|
- Profile 创建/初始化时完成 mount
|
||||||
|
- Browser 退出后保持 mount
|
||||||
|
- 删除 Profile 时才 unmount
|
||||||
|
- 共享 Extension 目录只读
|
||||||
|
- Profile 状态保持独立
|
||||||
|
- 不修改 Chromium/CloakBrowser 源码
|
||||||
|
|
||||||
|
先用单 Extension + 2 个 Profile 验证:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Shared Extension
|
||||||
|
|
|
||||||
|
+-- Profile 001
|
||||||
|
|
|
||||||
|
+-- Profile 002
|
||||||
|
```
|
||||||
|
|
||||||
|
确认 Extension 正常运行、两个 Profile 状态独立、`USER_DATA` 不再保存重复 Extension 文件后,再扩展到多 Extension / 多 Profile。
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# 插件加载实测:onInstalled("install") 触发机制与 bind mount 方案可行性
|
||||||
|
|
||||||
|
本文是 `docs/extension-bind-mount-minimal.md` 的实测补充与结论。
|
||||||
|
全部结论均在**生产同款浏览器 CloakBrowser 146.0.7680.177** 上实测得到
|
||||||
|
(不是 Playwright 自带的 Chromium 153),可用 `node/scripts/probe-install-modes.ts` 复现。
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
1. 按 bind mount 文档重构插件加载方式(共享扩展代码目录,不再每 Profile 复制一份)。
|
||||||
|
2. 开发一个小插件,测试**每次浏览器启动是否都会触发 `onInstalled("install")`**。
|
||||||
|
3. 目标:让 `install` **只在首次触发**。
|
||||||
|
|
||||||
|
## 2. 实测环境
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 浏览器 | `/home/bigcode/data/.cloakbrowser/chromium-146.0.7680.177.5/chrome`(CloakBrowser 146) |
|
||||||
|
| 启动方式 | 直接 `spawn` 真实二进制 + `--headless=new`(**不走 Playwright**,原因见文末) |
|
||||||
|
| 探针扩展 | `node/tests/fixtures/probe-extension/`(MV3,`key` 内嵌固定公钥,ID 恒为 `lhfmmbkophlpeoebdjpgfampblhkdmjg`) |
|
||||||
|
| 观测方式 | CDP 在扩展 service worker 上下文求值 `chrome.storage.local.get(['installs','boots'])` |
|
||||||
|
|
||||||
|
探针扩展把**每次** `onInstalled` 追加写入 `chrome.storage.local.installs`,并用 `boots` 计数
|
||||||
|
区分「扩展被加载」与「install 事件触发」——这两者不是一回事,必须分开看。
|
||||||
|
|
||||||
|
> 踩过的坑:第一版探针用 `fetch` 上报到本地收集器,结果 146 上被策略拦截,读数恒为 0,
|
||||||
|
> 一度误判成「扩展没加载」。**观测必须走扩展自己的 storage,不能走网络。**
|
||||||
|
|
||||||
|
## 3. 核心实测结果
|
||||||
|
|
||||||
|
### 3.1 现状(`--load-extension`,即 cloakbrowser 的 `extensionPaths`)
|
||||||
|
|
||||||
|
```
|
||||||
|
launch 1: installs=1 boots=1 reasons=["install"]
|
||||||
|
launch 2: installs=2 boots=2 reasons=["install"]
|
||||||
|
launch 3: installs=3 boots=3 reasons=["install"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**每次启动都触发 `onInstalled(reason="install")`,3 次启动 3 次事件。**
|
||||||
|
|
||||||
|
原因在 Chromium 内部写得很清楚:这种扩展在 `Default/Preferences` 里记为
|
||||||
|
|
||||||
|
```
|
||||||
|
location = 8 (COMMAND_LINE)
|
||||||
|
creation_flags = 38
|
||||||
|
path = <共享解压目录>
|
||||||
|
disable_reasons = []
|
||||||
|
```
|
||||||
|
|
||||||
|
`location=8` 意味着它**与命令行绑定**:每次启动命令行都重新「安装」一遍,故 `install` 必然重发。
|
||||||
|
这是 `--load-extension` 的固有语义,不是 bug。
|
||||||
|
|
||||||
|
### 3.2 替代路线逐条实测
|
||||||
|
|
||||||
|
| # | 方案 | install 次数 | 扩展是否加载 | 关键证据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | bind mount 到 `Default/Extensions/<id>/<version>`,启动不带任何扩展参数 | 0 | **否** | 目录里文件在(只读、hash 一致),但 Chromium 根本不扫描 `Default/Extensions` |
|
||||||
|
| 2 | 手改 `Default/Preferences` 伪造注册条目(`location=4`) | 0 | **否** | 回写后 `disable_reasons=[16777216]`(MAC 校验失败被禁用) |
|
||||||
|
| 3 | CDP `Extensions.loadUnpacked`(浏览器级) | — | — | 146 上 `Extensions.getExtensions` 未实现、`loadUnpacked` 报 `Method not available`;加 `--enable-unsafe-extension-debugging` 仍不行 |
|
||||||
|
| 4 | 策略 `ExtensionSettings` + `force_installed` + 本地 CRX/update 服务 | 0 | **否** | 日志确认读到策略文件(`Found mandatory policy file`),但外部安装器**从不启动**,update 服务 0 次请求 |
|
||||||
|
| 5 | CRX 外部安装,只放文件不写对清单键名 | 0 | **否** | 见 3.3 的键名问题,`external_extensions.json` 用文档里的 `path` 会被判 `key external_extensions is not a valid id` |
|
||||||
|
| 6 | `chrome://extensions` UI「加载未打包的扩展程序」 | — | — | 按钮点击后弹**系统原生文件框**;无 `xdotool`、`Page.setInterceptFileChooserDialog` 拦不到(`fileChooserOpened` 事件 0 次) |
|
||||||
|
| 7 | **真实外部安装**:`external_extensions.json` + 内部键名 + 浏览器自打 CRX | **3/3** | **是** | 装成 `location=2`(EXTERNAL_DOWNLOAD),`Default/Extensions/<id>/1.0.0_0` 落盘,跨启动存活、`disable_reasons=[]`——**但 install 事件照样每次启动重发** |
|
||||||
|
|
||||||
|
第 7 条是唯一拿到「正式注册」的路线(详见 3.3),但它**依然没解决本问题**:
|
||||||
|
Chromium 每次启动都会把外部安装重新走一遍,于是每次都重新发 `install`。
|
||||||
|
|
||||||
|
补充:CRX 打包时**签名私钥必须与 `manifest.json` 的 `key` 同一把**,否则 CRX 头里的
|
||||||
|
`crx_id` 与 manifest 推导出的 ID 不一致,Chromium 直接拒装。
|
||||||
|
第一版测试就栽在这里(`scripts/mkcrx.py` 现在会强制回写 `key` 对齐)。
|
||||||
|
|
||||||
|
### 3.3 真实外部安装能装上,但仍然每次触发 install
|
||||||
|
|
||||||
|
这是本轮最关键的一条,补充记录成方法论:
|
||||||
|
|
||||||
|
**能装上的写法**(位置 `<USER_DATA>/External Extensions/external_extensions.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "<扩展ID>": { "external_crx": "/abs/path/probe.crx", "external_version": "1.0.0" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
三个坑,每个都对应一次失败:
|
||||||
|
|
||||||
|
1. **键名不是文档里的那套。** 顶层必须是 `{"<id>": {...}}` 字典(不是
|
||||||
|
`{"external_extensions":[...]}` 列表);条目里认的是 `external_crx` / `external_update_url`
|
||||||
|
这类内部键,不是 `path`。用错时的报错分别是
|
||||||
|
`key external_extensions is not a valid id`、
|
||||||
|
`Exactly one of the following keys should be used: external_crx, external_update_url`、
|
||||||
|
`external_crx and external_version must be used together`。
|
||||||
|
2. **CRX 必须用浏览器自己打。** 手写的 CRX3 被拒:`程序包无效:"CRX_REQUIRED_PROOF_MISSING"`。
|
||||||
|
可用 `chrome --pack-extension=<dir> --pack-extension-key=<pem>` 生成。
|
||||||
|
3. **CRX 的 manifest 里不能留 `key` 字段**(或必须与签名公钥一致),否则 ID 对不上。
|
||||||
|
|
||||||
|
装上之后的实测:
|
||||||
|
|
||||||
|
```
|
||||||
|
location = 2 (EXTERNAL_DOWNLOAD)
|
||||||
|
path = <id>/1.0.0_0 # 版本目录带 _N 后缀 = 每次启动重新装一遍
|
||||||
|
disable_reasons = []
|
||||||
|
```
|
||||||
|
|
||||||
|
`external_update_url` 那条路在这个浏览器上是死的:`ExternalCrxInstaller` /
|
||||||
|
`ExternalDownloader` 符号在二进制里 **0 命中**(所有 build 都没有),
|
||||||
|
只有 `CrxUpdateService` 在,拉不到 CRX。本地 update 服务 0 次请求。
|
||||||
|
|
||||||
|
**而事件计数(3 次启动):**
|
||||||
|
|
||||||
|
```
|
||||||
|
launch 1: installs=1
|
||||||
|
launch 2: installs=2
|
||||||
|
launch 3: installs=3
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论:在 CloakBrowser 146 上,不存在任何一种加载方式能让
|
||||||
|
`onInstalled("install")` 只触发一次。** 连「正式的外部安装」都做不到,
|
||||||
|
因为它每次启动同样会重跑安装流程。
|
||||||
|
|
||||||
|
### 3.3 重要副作用:扩展数据其实是持久的
|
||||||
|
|
||||||
|
首启写入 `marker`,后续不带任何标记重启:
|
||||||
|
|
||||||
|
```
|
||||||
|
B marker = {"boots":2,"marker":"written-in-first-launch"}
|
||||||
|
C marker = {"boots":3,"marker":"written-in-first-launch"}
|
||||||
|
```
|
||||||
|
|
||||||
|
即 **`chrome.storage.local` 跨启动保留,数据不丢**;重发的只是 `install` 事件本身。
|
||||||
|
另外 `Default/Extensions` 目录**根本不存在**——扩展代码只有共享目录那一份,
|
||||||
|
`USER_DATA` 里只留运行态数据(`Default/Local Extension Settings/<id>`)。
|
||||||
|
|
||||||
|
## 4. 结论
|
||||||
|
|
||||||
|
### 4.1 关于 bind mount 文档
|
||||||
|
|
||||||
|
文档第 12.4 节自己已经写明了这个前提:
|
||||||
|
|
||||||
|
> bind mount 本身不会改变 Chromium 的 Extension 生命周期。它只解决文件存储层共享问题。
|
||||||
|
> 要避免 `--load-extension` 每次启动产生的 install 行为,必须使用正常的 Extension 安装/注册方式。
|
||||||
|
|
||||||
|
实测把这句话补全了:**在 CloakBrowser 146 上,「正常的安装/注册方式」无法自动化。**
|
||||||
|
「只触发一次」要求扩展被写进 `Secure Preferences` 且 HMAC 正确,唯一能产生这个结果的
|
||||||
|
入口(外部安装器 / `loadUnpacked` / UI 加载)在此版本全部不可用。
|
||||||
|
|
||||||
|
所以:**照 bind mount 文档改,能达成「共享代码、不复制多份」,但达不成「install 只触发一次」。**
|
||||||
|
|
||||||
|
### 4.2 一个反直觉的结论:bind mount 想要的收益,现状已经拿到了
|
||||||
|
|
||||||
|
现状(`--load-extension` + 共享解压目录)其实**已经**满足文档第 1 节的目标
|
||||||
|
「让多个 Profile 共用同一份 Extension 文件」:
|
||||||
|
|
||||||
|
- 扩展代码:只有 `data/extensions/<extId>/` 一份,各 Profile 共享;
|
||||||
|
- `USER_DATA/Default/Extensions` 不存在,没有重复副本;
|
||||||
|
- 运行态数据:每 Profile 独立在 `Default/Local Extension Settings/<id>`。
|
||||||
|
|
||||||
|
也就是说,**改 bind mount 主要是把「共享」这件事从「共享目录 + 命令行指路」换成
|
||||||
|
「共享目录 + 挂载到 Profile 内」,收益是结构上的整洁,不是磁盘占用的大幅下降。**
|
||||||
|
(除非某些扩展会往自身目录写文件,那种情况下 bind mount 还能顺带省空间。)
|
||||||
|
|
||||||
|
### 4.3 想要「install 只触发一次」的可落地做法
|
||||||
|
|
||||||
|
> **2026-10-01 更新:本节"做不到"的结论已被静默安装方案推翻**,见 §7——
|
||||||
|
> 第 7 条路线的探针把 CRX 放在 Profile **外部**,这是「每次重装」的混淆变量;
|
||||||
|
> CRX 放进 Profile 内部(External Extensions 目录)后 install 天然只发一次。
|
||||||
|
> 下面的垫片方案保留,现仅用于 `EXTENSION_INSTALL_MODE=loadext` 回退链路。
|
||||||
|
|
||||||
|
**先说死结论:在 CloakBrowser 146 上做不到。** 3.2 的七条路线里,连拿到正式注册的
|
||||||
|
第 7 条(真实外部安装)都每次重发 `install`。事件本身无法阻止。
|
||||||
|
|
||||||
|
能落地的是**让这件事无害**,SerpMax 侧已实现的做法是「幂等安装垫片」:
|
||||||
|
|
||||||
|
- 实现:`node/src/core/browser/extension.install-shim.ts`
|
||||||
|
- 落点:`extension.sync.ts` 解压完插件后,把垫片拼到后台脚本(`sw.js` /
|
||||||
|
`service_worker.js` / `background.js`)最前面,把 `chrome.runtime.onInstalled.addListener`
|
||||||
|
包一层幂等闸门——首次放行,之后直接吞掉。
|
||||||
|
- 开关:`EXTENSION_INSTALL_SHIM`(默认开,置 `0` 关闭回到插件原始行为)。
|
||||||
|
- 标记键:`chrome.storage.local.__serpmax_init_done`,随 Profile 持久化,
|
||||||
|
因此「首次」是**每个 Profile 的首次**,跨浏览器重启有效。
|
||||||
|
|
||||||
|
实测(同一 Profile 连开 3 次,真实二进制 CloakBrowser 146):
|
||||||
|
|
||||||
|
| 模式 | install 事件 | 插件初始化真正执行次数 |
|
||||||
|
|---|---|---|
|
||||||
|
| `loadext`(现状) | 1 → 2 → 3 | 1 → 2 → 3 |
|
||||||
|
| `external`(真实外部安装) | 1 → 2 → 3 | 1 → 2 → 3 |
|
||||||
|
| `shim`(垫片) | 1 → 1 → 1 | 1 → 1 → **1** |
|
||||||
|
|
||||||
|
`shim` 那一行 `install 事件` 也是 1,是因为连探针自己的计数回调都被闸门挡住了——
|
||||||
|
这正是效果:**从插件视角看,install 只发生了第一次**。
|
||||||
|
|
||||||
|
> 垫片的取舍:它改的是第三方插件的后台脚本。SerpMax 的插件是用户上传的产物,
|
||||||
|
> 我们不掌握其源码,所以只能像这样在分发前注入。风险点是改坏了插件,
|
||||||
|
> 因此默认开但可一键关闭,且注入位置/幂等性都有单测覆盖
|
||||||
|
> (`node/tests/extension-install-shim.test.ts`)。
|
||||||
|
> 如果某个插件自己已经幂等,注入是无害的(闸门只会让它更早返回)。
|
||||||
|
|
||||||
|
## 5. 复现方式
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 三种模式对照,各连开 3 次统计 install 与初始化次数
|
||||||
|
cd node
|
||||||
|
bun scripts/probe-install-modes.ts loadext 3 # 现状:--load-extension
|
||||||
|
bun scripts/probe-install-modes.ts shim 3 # 加幂等垫片
|
||||||
|
|
||||||
|
# 真实外部安装模式需要先用浏览器自己打 CRX
|
||||||
|
EXE=/home/bigcode/data/.cloakbrowser/chromium-146.0.7680.177.5/chrome
|
||||||
|
cp -r tests/fixtures/probe-extension /tmp/pack-ext && rm -f /tmp/pack-ext/README.md
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json; p='/tmp/pack-ext/manifest.json'
|
||||||
|
m=json.load(open(p)); m.pop('key', None); json.dump(m, open(p,'w'), indent=2, ensure_ascii=False)
|
||||||
|
PY
|
||||||
|
openssl genrsa -out /tmp/pack-key.pem 2048
|
||||||
|
$EXE --headless=new --no-sandbox --pack-extension=/tmp/pack-ext --pack-extension-key=/tmp/pack-key.pem
|
||||||
|
bun scripts/probe-install-modes.ts external 3 \
|
||||||
|
CRX_PATH=/tmp/pack-ext.crx EXT_ID=$(python3 -c "
|
||||||
|
import hashlib,subprocess
|
||||||
|
subprocess.run(['openssl','rsa','-in','/tmp/pack-key.pem','-pubout','-outform','DER','-out','/tmp/pub.der'],check=True)
|
||||||
|
h=hashlib.sha256(open('/tmp/pub.der','rb').read()).hexdigest()[:32]
|
||||||
|
print(''.join(chr(ord('a')+int(c,16)) for c in h))")
|
||||||
|
|
||||||
|
# 需要自己写 CRX 打包时(会强制回写 manifest.key 与签名私钥对齐)
|
||||||
|
python3 scripts/mkcrx.py tests/fixtures/probe-extension /tmp/probe.crx /tmp/probe.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 涉及的仓库文件
|
||||||
|
|
||||||
|
- `node/tests/fixtures/probe-extension/` — 探针扩展(`manifest.json` / `sw.js` / `README.md`)
|
||||||
|
- `node/scripts/probe-install-modes.ts` — 三模式(loadext / external / shim)多启动计数脚本
|
||||||
|
- `node/scripts/probe-silent-install.ts` — **静默安装端到端探针(生产模块链,见 §7)**
|
||||||
|
- `node/scripts/mkcrx.py` — CRX3 打包(强制 manifest.key 与签名私钥一致)
|
||||||
|
- `node/src/core/browser/extension.install-shim.ts` — 幂等安装垫片(注入 + 幂等闸门,现仅 loadext 回退用)
|
||||||
|
- `node/src/core/browser/extension.silent.ts` — **静默安装(稳定私钥/CRX 打包/External Extensions 写入)**
|
||||||
|
- `node/src/core/browser/extension.sync.ts` — 同步分流:silent(默认)/ loadext 回退
|
||||||
|
- `node/tests/extension-install-shim.test.ts` — 垫片单测(定位/幂等/语法/异常兜底)
|
||||||
|
- `node/tests/extension.silent.test.ts` — 静默安装单测(buildVersion/extIdFromPem/installSilently)
|
||||||
|
|
||||||
|
> 脚本里刻意**不用 Playwright** 启动:Playwright 默认带的是**旧 headless**
|
||||||
|
> (`--headless`),旧 headless 根本不加载扩展,会读出「扩展没装」的假象。
|
||||||
|
> 必须 `--headless=new`,或直接在有显示的环境里有头启动。
|
||||||
|
|
||||||
|
## 7. 静默安装定版(2026-10-01,推翻 §4.3 的"做不到")
|
||||||
|
|
||||||
|
Policy 变更(用户定版):**插件装载从「共享目录 + `--load-extension` 命令行装载」
|
||||||
|
改为「每个 Profile 静默安装」,不再坚持插件文件共享。**
|
||||||
|
|
||||||
|
### 7.1 为什么旧探针得出"外部安装每次重发 install"的假结论
|
||||||
|
|
||||||
|
§3.2 第 7 条(真实外部安装)的探针把 CRX 放在 **Profile 外部**(如 `/tmp/probe.crx`),
|
||||||
|
`external_extensions.json` 指过去。实测版本目录出现 `_N` 后缀、install 每次重发。
|
||||||
|
|
||||||
|
2026-10-01 复测发现混淆变量:**CRX 放进 Profile 内部**(`<USER_DATA>/External Extensions/`
|
||||||
|
目录)后,install 天然只在首次触发(同 Profile 连开 3 次 installs = 1→1→1,
|
||||||
|
boots = 1→2→3,SW 每次正常唤醒)。此 build 的外部安装不落
|
||||||
|
`Default/Extensions/<id>/<version>`(始终为空),扩展直接从 CRX 路径装载。
|
||||||
|
|
||||||
|
### 7.2 两个杀手旗标(Playwright 默认参数集)
|
||||||
|
|
||||||
|
裸 spawn 下能装 ≠ 生产 launchPersistentContext 下能装。逐旗标二分(每轮全新 Profile)
|
||||||
|
定位出两个必须经 `launchOptions.ignoreDefaultArgs` 剔除的 Playwright 默认旗标:
|
||||||
|
|
||||||
|
| 旗标 | 效果 |
|
||||||
|
|---|---|
|
||||||
|
| `--disable-extensions` | 扩展系统整体关闭(§3.2 已知) |
|
||||||
|
| `--disable-default-apps` | **关闭 ExternalProvider,外部 CRX 静默安装直接不执行(新发现)** |
|
||||||
|
|
||||||
|
注:仅有 `--disable-extensions-except`(不带 `--load-extension`)**救不回来**——
|
||||||
|
实测装不上。cloakbrowser 的 `ignoreDefaultArgs` 字段被 `launchOptions` 展开
|
||||||
|
顺序覆盖(`dist/playwright.js` 装配顺序),所以生产代码经
|
||||||
|
`launchOptions: { ignoreDefaultArgs: ["--enable-automation", "--enable-unsafe-swiftshader", "--disable-extensions", "--disable-default-apps"] }`
|
||||||
|
一并剔除(cloak 原有两项必须保留,否则 `--enable-automation` 回来破坏隐身)。
|
||||||
|
|
||||||
|
### 7.3 稳定扩展 ID 与更新语义
|
||||||
|
|
||||||
|
- 扩展 ID = 签名公钥推导(SPKI DER → sha256 前 16 字节 → a..p)。私钥按 extId
|
||||||
|
固定存 `{extensionDir}/{extId}.key.pem`(600)→ 重打包 ID 不变。
|
||||||
|
**私钥不能放扩展目录内**——`--pack-extension` 遇目录内 .pem 直接拒绝
|
||||||
|
(「此扩展程序包含密钥文件」)。manifest 必须删 `key` 字段(实测保留会 ID 漂移)。
|
||||||
|
- CRX 只能浏览器自打(§3.2 第 7 条),命令:
|
||||||
|
`$EXE --headless=new --no-sandbox --pack-extension=<dir> --pack-extension-key=<pem>`
|
||||||
|
- **更新语义**(每轮全新 Profile 实测):
|
||||||
|
- 同 version 覆盖 CRX → **不会更新**,一直跑旧代码;
|
||||||
|
- manifest 版本追加第 4 段(如 `1.0.0` → `1.0.0.2`)且 `external_version` 同步 →
|
||||||
|
下次启动 Chromium 重装,install 以 `reason="update"` 恰好重发一次
|
||||||
|
(`previousVersion` 正确),新代码从再下一次 SW 启动生效。
|
||||||
|
- 因此 staging 状态文件记录 `{ sha256, origVersion, build }`:zip sha256 每变化
|
||||||
|
一次 build+1,打包版本 = `buildVersion(origVersion, build)`。
|
||||||
|
|
||||||
|
### 7.4 生产链路落点(node v0.8.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
extension.sync.ts ensureExtensionsReady → { dirs(loadext), silent(silent) }
|
||||||
|
staging:下载/解压/sha 增量;sha 变化时删外层旧 CRX + build+1
|
||||||
|
extension.silent.ts 稳定私钥 → manifest 变换(删 key/版本对齐)→ 浏览器自打 CRX
|
||||||
|
→ installSilently(复制 CRX 进 Profile + external_extensions.json
|
||||||
|
整文件重写 + 无主 CRX 清退)
|
||||||
|
browser.manager.ts silent:installSilently 写 Profile + ignoreDefaultArgs 剔除两旗标
|
||||||
|
(不再传 extensionPaths);无插件仍 --disable-extensions
|
||||||
|
```
|
||||||
|
|
||||||
|
配置:`EXTENSION_INSTALL_MODE`(默认 `silent`,`loadext` 回退旧链路,垫片只在
|
||||||
|
loadext 生效)、`EXTENSION_INSTALL_SHIM`(不变)。
|
||||||
|
|
||||||
|
复现:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd node
|
||||||
|
xvfb-run -a bun scripts/probe-silent-install.ts 3 # 生产模块链端到端,预期 installs 1→1→1
|
||||||
|
bun test tests/extension.silent.test.ts tests/extension.sync.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
实测记录(2026-10-01,真实二进制 cloak 146):
|
||||||
|
首装 installs=1;三连启 1→1→1;更新启动后 installs=["install","update"]、
|
||||||
|
codeTags 出现新版本标记;同 Profile 数据跨更新保留。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
2607:f8b0:4006:804::200e
|
||||||
|
2607:f8b0:4006:80f::200e
|
||||||
|
2607:f8b0:4002:c00::8a
|
||||||
|
2607:f8b0:4002:c00::66
|
||||||
|
2607:f8b0:4002:c00::71
|
||||||
|
2607:f8b0:4002:c00::8b
|
||||||
|
2607:f8b0:4006:800::200e
|
||||||
|
2607:f8b0:4006:811::200e
|
||||||
|
2a00:1450:4025:401::65
|
||||||
|
2a00:1450:4025:401::71
|
||||||
|
2a00:1450:4025:401::64
|
||||||
|
2a00:1450:4025:401::66
|
||||||
|
2a00:1450:4019:812::200e
|
||||||
|
2607:f8b0:400a:809::200e
|
||||||
|
2a00:1450:4007:812::200e
|
||||||
|
2a00:1450:4003:809::200e
|
||||||
|
2a00:1450:400a:1001::65
|
||||||
|
2a00:1450:400a:1001::66
|
||||||
|
2a00:1450:400a:1001::8a
|
||||||
|
2a00:1450:400a:1001::71
|
||||||
|
2a00:1450:4001:c17::71
|
||||||
|
2a00:1450:4001:c17::8b
|
||||||
|
2a00:1450:4001:c17::64
|
||||||
|
2a00:1450:4001:c17::8a
|
||||||
|
2607:f8b0:4004:c25::65
|
||||||
|
2607:f8b0:4004:c25::71
|
||||||
|
2607:f8b0:4004:c25::8a
|
||||||
|
2607:f8b0:4004:c25::64
|
||||||
|
2a00:1450:400e:806::200e
|
||||||
|
2a00:1450:4001:c15::66
|
||||||
|
2a00:1450:4001:c15::8b
|
||||||
|
2a00:1450:4001:c15::65
|
||||||
|
2a00:1450:4001:c15::71
|
||||||
|
2607:f8b0:4012:828::200e
|
||||||
|
2607:f8b0:4005:80b::200e
|
||||||
|
2404:6800:4001:c01::8b
|
||||||
|
2404:6800:4001:c01::66
|
||||||
|
2404:6800:4001:c01::65
|
||||||
|
2404:6800:4001:c01::71
|
||||||
|
2607:f8b0:4006:81f::200e
|
||||||
|
2404:6800:4006:803::200e
|
||||||
|
2001:4860:4802:32::78
|
||||||
|
2404:6800:400a:1000::8b
|
||||||
|
2404:6800:400a:1000::65
|
||||||
|
2404:6800:400a:1000::66
|
||||||
|
2404:6800:400a:1000::71
|
||||||
|
2a00:1450:4017:815::200e
|
||||||
|
2404:6800:4007:83c::200e
|
||||||
|
2a00:1450:4019:803::200e
|
||||||
|
2a00:1450:4003:818::200e
|
||||||
|
2607:f8b0:4006:816::200e
|
||||||
|
2404:6800:4007:83a::200e
|
||||||
|
2607:f8b0:4004:c2f::64
|
||||||
|
2607:f8b0:4004:c2f::65
|
||||||
|
2607:f8b0:4004:c2f::66
|
||||||
|
2607:f8b0:4004:c2f::71
|
||||||
|
2607:f8b0:4004:c1b::64
|
||||||
|
2607:f8b0:4004:c1b::65
|
||||||
|
2607:f8b0:4004:c1b::66
|
||||||
|
2607:f8b0:4004:c1b::71
|
||||||
|
2607:f8b0:400a:80d::200e
|
||||||
|
2607:f8b0:4007:80b::200e
|
||||||
|
2800:3f0:4001:846::200e
|
||||||
|
2a00:1450:4007:815::200e
|
||||||
|
2a00:1450:4017:819::200e
|
||||||
|
2a00:1450:4001:c1f::64
|
||||||
|
2a00:1450:4001:c1f::65
|
||||||
|
2a00:1450:4001:c1f::66
|
||||||
|
2a00:1450:4001:c1f::8b
|
||||||
|
2c0f:fb50:4002:813::200e
|
||||||
|
2a00:1450:4017:807::200e
|
||||||
|
2a00:1450:4010:c1c::64
|
||||||
|
2a00:1450:4010:c1c::65
|
||||||
|
2a00:1450:4010:c1c::71
|
||||||
|
2a00:1450:4010:c1c::8b
|
||||||
|
2a00:1450:4019:815::200e
|
||||||
|
2404:6800:4000:1015::64
|
||||||
|
2404:6800:4000:1015::71
|
||||||
|
2404:6800:4000:1015::8a
|
||||||
|
2404:6800:4000:1015::8b
|
||||||
|
2404:6800:4008:c13::64
|
||||||
|
2404:6800:4008:c13::65
|
||||||
|
2404:6800:4008:c13::66
|
||||||
|
2404:6800:4008:c13::8a
|
||||||
|
2404:6800:4005:81a::200e
|
||||||
|
2404:6800:4006:800::200e
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# SerpMax(ProfileMax)审计准备:功能清单与技术栈
|
||||||
|
|
||||||
|
> 生成日期:2026-10-28。版本以各服务 package.json 为准:admin v1.74.2、node v0.43.0。
|
||||||
|
> 本文所有"审计重点"均已逐条核实源码(含 file:line),非推断。
|
||||||
|
|
||||||
|
## 一、项目定位与总体架构
|
||||||
|
|
||||||
|
SerpMax 是一套**反检测指纹浏览器农场管理平台**:集中管理大量带浏览器指纹的 Profile(指纹、代理、Cookie、插件),在多个远程节点上批量启动 Chromium 并自动化,配套 SERP 采集、Cloudflare/reCAPTCHA 挑战处理、VNC/WebDesk 人工接管,并通过 MCP 接口暴露给 AI Agent。
|
||||||
|
|
||||||
|
- **admin**(`admin/`,profilemax-admin v1.74.2):管理后台单进程 Bun 服务(端口 5300),持有 PostgreSQL;React 前端构建产物由 nginx 直出。浏览器操作类 API 大多为**中继**:按 `profile.node → nodes.base_url` 转发到节点并附节点密钥(见 `docs/API.md` 头部)。
|
||||||
|
- **node**(`node/`,profilemax-node v0.43.0):运行节点(端口 3100),无 UI、无业务数据库(仅只读 Chrome History 的本地 SQLite);Docker privileged 部署,管理浏览器进程、Xvfb/x11vnc、WebDesk、impersonate MITM 代理、CRIU 快照。
|
||||||
|
- **stress/**:并发常驻浏览器压测套件(造号、发射器、/proc PSS 采样、报告)。
|
||||||
|
|
||||||
|
## 二、技术栈
|
||||||
|
|
||||||
|
| 层 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 运行时 | Bun(oven/bun 1.4)+ TypeScript,裸 `Bun.serve()` routes,无 Express/Hono 等框架 |
|
||||||
|
| 浏览器内核 | **双内核**:veil-chromium(自研,ungoogled-chromium 153 + C++ 指纹补丁,指纹由启动旗标驱动)与 cloakbrowser npm 包(包装 Chromium + Playwright API) |
|
||||||
|
| 自动化 | playwright-core(CDP 驱动) |
|
||||||
|
| admin 数据库 | PostgreSQL(`Bun.sql`),`pgarray.ts` 处理 text[] |
|
||||||
|
| admin 前端 | React 19 + antd 6 + Tailwind 4(`bun-plugin-tailwind` + `Bun.build`,无 vite/webpack),lucide-react、flag-icons、**@novnc/novnc**(RFB over WebSocket) |
|
||||||
|
| 查看器 | noVNC + 自研 WebDesk(`vendor/webdesk/webdesk-linux-static` 静态二进制:WebP 帧流 + 私有二进制协议 0x10/0x11/0x12/0x13-14/0x16,XTEST 键鼠注入) |
|
||||||
|
| 网络伪装 | 自研 impersonate-proxy(Go,本地 MITM:uTLS/JA3/HTTP2/头序伪装)+ socks5 链;`socks-proxy-agent`;mmdb-lib(GeoIP) |
|
||||||
|
| 对象存储 | 自研 S3 SigV4 客户端(`admin/src/core/s3.ts`,path-style、Multipart 64MB 分片,零第三方依赖),用于备份上传 |
|
||||||
|
| 身份 | 本地账密(Bun.password/bcrypt)+ ZITADEL OIDC(Authorization Code + PKCE,公共客户端) |
|
||||||
|
| 打码 | recaptcha-answer 外部识别服务(4x4 宫格远端 AI,其余 ONNX 本地) |
|
||||||
|
| 快照 | CRIU checkpoint/restore 整棵 Chromium 进程树(仅 headless,D-Bus 隔离) |
|
||||||
|
| 部署 | Docker(privileged,代码挂卷 + `/system/update` 自更新)、systemd 免 Docker 安装包、pg_basebackup/pg_dump 备份 |
|
||||||
|
|
||||||
|
## 三、功能清单
|
||||||
|
|
||||||
|
### admin 侧(管理后台)
|
||||||
|
|
||||||
|
**认证与多用户**:登录/登出/当前用户、改密、API Token 签发与轮换;ZITADEL OIDC 绑定/解绑/JIT 建号;用户 CRUD 与菜单权限分配(仅内置 id=1 为 admin,`session.ts` 强制)。
|
||||||
|
|
||||||
|
**Profile 管理**:CRUD、创建时一次生成指纹快照并固化(数字种子/UA/屏幕/时区语言/WebGL/canvas·audio 噪声种子/字体)、批量、复制(duplicate)、转移、分享(`profile_shares`:use=查看运行 / edit=编辑删除分享)、启停、浏览器状态、磁盘软清/硬重置。
|
||||||
|
|
||||||
|
**Cookie 与存储**:Cookie Jar 读写/批量删除/导入导出;浏览器运行时 Cookie 与 Jar 双写同步;Profile 存储(IndexedDB/localStorage)与插件自有数据 zip 导出导入。
|
||||||
|
|
||||||
|
**代理**:CRUD、批量导入、标签/facets、出口 IP 测试、启停;socks5/socks5h/http/https。
|
||||||
|
|
||||||
|
**DNS(替代 hosts)**:host→IP 条目 CRUD、内容检测规则(正则/子串,如 google-serp)、经指定代理探测(google 系取首页匹配+延迟)、通过的条目打标签组并随代理下发到绑定 Profile;节点经 `/api/node/dns/entries.json` 全量同步。
|
||||||
|
|
||||||
|
**插件库**:上传 zip、Chrome Web Store 详情页导入、sha256 增量下发节点、静默安装(删 manifest.key+版本加 build 号→浏览器自打 CRX→External Extensions)。
|
||||||
|
|
||||||
|
**脚本系统**:两种 kind——`code` 存 JS/TS 源码、`schema` 存 JSON 步骤编排;标签;直接执行(run 需显式 profileRefs)。
|
||||||
|
|
||||||
|
**任务系统**:任务=脚本+Profile 标签圈定+并发上限+逐目标错峰+周期+jitter;调度器每 60s tick,SQL 原子认领到期任务;运行记录、取消;`zombieReaper` 收割重启遗留的 RUNNING 僵尸。
|
||||||
|
|
||||||
|
**SERP 采集**:Google 搜索,json/html/raw 三形态输出(兼容 Rust serp-api 形状)、温页池(warm→直接在温页上搜索,降低触发风控)、缓存、统计、健康检查。
|
||||||
|
|
||||||
|
**挑战处理**:每 Profile 可开关的 CF Turnstile 自动过盾与 reCAPTCHA 自动打码;reCAPTCHA 手动打码会话(前端 CaptchaSolveModal 经中继点格子)。
|
||||||
|
|
||||||
|
**浏览器中继**(`routes/browser-relay.ts`,全部转发节点):SSE 事件流(state/动态 FPS 截图)、批量动作、execute、启停/窗口、cf-solver、recaptcha-solver、VNC/WebDesk WS 中继(WS 握手做 Profile 访问控制)、captcha 图块接口、httpcache 统计。
|
||||||
|
|
||||||
|
**节点管理**:注册/心跳/自更新(`/api/node-package`、`/node/version`,含 `/api` 别名规避 CF 520)/安装脚本下发/硬件信息。
|
||||||
|
|
||||||
|
**MCP Server**(`admin/src/api/mcp.ts`,1033 行):`POST /mcp`,JSON-RPC 2.0 无状态 Streamable HTTP,协议版本 2025-03-26;**54 个工具**(Profile 全生命周期、fetch_url/fetch_by_condition、execute_js、代理/插件/Cookie/节点查询、脚本与任务 CRUD+运行+取消等);认证=Bearer 用户 API Token;工具直接调用内部处理器,自动继承多用户访问控制。
|
||||||
|
|
||||||
|
**运维**:事件日志查询/清理、Dashboard 聚合统计、S3 配置(仅 root)与备份(pg_basebackup 全量/增量 + pg_dump 逻辑 + S3 上传,`backup_runs` 表)、settings KV、Prometheus `/metrics`(节点侧)、WebDesk 免登录起始页(HMAC 节点密钥签名)。
|
||||||
|
|
||||||
|
### node 侧(运行节点)
|
||||||
|
|
||||||
|
- **Profile 运行**:启动/停止/重启、CDP 端点、crash 处理与恢复、空闲回收(reaper)、磁盘配额;快照开关(CRIU dump/restore 保留页面现场)。
|
||||||
|
- **Lease**:Acquire/Heartbeat/Release/超时扫描,保证一个 Profile 同一时间一个消费者;超时自动停浏览器回 READY。
|
||||||
|
- **Fetch 体系**:Profile 上下文 HTTP fetch(代理+UA+Cookie 三来源合并);`impersonate.fetch` 走本地 impersonate-proxy MITM(uTLS/JA3/HTTP2)再经 socks5 出站,执行体 go-fetch 二进制、curl 回落;每 Profile 一个 impersonate-proxy 进程(独立端口+YAML+共享 CA)。
|
||||||
|
- **WebRTC 防泄露**:`--force-webrtc-ip-handling-policy=disable_non_proxied_udp` + init script 改写 SDP/ICE 候选为代理出口 IP。
|
||||||
|
- **浏览器控制**:`browser-control.ts` 批量动作 + SSE(state/screenshot 动态 FPS)。
|
||||||
|
- **SERP 浏览池**:pool 快照/warm/search/unwarm。
|
||||||
|
- **VNC/WebDesk**:每浏览器独立 Xvfb + x11vnc(display≥100、rfbport≥6100);WebDesk 挂同一 Xvfb 提供私有 WS 协议,agent 只听 127.0.0.1+随机 token,node/admin 透明字节管道。
|
||||||
|
- **一次性直连票据**(`direct.ts`):VNC/直连 5 分钟有效。
|
||||||
|
- **节点系统 API**:`/health` `/ready` `/metrics` `/events`、目录覆盖、自更新、Schema 编排执行器(lease→起浏览器→逐步执行→释放,错误策略 stop/continue/retry)。
|
||||||
|
|
||||||
|
## 四、数据层(admin,PostgreSQL,24 张表)
|
||||||
|
|
||||||
|
`migrate.ts`(1075 行,幂等 `CREATE TABLE IF NOT EXISTS` + `ALTER TABLE ADD COLUMN IF NOT EXISTS`,启动即跑,无版本表):
|
||||||
|
|
||||||
|
`proxies`、`profiles`、`profile_statistics`、`cookies`、`profile_storage`、`logs`、`captcha_events`、`settings`(KV)、`admin_user`、`sessions`、`users`、`scripts`、`tasks`、`profile_task_state`、`task_runs`、`profile_shares`、`dns_rules`、`dns_entries`、`extensions`、`nodes`、`oidc_identities`、`serp_requests`、`goto_cache`、`backup_runs`。
|
||||||
|
|
||||||
|
repo 层 `admin/src/repos/*` 每表一文件。
|
||||||
|
|
||||||
|
## 五、认证与授权模型(已核实)
|
||||||
|
|
||||||
|
- **会话双通道**(`admin/src/api/session.ts`):①Cookie `smax_session`(HttpOnly + **SameSite=Lax** + 可选 Secure,token=两个 uuidv7 拼接,存 sessions 表,TTL 可配);②`Bearer <users.api_token>`(`smax_` 前缀)。
|
||||||
|
- **权限阶梯**:`requireUser` → `requireAdmin`(role=admin)→ `requireRoot`(仅 id=1);业务菜单级权限 `requirePerm(req, "tasks" 等)`;Profile 级 `loadProfileFor`(归属者/被分享者/admin),`access.ts` 统一 404/403。
|
||||||
|
- **密码**:Bun.password(bcrypt);**默认管理员密码**来自 `config.defaultAdminPassword`。
|
||||||
|
- **OIDC**:ZITADEL Auth Code + PKCE(公共客户端);state 一次性消费 10 分钟(注释明示作 CSRF 防护);身份主键 (issuer, subject),绝不以 email 为键;配置存 settings KV。
|
||||||
|
- **节点认证**(`node/src/api/middleware/node-auth.ts`):配置 `NODE_KEY` 时要求 `x-node-key` 头或 Bearer;**未配置则全放行**(注释:本机/内网模式)。
|
||||||
|
- **MCP**:仅 Bearer 用户 API Token,未认证 401。
|
||||||
|
|
||||||
|
## 六、审计重点关注项(逐条核实)
|
||||||
|
|
||||||
|
1. **脚本执行无沙箱(最高优先级)** —— `node/src/core/schema/schema.actions.ts:246-257`:用户 JS/TS 先包进 `async function __task__(ctx){...}`,`Bun.Transpiler` 剥 TS 类型后 `new Function(...)` 在**节点服务进程内**执行。`new Function` 作用域是全局,Bun/node API(fs、网络、子进程)全部可达 —— 等于服务器任意代码执行。`ctx` 本身也提供完整 Playwright API 与 task_state 读写。多用户部署下,任何拥有 tasks 菜单权限的普通用户可创建/运行脚本(`handleScriptCreate/Run` 仅 `requirePerm(req,"tasks")`,见 `admin/src/api/routes/tasks.ts:148/779`)→ 等效 RCE;单管理员私有部署则属设计使然,但仍建议在报告中明确标注。
|
||||||
|
2. **仓库内硬编码密钥** —— `node/deploy/docker-compose.yml:10`:`NODE_KEY=18036c...` 为未注释的真实样例值,随仓库分发。若该 key 曾用于生产,需轮换。
|
||||||
|
3. **节点认证强度** —— `node-auth.ts` 为普通字符串比较(非常量时间);`timingSafeEqual` 仅在 `admin/src/api/routes/webdesk.ts:181`(HMAC 比较)使用。未配置 NODE_KEY 时节点 API 完全开放(依赖网络隔离)。
|
||||||
|
4. **登录无防爆破** —— 全库未发现 rate limit / 登录失败锁定;无独立 CSRF token,依赖会话 Cookie 的 SameSite=Lax 兜底(session.ts:144-151,已核实)。
|
||||||
|
5. **CORS 通配** —— `admin/src/api/routes/webdesk.ts:25`:`access-control-allow-origin: *`,仅作用于两条 WebDesk 免登录路由(已核实仅此文件),该组路由用 HMAC 节点密钥保护。
|
||||||
|
6. **查询参数携带凭证** —— VNC/WebDesk WS 鉴权使用 `?token=` / `?ticket=`(SDK 限制),凭证会进代理/访问日志;票据为 5 分钟一次性(direct.ts)。
|
||||||
|
7. **节点本身高权限** —— Docker privileged 容器内跑 Xvfb/x11vnc/CRIU dump-restore/impersonate MITM(共享本地 CA 私钥);节点被攻破=宿主机被攻破,节点与管理面间是 NODE_KEY 单因素。
|
||||||
|
8. **execute_js(MCP 工具)** —— 在 Profile 页面上下文执行任意 JS;单看是低危页面脚本,但结合第 1 条(脚本系统)即构成完整 RCE 面;MCP Token 与用户 API Token 同一凭证,泄露即获得该用户全部 54 个工具能力。
|
||||||
|
9. **多用户边界** —— Profile 权限模型完善(归属/分享两级+菜单权限),但 admin 强制仅 id=1 为 admin(`session.ts`);历史 admin 角色降级,需在审计中确认用户升级路径的一致性。
|
||||||
|
|
||||||
|
## 七、仓库结构与文档地图
|
||||||
|
|
||||||
|
- `docs/`:`API.md`(全栈 API:admin 5310 行/node 3100 行+中继架构)、CRIU 快照方案、插件 bind-mount 存档、Google IPv6 列表。
|
||||||
|
- `admin/docs/`(AI 可读,经 /docs 免登录直出):overview、rest-api、mcp、schema、scripts、tasks、serp、VNC、API、开发执行文档、验收截图。
|
||||||
|
- `node/docs/`:技术开发规范、Browser-Control 协议、webdesk-viewer 协议、动态 FPS 截图模块、veil 内核迁移问题清单。
|
||||||
|
- `admin/scripts/`:前端构建、CDP 可视化验收、本地 SOCKS5 开发代理、**keepalive-flow.ts(生产级保活脚本 v3)**、插件恢复。
|
||||||
|
- `node/scripts/`:验收(20 步含 crash/lease/schema 超时)、CRIU PoC/基准、各探针、mkcrx.py、systemd 打包。
|
||||||
|
- `node/deploy/`:Dockerfile.node、docker-compose.yml(privileged)。
|
||||||
|
- `stress/`:造号、压测发射器、PSS 采样、报告(OVH-FR 8C/31G 实测)、清理工具。
|
||||||
|
- 根目录 `tests-ka-flow.ts`:保活脚本状态机测试(mock ctx 复刻节点 script.run 管线)。
|
||||||
Submodule
+1
Submodule node added at 32f06e09f0
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: profilemax-publishing
|
||||||
|
description: ProfileMax「发布」能力的外部 Agent 操作手册:导入站点、学习站点并生成/复用 Schema 发布脚本、生成文章、创建发布作业、诊断失败并提交修复、验证已发布链接。所有操作经 ProfileMax MCP 工具或 /api/publishing REST 完成;ProfileMax 只负责确定性执行、持久化与校验,不运行大模型。
|
||||||
|
---
|
||||||
|
|
||||||
|
# ProfileMax 发布(Publishing)Agent 操作手册
|
||||||
|
|
||||||
|
适用场景:在已授权或允许投稿的第三方网站发布含目标外链的文章(外链建设/内容分发)。
|
||||||
|
执行链路:你(外部 Agent)负责学习站点、写/修脚本、生成内容;ProfileMax 负责调度执行、
|
||||||
|
幂等防重、持久化、失败记录与链接检查。
|
||||||
|
|
||||||
|
## 0. 前置与鉴权
|
||||||
|
|
||||||
|
- MCP:Bearer 用户 API Token(与 MCP Token 同值);先 `whoami` 核对身份与权限(需 publishing)。
|
||||||
|
- 核心工具:`publishing_import_sites` / `publishing_save_article` / `publishing_create_job` /
|
||||||
|
`publishing_get_job` / `publishing_verify_job` / `publishing_get_repair_context`;
|
||||||
|
站点与脚本管理复用 `list_profiles` / `create_script` / `update_script` / `run_script` / `get_script_run`。
|
||||||
|
- 项目列表经 REST `GET /api/publishing/projects`(无专用 MCP 工具)。
|
||||||
|
|
||||||
|
## 1. 导入站点
|
||||||
|
|
||||||
|
1. `publishing_import_sites { projectId, csv | items }` 批量导入(项目内按 URL 去重,重复跳过)。
|
||||||
|
2. 对每个站点:从返回列表/`GET /api/publishing/sites?projectId=` 取 id;
|
||||||
|
`PATCH /api/publishing/sites/:id` 关联:
|
||||||
|
- `profileId`:该站点获授权的发布账号 Profile(必须已有登录态,见 Profile Cookie 能力);
|
||||||
|
- `scriptId`:发布脚本(见 §2;阶段 A 仅接受 Schema 编排脚本);
|
||||||
|
- `platform` / `tags` 按实际填写(业务标签用于筛选,与页面 Tabs 无关)。
|
||||||
|
|
||||||
|
## 2. 学习站点与发布脚本
|
||||||
|
|
||||||
|
1. **学习**:用现有浏览器能力学习站点(`start_browser` + `execute_js` / `fetch_url`),
|
||||||
|
弄清登录态、发帖入口、表单字段、富文本编辑器行为、防 Bot 措施。
|
||||||
|
2. **脚本形态**:发布脚本必须是 **Schema 编排(kind=schema)**,其中至少一个
|
||||||
|
`script.run` 步骤承载主流程。运行时 ProfileMax 把本次文章参数注入该步骤的
|
||||||
|
`ctx.vars.publish`(标准 I/O):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublishInput {
|
||||||
|
siteId: string;
|
||||||
|
article: { title: string; html: string; markdown?: string; images?: string[] };
|
||||||
|
targetLinks: Array<{ url: string; anchor: string }>;
|
||||||
|
mode: "draft" | "publish";
|
||||||
|
}
|
||||||
|
interface PublishResult {
|
||||||
|
status: "draft" | "published" | "failed" | "unknown";
|
||||||
|
articleUrl?: string; // 发布后的文章 URL(验证与评分依赖它)
|
||||||
|
externalId?: string; // 站点侧文章 ID
|
||||||
|
errorCode?: string; // 稳定错误码(如 LOGIN_EXPIRED / CAPTCHA / SELECTOR_MISS)
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本末尾 `return { status, articleUrl, ... }`;ProfileMax 据此把执行终态映射为业务状态。
|
||||||
|
**不要**在脚本里硬编码文章内容;**不要**写死 Selector 之外的 URL。
|
||||||
|
3. **测试**:开发期用 `run_script { profileRefs:[指纹] }` 直接试跑(可先给样例 vars 手动验证),
|
||||||
|
或先建一篇文章走一次完整 `publishing_create_job`。
|
||||||
|
4. **提交候选**:脚本改动用 `update_script` 保存(不要覆盖正在运行的版本时再改;先在测试号验证)。
|
||||||
|
5. **安全红线**:节点侧 `code` 以 `new Function` 执行、无沙箱;脚本内容要像对待生产代码一样审查,
|
||||||
|
不得引入抓取密钥、横向访问内网等行为。Agent 生成的代码在审查通过前不得进入生产站点脚本。
|
||||||
|
|
||||||
|
## 3. 生成文章
|
||||||
|
|
||||||
|
1. 结合项目 config(关键词/语言/内容规则,见 `GET /api/publishing/projects`)生成有实际价值的文章,
|
||||||
|
自然植入目标外链(targetLinks 的 anchor 与上下文匹配)。
|
||||||
|
2. `publishing_save_article { projectId, title, markdown|html, targetLinks, status }`;
|
||||||
|
内部审阅通过后置 `status=ready`。内容更新走同一工具带 `articleId`(版本自动自增)。
|
||||||
|
|
||||||
|
## 4. 创建发布作业与跟进
|
||||||
|
|
||||||
|
1. `publishing_create_job { articleId, siteId, mode }`(站点须已绑定脚本+Profile;
|
||||||
|
同文章/站点/模式有在途作业会 409;同 Profile 串行执行)。
|
||||||
|
2. 轮询 `publishing_get_job { id }`:
|
||||||
|
- `published`(附 articleUrl)/`draft` → 完成,转 §6;
|
||||||
|
- `unknown`(超时)→ **先 `publishing_verify_job`(或人工核对 URL)确认是否已发布,再决定重试**,
|
||||||
|
避免重复提交;
|
||||||
|
- `failed` / `needs_repair` → 转 §5;
|
||||||
|
- 幂等:作业的 `idempotencyKey` 全程不变;`retry` 会复用它,不会产生重复发布记录。
|
||||||
|
|
||||||
|
## 5. 失败诊断与修复
|
||||||
|
|
||||||
|
1. `publishing_get_repair_context { id }` 领取:返回脱敏错误、执行步骤/日志、站点信息、
|
||||||
|
当前脚本 Schema 与本次 PublishInput;failed/unknown 作业同时标记 `needs_repair`。
|
||||||
|
2. 依据 errorCode 与步骤日志定位(常见:LOGIN_EXPIRED → 提示用户续 Cookie;SELECTOR_MISS →
|
||||||
|
站点改版,重新学习页面;CAPTCHA → 建议站点脚本接入挑战开关)。
|
||||||
|
3. 修改脚本 → `update_script` 提交 → 用测试 Profile `run_script` 验证 → 对该作业 `POST
|
||||||
|
/api/publishing/jobs/:id/retry`(或重跑 `publishing_create_job`)。
|
||||||
|
|
||||||
|
## 6. 发布后验证
|
||||||
|
|
||||||
|
- `publishing_verify_job { id }`:HTTP 拉取 articleUrl,记录页面状态码、目标链接存在性与
|
||||||
|
rel 属性(nofollow/sponsored 会体现在检查记录),结果进入质量评分。
|
||||||
|
- 需要登录/JS 渲染才能核验的页面,用 Profile `fetch_url`(browser 运行时)人工复核,阶段 C 将自动化。
|
||||||
|
|
||||||
|
## 7. 约束
|
||||||
|
|
||||||
|
- 只发布有授权或允许投稿的站点,遵守站点规则与频率限制;
|
||||||
|
- Profile/脚本/作业均有归属与权限校验,越权请求直接 403/404;
|
||||||
|
- 不要绕过 ProfileMax 直接操作数据库;执行、重试、幂等全部走上述接口。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ProfileMax(CloakB + ipmart.io-US)并发常驻浏览器压测报告
|
||||||
|
|
||||||
|
- 日期:2026-10-09
|
||||||
|
- 服务器:OVH-FR `37.187.92.169`(8 核 / 31GB 内存 / /dev/shm 16G / 同机还跑 mysql、clickhouse、plausible 等其它服务)
|
||||||
|
- 链路:ProfileMax admin(127.0.0.1:5310)→ 节点 OVH-FR(node :3106,systemd 直跑,无 Docker)
|
||||||
|
- 测试身份:MCP 用户 phptits(id=2);Profile 全部归属该用户
|
||||||
|
- 方式:节点 API `POST /profiles/:id/lease(ttlMs=2h)` → `POST /profiles/:id/browser/start` → CDP `/json/new` + WS `Page.navigate` 开真实页面;每 20s 扫 `/proc` 按 `--user-data-dir` 聚合每 Profile 进程树
|
||||||
|
- 内存口径:**PSS**(smaps_rollup,去共享重复);RSS 会把共享页重复计数(报告注明)
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 浏览器内核 | CloakB(cloak,146.0.7680.177.5) |
|
||||||
|
| 代理 | `ipmart.io-US`(socks5 gateway.ipmart.io:9999,用户名含 `{sid}` → 每指纹独立 US 住宅 sticky 出口) |
|
||||||
|
| 标签 | `SERP` |
|
||||||
|
| 模式 | 无头(用户中途指示全部改无头) |
|
||||||
|
| 保活 | `BROWSER_IDLE_TIMEOUT=0`(禁用 10 分钟空闲自动关闭)+ 每 Profile 2 小时 lease |
|
||||||
|
| Profile 池 | 10,002 个(`sz-serp-*`,CloakB + ipmart.io-US + SERP) |
|
||||||
|
|
||||||
|
## 结果总览
|
||||||
|
|
||||||
|
| 指标 | 测试1:3 标签(google/yahoo/x) | 测试2:1 标签(google) |
|
||||||
|
|---|---|---|
|
||||||
|
| **同时常驻浏览器上限** | **82 个** | **189 个** |
|
||||||
|
| 常驻时长 | ≥40 分钟(最老的) | ≥40 分钟(最老的) |
|
||||||
|
| 系统峰值 PSS | 25.0GB | 25.5GB |
|
||||||
|
| 内存最低可用(avail) | **542MB(耗尽)** | 2793MB |
|
||||||
|
| swap 峰值 | **9.2GB 全满(耗尽)** | 7.6GB(触发停止线) |
|
||||||
|
| load 峰值(8 核) | **118(kswapd 抢转,CPU 也耗尽)** | 46 |
|
||||||
|
| 总进程/线程数峰值 | 1280 进程 | 1702 进程 |
|
||||||
|
| 每浏览器进程数(均值) | 15.1(重载页到 50+) | 9.0 |
|
||||||
|
| 每 Profile 内存(稳定期) | p50=125 / p90=906 / max=1560 MB;总量均值 ≈305MB | p50=152 / p90=158 / max=179 MB;总量均值 ≈129MB |
|
||||||
|
| 每 Profile swap | p50=20MB | p50=2MB |
|
||||||
|
| 每 Profile CPU(约 40 分钟常驻) | 均值 ≈164s | 均值 ≈46s |
|
||||||
|
| 每 Profile 磁盘(user-data) | p50=25MB / max=115MB | p50=10MB / max=115MB |
|
||||||
|
|
||||||
|
**结论:当前服务器(31GB 内存,其余 3.7GB 被 mysql/clickhouse/系统占用)**
|
||||||
|
- 3 标签(轻量页)可常驻 **约 80 个** Profile;3 标签里出现 yahoo/x 重页时重载 Profile 会拖到 1.5GB/个,是方差的主因。
|
||||||
|
- 1 标签(google)可常驻 **约 190 个** Profile(停止在 swap 7.2GB 保守线,真实极限约 200±10)。
|
||||||
|
- 内存是第一瓶颈,PSS 是衡量口径;3 标签场景 CPU(kswapd + 软渲染)会成为第二瓶颈。
|
||||||
|
|
||||||
|
## 每轮细节
|
||||||
|
|
||||||
|
### 测试1(3 标签)
|
||||||
|
- 发射阶段:并发 4,fd 修复后从 58 → 82 一路推进;`MemAvailable<1.5GB` + swap 满后页面加载/启动开始超时与抖动(load 118),停止发射。
|
||||||
|
- 残留 82 个全部驻留至数据采集完成(带 OOM 保护策略:浏览器主进程 oom_score_adj=200,其它服务进程=-600,整个压测期间 mysqld/clickhouse/PG 等零损伤)。
|
||||||
|
- 重载 Profile(x.com/yahoo 富页)实测会把进程树拉到 50+ 进程 / 1.5GB PSS。
|
||||||
|
|
||||||
|
### 测试2(1 标签)
|
||||||
|
- 清场回收后重跑同一 10002 池;发射 25 分钟到 189 个,唯一失败 1 次;swap 7458MB ≥ 7200MB 触发保守停止(avail 仍有 3.3GB,真实极限略高)。
|
||||||
|
- 单标签 google 的内存分布极稳(129MB 均值,极差 179)——google 轻页没有长尾。
|
||||||
|
|
||||||
|
## 过程中修复的 Docker/系统/产品问题
|
||||||
|
|
||||||
|
1. **`fs.file-max=65535` 系统级文件句柄上限**——58 个浏览器即吃满(65216/65535),新 chrome 启动静默失败 → 启动超时。已放宽:`fs.file-max=2097152`(已写入 /etc/sysctl.conf)。
|
||||||
|
2. **inotify 额度**:max_user_instances 128→1024、max_user_watches 261756→524288(chrome 每 Profile 多个 watcher,128 实例上限也会卡死启动)。
|
||||||
|
3. **swap 仅 1GB**:追加 8G swapfile `/testswap`(priority 10),现共 9GB,保留作为长期优化(防 OOM 误伤同机其它服务)。
|
||||||
|
4. **10 分钟空闲自动关闭**(节点 `BROWSER_IDLE_TIMEOUT` 默认 600s):测试期间置 0 禁用,已恢复默认。
|
||||||
|
5. 有头模式(浏览器经 VNC/WebDesk 跑在每 Profile Xvfb 上,llvmpipe 软渲染)在批量场景会瞬间把 8 核打满(load 96),**批量常驻必须用无头**;webdesk 二进制与 x11vnc 测试期间暂移、已恢复。
|
||||||
|
6. 产品坑(记录进记忆):节点有「UI 误关自动复活」(SIGKILL 杀浏览器会被复活,重启风暴);停浏览器必须走 API 显式 stop(closing 路径)。`browser/start` 返回的 `processId` 是包装进程(拉起 chrome 后退出),不能当存活判据;存活判据应按 `--user-data-dir` 进程树聚合。响应体统一 `{success,data}` 包装。
|
||||||
|
7. admin `DELETE /api/profiles/:id` 只删库行、**不清理节点侧 Profile 数据**(节点清理只在转移链路触发)——压测清理需要另对节点调 `DELETE /profiles/:id?force=true`(本次已处理)。
|
||||||
|
|
||||||
|
## 清理与恢复
|
||||||
|
|
||||||
|
- 测试 Profile:admin 侧 10,002 个全部删除;节点侧已启动过的 190 个数据目录全部强删(`force=true`);节点现存 139 个目录均为合法 Profile(未动)。
|
||||||
|
- 配置:webdesk 二进制、x11vnc、`BROWSER_IDLE_TIMEOUT`、全部恢复;`fs.file-max`/inotify/8G swapfile 保留(系统优化项)。
|
||||||
|
- 终态:节点在线(0 running)、admin 正常、内存回收(avail 29GB)、swap 余量 6.2GB。
|
||||||
|
|
||||||
|
## 复现物
|
||||||
|
|
||||||
|
- 服务器 `/root/serpmax-stress/`:`create-profiles.ts / run-stress.ts / sampler.ts / stop-browsers.ts / report.ts / cleanup-profiles.ts / node-wipe-stress.ts`
|
||||||
|
- 数据:`data/test{1,2}-summary.json|md`、`data/test1-stats-final.jsonl`(154 轮)、`data/test2-stats.jsonl`(117 轮)、各轮 run 日志
|
||||||
|
- 本地:`/home/bigcode/data/dev/bun/SerpMax/stress/REPORT.md`(本文件)+ 同套脚本与 summary JSON
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 压测收尾:删除全部测试 Profile(SERP 标签)。
|
||||||
|
* 分页拉取(限 500)→ 并发 DELETE /api/profiles/:id(admin 级联:节点数据删除/扩展绑定清理)。
|
||||||
|
* 404 视为已删;最后复查 admin DB 与节点 profile 目录残留数。
|
||||||
|
* 用法:bun cleanup-profiles.ts
|
||||||
|
*/
|
||||||
|
const fs = require("fs") as typeof import("fs");
|
||||||
|
const ADMIN = "http://127.0.0.1:5310";
|
||||||
|
const DATA = `${new URL(".", import.meta.url).pathname}data`;
|
||||||
|
|
||||||
|
/** # 函数:docker exec 查 admin DB 单值 **/
|
||||||
|
function sqlOne(sql: string): string {
|
||||||
|
const p = Bun.spawnSync(["docker", "exec", "serpmax-pg", "psql", "-U", "postgres", "-d", "serpmax_admin", "-tAc", sql], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const out = p.stdout.toString().trim();
|
||||||
|
if (p.exitCode !== 0 || !out) throw new Error(`SQL 失败`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const TOKEN = sqlOne("SELECT api_token FROM users WHERE id=2");
|
||||||
|
|
||||||
|
// 分页拉取 SERP 标签 Profile(标签过滤走服务端,兜底本地再筛)
|
||||||
|
const fps: { id: string; fp: string }[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (page <= 40) {
|
||||||
|
const res = await fetch(`${ADMIN}/api/profiles?limit=500&page=${page}`, {
|
||||||
|
headers: { authorization: `Bearer ${TOKEN}` },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`列表 ${res.status}`);
|
||||||
|
const j: any = await res.json();
|
||||||
|
const data = j.data ?? j;
|
||||||
|
const items: any[] = data.items ?? [];
|
||||||
|
for (const it of items) {
|
||||||
|
const tags: string[] = it.tags ?? [];
|
||||||
|
if (tags.includes("SERP") && (it.name ?? "").startsWith("sz-serp-")) {
|
||||||
|
fps.push({ id: it.id, fp: it.fingerprint ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = data.total ?? 0;
|
||||||
|
if (items.length < 500 || fps.length >= total) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
console.log(`[cleanup] 目标 ${fps.length} 个`);
|
||||||
|
|
||||||
|
let deleted = 0, skipped = 0;
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = 16;
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (cursor < fps.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
const t = fps[i];
|
||||||
|
if (!t.id) { skipped++; continue; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ADMIN}/api/profiles/${t.id}`, { method: "DELETE", headers: { authorization: `Bearer ${TOKEN}` }, signal: AbortSignal.timeout(60000) });
|
||||||
|
if (res.ok || res.status === 404) deleted++;
|
||||||
|
else { skipped++; if (skipped < 10) console.error(`[cleanup] 删 ${t.fp} 状态 ${res.status}`); }
|
||||||
|
} catch (e) { skipped++; }
|
||||||
|
if (deleted % 500 === 0 && deleted > 0) console.log(`[cleanup] deleted=${deleted} skipped=${skipped}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||||
|
console.log(`[cleanup] 完成 deleted=${deleted} skipped=${skipped}`);
|
||||||
|
console.log(`[cleanup] admin 残留 SERP=${sqlOne("SELECT count(*) FROM profiles WHERE tags @> ARRAY['SERP']::text[]")}`);
|
||||||
|
console.log(`[cleanup] 节点目录残留=${Bun.spawnSync(["bash", "-c", "ls /opt/serpmax/node/data/profiles/ | wc -l"], { stdout: "pipe" }).stdout.toString().trim()}`);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 压测准备:批量在 admin(127.0.0.1:5310)创建 N 个 Profile。
|
||||||
|
* - 归属当前 MCP 用户(users.id=2,phptits),Token 由脚本直取 DB 不落盘不回显
|
||||||
|
* - 浏览器内核 CloakB(cloak)、代理 ipmart.io-US、标签 SERP、节点 OVH-FR
|
||||||
|
* - 输出 data/pool.json:[{ id, fingerprint, name }]
|
||||||
|
*
|
||||||
|
* 用法:bun create-profiles.ts [N=10000]
|
||||||
|
*/
|
||||||
|
import { $ } from "bun";
|
||||||
|
|
||||||
|
/** # 成员变量 admin 基址 **/
|
||||||
|
const ADMIN = "http://127.0.0.1:5310";
|
||||||
|
|
||||||
|
/** # 成员变量 目标代理(pressure 测试固定 ipmart.io-US,{sid} 每指纹独立出口) **/
|
||||||
|
const PROXY_ID = "19e3ab5c-c61e-4936-8a7f-d78f6635f57c";
|
||||||
|
|
||||||
|
/** # 成员变量 归属节点 **/
|
||||||
|
const NODE = "OVH-FR";
|
||||||
|
|
||||||
|
/** # 成员变量 标签 **/
|
||||||
|
const TAGS = ["SERP"];
|
||||||
|
|
||||||
|
const N = Number(process.argv[2] ?? 10000);
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 通过 docker exec 查询 admin DB,取单值文本。
|
||||||
|
* 参数 sql:完整 SQL 语句
|
||||||
|
* 返回值:第一行第一列,去尾部换行
|
||||||
|
*/
|
||||||
|
function sqlOne(sql: string): string {
|
||||||
|
const p = Bun.spawnSync(["docker", "exec", "serpmax-pg", "psql", "-U", "postgres", "-d", "serpmax_admin", "-tAc", sql], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const out = p.stdout.toString().trim();
|
||||||
|
if (p.exitCode !== 0 || !out) throw new Error(`SQL 失败: ${p.stderr.toString().slice(0, 300)}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 admin API Token(phptits,id=2;仅内存) **/
|
||||||
|
const TOKEN = sqlOne("SELECT api_token FROM users WHERE id=2");
|
||||||
|
|
||||||
|
const results: { id: string; fingerprint: string; name: string }[] = [];
|
||||||
|
let okCount = 0;
|
||||||
|
let errCount = 0;
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 创建单个 Profile,成功返回 {id, fingerprint, name},失败返回 null。
|
||||||
|
*/
|
||||||
|
async function createOne(i: number): Promise<{ id: string; fingerprint: string; name: string } | null> {
|
||||||
|
const name = `sz-serp-${String(i + 1).padStart(5, "0")}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ADMIN}/api/profiles`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
|
||||||
|
// 浏览器外观随机浅色/深色(用户 2026-10-20 要求 SERP 号不再全浅色)
|
||||||
|
body: JSON.stringify({ name, browserKind: "cloak", browserTheme: Math.random() < 0.5 ? "light" : "dark", proxyId: PROXY_ID, tags: TAGS, node: NODE }),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
});
|
||||||
|
if (res.status === 409) return null; // 指纹冲突(极小概率),跳过
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 120)}`);
|
||||||
|
const j = (await res.json()) as any;
|
||||||
|
const d = (j.data ?? j) as { id?: string; fingerprint?: string };
|
||||||
|
okCount++;
|
||||||
|
return { id: d.id ?? "", fingerprint: d.fingerprint ?? d.id ?? "", name };
|
||||||
|
} catch (e) {
|
||||||
|
errCount++;
|
||||||
|
if (errCount < 20) console.error(`create fail ${i}: ${String(e).slice(0, 140)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主循环:16 并发创建 N 个 */
|
||||||
|
const workers = 16;
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < N) {
|
||||||
|
const i = cursor++;
|
||||||
|
const r = await createOne(i);
|
||||||
|
if (r) results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||||
|
|
||||||
|
await Bun.write(new URL(import.meta.url).pathname.replace(/[^/]+$/, "") + `data/pool.json`, JSON.stringify(results));
|
||||||
|
console.log(`done: created=${okCount} failed=${errCount} elapsed=${((Date.now() - t0) / 1000).toFixed(1)}s -> data/pool.json`);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** # 调试脚本:复算杀伤误判(服务器上一次性使用) **/
|
||||||
|
const fs = require("fs");
|
||||||
|
const launched = JSON.parse(await Bun.file("data/test1-launched.json").text());
|
||||||
|
const alive = new Set<string>();
|
||||||
|
for (const pid of fs.readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
|
||||||
|
try {
|
||||||
|
const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||||
|
const m = /--user-data-dir=\S*\/profiles\/([\w.]+)\/browser/.exec(cmd);
|
||||||
|
if (m) alive.add(m[1]);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
console.log("alive trees:", alive.size);
|
||||||
|
const dead = launched.filter((e: any) => Date.now() - Date.parse(e.startedAt) > 60000 && !alive.has(e.fp));
|
||||||
|
console.log("dead count:", dead.length, dead.slice(0, 5).map((e: any) => e.fp));
|
||||||
|
const sample = launched[0] && [...alive].slice(0, 5);
|
||||||
|
console.log("alive sample:", sample);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 节点侧清理:按 pool.json 清单对节点逐个 DELETE /profiles/:fp?force=true
|
||||||
|
* (admin API 删除不清节点数据;本脚本只处理压测池内的指纹,不动合法 Profile)。
|
||||||
|
* 用法:bun node-wipe-stress.ts
|
||||||
|
*/
|
||||||
|
const fs = require("fs") as typeof import("fs");
|
||||||
|
const NODE = "http://127.0.0.1:3106";
|
||||||
|
const DATA = `${new URL(".", import.meta.url).pathname}data`;
|
||||||
|
const NODE_KEY = /^NODE_KEY=(.*)$/m.exec(fs.readFileSync("/opt/serpmax/node/.env", "utf8"))?.[1]?.trim() ?? "";
|
||||||
|
const pool = (await Bun.file(`${DATA}/pool.json`).json()) as { fingerprint: string }[];
|
||||||
|
const fps = [...new Set(pool.map(p => p.fingerprint).filter(Boolean))];
|
||||||
|
console.log(`[wipe] 池内指纹 ${fps.length} 个`);
|
||||||
|
let ok = 0, miss = 0, err = 0;
|
||||||
|
const workers = 12;
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (cursor < fps.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
const fp = fps[i];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${NODE}/profiles/${fp}?force=true`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "x-node-key": NODE_KEY },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (res.ok) ok++;
|
||||||
|
else if (res.status === 404) miss++;
|
||||||
|
else { err++; if (err < 8) console.error(`[wipe] ${fp} -> ${res.status}`); }
|
||||||
|
} catch { err++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||||
|
console.log(`[wipe] ok=${ok} miss(本就不在)=${miss} err=${err}`);
|
||||||
|
const left = Bun.spawnSync(["bash", "-c", "ls /opt/serpmax/node/data/profiles/ | wc -l"], { stdout: "pipe" }).stdout.toString().trim();
|
||||||
|
console.log(`[wipe] 节点目录剩余=${left}`);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 存量 SERP Profile 浏览器外观随机化:标签含 SERP 的 Profile 主题随机浅色/深色(50/50)。
|
||||||
|
* 两步:① admin DB 批量 UPDATE browser_theme;② 逐个 PATCH 节点
|
||||||
|
* /profiles/:指纹/browser-env {theme}(下一次浏览器启动生效;节点侧 ID 统一为指纹)。
|
||||||
|
* 认证:节点侧 x-node-key = nodes.key;节点寻址 nodes.base_url。
|
||||||
|
* 幂等可重跑(每次重新随机)。
|
||||||
|
* 用法:在 admin 机上执行 bun randomize-themes.ts [并发=24]
|
||||||
|
* (bun 自动加载 admin/.env 的 DATABASE_URL;本地默认 serpmax_admin@127.0.0.1:5432)
|
||||||
|
*/
|
||||||
|
import { SQL } from "bun";
|
||||||
|
|
||||||
|
/** # 成员变量 admin Postgres 连接(DATABASE_URL 覆盖;默认本机 serpmax_admin) **/
|
||||||
|
const sql = new SQL(process.env.DATABASE_URL ?? "postgres://postgres:postgres@127.0.0.1:5432/serpmax_admin");
|
||||||
|
|
||||||
|
/** # 成员变量 节点 PATCH 并发数 **/
|
||||||
|
const CONCURRENCY = Math.max(1, Number(process.argv[2] ?? 24));
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 主流程:拉取 SERP Profile 与节点信息 → 每号随机主题 → 批量落 admin DB → 并发推节点。
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// 节点寻址与密钥(name → base_url / key)
|
||||||
|
const nodes = await sql`SELECT name, base_url, key FROM nodes`;
|
||||||
|
const nodeById = new Map<string, { baseUrl: string; key: string }>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
nodeById.set(String(n.name), { baseUrl: String(n.base_url ?? "").replace(/\/$/, ""), key: String(n.key ?? "") });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SERP Profile 池(admin 侧 ID 为 UUID;指纹是节点侧 ID)
|
||||||
|
const rows = await sql`SELECT id::text AS id, fingerprint, node, browser_theme FROM profiles WHERE tags @> ARRAY['SERP']::text[]`;
|
||||||
|
console.log(`[randomize] SERP Profile 共 ${rows.length} 个`);
|
||||||
|
if (!rows.length) return;
|
||||||
|
|
||||||
|
// 逐号随机主题并按目标节点分组
|
||||||
|
type Item = { id: string; fingerprint: string; theme: string };
|
||||||
|
const byNode = new Map<string, Item[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const item: Item = {
|
||||||
|
id: String(r.id),
|
||||||
|
fingerprint: String(r.fingerprint ?? r.id),
|
||||||
|
theme: Math.random() < 0.5 ? "light" : "dark",
|
||||||
|
};
|
||||||
|
const list = byNode.get(String(r.node ?? "")) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
byNode.set(String(r.node ?? ""), list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① admin DB 批量落库(2000/批,UPDATE...FROM VALUES)
|
||||||
|
let dbUpdated = 0;
|
||||||
|
for (const items of byNode.values()) {
|
||||||
|
for (let i = 0; i < items.length; i += 2000) {
|
||||||
|
const chunk = items.slice(i, i + 2000);
|
||||||
|
const params = chunk.flatMap((it) => [it.id, it.theme]);
|
||||||
|
const ph = chunk.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(",");
|
||||||
|
await sql.unsafe(`UPDATE profiles AS p SET browser_theme = v.theme FROM (VALUES ${ph}) AS v(id, theme) WHERE p.id::text = v.id`, params);
|
||||||
|
dbUpdated += chunk.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[randomize] admin DB 已更新 ${dbUpdated} 个`);
|
||||||
|
|
||||||
|
// ② 并发推节点(失败不阻塞,最后汇总)
|
||||||
|
let okCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const cursor = { n: 0 };
|
||||||
|
const all = [...byNode.entries()].flatMap(([node, items]) => items.map((it) => ({ node, ...it })));
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (cursor.n < all.length) {
|
||||||
|
const it = all[cursor.n++];
|
||||||
|
const target = nodeById.get(it.node);
|
||||||
|
if (!target?.baseUrl) { failCount++; continue; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${target.baseUrl}/profiles/${encodeURIComponent(it.fingerprint)}/browser-env`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json", "x-node-key": target.key },
|
||||||
|
body: JSON.stringify({ theme: it.theme }),
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (res.ok) okCount++; else failCount++;
|
||||||
|
} catch {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
if ((okCount + failCount) % 1000 === 0) console.log(`[randomize] 节点推送 ${okCount + failCount}/${all.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
||||||
|
console.log(`[randomize] 完成:节点推送成功 ${okCount},失败 ${failCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 压测数据汇总(服务器上运行):
|
||||||
|
* 输入 data/<label>-stats.jsonl(采样器输出)+ data/<label>-launched.json(发射清单)
|
||||||
|
* 输出:
|
||||||
|
* - 峰值/残存浏览器数、发射总量、系统峰值(avail 最低/swap 最高/PSS 最高)
|
||||||
|
* - 每 Profile 稳定期均值(PSS/swap/CPU/进程数)与分布(p50/p90/p99/max)
|
||||||
|
* - 每 Profile 磁盘占用(du data/profiles/<fp>/browser)
|
||||||
|
* - top 明细表(MD 片段)→ data/<label>-summary.json + data/<label>-summary.md
|
||||||
|
* 用法:bun report.ts --label test1 [--tail 10]
|
||||||
|
*/
|
||||||
|
const fs = require("fs") as typeof import("fs");
|
||||||
|
import { $ } from "bun";
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 && i + 1 < args.length ? args[i + 1] : d; }
|
||||||
|
const LABEL = argOf("--label", "test1");
|
||||||
|
const TAILN = Number(argOf("--tail", "10"));
|
||||||
|
const DATA = `${new URL(".", import.meta.url).pathname}data`;
|
||||||
|
|
||||||
|
/** # 成员变量 采样行类型 **/
|
||||||
|
type Sample = { ts: string; browsers: number; totalPssMB: number; totalRssMB: number; totalSwapMB: number; totalProcs: number; availMB: number; swapUsedMB: number; load: number[]; detail: any[] };
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(`${DATA}/${LABEL}-stats.jsonl`, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l) as Sample);
|
||||||
|
console.log(`samples=${lines.length}`);
|
||||||
|
const peak = {
|
||||||
|
browsers: Math.max(...lines.map(s => s.browsers)),
|
||||||
|
pssMB: Math.max(...lines.map(s => s.totalPssMB)),
|
||||||
|
minAvailMB: Math.min(...lines.map(s => s.availMB)),
|
||||||
|
maxSwapMB: Math.max(...lines.map(s => s.swapUsedMB)),
|
||||||
|
maxLoad: Math.max(...lines.map(s => s.load?.[0] ?? 0)),
|
||||||
|
maxProcs: Math.max(...lines.map(s => s.totalProcs)),
|
||||||
|
};
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
console.log(`peak: ${JSON.stringify(peak)}`);
|
||||||
|
console.log(`last: browsers=${last.browsers} pss=${Math.round(last.totalPssMB)}MB avail=${Math.round(last.availMB)}MB`);
|
||||||
|
|
||||||
|
/** # 成员变量 稳定期尾窗样本(后 N 个) **/
|
||||||
|
const tail = lines.slice(-TAILN);
|
||||||
|
|
||||||
|
/** # 函数:取多组数分布(p50/p90/p99/max) **/
|
||||||
|
function dist(values: number[]): { p50: number; p90: number; p99: number; max: number } | null {
|
||||||
|
if (!values.length) return null;
|
||||||
|
const v = [...values].sort((a, b) => a - b);
|
||||||
|
const q = (p: number) => v[Math.min(v.length - 1, Math.floor(p * (v.length - 1)))];
|
||||||
|
return { p50: q(0.5), p90: q(0.9), p99: q(0.99), max: v[v.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:收集每 Profile 稳定期聚合(窗口内出现的样本取均值;现存取最后样本) **/
|
||||||
|
const per = new Map<string, { fp: string; pss: number[]; swap: number[]; procs: number[]; cpu: number[]; udd: string; lastTs: string }>();
|
||||||
|
for (const s of tail) {
|
||||||
|
for (const t of s.detail) {
|
||||||
|
const e = per.get(t.fp) ?? { fp: t.fp, pss: [], swap: [], procs: [], cpu: [], udd: t.userDataDir ?? "", lastTs: s.ts };
|
||||||
|
e.pss.push(t.pssKB / 1024);
|
||||||
|
e.swap.push((t.swapPssKB ?? t.swapKB) / 1024);
|
||||||
|
e.procs.push(t.procs);
|
||||||
|
e.cpu.push((t.cpuDeltaMs ?? t.cpuMs) / 1000);
|
||||||
|
e.lastTs = s.ts;
|
||||||
|
e.udd = e.udd || t.userDataDir || "";
|
||||||
|
per.set(t.fp, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const avg = (a: number[]) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0);
|
||||||
|
const rows = [...per.values()].map(e => ({
|
||||||
|
fp: e.fp,
|
||||||
|
pssMB: avg(e.pss),
|
||||||
|
swapMB: avg(e.swap),
|
||||||
|
procs: avg(e.procs),
|
||||||
|
cpuSec: avg(e.cpu),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** # 成员变量 每 Profile 磁盘占用(du user-data 目录) **/
|
||||||
|
const disk: Record<string, number> = {};
|
||||||
|
try {
|
||||||
|
const paths = rows.map(r => r.udd).filter(Boolean).slice(0, 400);
|
||||||
|
const chunks: { stdout: string }[] = [];
|
||||||
|
for (let i = 0; i < paths.length; i += 200) {
|
||||||
|
const p = Bun.spawnSync(["du", "-sb", ...paths.slice(i, i + 200)], { stdout: "pipe" });
|
||||||
|
chunks.push(p as unknown as { stdout: string });
|
||||||
|
}
|
||||||
|
for (const p of chunks) {
|
||||||
|
for (const line of (p as any).stdout.toString().split("\n")) {
|
||||||
|
const m = /^(\d+)\t(.+)$/.exec(line);
|
||||||
|
if (m) disk[m[2].split("/").slice(-2)[0]] = Number(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`disk rows=${Object.keys(disk).length}`);
|
||||||
|
} catch (e) { console.error("du 失败", e); }
|
||||||
|
|
||||||
|
/** # 成员变量 汇总分布 **/
|
||||||
|
const dPss = dist(rows.map(r => r.pssMB));
|
||||||
|
const dSwap = dist(rows.map(r => r.swapMB));
|
||||||
|
const dProcs = dist(rows.map(r => r.procs));
|
||||||
|
const dCpu = dist(rows.map(r => r.cpuSec));
|
||||||
|
const dDisk = dist(rows.map(r => disk[r.fp] ?? 0));
|
||||||
|
|
||||||
|
const aliveFps = new Set(last.detail.map(t => t.fp));
|
||||||
|
const summary = {
|
||||||
|
label: LABEL,
|
||||||
|
samples: lines.length,
|
||||||
|
tailSamples: tail.length,
|
||||||
|
peak,
|
||||||
|
last: { ts: last.ts, browsers: last.browsers, pssMB: Math.round(last.totalPssMB), availMB: Math.round(last.availMB), swapUsedMB: Math.round(last.swapUsedMB), load: last.load, procs: last.totalProcs },
|
||||||
|
aliveRows: rows.length,
|
||||||
|
distPerProfile: { pssMB: dPss, swapMB: dSwap, procs: dProcs, cpuSec: dCpu, diskMB: dDisk ? { p50: dDisk.p50 / 1048576, p90: dDisk.p90 / 1048576, max: dDisk.max / 1048576 } : null },
|
||||||
|
top10: rows.sort((a, b) => b.pssMB - a.pssMB).slice(0, 10).map(r => ({ fp: r.fp, pssMB: Math.round(r.pssMB), swapMB: Math.round(r.swapMB), procs: Math.round(r.procs), diskMB: Math.round((disk[r.fp] ?? 0) / 1048576) })),
|
||||||
|
};
|
||||||
|
await Bun.write(`${DATA}/${LABEL}-summary.json`, JSON.stringify(summary, null, 2));
|
||||||
|
|
||||||
|
const md = [
|
||||||
|
`## ${LABEL} 汇总`,
|
||||||
|
`- 采样 ${lines.length} 轮(尾窗 ${tail.length} 轮);峰值浏览器 **${peak.browsers}**;残存 **${summary.last.browsers}**`,
|
||||||
|
`- 峰值系统:PSS ${Math.round(peak.pssMB)}MB / avail 最低 ${Math.round(peak.minAvailMB)}MB / swap 最高 ${Math.round(peak.maxSwapMB)}MB / load ${Math.round(peak.maxLoad)} / 总进程 ${peak.maxProcs}`,
|
||||||
|
`- 每 Profile(稳定期均值,MB):PSS p50=${dPss?.p50.toFixed(0)} p90=${dPss?.p90.toFixed(0)} p99=${dPss?.p99.toFixed(0)} max=${dPss?.max.toFixed(0)}`,
|
||||||
|
`- 每 Profile:swapMB p50=${dSwap?.p50.toFixed(0)} / 进程数 avg=${(rows.reduce((a, b) => a + b.procs, 0) / rows.length).toFixed(1)} / CPU 秒 avg=${(rows.reduce((a, b) => a + b.cpuSec, 0) / rows.length).toFixed(0)}`,
|
||||||
|
`- 每 Profile 磁盘(user-data 目录,MB):p50=${dDisk ? (dDisk.p50 / 1048576).toFixed(0) : "-"} max=${dDisk ? (dDisk.max / 1048576).toFixed(0) : "-"}`,
|
||||||
|
"",
|
||||||
|
markdownTable(summary.top10),
|
||||||
|
].join("\n");
|
||||||
|
await Bun.write(`${DATA}/${LABEL}-summary.md`, md);
|
||||||
|
console.log(md);
|
||||||
|
|
||||||
|
/** # 函数:top10 markdown 表 **/
|
||||||
|
function markdownTable(rows: any[]): string {
|
||||||
|
const head = "| fp | PSS MB | swap MB | procs | disk MB |\n|---|---|---|---|---|";
|
||||||
|
return [head, ...rows.map(r => `| ${r.fp} | ${r.pssMB} | ${r.swapMB} | ${r.procs} | ${r.diskMB} |`)].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 压测发射器(在 37 服务器以 root 运行):
|
||||||
|
* 从 pool.json 逐个(并发 workers)执行:
|
||||||
|
* 节点导入(幂等)→ POST lease(ttlMs=2h,规避 10 分钟空闲自动关闭)
|
||||||
|
* → POST browser/start → 按标签数经 CDP /json/new 开真实页面。
|
||||||
|
* 直到「资源耗尽」触发:MemAvailable<1.5GB / swap已用≥7.2GB /
|
||||||
|
* 连续启动失败≥10 / 已开浏览器被 OOM 杀≥10 / 达到 --max 封顶。
|
||||||
|
* 触发后停止发射(已开浏览器保持常驻),结论写 data/<label>-stop.json。
|
||||||
|
* 断点续跑:进度在 data/<label>-launched.jsonl,重启脚本会跳过已开指纹。
|
||||||
|
*
|
||||||
|
* 用法:bun run-stress.ts --pool data/pool.json --tabs 3 --label test1 [--max 500] [--concurrency 6]
|
||||||
|
*/
|
||||||
|
/** # 成员变量 辅助:进程内同步读文本(供 /proc 扫描) **/
|
||||||
|
const fs = require("fs") as typeof import("fs");
|
||||||
|
|
||||||
|
/** # 成员变量 服务基址(同机) **/
|
||||||
|
const ADMIN = "http://127.0.0.1:5310";
|
||||||
|
const NODE = "http://127.0.0.1:3106";
|
||||||
|
|
||||||
|
/** # 成员变量 基目录与数据目录 **/
|
||||||
|
const BASE = new URL(".", import.meta.url).pathname;
|
||||||
|
const DATA = `${BASE}data`;
|
||||||
|
|
||||||
|
/** # 成员变量 3 标签站点;1 标签测试只取第一个(google) **/
|
||||||
|
const URLS3 = ["https://www.google.com/", "https://www.yahoo.com/", "https://x.com/"];
|
||||||
|
|
||||||
|
/** # 成员变量 Lease TTL:2 小时(远大于 10 分钟空闲关闭窗口) **/
|
||||||
|
const LEASE_TTL_MS = 7_200_000;
|
||||||
|
|
||||||
|
/** # 成员变量 资源耗尽触发阈值(连续失败放宽,配合冷却防瞬时抖动误停) **/
|
||||||
|
const MEM_FLOOR_MB = 1500;
|
||||||
|
const SWAP_CEIL_MB = 7200;
|
||||||
|
const MAX_CONSEC_FAIL = 20;
|
||||||
|
const MAX_OOM_KILLED = 10;
|
||||||
|
/** # 成员变量 发射节流:load1 超过核数×3(37 为 8 核 → 24)暂停发射 **/
|
||||||
|
const LOAD_GATE = 24;
|
||||||
|
|
||||||
|
// ---- 参数解析 ----
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }
|
||||||
|
const TABS = Number(argOf("--tabs", "3"));
|
||||||
|
const LABEL = argOf("--label", "test1");
|
||||||
|
const POOL_FILE = argOf("--pool", `${DATA}/pool.json`);
|
||||||
|
const MAX = Number(argOf("--max", "0"));
|
||||||
|
const CONCURRENCY = Number(argOf("--concurrency", "6"));
|
||||||
|
/** # 成员变量 本轮要开的真实站点列表 **/
|
||||||
|
const OPEN_URLS = TABS === 1 ? URLS3.slice(0, 1) : URLS3;
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
fp: string; name: string; startedAt: string;
|
||||||
|
cdpPort: number | null; mainPid: number | null; tabs: string[];
|
||||||
|
leaseId?: string; userDataDir?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 密钥(仅内存,不落盘不回显) ----
|
||||||
|
/** # 函数:通过 docker exec 查询 admin DB 取单值文本 **/
|
||||||
|
function sqlOne(sql: string): string {
|
||||||
|
const p = Bun.spawnSync(["docker", "exec", "serpmax-pg", "psql", "-U", "postgres", "-d", "serpmax_admin", "-tAc", sql], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const out = p.stdout.toString().trim();
|
||||||
|
if (p.exitCode !== 0 || !out) throw new Error(`SQL 失败: ${p.stderr.toString().slice(0, 300)}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const TOKEN = sqlOne("SELECT api_token FROM users WHERE id=2");
|
||||||
|
const envText = fs.readFileSync("/opt/serpmax/node/.env", "utf8");
|
||||||
|
const NODE_KEY = /^NODE_KEY=(.*)$/m.exec(envText)?.[1]?.trim() ?? "";
|
||||||
|
/** # 成员变量 ipmart.io-US 代理四元组(DB 直取) **/
|
||||||
|
const PROXY_ID = "19e3ab5c-c61e-4936-8a7f-d78f6635f57c";
|
||||||
|
const prow = sqlOne(`SELECT type,host,port,coalesce(username,''),coalesce(password,'') FROM proxies WHERE id='${PROXY_ID}'`).split("|");
|
||||||
|
|
||||||
|
/** # 成员变量 节点公共头(与 admin relayHeaders 一致:Token+节点密钥双证) **/
|
||||||
|
function nodeHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
|
return { "content-type": "application/json", authorization: `Bearer ${TOKEN}`, "x-node-key": NODE_KEY, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态 ----
|
||||||
|
const pool = (await Bun.file(POOL_FILE).json()) as { fingerprint: string; name: string }[];
|
||||||
|
const LAUNCHED_FILE = `${DATA}/${LABEL}-launched.json`;
|
||||||
|
/** # 成员变量 已启动记录(含续跑读取) **/
|
||||||
|
let launched: Entry[] = [];
|
||||||
|
try { launched = JSON.parse(await Bun.file(LAUNCHED_FILE).text() || "[]"); } catch { launched = []; }
|
||||||
|
const doneSet = new Set(launched.map((e) => e.fp));
|
||||||
|
console.log(`[run] pool=${pool.length} tabs=${TABS} 续跑已开=${launched.length}`);
|
||||||
|
const persist = () => Bun.write(LAUNCHED_FILE, JSON.stringify(launched));
|
||||||
|
|
||||||
|
// ---- 统计/控制 ----
|
||||||
|
let consecutiveFail = 0;
|
||||||
|
let totalFail = 0;
|
||||||
|
let oomKilled = 0;
|
||||||
|
let stopReason: string | null = null;
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
/** # 函数:读内存水位(MB) **/
|
||||||
|
function meminfo(): { availMB: number; swapUsedMB: number } {
|
||||||
|
const txt = fs.readFileSync("/proc/meminfo", "utf8");
|
||||||
|
const get = (k: string) => Number(new RegExp(`${k}:\\s+(\\d+)`).exec(txt)?.[1] ?? 0);
|
||||||
|
return { availMB: get("MemAvailable") / 1024, swapUsedMB: (get("SwapTotal") - get("SwapFree")) / 1024 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:统计已开浏览器中被杀死的数量。
|
||||||
|
* 判据:该 Profile 的 user-data-dir 下已无任何 chrome 进程,且记录时长超过 60s
|
||||||
|
* (startBrowser 返回的 processId 是包装进程,会自然退出,不能逐 PID 判)。
|
||||||
|
**/
|
||||||
|
function countKilled(): number {
|
||||||
|
const alive = new Set<string>();
|
||||||
|
for (const pid of fs.readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
|
||||||
|
try {
|
||||||
|
const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||||
|
const m = /--user-data-dir=\S*\/profiles\/([\w.]+)\/browser/.exec(cmd);
|
||||||
|
if (m) alive.add(m[1]);
|
||||||
|
} catch { /* 进程已退出 */ }
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
let dead = 0;
|
||||||
|
for (const e of launched) {
|
||||||
|
if (now - Date.parse(e.startedAt) < 60_000) continue;
|
||||||
|
if (!alive.has(e.fp)) dead++;
|
||||||
|
}
|
||||||
|
return dead;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:能否继续发射;返回触发原因或 null,顺带刷新 oomKilled **/
|
||||||
|
function budget(): string | null {
|
||||||
|
if (stopReason) return stopReason;
|
||||||
|
if (consecutiveFail >= MAX_CONSEC_FAIL) return `连续启动失败 ${consecutiveFail} 次(共失败 ${totalFail})`;
|
||||||
|
oomKilled = countKilled();
|
||||||
|
if (oomKilled >= MAX_OOM_KILLED) return `浏览器主进程消失(疑似 OOM)${oomKilled} 个`;
|
||||||
|
if (MAX > 0 && launched.length >= MAX) return `达到封顶数 ${MAX}`;
|
||||||
|
const m = meminfo();
|
||||||
|
if (m.availMB < MEM_FLOOR_MB) return `MemAvailable ${m.availMB | 0}MB < ${MEM_FLOOR_MB}MB`;
|
||||||
|
if (m.swapUsedMB >= SWAP_CEIL_MB) return `swap 已用 ${m.swapUsedMB | 0}MB ≥ ${SWAP_CEIL_MB}MB`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nodeFetch(path: string, init: RequestInit = {}, timeout = 120000): Promise<Response> {
|
||||||
|
return fetch(`${NODE}${path}`, { ...init, headers: nodeHeaders(init.headers as Record<string, string> | undefined), signal: AbortSignal.timeout(timeout) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:解开 {success, data} 包装,拿 payload **/
|
||||||
|
function unwrap<T>(j: T | { data: T }): T {
|
||||||
|
return (j as any)?.data ?? (j as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:节点导入(幂等;载荷与 admin relay 的 importProfileToNode 等价) **/
|
||||||
|
async function ensureImported(fp: string, name: string): Promise<void> {
|
||||||
|
const chk = await nodeFetch(`/profiles/${fp}`, {}, 10000);
|
||||||
|
if (chk.ok) return;
|
||||||
|
if (chk.status !== 404) throw new Error(`节点查询 ${fp} 状态 ${chk.status}`);
|
||||||
|
const res = await nodeFetch("/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...nodeHeaders(), "idempotency-key": `stress-import-${fp}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: fp, name,
|
||||||
|
browser: { kind: "cloak", theme: "light" },
|
||||||
|
network: {},
|
||||||
|
proxy: { id: PROXY_ID, protocol: prow[0], host: prow[1], port: Number(prow[2]), username: prow[3], password: prow[4] },
|
||||||
|
metadata: { importedBy: "stress-run" },
|
||||||
|
}),
|
||||||
|
}, 15000);
|
||||||
|
if (!res.ok && res.status !== 409) throw new Error(`导入 ${fp} 失败 ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 通过 WS 对指定标签发送 Page.navigate(内核 /json/new?url= 不生效,需逐标签导航)。
|
||||||
|
* 参数 port:CDP 端口;targetId:标签 id;url:目标网址
|
||||||
|
* 返回值:是否发出成功(10s 超时视为失败,不阻塞)
|
||||||
|
*/
|
||||||
|
async function navigateTab(port: number, targetId: string, url: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
const done = (ok: boolean) => { try { ws?.close(); } catch { /* ignore */ } resolve(ok); };
|
||||||
|
const timer = setTimeout(() => done(false), 10000);
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(`ws://127.0.0.1:${port}/devtools/page/${targetId}`);
|
||||||
|
ws.onopen = () => ws!.send(JSON.stringify({ id: 1, method: "Page.navigate", params: { url } }));
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(String(ev.data));
|
||||||
|
if (m.id === 1) { clearTimeout(timer); done(!m.error); }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
ws.onerror = () => { clearTimeout(timer); done(false); };
|
||||||
|
} catch { clearTimeout(timer); done(false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:lease(2h)→ start → 开标签;返回每 Profile 记录 **/
|
||||||
|
async function launchOne(fp: string, name: string): Promise<Entry> {
|
||||||
|
await ensureImported(fp, name);
|
||||||
|
// 已有未过期 lease(如脚本重启续跑)则复用其 leaseId,否则新拿一个
|
||||||
|
let leaseId = "";
|
||||||
|
const detail = await nodeFetch(`/profiles/${fp}`, {}, 10000);
|
||||||
|
if (detail.ok) {
|
||||||
|
const d = unwrap<{ lease?: { leaseId: string; expiresAt: string } | null }>(await detail.json());
|
||||||
|
if (d.lease && new Date(d.lease.expiresAt).getTime() > Date.now()) leaseId = d.lease.leaseId;
|
||||||
|
}
|
||||||
|
if (!leaseId) {
|
||||||
|
const r = await nodeFetch(`/profiles/${fp}/lease`, { method: "POST", body: JSON.stringify({ consumerId: "stress", ttlMs: LEASE_TTL_MS }) }, 15000);
|
||||||
|
if (!r.ok) throw new Error(`lease ${r.status} ${(await r.text()).slice(0, 100)}`);
|
||||||
|
leaseId = unwrap<{ leaseId: string }>(await r.json()).leaseId;
|
||||||
|
}
|
||||||
|
const start = await nodeFetch(`/profiles/${fp}/browser/start`, { method: "POST", headers: { "x-lease-id": leaseId }, body: "{}" }, 150000);
|
||||||
|
if (!start.ok) throw new Error(`start ${start.status} ${(await start.text()).slice(0, 130)}`);
|
||||||
|
const s = unwrap<{ cdp?: { endpoint?: string; port?: number }; processId?: number; userDataDir?: string }>(await start.json());
|
||||||
|
const entry: Entry = { fp, name, startedAt: new Date().toISOString(), cdpPort: s.cdp?.port ?? null, mainPid: s.processId ?? null, tabs: [], leaseId, userDataDir: s.userDataDir };
|
||||||
|
// OOM 角色反转:浏览器主进程调「易杀」,系统其它进程由采样器置「受保护」
|
||||||
|
if (entry.mainPid) { try { fs.writeFileSync(`/proc/${entry.mainPid}/oom_score_adj`, "200"); } catch { /* 进程已退出 */ } }
|
||||||
|
// 开标签:CDP /json/new 建空页(Chrome 111+ 要求 PUT;405 回退 POST)→ WS Page.navigate 逐个导航
|
||||||
|
if (entry.cdpPort) {
|
||||||
|
for (const u of OPEN_URLS) {
|
||||||
|
const url = `http://127.0.0.1:${entry.cdpPort}/json/new`;
|
||||||
|
try {
|
||||||
|
let t = await fetch(url, { method: "PUT", signal: AbortSignal.timeout(15000) });
|
||||||
|
if (t.status === 405) t = await fetch(url, { method: "POST", signal: AbortSignal.timeout(15000) });
|
||||||
|
if (t.ok) {
|
||||||
|
const j: any = unwrap(await t.json().catch(() => null) ?? {});
|
||||||
|
const tid = j?.id ?? "";
|
||||||
|
entry.tabs.push(tid);
|
||||||
|
if (tid && !j?.url?.startsWith(u)) void navigateTab(entry.cdpPort, tid, u);
|
||||||
|
}
|
||||||
|
} catch { /* 单个标签失败不阻塞 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 主 workers ----
|
||||||
|
let cursor = 0;
|
||||||
|
/** # 成员变量 每 Profile 启动失败计数(≥2 判代理/数据坏,节点强删后跳过) **/
|
||||||
|
const failByFp = new Map<string, number>();
|
||||||
|
/** # 函数:读 1 分钟负载(发射节流用) **/
|
||||||
|
function load1(): number {
|
||||||
|
try { return Number(fs.readFileSync("/proc/loadavg", "utf8").split(/\s+/)[0]) || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (!stopReason) {
|
||||||
|
if (cursor >= pool.length) return;
|
||||||
|
// 发射节流:系统负载超过门限则等待(防 CPU 打满导致的启动超时雪崩)
|
||||||
|
while (load1() > LOAD_GATE && !stopReason) await new Promise((r) => setTimeout(r, 10_000));
|
||||||
|
const i = cursor++;
|
||||||
|
const item = pool[i];
|
||||||
|
if (!item || doneSet.has(item.fingerprint)) continue;
|
||||||
|
const b = budget();
|
||||||
|
if (b) { stopReason = stopReason ?? b; return; }
|
||||||
|
try {
|
||||||
|
const entry = await launchOne(item.fingerprint, item.name);
|
||||||
|
doneSet.add(item.fingerprint);
|
||||||
|
launched.push(entry);
|
||||||
|
consecutiveFail = 0;
|
||||||
|
if (launched.length % 10 === 0) {
|
||||||
|
const m = meminfo();
|
||||||
|
console.log(`[run] launched=${launched.length} avail=${m.availMB | 0}MB swapUsed=${m.swapUsedMB | 0}MB t=${((Date.now() - t0) / 60000).toFixed(1)}min`);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
consecutiveFail++;
|
||||||
|
totalFail++;
|
||||||
|
console.error(`[run] fail(${consecutiveFail}) ${item.fingerprint}: ${String(e).slice(0, 160)}`);
|
||||||
|
// 坏 Profile(嫌疑代理不通/数据脏,非超时类错误)失败两次 → 节点强删后跳过
|
||||||
|
// 启动超时(Timeout)不删:属系统瞬时拥塞,续跑时还会重试
|
||||||
|
const msg = String(e);
|
||||||
|
if (!msg.includes("Timeout")) {
|
||||||
|
const n = (failByFp.get(item.fingerprint) ?? 0) + 1;
|
||||||
|
failByFp.set(item.fingerprint, n);
|
||||||
|
if (n >= 2) {
|
||||||
|
try { await nodeFetch(`/profiles/${item.fingerprint}?force=true`, { method: "DELETE" }, 30000); } catch { /* best-effort */ }
|
||||||
|
doneSet.add(item.fingerprint);
|
||||||
|
console.error(`[run] drop ${item.fingerprint}(失败 ${n} 次,节点已强删)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 连续失败冷却:6 次后等 45s 再继续(躲开发射抖动期)
|
||||||
|
if (consecutiveFail === 6) { console.error(`[run] 连续失败,冷却 45s`); await new Promise((r) => setTimeout(r, 45_000)); }
|
||||||
|
}
|
||||||
|
const b2 = budget();
|
||||||
|
if (b2 && !stopReason) stopReason = b2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persist();
|
||||||
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
||||||
|
await persist();
|
||||||
|
const m = meminfo();
|
||||||
|
const final = { stopReason, launchedCount: launched.length, totalFail, oomKilled, tabs: TABS, minutes: +((Date.now() - t0) / 60000).toFixed(1), availMB: m.availMB | 0, swapUsedMB: m.swapUsedMB | 0, stoppedAt: new Date().toISOString() };
|
||||||
|
await Bun.write(`${DATA}/${LABEL}-stop.json`, JSON.stringify(final, null, 2));
|
||||||
|
console.log(`[run] STOP: ${JSON.stringify(final)}`);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 压测采样器(与发射器并行跑,同机 root):
|
||||||
|
* 每 20s 扫描 /proc:
|
||||||
|
* 1) 按 --user-data-dir 路径的指纹段聚合「每浏览器进程树」:
|
||||||
|
* 进程数 / RSS 合计 / swap 合计 / CPU 累计(utime+stime, ms)
|
||||||
|
* 2) 系统快照:MemAvailable / swap 已用 / loadavg / 浏览器树总数
|
||||||
|
* 3) OOM 保护:非浏览器进程(且非本采样器自身)写 oom_score_adj=-600,
|
||||||
|
* 让内核 OOM 时优先杀「易杀」的浏览器(发射器已把新浏览器主进程置 200)
|
||||||
|
* 写 data/<label>-stats.jsonl(每轮一行 JSON)。
|
||||||
|
*
|
||||||
|
* 用法:bun sampler.ts --label test1 [--interval 20]
|
||||||
|
*/
|
||||||
|
const fs = require("fs") as typeof import("fs");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }
|
||||||
|
const LABEL = argOf("--label", "test1");
|
||||||
|
const INTERVAL = Number(argOf("--interval", "20")) * 1000;
|
||||||
|
const BASE = new URL(".", import.meta.url).pathname;
|
||||||
|
const OUT = `${BASE}data/${LABEL}-stats.jsonl`;
|
||||||
|
|
||||||
|
/** # 成员变量 本采样器自身进程组(不碰 oom_score_adj) **/
|
||||||
|
const SELF_PIDS = new Set([process.pid, process.ppid ?? 0]);
|
||||||
|
|
||||||
|
/** # 成员变量 每浏览器树快照类型 **/
|
||||||
|
type Tree = { fp: string; procs: number; rssKB: number; pssKB: number; swapKB: number; cpuMs: number; userDataDir: string };
|
||||||
|
|
||||||
|
/** # 函数:一轮 /proc 扫描,聚合浏览器进程树并返回系统快照 **/
|
||||||
|
function scanOnce(): { trees: Map<string, Tree>; availMB: number; swapUsedMB: number; load: number[]; cpuCount: number } {
|
||||||
|
const trees = new Map<string, Tree>();
|
||||||
|
const mySig = `run-stress.ts`; // 自身及其 bash 父链保护
|
||||||
|
const txt = fs.readFileSync("/proc/meminfo", "utf8");
|
||||||
|
const get = (k: string) => Number(new RegExp(`${k}:\\s+(\\d+)`).exec(txt)?.[1] ?? 0);
|
||||||
|
const cpuCount = osCpus();
|
||||||
|
const load = fs.readFileSync("/proc/loadavg", "utf8").split(/\s+/).slice(0, 3).map(Number);
|
||||||
|
|
||||||
|
for (const pid of fs.readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
|
||||||
|
let cmd: string;
|
||||||
|
try { cmd = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " "); } catch { continue; }
|
||||||
|
const udd = /--user-data-dir=(\S+)/.exec(cmd)?.[1] ?? null;
|
||||||
|
if (udd) {
|
||||||
|
// 目录形如 .../data/profiles/<fp>/browser,指纹取「browser」的父目录名
|
||||||
|
const seg = udd.replace(/\/browser\/?$/, "").split("/").pop() ?? udd.split("/").pop() ?? "?";
|
||||||
|
const fp = seg;
|
||||||
|
const st = readStat(pid);
|
||||||
|
const t = trees.get(fp) ?? { fp, procs: 0, rssKB: 0, pssKB: 0, swapKB: 0, cpuMs: 0, userDataDir: udd };
|
||||||
|
t.procs++;
|
||||||
|
t.rssKB += st.rssKB;
|
||||||
|
t.pssKB += st.pssKB;
|
||||||
|
t.swapKB += st.swapKB;
|
||||||
|
t.cpuMs += st.cpuMs;
|
||||||
|
trees.set(fp, t);
|
||||||
|
// 浏览器树整体保持「易杀」(主进程已设 200,子进程继承;这里给漏网进程补救)
|
||||||
|
try { fs.writeFileSync(`/proc/${pid}/oom_score_adj`, "200"); } catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
// 非浏览器进程:OOM 保护(排除自身/内核线程写失败忽略)
|
||||||
|
const p = Number(pid);
|
||||||
|
if (SELF_PIDS.has(p)) continue;
|
||||||
|
if (cmd.includes(mySig) || cmd.includes("sampler.ts") || cmd.includes("run-stress")) continue;
|
||||||
|
try { fs.writeFileSync(`/proc/${pid}/oom_score_adj`, "-600"); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const swapUsedMB = (get("SwapTotal") - get("SwapFree")) / 1024;
|
||||||
|
const availMB = get("MemAvailable") / 1024;
|
||||||
|
return { trees, availMB, swapUsedMB, load, cpuCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:读单进程 RSS/PSS/swap/CPU(kB、ms) **/
|
||||||
|
function readStat(pid: string): { rssKB: number; pssKB: number; swapKB: number; cpuMs: number } {
|
||||||
|
let rssKB = 0, pssKB = 0, swapKB = 0, cpuMs = 0;
|
||||||
|
try {
|
||||||
|
const s = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||||
|
// stat 第 14/15 列为 utime/stime(tick);comm 含空格,取最后一个 ')' 之后按列号回算
|
||||||
|
const tail = s.slice(s.lastIndexOf(")") + 2).split(" ");
|
||||||
|
const utime = Number(tail[11]) || 0, stime = Number(tail[12]) || 0;
|
||||||
|
cpuMs = ((utime + stime) * 1000) / 100; // CLK_TCK=100
|
||||||
|
} catch { /* 进程已消失 */ }
|
||||||
|
try {
|
||||||
|
// smaps_rollup 是内核快速汇总:Pss = 独占内存(去共享重复),Swap 含交换的共享分摊
|
||||||
|
const roll = fs.readFileSync(`/proc/${pid}/smaps_rollup`, "utf8");
|
||||||
|
pssKB = Number(/^Pss:\s+(\d+)/m.exec(roll)?.[1] ?? 0);
|
||||||
|
rssKB = Number(/^Rss:\s+(\d+)/m.exec(roll)?.[1] ?? 0);
|
||||||
|
const swapPss = Number(/^SwapPss:\s+(\d+)/m.exec(roll)?.[1] ?? 0);
|
||||||
|
const swapDirect = Number(/^Swap:\s+(\d+)/m.exec(roll)?.[1] ?? 0);
|
||||||
|
swapKB = swapPss || swapDirect;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { rssKB, pssKB, swapKB, cpuMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数:CPU 核数 **/
|
||||||
|
function osCpus(): number {
|
||||||
|
try { return require("os").cpus().length; } catch { return 8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主循环
|
||||||
|
while (true) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const { trees, availMB, swapUsedMB, load, cpuCount } = scanOnce();
|
||||||
|
const treesArr = [...trees.values()];
|
||||||
|
const line = JSON.stringify({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
browsers: treesArr.length,
|
||||||
|
totalRssMB: +(treesArr.reduce((a, b) => a + b.rssKB, 0) / 1024).toFixed(1),
|
||||||
|
totalPssMB: +(treesArr.reduce((a, b) => a + b.pssKB, 0) / 1024).toFixed(1),
|
||||||
|
totalSwapMB: +(treesArr.reduce((a, b) => a + b.swapKB, 0) / 1024).toFixed(1),
|
||||||
|
totalProcs: treesArr.reduce((a, b) => a + b.procs, 0),
|
||||||
|
availMB: +availMB.toFixed(1), swapUsedMB: +swapUsedMB.toFixed(1),
|
||||||
|
load, cpuCount,
|
||||||
|
detail: treesArr,
|
||||||
|
});
|
||||||
|
try { fs.appendFileSync(OUT, line + "\n"); } catch { /* ignore */ }
|
||||||
|
const wait = Math.max(0, INTERVAL - (Date.now() - t0));
|
||||||
|
await new Promise((r) => setTimeout(r, wait));
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* SERP 24h 监控采样器(在 admin 服务器本机运行,零额外负载:只读管理接口):
|
||||||
|
* nohup bun serp-monitor.ts > data/monitor.log 2>&1 &
|
||||||
|
* 每 60s 采样 /api/serp/stats(hours24)+ /api/serp/pool(浏览器池)+ /api/serp/cache(缓存池),
|
||||||
|
* 追加 JSONL 到 data/serp-monitor.jsonl;每 10 分钟输出一行摘要到 stdout(进 monitor.log)。
|
||||||
|
* 用途:跑一天边测边修的长期证据链(吞吐/错误率/门壳/池水位)。
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
/** # 成员变量 基址与令牌(本机) **/
|
||||||
|
const BASE = "http://127.0.0.1:53100";
|
||||||
|
const TOKEN = process.env.SERP_TOKEN ?? "";
|
||||||
|
if (!TOKEN) { console.error("缺少 SERP_TOKEN"); process.exit(1); }
|
||||||
|
|
||||||
|
/** # 成员变量 输出目录(data/,相对脚本) **/
|
||||||
|
const DATA = new URL("./data/", import.meta.url).pathname;
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const JSONL = `${DATA}serp-monitor.jsonl`;
|
||||||
|
|
||||||
|
/** # 成员变量 上一次快照(用于计算每分钟增量) **/
|
||||||
|
let prev: { total: number; ok: number; failed: number; captcha: number; gate: number; at: number } | null = null;
|
||||||
|
/** # 成员变量 每 10 分钟摘要计数器 **/
|
||||||
|
let sinceSummary = 0;
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 带超时 GET JSON。
|
||||||
|
*/
|
||||||
|
async function getJson(path: string): Promise<any> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, { headers: { authorization: `Bearer ${TOKEN}` }, signal: AbortSignal.timeout(15000) });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} ${path}`);
|
||||||
|
const j = await res.json() as any;
|
||||||
|
return j?.data ?? j;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 单轮采样:统计/池/缓存 → 增量计算 → JSONL 追加 → 10 分钟摘要打印。
|
||||||
|
*/
|
||||||
|
async function sample(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const [stats, pool, cache] = await Promise.all([
|
||||||
|
getJson("/api/serp/stats").catch(() => null),
|
||||||
|
getJson("/api/serp/pool").catch(() => null),
|
||||||
|
getJson("/api/serp/cache").catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!stats) return;
|
||||||
|
const h = stats.hours24 ?? {};
|
||||||
|
const row = {
|
||||||
|
at: new Date(now).toISOString(),
|
||||||
|
h24: { total: h.total ?? 0, ok: h.ok ?? 0, failed: h.failed ?? 0, captcha: h.captcha ?? 0, gate: h.gate ?? 0, avg_ms: h.avg_ms ?? 0, p95_ms: h.p95_ms ?? 0 },
|
||||||
|
delta: null as any,
|
||||||
|
browserPool: null as any,
|
||||||
|
cachePool: null as any,
|
||||||
|
};
|
||||||
|
if (prev && now - prev.at > 30000) {
|
||||||
|
row.delta = {
|
||||||
|
total: (h.total ?? 0) - prev.total,
|
||||||
|
failed: (h.failed ?? 0) - prev.failed,
|
||||||
|
gate: (h.gate ?? 0) - prev.gate,
|
||||||
|
captcha: (h.captcha ?? 0) - prev.captcha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
prev = { total: h.total ?? 0, ok: h.ok ?? 0, failed: h.failed ?? 0, captcha: h.captcha ?? 0, gate: h.gate ?? 0, at: now };
|
||||||
|
if (pool?.totals) row.browserPool = { ...pool.totals, targets: pool.nodes.map((n: any) => `${n.node}:${n.total}/${n.target}`).join(",") };
|
||||||
|
if (cache) row.cachePool = { count: cache.count, maxCache: cache.maxCache };
|
||||||
|
fs.appendFileSync(JSONL, JSON.stringify(row) + "\n");
|
||||||
|
sinceSummary++;
|
||||||
|
if (sinceSummary >= 10) {
|
||||||
|
sinceSummary = 0;
|
||||||
|
const d = row.delta;
|
||||||
|
const errRate = d && d.total > 0 ? +(((d.failed) / d.total) * 100).toFixed(1) : null;
|
||||||
|
console.log(`${row.at} Δ10min total=${d?.total} failed=${d?.failed} gate=${d?.gate} captcha=${d?.captcha} err=${errRate}% avg=${row.h24.avg_ms}ms browserPool=${row.browserPool ? `${row.browserPool.total}(ready${row.browserPool.ready})` : "?"} cache=${row.cachePool?.count}/${row.cachePool?.maxCache}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 主流程:60s 一轮,永久运行(systemd/nohup 托管) **/
|
||||||
|
console.log(`serp-monitor started ${new Date().toISOString()}`);
|
||||||
|
for (;;) {
|
||||||
|
await sample().catch((e) => console.error(`${new Date().toISOString()} sample error: ${String(e).slice(0, 160)}`));
|
||||||
|
await Bun.sleep(60000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* SERP go-fetch 速率-门壳实验发射器(在 admin 服务器本机运行,消除跨网噪声):
|
||||||
|
* SERP_TOKEN=xxx bun serp-rate-test.ts --rate 2 --minutes 5 --label r2
|
||||||
|
* 以指定速率发射 GET /api/serp/html/search?...&runtime=fetch(强制 go-fetch,绕过令牌桶,
|
||||||
|
* 用于测「该速率下 Google 是否发门壳」),逐条记录 HTTP 状态 / x-serp-gate / attempts / 耗时,
|
||||||
|
* 写 data/<label>.jsonl 与 data/<label>-summary.json。
|
||||||
|
* 门壳判定:x-serp-gate:1(admin 全候选门壳兜底);HTTP!=200 记错误码;网络异常单独计。
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
/** # 成员变量 服务基址(同机直连) **/
|
||||||
|
const BASE = "http://127.0.0.1:53100";
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; }
|
||||||
|
const RATE = Number(argOf("--rate", "1"));
|
||||||
|
const MINUTES = Number(argOf("--minutes", "5"));
|
||||||
|
const LABEL = argOf("--label", `r${RATE}`);
|
||||||
|
/** # 成员变量 runtime 覆写:fetch=强制 go-fetch(绕令牌桶,测速率阈值);auto=双模式生产路由 **/
|
||||||
|
const RUNTIME = argOf("--runtime", "fetch");
|
||||||
|
const TOKEN = process.env.SERP_TOKEN ?? "";
|
||||||
|
if (!TOKEN) { console.error("缺少 SERP_TOKEN 环境变量"); process.exit(1); }
|
||||||
|
|
||||||
|
/** # 成员变量 输出目录(data/,相对脚本) **/
|
||||||
|
const DATA = new URL("./data/", import.meta.url).pathname;
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const JSONL = `${DATA}${LABEL}.jsonl`;
|
||||||
|
const SUMMARY = `${DATA}${LABEL}-summary.json`;
|
||||||
|
fs.writeFileSync(JSONL, "");
|
||||||
|
|
||||||
|
/** # 成员变量 长尾查询词表(A×B×C 组合,按序消费避免重复) **/
|
||||||
|
const A = ["best", "top rated", "cheap", "review of", "how to choose a", "buy", "compare", "2026 guide for", "price of", "warranty for"];
|
||||||
|
const B = ["coffee maker", "running shoes", "laptop stand", "rice cooker", "air fryer", "standing desk", "office chair", "mechanical keyboard", "monitor arm", "webcam", "espresso machine", "vacuum cleaner", "electric kettle", "gaming mouse", "noise cancelling headphones", "desk lamp", "water bottle", "yoga mat", "blender", "space heater"];
|
||||||
|
const C = ["for home use", "for small office", "under 100 dollars", "in 2026", "with long warranty", "for beginners", "pro model", "on sale", "quiet operation", "energy efficient"];
|
||||||
|
const combos: string[] = [];
|
||||||
|
for (const a of A) for (const b of B) for (const c of C) combos.push(`${a} ${b} ${c}`);
|
||||||
|
/** # 成员变量 洗牌后的查询队列指针 **/
|
||||||
|
let qi = 0;
|
||||||
|
for (let i = combos.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [combos[i], combos[j]] = [combos[j], combos[i]]; }
|
||||||
|
function nextQuery(): string { const q = combos[qi % combos.length]; qi++; return q; }
|
||||||
|
|
||||||
|
/** # 成员变量 统计桶 **/
|
||||||
|
const stat = { sent: 0, ok200: 0, gate: 0, httpErr: 0, netErr: 0, inflight: 0, done: 0 };
|
||||||
|
const totalMsArr: number[] = [];
|
||||||
|
const clientMsArr: number[] = [];
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 单次记录:追加 JSONL 与统计。
|
||||||
|
*/
|
||||||
|
function rec(o: Record<string, unknown>): void {
|
||||||
|
fs.appendFileSync(JSONL, JSON.stringify(o) + "\n");
|
||||||
|
stat.done++;
|
||||||
|
if (typeof o.totalMs === "number" && o.totalMs > 0) totalMsArr.push(o.totalMs);
|
||||||
|
if (typeof o.ms === "number") clientMsArr.push(o.ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 发射单次搜索(runtime=fetch 强制 go-fetch)。60s 客户端超时;失败分类:HTTP!=200=错误,
|
||||||
|
* x-serp-gate:1=门壳兜底,catch=网络异常。
|
||||||
|
*/
|
||||||
|
async function fire(): Promise<void> {
|
||||||
|
const q = nextQuery();
|
||||||
|
const url = `${BASE}/api/serp/html/search?q=${encodeURIComponent(q)}&num=10${RUNTIME ? `&runtime=${RUNTIME}` : ""}`;
|
||||||
|
const t = Date.now();
|
||||||
|
stat.sent++; stat.inflight++;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { authorization: `Bearer ${TOKEN}` }, signal: AbortSignal.timeout(60000) });
|
||||||
|
const gate = res.headers.get("x-serp-gate");
|
||||||
|
const attempts = res.headers.get("x-serp-attempts");
|
||||||
|
const totalMs = Number(res.headers.get("x-serp-total-ms") ?? 0);
|
||||||
|
const nodeMs = Number(res.headers.get("x-serp-node-ms") ?? 0);
|
||||||
|
const profile = res.headers.get("x-serp-profile") ?? "";
|
||||||
|
const node = res.headers.get("x-serp-node") ?? "";
|
||||||
|
let code = ""; let msg = "";
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => null) as any;
|
||||||
|
code = String(j?.code ?? ""); msg = String(j?.message ?? "").slice(0, 120);
|
||||||
|
}
|
||||||
|
const kind = !res.ok ? "httpErr" : gate === "1" ? "gate" : "ok200";
|
||||||
|
stat[kind]++;
|
||||||
|
rec({ q, status: res.status, kind, gate: gate === "1", attempts, totalMs, nodeMs, profile, node, code, msg, ms: Date.now() - t });
|
||||||
|
} catch (e) {
|
||||||
|
stat.netErr++;
|
||||||
|
rec({ q, status: 0, kind: "netErr", error: String(e).slice(0, 160), ms: Date.now() - t });
|
||||||
|
} finally {
|
||||||
|
stat.inflight--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(arr: number[], p: number): number {
|
||||||
|
if (arr.length === 0) return 0;
|
||||||
|
const s = [...arr].sort((a, b) => a - b);
|
||||||
|
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 主流程:恒定速率发射(每 tick 发 1 发),结束后等在途清空并写摘要 **/
|
||||||
|
console.log(`开始:rate=${RATE}/s minutes=${MINUTES} label=${LABEL}`);
|
||||||
|
const interval = 1000 / RATE;
|
||||||
|
const endAt = Date.now() + MINUTES * 60_000;
|
||||||
|
const MAX_INFLIGHT = Math.max(30, Math.ceil(RATE) * 20);
|
||||||
|
while (Date.now() < endAt) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
if (stat.inflight < MAX_INFLIGHT) void fire();
|
||||||
|
const wait = interval - (Date.now() - t0);
|
||||||
|
if (wait > 0) await Bun.sleep(wait);
|
||||||
|
}
|
||||||
|
const drainDeadline = Date.now() + 70000;
|
||||||
|
while (stat.inflight > 0 && Date.now() < drainDeadline) await Bun.sleep(500);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
label: LABEL, rate: RATE, minutes: MINUTES,
|
||||||
|
sent: stat.sent, ok200: stat.ok200, gate: stat.gate, httpErr: stat.httpErr, netErr: stat.netErr,
|
||||||
|
gateRate: stat.done ? +(stat.gate / stat.done * 100).toFixed(2) : 0,
|
||||||
|
errRate: stat.done ? +((stat.httpErr + stat.netErr) / stat.done * 100).toFixed(2) : 0,
|
||||||
|
totalMs_p50: pct(totalMsArr, 50), totalMs_p95: pct(totalMsArr, 95),
|
||||||
|
clientMs_p50: pct(clientMsArr, 50), clientMs_p95: pct(clientMsArr, 95),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(SUMMARY, JSON.stringify(summary, null, 2));
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/** # 脚本说明
|
||||||
|
* 压测清场:把某一轮发射器开过的浏览器全部停止。
|
||||||
|
* 1) 读 data/<label>-launched.json 的每条记录(--scan 模式则改从节点列表取全部 IN_USE Profile)
|
||||||
|
* 2) 逐个 GET /profiles/:fp 拿当前 leaseId → POST /profiles/:fp/browser/stop(x-lease-id;
|
||||||
|
* 显式停止走 closing 路径,不会触发「UI 误关自动复活」)
|
||||||
|
* 3) 兜底:对仍存的 user-data-dir 进程 SIGKILL(等待复活限频窗口过期,前后共两轮)
|
||||||
|
* 用法:bun stop-browsers.ts --label test1 [--scan]
|
||||||
|
*/
|
||||||
|
const fs = require("fs") as typeof import("fs");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function argOf(k: string, d: string): string { const i = args.indexOf(k); return i >= 0 && i + 1 < args.length ? args[i + 1] : d; }
|
||||||
|
const LABEL = argOf("--label", "test1");
|
||||||
|
const SCAN = args.includes("--scan");
|
||||||
|
const NODE = "http://127.0.0.1:3106";
|
||||||
|
const DATA = `${new URL(".", import.meta.url).pathname}data`;
|
||||||
|
|
||||||
|
/** # 函数:docker exec 查 admin DB 单值(取 Token 用) **/
|
||||||
|
function sqlOne(sql: string): string {
|
||||||
|
const p = Bun.spawnSync(["docker", "exec", "serpmax-pg", "psql", "-U", "postgres", "-d", "serpmax_admin", "-tAc", sql], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const out = p.stdout.toString().trim();
|
||||||
|
if (p.exitCode !== 0 || !out) throw new Error(`SQL 失败`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const TOKEN = sqlOne("SELECT api_token FROM users WHERE id=2");
|
||||||
|
const NODE_KEY = /^NODE_KEY=(.*)$/m.exec(fs.readFileSync("/opt/serpmax/node/.env", "utf8"))?.[1]?.trim() ?? "";
|
||||||
|
const LEASE_TTL_MS = 120000;
|
||||||
|
|
||||||
|
const headersBase: Record<string, string> = { "content-type": "application/json", authorization: `Bearer ${TOKEN}`, "x-node-key": NODE_KEY };
|
||||||
|
|
||||||
|
/** # 成员变量 待停清单:label 文件或节点扫描(IN_USE/有活动 lease 的 Profile) **/
|
||||||
|
let targets: { fp: string }[] = [];
|
||||||
|
if (SCAN) {
|
||||||
|
const res = await fetch(`${NODE}/profiles`, { headers: headersBase, signal: AbortSignal.timeout(60000) });
|
||||||
|
const j: any = await res.json();
|
||||||
|
const rows: any[] = j?.data?.profiles ?? j?.profiles ?? [];
|
||||||
|
targets = rows.filter((r) => {
|
||||||
|
const lease = r?.lease ?? null;
|
||||||
|
return (lease && new Date(lease.expiresAt).getTime() > Date.now()) || ["IN_USE"].includes(r?.state);
|
||||||
|
}).map((r) => ({ fp: String(r.id) }));
|
||||||
|
console.log(`[stop] 扫描到 ${targets.length} 个在用 Profile`);
|
||||||
|
} else {
|
||||||
|
let list: { fp: string }[] = [];
|
||||||
|
try { list = JSON.parse(await Bun.file(`${DATA}/${LABEL}-launched.json`).text() || "[]"); } catch { list = []; }
|
||||||
|
targets = list.map((e: any) => ({ fp: String(e.fp) }));
|
||||||
|
}
|
||||||
|
console.log(`[stop] 目标 ${targets.length} 个`);
|
||||||
|
|
||||||
|
/** # 函数:显式停止一个 Profile 的浏览器(先拿当前 leaseId,过期则补一个短 lease) **/
|
||||||
|
async function stopOne(fp: string): Promise<void> {
|
||||||
|
const headers = { ...headersBase } as Record<string, string>;
|
||||||
|
try {
|
||||||
|
const d = await fetch(`${NODE}/profiles/${fp}`, { headers: headersBase, signal: AbortSignal.timeout(10000) });
|
||||||
|
const j: any = await d.json();
|
||||||
|
const lease = j?.data?.lease ?? j?.lease;
|
||||||
|
if (lease?.leaseId && new Date(lease.expiresAt).getTime() > Date.now()) headers["x-lease-id"] = lease.leaseId;
|
||||||
|
} catch { /* detail 拿不到就直接停 */ }
|
||||||
|
if (!headers["x-lease-id"]) {
|
||||||
|
const r = await fetch(`${NODE}/profiles/${fp}/lease`, { method: "POST", headers: headersBase, body: JSON.stringify({ consumerId: "stress-stop", ttlMs: LEASE_TTL_MS }), signal: AbortSignal.timeout(10000) });
|
||||||
|
if (r.ok) { const j2: any = await r.json(); headers["x-lease-id"] = (j2?.data ?? j2)?.leaseId; }
|
||||||
|
}
|
||||||
|
await fetch(`${NODE}/profiles/${fp}/browser/stop`, { method: "POST", headers, body: "{}", signal: AbortSignal.timeout(30000) }).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = 10;
|
||||||
|
let cursor = 0;
|
||||||
|
let stopped = 0;
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (cursor < targets.length) {
|
||||||
|
const e = targets[cursor++];
|
||||||
|
await stopOne(e.fp);
|
||||||
|
stopped++;
|
||||||
|
if (stopped % 25 === 0) console.log(`[stop] ${stopped}/${targets.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||||
|
console.log(`[stop] API 显式停完 ${stopped}`);
|
||||||
|
|
||||||
|
/** # 函数:兜底统计存活压测浏览器进程数 **/
|
||||||
|
function countLeft(): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const pid of fs.readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
|
||||||
|
try { if (fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes("--user-data-dir=")) n++; } catch { /* 已退出 */ }
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
for (const round of [1, 2]) {
|
||||||
|
const left = countLeft();
|
||||||
|
console.log(`[stop] round${round} 残存进程 ${left}`);
|
||||||
|
if (left === 0) break;
|
||||||
|
for (const pid of fs.readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
|
||||||
|
try {
|
||||||
|
if (fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes("--user-data-dir=")) process.kill(Number(pid), "SIGKILL");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 8000));
|
||||||
|
}
|
||||||
|
console.log(`[stop] done`);
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/** # 保活脚本状态机测试(不走浏览器;模拟 ctx.page/gTask/adminFetch)
|
||||||
|
* 复刻节点 script.run 的管线:包函数 → Transpiler → new Function 实例化。
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const src = readFileSync(new URL("./admin/scripts/keepalive-flow.ts", import.meta.url), "utf8");
|
||||||
|
const wrapped = `async function __task__(ctx) {\n${src}\n}`;
|
||||||
|
const js = new Bun.Transpiler({ loader: "ts" }).transformSync(wrapped);
|
||||||
|
const factory = new Function(`${js}\nreturn __task__;`)() as (ctx: any) => Promise<unknown>;
|
||||||
|
|
||||||
|
/** # 函数 模拟页面(导航 URL 由脚本自己 goto,我们记录每次 goto 的 url) */
|
||||||
|
function mockPage(urlsOnGoto: (url: string) => string) {
|
||||||
|
let url = "about:blank";
|
||||||
|
return {
|
||||||
|
url: () => url,
|
||||||
|
viewportSize: async () => ({ width: 1280, height: 720 }),
|
||||||
|
goto: async (u: string) => { url = urlsOnGoto(u); return { url }; },
|
||||||
|
waitForLoadState: async () => { },
|
||||||
|
evaluate: async (code: string) => {
|
||||||
|
if (code.includes("sorry")) return '{"sorry":false,"captchaForm":false,"unusual":false}';
|
||||||
|
if (code.includes("closest")) return null;
|
||||||
|
if (code.includes("getBoundingClientRect")) return null;
|
||||||
|
return '{"host":"www.google.com","act":""}';
|
||||||
|
},
|
||||||
|
$$eval: async () => [],
|
||||||
|
locator: (_sel: string) => ({ first: () => ({ count: async () => 0, isVisible: async () => false, click: async () => { }, fill: async () => { } }) }),
|
||||||
|
keyboard: { type: async () => { }, press: async () => { } },
|
||||||
|
mouse: { move: async () => { }, down: async () => { }, up: async () => { }, wheel: async () => { } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 构造一组可被任意改的假数据页 */
|
||||||
|
function makeCtx(opts: {
|
||||||
|
data?: any;
|
||||||
|
captchaOn?: boolean;
|
||||||
|
adminOk?: boolean;
|
||||||
|
reloadExprOn?: boolean;
|
||||||
|
}) {
|
||||||
|
return makeCtxArg(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtxArg(opts: { data?: any; captchaOn?: boolean; adminOk?: boolean }) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let cur = JSON.parse(JSON.stringify(opts.data ?? {}));
|
||||||
|
const page = mockPage((u: string) => (u.startsWith("https://www.google.com/search") && opts.captchaOn ? "https://www.google.com/sorry/index?id=xx" : u.startsWith("https://www.google") ? "https://www.google.com/" : u));
|
||||||
|
const page2: any = page;
|
||||||
|
page2.evaluate = async (code: string) => {
|
||||||
|
if (code.includes("unusual")) return opts.captchaOn ? '{"sorry":true,"captchaForm":true,"unusual":true}' : '{"sorry":false,"captchaForm":false,"unusual":false}';
|
||||||
|
if (code.includes("closest")) return null;
|
||||||
|
return '{"host":"www.google.com","act":""}';
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
profileId: "21111111",
|
||||||
|
page: page2,
|
||||||
|
sleep: async (ms: number) => { /* 不真的睡,秒进 */ },
|
||||||
|
log: (...a: any[]) => calls.push("L " + a.join(" ")),
|
||||||
|
getTaskState: async () => ({ exists: !!Object.keys(cur).length, state: null, data: cur }),
|
||||||
|
setTaskState: async (state: string, data?: any) => {
|
||||||
|
cur = { ...cur, ...(data ?? {}) };
|
||||||
|
calls.push(`S ${state} next=${cur.nextCheckAt ?? "-"}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
adminFetch: async (path: string, init?: any) => {
|
||||||
|
if (opts.adminOk === false) return null;
|
||||||
|
if (path === `/api/profiles/${ctx.profileId}` && init?.method === "GET") return { ok: true, status: 200, json: { data: { name: "keepalive-8655-US", browserKind: "veil", browserTheme: "light", node: "OVH-FR", tags: ["keepalive"], notes: "ka slot=8655", proxy: { id: "68b1...uuid" }, browser: { locale: "en-US", timezone: "America/New_York" } } } };
|
||||||
|
if (path === "/api/profiles" && init?.method === "POST") return { ok: true, status: 201, json: { data: { id: "99999999" } } };
|
||||||
|
if (path === `/api/profiles/${ctx.profileId}` && init?.method === "DELETE") return { ok: true, status: 200, json: { data: null } };
|
||||||
|
return { ok: false, status: 404, json: null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.setTaskState = async (state: string, data?: any) => {
|
||||||
|
cur = { ...cur, ...(data ?? {}) };
|
||||||
|
calls.push(`S ${state} next=${cur.nextCheckAt ?? "-"}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
(ctx as any).calls = calls;
|
||||||
|
(ctx as any).cur = cur;
|
||||||
|
return ctx as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 用例 1:全新档案 → v1 → 等待-v2 */
|
||||||
|
{
|
||||||
|
const c = makeCtxArg({ data: {} });
|
||||||
|
const r = await factory(c);
|
||||||
|
const r1 = await factory(c);
|
||||||
|
console.log("case1 fresh:", r, "| 再跑:", r1);
|
||||||
|
console.log(" 状态轨迹:", c.calls.filter(s => s.startsWith("S ")).join(" / "));
|
||||||
|
}
|
||||||
|
/** # 用例 2:v1 已达 2h → v2 完成 */
|
||||||
|
{
|
||||||
|
const c = makeCtxArg({ data: { v1At: new Date(Date.now() - 2.2 * 3600e3).toISOString() } });
|
||||||
|
const r = await factory(c);
|
||||||
|
const r2 = await factory(c); // v2 刚完成,2h 未到
|
||||||
|
console.log("case2 v2:", r, "| 紧接:", r2);
|
||||||
|
}
|
||||||
|
/** # 用例 3:s1 全链(x/amazon/google)完成后 s2 立即跑,次日收敛 */
|
||||||
|
{
|
||||||
|
const c = makeCtxArg({ data: { v1At: new Date(Date.now() - 5 * 3600e3).toISOString(), v2At: new Date(Date.now() - 2.1 * 3600e3).toISOString(), s1At: undefined } });
|
||||||
|
// 修正:s1At 未定,先跑 s1
|
||||||
|
c.cur.s1At = undefined;
|
||||||
|
const r = await factory(c);
|
||||||
|
const r2 = await factory(c); // s2
|
||||||
|
const r3 = await factory(c); // 当日再跑 → idle-today
|
||||||
|
console.log("case3 s1:", r, "| s2:", r2, "| 同日再跑:", r3);
|
||||||
|
}
|
||||||
|
/** # 用例 4:s2 命中 CAPTCHA → 放行删建路径(admin 正常) */
|
||||||
|
{
|
||||||
|
const c = makeCtxArg({ data: { v1At: new Date(Date.now() - 3 * 3600e3).toISOString(), v2At: new Date(Date.now() - 2.2 * 3600e3).toISOString() }, captchaOn: true });
|
||||||
|
// 没写 s1At:会走 s1(cap 命中即重建)
|
||||||
|
c.cur.s1At = new Date(Date.now() - 3600e3).toISOString();
|
||||||
|
const r = await factory(c);
|
||||||
|
console.log("case4 captcha:", r, "| 状态尾:", c.calls.filter(s => s.startsWith("S ")).slice(-2).join(" / "));
|
||||||
|
}
|
||||||
|
/** # 用例 5:admin 关闭 → CAPTCHA 挂起 30 分钟 */
|
||||||
|
{
|
||||||
|
const c = makeCtxArg({ data: { v1At: new Date(Date.now() - 3 * 3600e3).toISOString(), v2At: new Date(Date.now() - 2.2 * 3600e3).toISOString(), s1At: new Date(Date.now() - 3600e3).toISOString() }, captchaOn: true, adminOk: false });
|
||||||
|
const r = await factory(c);
|
||||||
|
console.log("case5 admin-null:", r);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user