源码教程:批量、配方、后台作业与维护
这些能力不增加新的浏览器原语,而是组合和管理已有命令。重点是避免 HTTP 超时被误认为执行失败,以及区分“取消后续步骤”和“撤销已执行操作”。
1. commands 批量入口
commands 是 ActionService.execute 特殊处理的入口,不包含在 116 个注册方法之中。同步批次逐项解析单命令对象,按顺序调用同一执行层,因此单条与批量应具有一致的截图和错误处理语义。
{
"id":"1001",
"method":"commands",
"params":{
"stopOnError":true,
"commands":[
{"go_to_url":{"url":"https://example.com"}},
{"get_title":{}},
{"get_browser_state":{"highlight":false}}
]
}
}
批次不允许嵌套 commands,每项只有一个命令键,有最大步骤数限制。stopOnError 控制遇到失败是否停止,expect 则用来判断业务观察条件;条件失败不能被一次浏览器 API 返回成功掩盖。
页面变化不会自动终止整个批次。依赖新快照索引的点击应放到下一次请求中,不能在同一批次预先猜好未来页面索引。
2. 后台作业
params 加 async=true 时,JobRegistry.submit 将批次交给后台执行器,立即返回 jobId。注册表保存状态、耗时、步骤数、取消标志和最终结果。
| 命令 | 实现 |
|---|---|
list_jobs | 返回最近作业摘要,可通过 limit 限制数量 |
get_job | 按 jobId 查内存记录,includeResult 默认包含已有结果;运行中返回状态提示 |
cancel_job | 标记 cancelRequested,在后续步骤边界协作停止,不打断已发送的浏览器动作 |
状态为 running、done、failed 或 cancelled。记录保存在内存,服务重启后丢失;注册表会清理较旧的已结束作业,不能当成持久化消息队列。
当前 get_job 注册只读取 jobId 与 includeResult。不能因为某条提示提到长轮询,就自行增加未实现的参数并假定服务真的等待;客户端按实际接口查询即可。HTTP 客户端超时后先查询已存在作业,不能重发整个批次。
3. 配方复用
list_recipes 调用 RecipeStore.list,返回可用配方、数量和目录。run_recipe 按 name 读取配方,合并变量并生成 commands,随后仍由公共执行链执行。配方格式与示例见第 14 篇。
配方必须显式点名,不从网站地址猜测执行哪套流程。把稳定步骤放入配方,把需要新快照判断、短信验证或人工确认的分支保留在上层控制逻辑。变量是参数化工具,不是隐含扩大操作权限的机制。
4. 自省与清理
| 命令 | 实现与返回 |
|---|---|
list_methods | 从 CommandTable 的键生成方法名列表,可按 filter 筛选,返回 count/methods/total |
get_config | 返回服务端解析后的生效配置和路径,不能只看配置文件推断当前进程状态 |
list_tasks | 汇总活跃任务与共享浏览器状态,用于诊断启动中、页签和资源归属 |
cleanup | 根据 scope、olderThanHours、keepLatest 选择产物;默认 dryRun,只预演 |
cleanup 真正删除前先核对候选和保留策略。截图、trace 与上传文件可能仍被任务引用,不应为了节省磁盘盲目清空。命令清理运行产物,与关闭浏览器、删除账号或注销商户无关。
5. 验证方法
使用本地页面构造成功、失败、条件不满足的三个步骤,检查 stopOnError 和 expect 的计数及结果。异步测试在第一步与第二步之间发出取消,确认已执行动作保留、后续动作不执行。配方测试对比变量替换结果,不访问真实业务站点。cleanup 先测试 dryRun 无副作用,再在专用临时目录验证筛选条件,禁止将用户真实产物作为清理测试样本。
注册参数与 Java 入口
以下按 CommandTable 实际读取参数整理。* 表示注册层使用必填读取器;其余字段省略后由服务决定默认行为。带条件的入口仍需满足正文说明,例如上传文件来源、元素定位二选一。外层 id 不重复列出。
| 命令 | params 字段 | Java 入口 |
|---|---|---|
list_methods | filter | CommandTable.names |
get_config | 无 | getConfig |
list_tasks | 无 | listTasks |
cleanup | scope、olderThanHours、keepLatest、dryRun | cleanup |
list_recipes | 无 | RecipeStore.list |
run_recipe | name*、vars、stopOnError | CommandTable.runRecipe |
list_jobs | limit | JobRegistry.recent |
get_job | jobId*、includeResult | JobRegistry.get / Job.describe |
cancel_job | jobId* | JobRegistry.cancel |
当前源码:命令注册与执行
先在本章上半部分理解行为,再按命令展开实现。注册代码说明 JSON 参数如何传给 Java;服务方法展示实际浏览器操作。方法依赖共享类中的字段和辅助函数,不应脱离原类直接粘贴编译。
list_methods
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("list_methods", (svc, id, a) -> {
String filter = optStr(a, "filter");
List<String> names = new java.util.ArrayList<>();
for (String name : names()) {
if (filter == null || name.contains(filter.toLowerCase(Locale.ROOT))) {
names.add(name);
}
}
Kv data = Kv.by("count", names.size()).set("methods", names).set("total", names().size());
if (filter != null) {
data.set("filter", filter);
}
return RespBodyVo.ok(data);
});
本命令的主要处理直接在上述注册函数中完成;其中的作业或配方依赖保持与当前项目一致。
get_config
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("get_config", (svc, id, a) -> svc.getConfig(id));
展开 getConfig 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo getConfig(Long browserId) {
Kv data = new Kv();
data.set("workDir", Paths.get("").toAbsolutePath().normalize().toString());
data.set("jsDir", scriptDir().toString());
// 引擎与类型:engine 只分 chromium/firefox 两档,type 才是 auto/chromium/chrome/edge/firefox
data.set("engine", BrowserEngine.current().id());
data.set("configuredType", BrowserChoice.configured().id());
data.set("typeChoices", BrowserChoice.CHOICES);
data.set("profileDir", Kv.by("resolved", ChromeBrowser.managedProfileDir().toString())
.set("perPort", ChromeBrowser.perPortProfileDir())
.set("configured", ChromeBrowser.config(ChromeBrowser.KEY_PROFILE_DIR))
.set("note", "没显式配 browser.profileDir 且 perPort 开着时,目录按服务端口派生(shared-<端口>),"
+ "多个服务实例同时跑不会抢同一份 profile 锁"));
data.set("action", Kv.by("timeoutMs", actionTimeoutMs()).set("jsFallback", jsFallbackEnabled())
.set("mouseFallback", mouseFallbackEnabled()));
data.set("launchTimeoutMs", launchTimeoutMs());
data.set("trace", Kv.by("dir", CommandTraceLog.currentDir().toString())
.set("enabled", Boolean.parseBoolean(String.valueOf(ChromeBrowser.config(CommandTraceLog.KEY_ENABLED) == null
? "true" : ChromeBrowser.config(CommandTraceLog.KEY_ENABLED))))
.set("redact", CommandTraceLog.redactEnabled()));
data.set("upload", Kv.by("dir", UploadStore.dir().toString()).set("enabled", UploadStore.enabled())
.set("maxBytes", UploadStore.maxBytes()));
data.set("tasks", INSTANCES.size());
data.set("commands", CommandTableNamesHolder.names());
data.set("java", System.getProperty("java.version"));
Package playwrightPackage = Playwright.class.getPackage();
data.set("playwright", playwrightPackage == null ? null : playwrightPackage.getImplementationVersion());
if (browserId != null) {
data.set("browser", browserInfo(browserId));
}
return RespBodyVo.ok(data);
}
list_tasks
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("list_tasks", (svc, id, a) -> svc.listTasks());
展开 listTasks 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo listTasks() {
List<Kv> tasks = new ArrayList<>();
for (BrowserInstance inst : INSTANCES.values()) {
tasks.add(taskInfo(inst));
}
Kv data = Kv.by("count", tasks.size()).set("tasks", tasks);
SharedBrowser browser = sharedBrowser;
if (browser != null) {
data.set("browser", Kv.by("type", browser.resolvedType.id()).set("engine", browser.engine.id())
.set("profileDir", browser.profileDir.toAbsolutePath().toString()).set("headless", browser.headless)
.set("mode", browser.browser != null ? "cdp" : "managed"));
} else {
data.set("browser", null);
}
Kv launching = launchState;
if (launching != null) {
Long started = launching.getLong("startedAt");
long startedAt = started == null ? System.currentTimeMillis() : started;
data.set("launching", Kv.by("startedAt", startedAt).set("elapsedMs", System.currentTimeMillis() - startedAt)
.set("browser", launching.get("browser")).set("headless", launching.get("headless"))
.set("note", launching.get("note")))
.set("note", "浏览器正在启动中,所以 count=0 是正常的(任务要等浏览器起来才注册):"
+ "现在这一步可能是在下载/升级 Playwright 的浏览器,先别重复 start;详情见 launching 字段");
} else {
data.set("launching", null);
}
return RespBodyVo.ok(data);
}
cleanup
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("cleanup", (svc, id, a) -> svc.cleanup(optStr(a, "scope"), a.getInteger("olderThanHours"),
a.getInteger("keepLatest"), a.getBoolean("dryRun")));
展开 cleanup 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo cleanup(String scope, Integer olderThanHours, Integer keepLatest, Boolean dryRun) {
String target = scope == null || scope.isBlank() ? "all" : scope.trim().toLowerCase(java.util.Locale.ROOT);
long cutoff = System.currentTimeMillis()
- (olderThanHours == null ? 24L : Math.max(0, olderThanHours)) * 3600_000L;
int keep = keepLatest == null ? 0 : Math.max(0, keepLatest);
// 删文件是不可逆的:默认只预演,要真删必须显式关掉
boolean preview = dryRun == null || dryRun;
Kv report = new Kv();
long[] totals = new long[] {0, 0};
List<String> notes = new ArrayList<>();
if ("all".equals(target) || "data".equals(target)) {
Path data = Paths.get(DATA_DIR).toAbsolutePath().normalize();
report.set("data", sweep(data, cutoff, keep, preview, totals, notes));
}
if ("all".equals(target) || "trace".equals(target)) {
report.set("trace", sweep(CommandTraceLog.currentDir(), cutoff, keep, preview, totals, notes));
}
if ("all".equals(target) || "upload".equals(target)) {
// 暂存文件是「等会儿要交给页面」的,按时间清会把正在用的文件删掉,所以只在显式点名 upload 时才动
if ("upload".equals(target)) {
report.set("upload", sweep(UploadStore.dir(), cutoff, keep, preview, totals, notes));
} else {
notes.add("upload 目录没动:暂存文件可能正在被某个任务使用,要清请显式指定 scope:\"upload\"");
}
}
report.set("scope", target).set("olderThanHours", olderThanHours == null ? 24 : olderThanHours)
.set("keepLatest", keep).set("dryRun", preview).set("deletedFiles", totals[0])
.set("freedBytes", totals[1]);
if (!notes.isEmpty()) {
report.set("notes", notes);
}
return RespBodyVo.ok(report);
}
list_recipes
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("list_recipes", (svc, id, a) -> {
List<Kv> recipes = RecipeStore.list();
return RespBodyVo.ok(Kv.by("count", recipes.size()).set("recipes", recipes)
.set("dir", RecipeStore.dir().toString()));
});
本命令的主要处理直接在上述注册函数中完成;其中的作业或配方依赖保持与当前项目一致。
run_recipe
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("run_recipe", (svc, id, a) -> runRecipe(id, reqStr(a, "name"), a.getJSONObject("vars"),
a.getBoolean("stopOnError")));
本命令的主要处理直接在上述注册函数中完成;其中的作业或配方依赖保持与当前项目一致。
list_jobs
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("list_jobs", (svc, id, a) -> {
Integer limit = a.getInteger("limit");
List<Kv> jobs = new java.util.ArrayList<>();
for (nexus.io.ai.browser.service.JobRegistry.Job job : nexus.io.ai.browser.service.JobRegistry
.recent(limit == null ? 20 : limit)) {
jobs.add(job.describe(false));
}
return RespBodyVo.ok(Kv.by("count", jobs.size()).set("jobs", jobs));
});
本命令的主要处理直接在上述注册函数中完成;其中的作业或配方依赖保持与当前项目一致。
get_job
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("get_job", (svc, id, a) -> {
String jobId = reqStr(a, "jobId");
nexus.io.ai.browser.service.JobRegistry.Job job = nexus.io.ai.browser.service.JobRegistry.get(jobId);
if (job == null) {
return RespBodyVo.fail("没有这个任务:" + jobId + "(任务只保留最近 50 个,且服务重启即丢;"
+ "用 list_jobs 看现存任务)");
}
boolean withResult = a.getBoolean("includeResult") == null || a.getBoolean("includeResult");
Kv data = job.describe(withResult);
if ("running".equals(job.status)) {
data.set("hint", "还在跑:等一会儿再查,或用 waitForSeconds 做长轮询");
}
return RespBodyVo.ok(data);
});
本命令的主要处理直接在上述注册函数中完成;其中的作业或配方依赖保持与当前项目一致。
cancel_job
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("cancel_job", (svc, id, a) -> {
String jobId = reqStr(a, "jobId");
nexus.io.ai.browser.service.JobRegistry.Job job = nexus.io.ai.browser.service.JobRegistry.get(jobId);
if (job == null) {
return RespBodyVo.fail("没有这个任务:" + jobId);
}
boolean accepted = nexus.io.ai.browser.service.JobRegistry.cancel(jobId);
return RespBodyVo.ok(Kv.by("jobId", jobId).set("cancelRequested", accepted).set("status", job.status)
.set("note", "取消是协作式的:批次会在下一步之前停下来,当前这一步不会被中断"));
});
本命令的主要处理直接在上述注册函数中完成;其中的作业或配方依赖保持与当前项目一致。
commands 批量入口
批量入口由 ActionService 直接处理,不计入注册表的 116 条命令。逐条执行、停止策略和断言统计见下面的当前实现。
展开批量执行
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/ActionService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo batchExecute(Long id, JSONObject params, String jobId) {
Long browserId = id;
boolean stopOnError = true;
if (params == null) {
return RespBodyVo.fail("commands 需要 params.commands 命令数组");
}
if (params.getBoolean("stopOnError") != null) {
stopOnError = params.getBoolean("stopOnError");
}
Long maxDurationMs = params.getLong("maxDurationMs");
long batchStartedAt = System.currentTimeMillis();
JSONArray commands = params.getJSONArray("commands");
if (commands == null && params.size() == 1 && !params.containsKey("stopOnError")) {
// 宽容处理:只写一条命令时,params 本身就可以是那一条
commands = new JSONArray();
commands.add(params);
}
if (commands == null) {
return RespBodyVo.fail("commands 需要 params.commands 命令数组");
}
if (commands.isEmpty()) {
return RespBodyVo.fail("命令数组为空");
}
if (commands.size() > MAX_COMMANDS) {
return RespBodyVo.fail("命令数组最多 " + MAX_COMMANDS + " 条,当前 " + commands.size() + " 条");
}
if (browserId == null) {
return RespBodyVo.fail("缺少参数 id");
}
List<Object> results = new ArrayList<>(commands.size());
int succeeded = 0;
int failed = 0;
int expectFailed = 0;
String firstFailure = null;
String firstExpectFailure = null;
String stopReason = null;
boolean stopOnExpectFailure = Boolean.TRUE.equals(params.getBoolean("stopOnExpectFailure"));
for (int i = 0; i < commands.size(); i++) {
// 异步任务的取消是协作式的:在每一步之间生效,不会把已经发出的那一步打断
if (jobId != null && JobRegistry.cancelRequested(jobId)) {
stopReason = "cancelled";
break;
}
if (maxDurationMs != null && maxDurationMs > 0 && System.currentTimeMillis() - batchStartedAt > maxDurationMs) {
stopReason = "maxDurationMs(" + maxDurationMs + "ms)";
break;
}
String cmdName = "?";
RespBodyVo result;
JSONObject expectation = null;
try {
JSONObject entry = commands.getJSONObject(i);
if (entry == null) {
result = RespBodyVo.fail("每条命令对象只能包含一个键");
} else {
// 允许「一条命令 + 一个 expect 断言」的两键写法:{命令:{...}, expect:{...}}
expectation = entry.getJSONObject("expect");
JSONObject only = new JSONObject(entry);
only.remove("expect");
if (only.size() != 1) {
result = RespBodyVo.fail("每条命令对象只能包含一个键(外加可选的 expect)");
} else {
cmdName = only.keySet().iterator().next();
if ("commands".equals(cmdName)) {
result = RespBodyVo.fail("批量接口不支持嵌套调用 commands");
} else {
JSONObject args = only.getJSONObject(cmdName);
result = execute(browserId, cmdName, args == null ? new JSONObject() : args);
}
}
}
} catch (Exception e) {
result = RespBodyVo.fail(PlaywrightService.briefMessage(e.getMessage()));
}
Kv step = Kv.by("index", i).set("command", cmdName).set("ok", result.isOk()).set("data", result.getData())
.set("msg", result.getMsg());
if (!result.isOk()) {
// 失败步给一个统一的错误对象:调用方不必去解析中文 msg,也不必自己判断能不能重试
step.set("error", describeError(result));
}
// 断言在命令之后求值:动作说成功、状态其实没变的情况,靠它暴露出来
if (expectation != null && result.isOk()) {
Kv checked = checkExpectation(browserId, expectation);
step.set("expectResult", checked);
if (!Boolean.TRUE.equals(checked.getBoolean("passed"))) {
expectFailed++;
if (firstExpectFailure == null) {
firstExpectFailure = "第 " + i + " 条命令 " + cmdName + " 的断言没通过:"
+ (checked.get("error") != null ? checked.get("error")
: "实际值 " + checked.get("actual") + " 不满足 " + checked.get("matcher") + " " + checked.get("expected"));
}
}
}
results.add(step);
if (result.isOk()) {
succeeded++;
} else {
failed++;
if (firstFailure == null) {
firstFailure = "第 " + i + " 条命令 " + cmdName + " 失败:" + result.getMsg();
}
if (stopOnError) {
break;
}
}
if (stopOnExpectFailure && expectFailed > 0) {
stopReason = "stopOnExpectFailure";
break;
}
if (jobId != null) {
JobRegistry.Job job = JobRegistry.get(jobId);
if (job != null) {
job.steps = i + 1;
}
}
}
Kv data = Kv.by("count", results.size()).set("succeeded", succeeded).set("failed", failed)
.set("expectFailed", expectFailed)
.set("stopped", stopReason != null || (stopOnError && failed > 0)
|| (stopOnExpectFailure && expectFailed > 0))
.set("results", results);
if (stopReason != null) {
data.set("stopReason", stopReason);
}
if (jobId != null) {
data.set("jobId", jobId);
}
if (failed == 0 && expectFailed == 0) {
return RespBodyVo.ok(data);
}
RespBodyVo resp = RespBodyVo.fail(failed > 0 ? firstFailure : firstExpectFailure);
if (failed == 0) {
// 命令都执行了,只是断言没通过:回执要如实说明,别让调用方以为动作失败
data.set("note", "命令本身都执行成功,是 expect 断言没通过(动作发了、页面状态没变成期望的样子)");
}
resp.setData(data);
return resp;
}
展开配方执行
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
private static RespBodyVo runRecipe(Long id, String name, JSONObject vars, Boolean stopOnError) {
Kv recipe = RecipeStore.load(name);
if (recipe == null) {
List<Kv> available = RecipeStore.list();
StringBuilder hint = new StringBuilder();
for (Kv item : available) {
if (hint.length() > 0) {
hint.append(" / ");
}
hint.append(item.getStr("name"));
}
return RespBodyVo.fail("没有这个配方:" + name + (hint.length() == 0
? "(配方目录里没有可用配方,见 get_config 的 recipesDir / list_recipes)"
: ",可用配方:" + hint));
}
JSONArray commands = RecipeStore.applyVars((JSONArray) recipe.get("commands"),
RecipeStore.mergeVars((JSONObject) recipe.get("defaults"), vars));
JSONObject params = new JSONObject();
params.put("commands", commands);
params.put("stopOnError", stopOnError == null || stopOnError);
RespBodyVo result = nexus.io.jfinal.aop.Aop.get(nexus.io.ai.browser.service.ActionService.class)
.batchExecute(id, params);
Object data = result.getData();
Kv merged = data instanceof Kv ? (Kv) data : Kv.by("result", data);
merged.set("recipe", recipe.getStr("name"));
if (recipe.get("description") != null) {
merged.set("recipeDescription", recipe.get("description"));
}
result.setData(merged);
return result;
}
