<!-- Source: https://deepseek-browser-use.io.nexus/14 -->

# 源码教程：从 HTTP 请求到命令执行

本系列以当前 `deepseek-browser-use` 工程为准，按功能解释全部 122 个注册命令，以及额外的 `commands` 批量入口。先阅读本章理解公共执行链，再按章节进入具体实现。文中的源码路径均相对于浏览器项目根目录；示例中的 ID、域名及资料均为占位信息。

## 1. 建立源码地图

| 文件或类 | 职责 |
| --- | --- |
| `playwright-server/src/main/java/nexus/io/ai/browser/handler/PlaywrightHandler.java` | 接收 POST，解析 JSON，调用执行层，格式化响应并记录追踪 |
| `service/ActionService.java` | 单条和批量执行的公共流程、安全重试、截图附加、异步作业 |
| `actions/registry/CommandTable.java` | 注册命令名，读取参数，调用对应 Java 方法 |
| `service/PlaywrightService.java` | 操作任务、Page、Frame、Locator 与浏览器上下文 |
| `service/BrowserInstance.java` | 每个任务的当前页、DOM 快照、网络记录、弹窗等状态 |
| `dom/service/DomService.java` | 注入 DOM 采集脚本，构造带 frame 信息的索引 |
| `handler/ResponseFormatter.java` | 输出精简响应，保留业务结果 |
| `handler/CommandTraceLog.java` | 记录命令追踪；规则脱敏不等于所有留档都脱敏 |

表中未写完整前缀的 Java 文件，均在 `playwright-server/src/main/java/nexus/io/ai/browser/` 下。配置通过 `EnvUtils.get` 等框架配置入口读取；不要在扩展实现中直接读取系统环境变量。新增业务标识可用 `nexus.io.tio.utils.snowflake.SnowflakeIdUtils.id()` 生成。

## 2. 一条命令经过哪些层

```text
POST /playwright/command
  → PlaywrightHandler.dispatch：方法与 JSON 校验
  → ActionService.execute：区分 commands 与单条命令
  → CommandTable：读取必填/可选参数，选定执行器
  → PlaywrightService：查找任务，定位对象，执行浏览器操作
  → ActionService：处理可安全重试的异常、附加截图
  → ResponseFormatter：按请求裁剪协议字段
  → CommandTraceLog：追踪记录
  → HttpResponse
```

所有控制命令使用同一个入口，请求形状保持一致：

```json
{"id":"1001","method":"get_title","params":{}}
```

`id` 由 JSON 转为 Long，支持数字字符串；非法数字、空请求、非对象 JSON、缺失 method 都在入口返回失败。`params` 可以省略，由执行层处理。`start` 可省略 ID，其他方法是否依赖已有实例还要看该方法的实现，不能根据 Java 参数存在就推断必需浏览器页签。

## 3. 注册表如何连接参数与 Java 方法

以下是真实注册方式的简化展示：

```java
put("get_element_attribute", (svc, id, args) ->
    svc.getElementAttribute(id, reqInt(args, "index"), reqStr(args, "name")));
```

`reqInt`、`reqStr` 等负责必填参数；`optStr` 和 JSON 对象的可空 getter 保留“未传”状态，让服务方法决定默认值。不要把可选 Boolean 全部提前变成 false，例如截图、无头启动、停止策略各有自己的默认行为。

本系列各章末尾的“注册参数与入口”来自这张表：`*` 表示注册层要求该字段；“可选”不等于任何组合都有效，例如 `index` 与 `selector` 经常至少需要一个。方法内部仍会检查业务组合、范围和运行环境。

## 4. 返回值与错误不能只看一个字段

成功外层通常为 `ok=true`、`code=1`，业务内容在 `data`。失败原因在 `msg`，部分命令附有 `errorCode`、`retryable` 或诊断信息。不同方法的 data 结构不同，不应统一强转为某一种固定业务对象。

### 「没开始执行」的回执：三个来源，同一个判断方式

`data.started=false` + `data.retryable=true` 表示**这条命令根本没有执行**，重发是安全的。它有三个来源，回执里能区分：

| 回执标记 | 含义 |
| --- | --- |
| `data.serialized=true` | 同一个任务上已有命令在跑。同任务的命令是**串行**执行的（Playwright 的 Connection 不能被并发驱动），第二条最多等 5 秒。长批量进行中轮询命令排在它后面属正常现象 |
| 池与队列都满 | 服务端并发已满。命令池队列是**有界**的，过载会明确回一条「未执行」，而不是让调用方一直干等 |
| 超时但 `started=false` | 在队列里等到过期还没轮到 |

反过来，`started=true` 表示**已经开始执行**，此时一律 `retryable=false`：命令可能已经生效，先读页面状态再决定，别直接重发。

### 参数错与动作错是两回事

`INVALID_ARGUMENT` 表示命令表的参数校验没过（缺参、类型不对），命令**没有执行**，按提示改正参数重发即可。
它与 `ACTION_FAILED`（站点/引擎拒绝了这次动作）分开，因为两者要采取的行动完全不同：前者改命令，后者换思路。

动作执行与业务成功分开：按钮被点击、DOM 变化、请求已发送，都不能替代“审核已提交”或“订单已支付”。观察代码会比较动作前后的 URL、页签、文本、表单状态及 DOM 指纹，并短暂等待异步变化。`observationComplete=false` 时，探针没有取得充分证据，不要依据 `changed=false` 重复提交。

对象释放异常可能由事件泵在操作后投递。`ActionService` 只对安全名单中的只读命令进行有限重试；点击、提交等动作**默认一次都不重发**。

同一套重发名单还覆盖另一个瞬时状态：**页面正在导航/重建**（主 frame 为 null）时，只读命令也会被自动重试，回执里标 `pageNavigatingRetry`；动作类命令在这种情况下既不自动重发、也不会被归成可重试，见 [页面正在导航与主 Frame 缺失](<https://deepseek-browser-use.io.nexus/36>)。任何命令都可以由调用方按调用点声明 `retryOnSpurious:true` 来放行这类重试（`execute_js` 一开始就是这么设计的，现在这个口子对所有命令开放）：伪故障里有很大一支是「命令还没发出去就撞上了噪声」，那种情况重发无害，而这一次到底是不是写操作只有调用方清楚。详见[防重复提交](<https://deepseek-browser-use.io.nexus/08>)。

## 5. 学习顺序与扩展方法

1. [生命周期、导航和页签](<https://deepseek-browser-use.io.nexus/18>)：先理解任务和共享浏览器。
2. [DOM、页面状态与元素读取](<https://deepseek-browser-use.io.nexus/19>)：理解索引来自哪里。
3. [点击、输入、键盘和鼠标](<https://deepseek-browser-use.io.nexus/20>)：把索引转为实际交互。
4. [等待与 JavaScript](<https://deepseek-browser-use.io.nexus/21>)：处理异步页面。
5. [上传、截图、PDF 与 OCR](<https://deepseek-browser-use.io.nexus/22>)：处理文件和图像。
6. [Cookie、存储与设置](<https://deepseek-browser-use.io.nexus/23>)：区分 Page 与 Context。
7. [网络与控制台](<https://deepseek-browser-use.io.nexus/24>)：将操作与请求结果关联。
8. [对话框与人机协作](<https://deepseek-browser-use.io.nexus/25>)：处理页面弹窗和人工步骤。
9. [配方、批量、后台作业与维护](<https://deepseek-browser-use.io.nexus/26>)：组合能力并管理运行状态。

增加命令时先确定它是否需要实例、是否可能改变页面、是否可以安全重试，再注册参数读取和服务方法。随后检查 `PAGE_CHANGING`、重试名单、响应精简和技能命令一致性测试；不能把所有新方法一律加入重试名单。

测试优先使用本地 HTML 和本地 HTTP 服务，不用真实商户申请验证点击。断言应检查页面状态、副作用次数、返回信息和失败分类，而不只是 `ok=true`。

<!-- current-source-tutorial -->
## 代码阅读：统一 HTTP 执行链

从外层看，每次请求都是 JSON；从实现看，它依次完成方法校验、参数解析、命令分发、执行结果整理与留档。下面按真实调用顺序展开。

### HTTP 入口与参数解析

源码：`playwright-server/src/main/java/nexus/io/ai/browser/handler/PlaywrightHandler.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public HttpResponse handle(HttpRequest request) throws Exception {
    long startedAt = System.currentTimeMillis();
    HttpResponse response = TioRequestContext.getResponse();
    CORSUtils.enableCORS(response);
    String body = request.getBodyString();
    RespBodyVo result = ResponseFormatter.format(dispatch(request), body);
    // 每一次「请求 → 响应」都落一份到 logs/trace/<日期>/ 下(见 CommandTraceLog)。
    // 它自己吞掉所有异常:磁盘满、目录没权限都不会让浏览器命令失败。
    CommandTraceLog.record(body, result, startedAt);
    response.body(result);
    return response;
  }
```

源码：`playwright-server/src/main/java/nexus/io/ai/browser/handler/PlaywrightHandler.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  private RespBodyVo dispatch(HttpRequest request) {
    if (request.getMethod() != HttpMethod.POST) {
      return RespBodyVo.fail("浏览器控制接口只支持 POST,请把 {id, method, params} 放在 JSON 请求体里");
    }
    String body = request.getBodyString();
    if (body == null || body.isBlank()) {
      return RespBodyVo.fail("请求体不能为空,需要 {\"id\":123,\"method\":\"start\",\"params\":{}}");
    }
    JSONObject payload;
    try {
      payload = JSONObject.parseObject(body);
    } catch (Exception e) {
      return RespBodyVo.fail("请求体不是合法 JSON：" + PlaywrightService.briefMessage(e.getMessage()));
    }
    if (payload == null) {
      return RespBodyVo.fail("请求体不是合法 JSON 对象");
    }
    String method = payload.getString("method");
    if (method == null || method.isBlank()) {
      return RespBodyVo.fail("缺少参数 method");
    }
    Long id;
    try {
      id = payload.getLong("id");
    } catch (Exception e) {
      return RespBodyVo.fail("id 必须是数字或数字字符串");
    }
    JSONObject params = payload.getJSONObject("params");

    long startedAt = System.currentTimeMillis();
    RespBodyVo result = service().execute(id, method, params);
    if (log.isDebugEnabled()) {
      log.debug("id:{} method:{} ok:{} cost:{}ms", id, method, result.isOk(), System.currentTimeMillis() - startedAt);
    }
    return result;
  }
```

### 单条、批量与异常处理

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/ActionService.java`。入口是 `execute` → `runBounded`（有界等待）→ `run`（分发与异常分类）；以下为当前实现，可放回原类中阅读，依赖同类字段和辅助方法，并非独立编译单元。

```java
  // execute 只做公共装饰:失败回执补「现在页面在哪」。真正分发在 runBounded → run 里。
  public RespBodyVo execute(Long id, String method, JSONObject params) {
    RespBodyVo result = runBounded(id, method, params);
    if (result != null && !result.isOk()) {
      // 超时回执**不要再补页面上下文**:补它要再发一次 Playwright 调用,而此刻正是「底层调不动」
      // 的时候 —— 那一下会把刚拿到结论的 HTTP 线程重新挂死,把兜底的意义全抹掉。
      if (!ActionError.COMMAND_TIMEOUT.equals(errorCodeOf(result))) {
        attachPageContext(result, id);
      }
    }
    return result;
  }

  // runBounded 负责「有界等待」(超时/被拒/中断 → COMMAND_TIMEOUT 回执),分发在这里。
  private RespBodyVo run(Long id, String method, JSONObject params) {
    if (method == null || method.isBlank()) {
      return RespBodyVo.fail("缺少参数 method");
    }
    if ("commands".equals(method)) {
      // 长批次可以异步跑:立刻回 jobId,再用 get_job 取结果。客户端超时不再等于「任务失败」
      if (params != null && Boolean.TRUE.equals(params.getBoolean("async"))) {
        if (id == null) {
          return RespBodyVo.fail("异步执行 commands 需要参数 id");
        }
        JSONObject jobParams = params;
        JobRegistry.Job job = JobRegistry.submit("commands", id, started -> started.result = batchExecute(id,
            jobParams, started.id));
        return RespBodyVo.ok(Kv.by("jobId", job.id).set("status", job.status).set("browserId", id)
            .set("hint", "批次已在后台执行:用 get_job 轮询结果,用 cancel_job 取消(取消会在下一步之前生效)"));
      }
      return batchExecute(id, params);
    }
    CommandTable.Executor executor = CommandTable.get(method);
    if (executor == null) {
      return RespBodyVo.fail(unknownMethodMessage(method));
    }
    // 包一层「会记录读过哪些参数」的对象:命令跑完后就能看出哪些参数被传了却一次也没用上。
    TrackedArgs args = new TrackedArgs(params);
    // 页签类命令的效果可以直接读回来(见 verifyTabsAfterSpurious):发命令前先记一份页签状态。
    Kv tabsBefore = SPURIOUS_VERIFIABLE.contains(method) ? tabState(id) : null;
    try (RetryBudget commandBudget = RetryBudget.open(30000)) {
      RespBodyVo result = dispatchWithSpuriousRetry(method, args, () -> executor.run(svc, id, args));
      if (!result.isOk() && result.getMsg() != null) {
        java.util.regex.Matcher match = java.util.regex.Pattern.compile("\\[([A-Z_]+)\\]").matcher(result.getMsg());
        String errorCode = match.find() ? match.group(1) : ActionError.code(result.getMsg());
        Kv detail = result.getData() instanceof Kv ? (Kv) result.getData() : new Kv();
        if (detail.get("errorCode") != null) errorCode = detail.getStr("errorCode");
        detail.set("errorCode", errorCode);
        // 可重试性与建议退避:调用方据此自动重试,不必去猜中文提示
        boolean retryable = ActionError.retryable(errorCode);
        detail.set("retryable", retryable);
        if (retryable && detail.get("retryAfterMs") == null) {
          detail.set("retryAfterMs", ActionError.retryAfterMs(errorCode));
        }
        result.setData(detail);
      }
      attachCapture(result, id, method);
      if (result.getData() instanceof Kv data) {
        Kv metrics = data.get("retryBudget") instanceof Kv existing ? existing : new Kv();
        metrics.set("attempts", commandBudget.retries() + 1).set("retries", commandBudget.retries())
            .set("elapsedMs", commandBudget.elapsedMs()).set("remainingMs", commandBudget.remainingMs());
        data.set("retryBudget", metrics);
      }
      attachUnknownParams(result, args, method);
      return result;
    } catch (IllegalArgumentException e) {
      // 参数校验错(CommandTable 的 reqInt/reqStr 等):命令**根本没发出去**,不存在「可能已生效」的问题,
      // 照旧给一句干脆的失败信息即可
      return RespBodyVo.fail(method + " 失败：" + e.getMessage());
    } catch (Exception e) {
      // 执行器抛到这里的异常,处理不了「动作到底生效没有」——实测点下载按钮时底层抛
      // object-does-not-exist(artifact@/response@),文件其实已经落盘。老写法只说「失败」,
      // 调用方就会重试,而重试可能造成**重复下载 / 重复提交**。
      String rawMessage = e.getMessage();
      String detail = PlaywrightService.briefMessage(rawMessage);
      // 判伪故障用**原始文本**,不用 briefMessage 的结果:后者只保留 message=' 之后的第一行,
      // 而「Object doesn't exist: response@…」可能出现在别的位置,截断后就认不出来了。
      boolean spurious = ActionError.isSpuriousDispatch(rawMessage);
      boolean retrySafe = retrySafeFor(method, args);
      if (spurious && !retrySafe) {
        // 伪故障 + 动作类命令:能核对的就核对(页签类),核对不了才把结论留给调用方
        RespBodyVo verified = verifyTabsAfterSpurious(id, method, tabsBefore, detail);
        if (verified != null) {
          return verified;
        }
        Kv uncertain = Kv.by("errorCode", ActionError.ACTION_UNCERTAIN).set("retryable", false)
            .set("spuriousDispatch", true)
            .set("note", "这个异常来自 Playwright 的事件分发（底层对象已释放），不是 " + method
                + " 自己报的错：命令**可能已经生效**。请先用只读命令"
                + "（get_browser_state / get_form_state / get_page_snapshot）确认页面状态，"
                + "不要直接重试——重试可能造成重复下载 / 重复提交。"
                + "确认过「没生效」之后，写 retryOnSpurious:true 再发一次是安全的："
                + "伪故障里有很大一支是命令还没发出去就撞上了噪声，服务端会替你把这一类吃掉。");
        RespBodyVo resp = RespBodyVo.fail(method + " 失败：" + detail
            + "（[" + ActionError.SPURIOUS_DISPATCH + "] 疑似 Playwright 事件分发的伪故障，"
            + "但无法判断本次是否已生效，请先读页面状态再决定是否重试）");
        resp.setData(uncertain);
        return resp;
      }
      if (spurious) {
        // 伪故障 + 只读命令:服务端已经替调用方重发过 SPURIOUS_MAX_ATTEMPTS 次,仍失败就如实报,
        // 并明确「可以再发」——只读命令重发没有副作用,不必让调用方去猜
        boolean declared = callerAllowsSpuriousRetry(args);
        Kv detailKv = Kv.by("errorCode", ActionError.SPURIOUS_DISPATCH).set("retryable", true)
            .set("retryAfterMs", 200).set("spuriousDispatch", true)
            .set("note", "这是 Playwright 事件分发投递过来的伪故障（底层对象已释放，与本次命令无关）："
                + "本次有限重试未恢复；重试次数可能因共享额度或截止时间而减少，请先检查原结果。");
        if (declared) {
          detailKv.set("retryAllowedByCaller", true);
        }
        RespBodyVo resp = RespBodyVo.fail(method + " 失败：" + detail
            + "（[" + ActionError.SPURIOUS_DISPATCH + "] 疑似 Playwright 事件分发的伪故障，可以再发一次）");
        resp.setData(detailKv);
        return resp;
      }
      if (ActionError.isPageNavigating(rawMessage)) {
        // 页面正在导航/重建:这一刻拿不到 DOM。只读命令重发无害(服务端也已经按可重发名单重发过),
        // 必须归成可重试的 PAGE_NAVIGATING;动作类命令则保留「不确定」,不诱导调用方重复提交。
        Kv navigating = Kv.by("errorCode", ActionError.PAGE_NAVIGATING)
            .set("retryable", retrySafe)
            .set("pageNavigating", true)
            .set("retryAfterMs", ActionError.retryAfterMs(ActionError.PAGE_NAVIGATING))
            .set("note", retrySafe
                ? "页面正在导航/重建，这一刻拿不到 DOM；这是只读命令，重发无害，稍后再发即可。"
                : "页面正在导航/重建，这一刻拿不到 DOM；这是动作类命令，无法判断是否已生效，"
                    + "请先用只读命令确认页面状态再决定是否重发。");
        RespBodyVo resp = RespBodyVo.fail(method + " 失败：" + detail
            + "（[" + ActionError.PAGE_NAVIGATING + "] 页面正在导航/重建，DOM 这一刻不可用"
            + (retrySafe ? "，可以重发" : "，先读页面状态") + "）");
        resp.setData(navigating);
        return resp;
      }
      Kv uncertain = Kv.by("errorCode", ActionError.ACTION_UNCERTAIN)
          .set("retryable", false)
          .set("note", "执行器抛了未预期异常，无法判断动作是否已经生效。请先用只读命令"
              + "（get_browser_state / get_form_state / get_page_snapshot）确认页面状态，"
              + "不要直接重试——重试可能造成重复下载 / 重复提交。");
      RespBodyVo resp = RespBodyVo.fail(method + " 失败：" + detail
          + "（[" + ActionError.ACTION_UNCERTAIN + "] 无法判断动作是否已生效，请先读页面状态再决定是否重试）");
      resp.setData(uncertain);
      return resp;
    }
  }
```

### 注册表查询与参数读取

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public static Executor get(String command) {
    return TABLE.get(command);
  }
```

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  private static int reqInt(JSONObject args, String key) {
    Integer value = args.getInteger(key);
    if (value == null) {
      throw new IllegalArgumentException(missingParamMessage(args, key));
    }
    return value;
  }
```

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  private static String reqStr(JSONObject args, String key) {
    String value = args.getString(key);
    if (value == null || value.isEmpty()) {
      throw new IllegalArgumentException(missingParamMessage(args, key));
    }
    return value;
  }
```

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  private static String optStr(JSONObject args, String key) {
    String value = args.getString(key);
    return value == null || value.isEmpty() ? null : value;
  }
```

注册的每个 Executor 接收同一份服务实例、任务 ID 和 JSON 参数，后面的功能章节展示每条命令的注册代码与服务实现。公共执行层负责协议和结果处理，具体的 Page、Frame、Locator 操作由服务方法完成。
