From 05d946241427cf586ba208d484dd28ada9d33ea6 Mon Sep 17 00:00:00 2001 From: BigCode Date: Thu, 29 Oct 2026 15:54:37 +0800 Subject: [PATCH] =?UTF-8?q?1.77.6:=20SERP=20sorry-=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E6=8C=89=E9=AA=8C=E8=AF=81=E7=A0=81=E8=AF=AD=E4=B9=89?= =?UTF-8?q?(=E4=B8=8D=E6=8D=A2=E5=8F=B7+15min=E5=86=B7=E5=8D=B4);=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E6=B1=A0=E4=BF=9D=E6=B8=A9=E9=87=8A=E6=94=BE=E8=B7=B3?= =?UTF-8?q?=E8=BF=87+60min=E6=8C=81=E6=9C=89=E4=B8=8A=E9=99=90;=E9=80=9F?= =?UTF-8?q?=E7=8E=87-=E9=97=A8=E5=A3=B3=E5=AE=9E=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + VeilBrowser.svg | 28 + admin | 1 + docs/01_CRIU_BROWSER_SNAPSHOT.md | 483 +++++++++++ docs/API.md | 929 ++++++++++++++++++++++ docs/Publishing-功能扩展开发说明.md | 130 +++ docs/extension-bind-mount-minimal.md | 489 ++++++++++++ docs/extension-install-events-findings.md | 311 ++++++++ docs/google-v6.txt | 86 ++ docs/审计-功能清单与技术栈.md | 113 +++ node | 1 + skills/profilemax-publishing/SKILL.md | 98 +++ stress/REPORT.md | 74 ++ stress/cleanup-profiles.ts | 63 ++ stress/create-profiles.ts | 84 ++ stress/debug-dead.ts | 16 + stress/node-wipe-stress.ts | 35 + stress/randomize-themes.ts | 89 +++ stress/report.ts | 125 +++ stress/run-stress.ts | 283 +++++++ stress/sampler.ts | 113 +++ stress/serp-monitor.ts | 80 ++ stress/serp-rate-test.ts | 119 +++ stress/stop-browsers.ts | 98 +++ tests-ka-flow.ts | 119 +++ 25 files changed, 3973 insertions(+) create mode 100644 .gitignore create mode 100644 VeilBrowser.svg create mode 160000 admin create mode 100644 docs/01_CRIU_BROWSER_SNAPSHOT.md create mode 100644 docs/API.md create mode 100644 docs/Publishing-功能扩展开发说明.md create mode 100644 docs/extension-bind-mount-minimal.md create mode 100644 docs/extension-install-events-findings.md create mode 100644 docs/google-v6.txt create mode 100644 docs/审计-功能清单与技术栈.md create mode 160000 node create mode 100644 skills/profilemax-publishing/SKILL.md create mode 100644 stress/REPORT.md create mode 100644 stress/cleanup-profiles.ts create mode 100644 stress/create-profiles.ts create mode 100644 stress/debug-dead.ts create mode 100644 stress/node-wipe-stress.ts create mode 100644 stress/randomize-themes.ts create mode 100644 stress/report.ts create mode 100644 stress/run-stress.ts create mode 100644 stress/sampler.ts create mode 100644 stress/serp-monitor.ts create mode 100644 stress/serp-rate-test.ts create mode 100644 stress/stop-browsers.ts create mode 100644 tests-ka-flow.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f85d2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/ +*.log +dist/ +bundles/ +.env diff --git a/VeilBrowser.svg b/VeilBrowser.svg new file mode 100644 index 0000000..98b27c9 --- /dev/null +++ b/VeilBrowser.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin b/admin new file mode 160000 index 0000000..1c6a5d8 --- /dev/null +++ b/admin @@ -0,0 +1 @@ +Subproject commit 1c6a5d891a9953e4d737d590b2482342328d840d diff --git a/docs/01_CRIU_BROWSER_SNAPSHOT.md b/docs/01_CRIU_BROWSER_SNAPSHOT.md new file mode 100644 index 0000000..1970bcd --- /dev/null +++ b/docs/01_CRIU_BROWSER_SNAPSHOT.md @@ -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/ + / + 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; + + restore(snapshotDir: string): Promise; +} +``` + +这些接口是设计草案,不代表项目中已经存在对应模块。需要先查看 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: +- CRIU 官方文档: +- CRIU 使用手册: +- Podman checkpoint/restore(用于参考容器集成方式): + +## 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 三开关同行; +- 快照目录:`//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 耗时随当时浏览器内存占用浮动。 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..ec2e68c --- /dev/null +++ b/docs/API.md @@ -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 `) | +| `GET /node/install`、`GET /node/package` | query 中的节点密钥 `?key=` | +| `GET /node/version` | 公开 | + +### Node API + +- 配置了环境变量 `NODE_KEY` 后,所有路由要求携带: + - `x-node-key: `,或 + - `Authorization: Bearer ` +- 未配置 `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": "...", + "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:///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:` 短期接管(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:`,批次期间持有,完成即释放)。 +- 每个动作的进度经 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": "...", "truncated": false, + "timingMs": 900, "runtime": "chromium", + "captcha": { "type": "recaptcha", "kind": "...", "website": "...", "url": "..." } } +``` + +- Lease:已持有则复用;否则临时获取(`admin:browse:`),结束释放,并新开页渲染后关闭。 +- 检出验证码时发布 `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":"","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: " -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/ -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:///profiles/<指纹>/vnc/ws?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`。 diff --git a/docs/Publishing-功能扩展开发说明.md b/docs/Publishing-功能扩展开发说明.md new file mode 100644 index 0000000..884ee19 --- /dev/null +++ b/docs/Publishing-功能扩展开发说明.md @@ -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 开发原则:先检查现有源码/接口与表结构,再最小增量改造;禁止重复造已有模块。本文是实施目标,不代表接口或表已经存在。** diff --git a/docs/extension-bind-mount-minimal.md b/docs/extension-bind-mount-minimal.md new file mode 100644 index 0000000..213453f --- /dev/null +++ b/docs/extension-bind-mount-minimal.md @@ -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/// + | + +---- bind mount ---- Profile 001/UserData/Default/Extensions// + +---- bind mount ---- Profile 002/UserData/Default/Extensions// + +---- bind mount ---- Profile 003/UserData/Default/Extensions// +``` + +每个 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//1.0.0/ +extensions//1.1.0/ +``` + +不要让 Browser 运行期间直接修改: + +```text +extensions//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。 diff --git a/docs/extension-install-events-findings.md b/docs/extension-install-events-findings.md new file mode 100644 index 0000000..28e66f1 --- /dev/null +++ b/docs/extension-install-events-findings.md @@ -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//`,启动不带任何扩展参数 | 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//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 + +这是本轮最关键的一条,补充记录成方法论: + +**能装上的写法**(位置 `/External Extensions/external_extensions.json`): + +```json +{ "<扩展ID>": { "external_crx": "/abs/path/probe.crx", "external_version": "1.0.0" } } +``` + +三个坑,每个都对应一次失败: + +1. **键名不是文档里的那套。** 顶层必须是 `{"": {...}}` 字典(不是 + `{"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= --pack-extension-key=` 生成。 +3. **CRX 的 manifest 里不能留 `key` 字段**(或必须与签名公钥一致),否则 ID 对不上。 + +装上之后的实测: + +``` +location = 2 (EXTERNAL_DOWNLOAD) +path = /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/`)。 + +## 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//` 一份,各 Profile 共享; +- `USER_DATA/Default/Extensions` 不存在,没有重复副本; +- 运行态数据:每 Profile 独立在 `Default/Local Extension Settings/`。 + +也就是说,**改 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 内部**(`/External Extensions/` +目录)后,install 天然只在首次触发(同 Profile 连开 3 次 installs = 1→1→1, +boots = 1→2→3,SW 每次正常唤醒)。此 build 的外部安装不落 +`Default/Extensions//`(始终为空),扩展直接从 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= --pack-extension-key=` +- **更新语义**(每轮全新 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 数据跨更新保留。 diff --git a/docs/google-v6.txt b/docs/google-v6.txt new file mode 100644 index 0000000..d1f92aa --- /dev/null +++ b/docs/google-v6.txt @@ -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 diff --git a/docs/审计-功能清单与技术栈.md b/docs/审计-功能清单与技术栈.md new file mode 100644 index 0000000..6396c9b --- /dev/null +++ b/docs/审计-功能清单与技术栈.md @@ -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 `(`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 管线)。 diff --git a/node b/node new file mode 160000 index 0000000..32f06e0 --- /dev/null +++ b/node @@ -0,0 +1 @@ +Subproject commit 32f06e09f0c60aeae97d55fa6443067780cbfb22 diff --git a/skills/profilemax-publishing/SKILL.md b/skills/profilemax-publishing/SKILL.md new file mode 100644 index 0000000..3cf82af --- /dev/null +++ b/skills/profilemax-publishing/SKILL.md @@ -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 直接操作数据库;执行、重试、幂等全部走上述接口。 diff --git a/stress/REPORT.md b/stress/REPORT.md new file mode 100644 index 0000000..1b519f2 --- /dev/null +++ b/stress/REPORT.md @@ -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 diff --git a/stress/cleanup-profiles.ts b/stress/cleanup-profiles.ts new file mode 100644 index 0000000..fdf705a --- /dev/null +++ b/stress/cleanup-profiles.ts @@ -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 { + 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()}`); diff --git a/stress/create-profiles.ts b/stress/create-profiles.ts new file mode 100644 index 0000000..cb035e9 --- /dev/null +++ b/stress/create-profiles.ts @@ -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`); diff --git a/stress/debug-dead.ts b/stress/debug-dead.ts new file mode 100644 index 0000000..99066b9 --- /dev/null +++ b/stress/debug-dead.ts @@ -0,0 +1,16 @@ +/** # 调试脚本:复算杀伤误判(服务器上一次性使用) **/ +const fs = require("fs"); +const launched = JSON.parse(await Bun.file("data/test1-launched.json").text()); +const alive = new Set(); +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); diff --git a/stress/node-wipe-stress.ts b/stress/node-wipe-stress.ts new file mode 100644 index 0000000..28aaee4 --- /dev/null +++ b/stress/node-wipe-stress.ts @@ -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 { + 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}`); diff --git a/stress/randomize-themes.ts b/stress/randomize-themes.ts new file mode 100644 index 0000000..78253b9 --- /dev/null +++ b/stress/randomize-themes.ts @@ -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 { + // 节点寻址与密钥(name → base_url / key) + const nodes = await sql`SELECT name, base_url, key FROM nodes`; + const nodeById = new Map(); + 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(); + 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 { + 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(); diff --git a/stress/report.ts b/stress/report.ts new file mode 100644 index 0000000..efeb7bc --- /dev/null +++ b/stress/report.ts @@ -0,0 +1,125 @@ +/** # 脚本说明 + * 压测数据汇总(服务器上运行): + * 输入 data/