Skip to content

源码教程:从 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 缺失。任何命令都可以由调用方按调用点声明 retryOnSpurious:true 来放行这类重试(execute_js 一开始就是这么设计的,现在这个口子对所有命令开放):伪故障里有很大一支是「命令还没发出去就撞上了噪声」,那种情况重发无害,而这一次到底是不是写操作只有调用方清楚。详见防重复提交。

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

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

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

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

代码阅读:统一 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 操作由服务方法完成。