源码教程:从 HTTP 请求到命令执行
本系列以当前 deepseek-browser-use 工程为准,按功能解释全部 116 个注册命令,以及额外的 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. 一条命令经过哪些层
POST /playwright/command
→ PlaywrightHandler.dispatch:方法与 JSON 校验
→ ActionService.execute:区分 commands 与单条命令
→ CommandTable:读取必填/可选参数,选定执行器
→ PlaywrightService:查找任务,定位对象,执行浏览器操作
→ ActionService:处理可安全重试的异常、附加截图
→ ResponseFormatter:按请求裁剪协议字段
→ CommandTraceLog:追踪记录
→ HttpResponse
所有控制命令使用同一个入口,请求形状保持一致:
{"id":"1001","method":"get_title","params":{}}
id 由 JSON 转为 Long,支持数字字符串;非法数字、空请求、非对象 JSON、缺失 method 都在入口返回失败。params 可以省略,由执行层处理。start 可省略 ID,其他方法是否依赖已有实例还要看该方法的实现,不能根据 Java 参数存在就推断必需浏览器页签。
3. 注册表如何连接参数与 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 结构不同,不应统一强转为某一种固定业务对象。
动作执行与业务成功分开:按钮被点击、DOM 变化、请求已发送,都不能替代“审核已提交”或“订单已支付”。观察代码会比较动作前后的 URL、页签、文本、表单状态及 DOM 指纹,并短暂等待异步变化。observationComplete=false 时,探针没有取得充分证据,不要依据 changed=false 重复提交。
对象释放异常可能由事件泵在操作后投递。ActionService 只对安全名单中的只读命令进行有限重试;点击、提交等动作不能自动重发。execute_js 只有在调用方确认纯读取并显式传 retryOnSpurious:true 时才允许这类重试。详见防重复提交。
5. 学习顺序与扩展方法
- 生命周期、导航和页签:先理解任务和共享浏览器。
- DOM、页面状态与元素读取:理解索引来自哪里。
- 点击、输入、键盘和鼠标:把索引转为实际交互。
- 等待与 JavaScript:处理异步页面。
- 上传、截图、PDF 与 OCR:处理文件和图像。
- Cookie、存储与设置:区分 Page 与 Context。
- 网络与控制台:将操作与请求结果关联。
- 对话框与人机协作:处理页面弹窗和人工步骤。
- 配方、批量、后台作业与维护:组合能力并管理运行状态。
增加命令时先确定它是否需要实例、是否可能改变页面、是否可以安全重试,再注册参数读取和服务方法。随后检查 PAGE_CHANGING、重试名单、响应精简和技能命令一致性测试;不能把所有新方法一律加入重试名单。
测试优先使用本地 HTML 和本地 HTTP 服务,不用真实商户申请验证点击。断言应检查页面状态、副作用次数、返回信息和失败分类,而不只是 ok=true。
代码阅读:统一 HTTP 执行链
从外层看,每次请求都是 JSON;从实现看,它依次完成方法校验、参数解析、命令分发、执行结果整理与留档。下面按真实调用顺序展开。
HTTP 入口与参数解析
源码:playwright-server/src/main/java/nexus/io/ai/browser/handler/PlaywrightHandler.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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo execute(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));
}
JSONObject args = params == null ? new JSONObject() : params;
try {
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();
detail.set("errorCode", errorCode);
// 可重试性与建议退避:调用方据此自动重试,不必去猜中文提示
boolean retryable = ActionError.retryable(errorCode);
detail.set("retryable", retryable);
if (retryable) {
detail.set("retryAfterMs", ActionError.retryAfterMs(errorCode));
}
result.setData(detail);
}
attachCapture(result, id, method);
return result;
} catch (IllegalArgumentException e) {
// 参数校验错(CommandTable 的 reqInt/reqStr 等):命令**根本没发出去**,不存在「可能已生效」的问题,
// 照旧给一句干脆的失败信息即可
return RespBodyVo.fail(method + " 失败:" + e.getMessage());
} catch (Exception e) {
// 执行器抛到这里的异常,处理不了「动作到底生效没有」——实测点下载按钮时底层抛
// object-does-not-exist(artifact@/response@),文件其实已经落盘。老写法只说「失败」,
// 调用方就会重试,而重试可能造成**重复下载 / 重复提交**。所以这里明确标成「不确定」,
// 并把「先读状态、别直接重试」写进 data.note 与 msg。
String detail = PlaywrightService.briefMessage(e.getMessage());
boolean spurious = ActionError.isSpuriousDispatch(detail);
boolean retrySafe = retrySafeFor(method, args);
if (spurious && !retrySafe) {
// 伪故障 + 动作类命令:诊断说清楚,但结论仍是「不确定」——动作可能已经生效
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)确认页面状态,"
+ "不要直接重试——重试可能造成重复下载 / 重复提交。");
RespBodyVo resp = RespBodyVo.fail(method + " 失败:" + detail
+ "([" + ActionError.SPURIOUS_DISPATCH + "] 疑似 Playwright 事件分发的伪故障,"
+ "但无法判断本次是否已生效,请先读页面状态再决定是否重试)");
resp.setData(uncertain);
return resp;
}
if (spurious) {
// 伪故障 + 只读命令:服务端已经替调用方重发过 SPURIOUS_MAX_ATTEMPTS 次,仍失败就如实报,
// 并明确「可以再发」——只读命令重发没有副作用,不必让调用方去猜
Kv detailKv = Kv.by("errorCode", ActionError.SPURIOUS_DISPATCH).set("retryable", true)
.set("retryAfterMs", 200).set("spuriousDispatch", true)
.set("note", "这是 Playwright 事件分发投递过来的伪故障(底层对象已释放,与本次命令无关):"
+ "服务端已自动重发 " + SPURIOUS_MAX_ATTEMPTS + " 次仍未成功。只读命令可以放心再发一次。");
RespBodyVo resp = RespBodyVo.fail(method + " 失败:" + detail
+ "([" + ActionError.SPURIOUS_DISPATCH + "] 疑似 Playwright 事件分发的伪故障,只读命令可以再发一次)");
resp.setData(detailKv);
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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public static Executor get(String command) {
return TABLE.get(command);
}
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
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 操作由服务方法完成。
