源码教程:等待条件与 JavaScript 执行
异步页面不能只靠固定 sleep。等待命令分别判断元素、URL、网络、DOM 或数量条件;条件成立说明观测到了某种状态,不自动证明业务完成。
1. 原生等待与自定义等待
| 命令 | 实现思路与适用范围 |
|---|---|
wait | 按 seconds 等待;没有业务条件,适合明确要求的短暂间隔 |
wait_for_element | 在指定 frame 内构造 Locator 并等待,适合异步出现的控件 |
wait_for_text | 使用文本条件等待页面内容 |
wait_for_url | 调用页面 URL 等待逻辑,适合确定的跳转 |
wait_for_load | 将 state 映射到加载状态;加载完成不代表所有业务异步请求结束 |
wait_for_function | 在页面反复求值 expression,直到条件成立 |
wait_for_idle | 结合 DOM 变化和在途请求,检查连续 quietMs 的安静窗口,可加 selector/text 条件 |
wait_for_stable | 轮询所选区域的文本、表单等状态,检查连续 quietMs 不再变化 |
wait_for_count | 反复 count,检查 min、max、equals 约束;没有数量条件时采用默认下限 |
timeoutSeconds 与 quietMs 单位不同。错误时保留实际状态和耗时比只返回“失败”更有帮助。长期轮询的网站可能永远不满足 idle;这时应等待业务文字、具体元素数量或某个数据区域稳定,而不是无限增加超时。
{"id":"1001","method":"wait_for_count","params":{"selector":"#results tbody tr","min":1,"timeoutSeconds":10}}
{"id":"1001","method":"wait_for_stable","params":{"selector":"#results","quietMs":500,"timeoutSeconds":10}}
列表稳定不代表它已经从旧结果刷新到新结果。先观察加载标记、请求完成或查询条件对应的结果标识,再等待稳定,能避免读取上一次的数据。
稳定等待的循环如何写
waitForStable 使用 contentProbe 获取内容指纹,指纹改变就重置稳定起点。下面省略响应构造,保留源码的判断顺序:
String last = null;
long stableSince = startedAt;
while (true) {
long now = System.currentTimeMillis();
Kv probe = contentProbe(inst, selector);
if (probe == null) {
return RespBodyVo.fail("读不到内容指纹");
}
String fingerprint = probe.getStr("fingerprint");
if (!Objects.equals(fingerprint, last)) {
last = fingerprint;
stableSince = now;
}
if (now - stableSince >= quiet) {
return RespBodyVo.ok();
}
if (now >= deadline) {
return RespBodyVo.fail("内容未在限定时间内稳定");
}
inst.page.waitForTimeout(150);
}
服务默认 quiet 为 800 毫秒;第一次采样也算一次变化。成功返回 stable、waitedMs、changes、fingerprint 等信息。这个循环并不检测“业务数据正确”,只检测被采样内容持续不变。
2. execute_js 的执行链
CommandTable 要求 body 或 bodyFile 至少存在一个。PlaywrightService.executeJs 选择目标 Frame,读取脚本,应用 vars,再交给 Frame.evaluate。后者会等待 Promise 结果,所以服务返回 awaited:true,无需为了同步结果使用阻塞 XHR。
{"id":"1001","method":"execute_js","params":{"body":"() => ({title: document.title, count: document.querySelectorAll('article').length})","retryOnSpurious":true}}
bodyFile 只能从配置允许的脚本目录读取;不能把任意服务器路径暴露成脚本执行入口。vars 用于替换模板变量:applyVars 将值 JSON 编码,再替换 {{key}} 或双引号包裹的 "{{key}}"。模板变量应占据完整的 JavaScript 值位置,不用于拼接标识符、属性名或半截字符串。
{"id":"1001","method":"execute_js","params":{"body":"() => document.querySelector({{selector}})?.textContent","vars":{"selector":"#results"},"retryOnSpurious":true}}
例如 selector 值编码后成为合法字符串字面量,避免调用方手工处理引号。normalizeScript 会把包含 return、且不是函数形式的片段包装成箭头函数;普通表达式保持原样。
返回的核心字段是 result,按需附 varsApplied、frame 信息。脚本错误会返回错误分类、预览和长度;包含凭据的脚本可能进入错误日志,因此脚本参数同样需要控制敏感内容。
3. 自动重试边界
evaluate 可能执行读取,也可能点击、提交或写入。服务不能仅凭命令名推断脚本安全,所以默认不自动重试。只有确认脚本没有副作用时才显式传 retryOnSpurious:true。导航、对象释放与脚本语法错误也必须区别处理。
Windows 命令行报 Unexpected end of input 时,检查脚本是否在传输中被截断,优先将参数写 UTF-8 JSON 文件,或使用 bodyFile;不要反复修改一段本来正确的 JavaScript。
4. 本地验证
fixture 在延时后增加一行表格,再在稍后修改单元格。分别验证 count、stable 和 function 的触发时机;用持续网络轮询验证 idle 能超时而不是误判成功。脚本测试覆盖字符串、对象、Promise、语法错误和只读脚本的有限重试。等待失败后先读取当前状态,不自动再次点击“查询”。
注册参数与 Java 入口
以下按 CommandTable 实际读取参数整理。* 表示注册层使用必填读取器;其余字段省略后由服务决定默认行为。带条件的入口仍需满足正文说明,例如上传文件来源、元素定位二选一。外层 id 不重复列出。
| 命令 | params 字段 | Java 入口 |
|---|---|---|
wait | seconds* | waitSeconds |
wait_for_element | selector*、timeoutSeconds、frame | waitForElement |
wait_for_text | text*、timeoutSeconds | waitForText |
wait_for_url | url*、timeoutSeconds | waitForUrl |
wait_for_load | state、timeoutSeconds | waitForLoad |
wait_for_function | expression*、timeoutSeconds | waitForFunction |
wait_for_idle | quietMs、timeoutSeconds、selector、text | waitForIdle |
wait_for_stable | selector、quietMs、timeoutSeconds | waitForStable |
wait_for_count | selector*、min、max、equals、timeoutSeconds | waitForCount |
execute_js | body、bodyFile、vars、frame、retryOnSpurious | executeJs |
当前源码:命令注册与执行
先在本章上半部分理解行为,再按命令展开实现。注册代码说明 JSON 参数如何传给 Java;服务方法展示实际浏览器操作。方法依赖共享类中的字段和辅助函数,不应脱离原类直接粘贴编译。
wait
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait", (svc, id, a) -> svc.waitSeconds(id, reqInt(a, "seconds")));
展开 waitSeconds 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitSeconds(Long browserId, Integer seconds) {
if (!INSTANCES.containsKey(browserId)) {
return notFound(browserId);
}
if (seconds == null || seconds <= 0) {
return RespBodyVo.fail("wait 的 seconds 必须是正整数");
}
try {
Thread.sleep(seconds * 1_000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return RespBodyVo.fail("等待被中断");
}
return RespBodyVo.ok();
}
wait_for_element
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_element",
(svc, id, a) -> svc.waitForElement(id, reqStr(a, "selector"), a.getDouble("timeoutSeconds"),
optStr(a, "frame")));
展开 waitForElement 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForElement(Long browserId, String selector, Double timeoutSeconds, String frame) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
Locator target;
try {
target = locatorIn(inst, frame, selector);
} catch (IllegalArgumentException e) {
return RespBodyVo.fail("wait_for_element 失败:" + e.getMessage());
}
try {
target.waitFor(new Locator.WaitForOptions().setTimeout(timeoutMillis(timeoutSeconds)));
} catch (PlaywrightException e) {
return RespBodyVo.fail(waitFailure("wait_for_element", e));
}
return RespBodyVo.ok();
}
wait_for_text
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_text", (svc, id, a) -> svc.waitForText(id, reqStr(a, "text"), a.getDouble("timeoutSeconds")));
展开 waitForText 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForText(Long browserId, String text, Double timeoutSeconds) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
try {
inst.page.getByText(text).first().waitFor(new Locator.WaitForOptions().setTimeout(timeoutMillis(timeoutSeconds)));
} catch (PlaywrightException e) {
return RespBodyVo.fail(waitFailure("wait_for_text", e));
}
return RespBodyVo.ok();
}
wait_for_url
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_url", (svc, id, a) -> svc.waitForUrl(id, reqStr(a, "url"), a.getDouble("timeoutSeconds")));
展开 waitForUrl 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForUrl(Long browserId, String url, Double timeoutSeconds) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
try {
inst.page.waitForURL(url, new Page.WaitForURLOptions().setTimeout(timeoutMillis(timeoutSeconds)));
} catch (PlaywrightException e) {
return RespBodyVo.fail(waitFailure("wait_for_url", e));
}
return RespBodyVo.ok(Kv.by("url", inst.page.url()));
}
wait_for_load
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_load", (svc, id, a) -> svc.waitForLoad(id, optStr(a, "state"), a.getDouble("timeoutSeconds")));
展开 waitForLoad 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForLoad(Long browserId, String state, Double timeoutSeconds) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
LoadState loadState = "networkidle".equalsIgnoreCase(state) ? LoadState.NETWORKIDLE
: "domcontentloaded".equalsIgnoreCase(state) ? LoadState.DOMCONTENTLOADED : LoadState.LOAD;
try {
inst.page.waitForLoadState(loadState,
new Page.WaitForLoadStateOptions().setTimeout(timeoutMillis(timeoutSeconds)));
} catch (PlaywrightException e) {
return RespBodyVo.fail(waitFailure("wait_for_load", e));
}
return RespBodyVo.ok();
}
wait_for_function
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_function",
(svc, id, a) -> svc.waitForFunction(id, reqStr(a, "expression"), a.getDouble("timeoutSeconds")));
展开 waitForFunction 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForFunction(Long browserId, String expression, Double timeoutSeconds) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
try {
inst.page.waitForFunction(expression, null,
new Page.WaitForFunctionOptions().setTimeout(timeoutMillis(timeoutSeconds)));
} catch (PlaywrightException e) {
return RespBodyVo.fail(waitFailure("wait_for_function", e));
}
return RespBodyVo.ok();
}
wait_for_idle
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_idle", (svc, id, a) -> svc.waitForIdle(id, a.getInteger("quietMs"), a.getDouble("timeoutSeconds"),
optStr(a, "selector"), optStr(a, "text")));
展开 waitForIdle 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForIdle(Long browserId, Integer quietMs, Double timeoutSeconds, String selector, String text) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
long quiet = quietMs == null || quietMs < 0 ? 500 : quietMs;
long timeout = (long) timeoutMillis(timeoutSeconds);
long startedAt = System.currentTimeMillis();
long deadline = startedAt + timeout;
int mutations = -1;
long stableSince = startedAt;
int inflight = 0;
while (true) {
long now = System.currentTimeMillis();
int current = readMutationCount(inst);
inflight = inst.inflight.get();
boolean extra = (selector == null || selector.isBlank() || countOf(inst, selector) > 0)
&& (text == null || text.isBlank() || bodyContains(inst, text));
if (current != mutations) {
mutations = current;
stableSince = now;
}
if (extra && inflight == 0 && now - stableSince >= quiet) {
return RespBodyVo.ok(Kv.by("idle", true).set("waitedMs", now - startedAt).set("mutations", mutations)
.set("inflight", 0).set("quietMs", quiet).set("timeoutSeconds", timeout / 1000.0)
.set("url", safeUrl(inst.page)));
}
if (now >= deadline) {
Kv data = Kv.by("idle", false).set("waitedMs", now - startedAt).set("mutations", mutations)
.set("inflight", inflight).set("quietMs", quiet).set("url", safeUrl(inst.page))
.set("selectorMatched", selector == null || selector.isBlank() || countOf(inst, selector) > 0)
.set("textMatched", text == null || text.isBlank() || bodyContains(inst, text));
RespBodyVo failure = RespBodyVo.fail("wait_for_idle 失败:" + (timeout / 1000.0) + " 秒内页面没有安静下来"
+ "(在途请求 " + inflight + " 个,DOM 变更累计 " + mutations + " 次)");
failure.setData(data);
return failure;
}
inst.page.waitForTimeout(100);
}
}
wait_for_stable
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_stable", (svc, id, a) -> svc.waitForStable(id, optStr(a, "selector"), a.getInteger("quietMs"),
a.getDouble("timeoutSeconds")));
展开 waitForStable 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForStable(Long browserId, String selector, Integer quietMs, Double timeoutSeconds) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
long quiet = quietMs == null || quietMs < 0 ? 800 : quietMs;
long timeout = (long) timeoutMillis(timeoutSeconds);
long startedAt = System.currentTimeMillis();
long deadline = startedAt + timeout;
String last = null;
long stableSince = startedAt;
int changes = 0;
int length = 0;
while (true) {
long now = System.currentTimeMillis();
Kv probe = contentProbe(inst, selector);
if (probe == null) {
RespBodyVo failure = RespBodyVo.fail("wait_for_stable 失败:读不到内容指纹"
+ (selector == null || selector.isBlank() ? "" : "(选择器 " + selector + " 没有命中元素)"));
failure.setData(Kv.by("stable", false).set("selector", selector).set("url", safeUrl(inst.page)));
return failure;
}
String fingerprint = probe.getStr("fingerprint");
length = asInt(probe.get("length"));
if (!Objects.equals(fingerprint, last)) {
last = fingerprint;
stableSince = now;
changes++;
}
if (now - stableSince >= quiet) {
return RespBodyVo.ok(Kv.by("stable", true).set("waitedMs", now - startedAt).set("quietMs", quiet)
.set("changes", changes).set("length", length).set("fingerprint", fingerprint)
.set("text", probe.get("text")).set("selector", selector).set("url", safeUrl(inst.page)));
}
if (now >= deadline) {
RespBodyVo failure = RespBodyVo.fail("wait_for_stable 失败:" + (timeout / 1000.0)
+ " 秒内内容一直没稳定下来(已变化 " + changes + " 次,最近一次变化在 " + (now - stableSince) + " ms 前)");
failure.setData(Kv.by("stable", false).set("waitedMs", now - startedAt).set("changes", changes)
.set("quietMs", quiet).set("msSinceLastChange", now - stableSince).set("url", safeUrl(inst.page)));
return failure;
}
inst.page.waitForTimeout(150);
}
}
wait_for_count
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("wait_for_count", (svc, id, a) -> svc.waitForCount(id, reqStr(a, "selector"), a.getInteger("min"),
a.getInteger("max"), a.getInteger("equals"), a.getDouble("timeoutSeconds")));
展开 waitForCount 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo waitForCount(Long browserId, String selector, Integer min, Integer max, Integer equals,
Double timeoutSeconds) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
if (min == null && max == null && equals == null) {
return RespBodyVo.fail("wait_for_count 需要 min / max / equals 里至少一个条件");
}
long timeout = (long) timeoutMillis(timeoutSeconds);
long startedAt = System.currentTimeMillis();
long deadline = startedAt + timeout;
int count = 0;
while (true) {
count = countOf(inst, selector);
if ((min == null || count >= min) && (max == null || count <= max) && (equals == null || count == equals)) {
return RespBodyVo.ok(Kv.by("matched", true).set("count", count).set("waitedMs",
System.currentTimeMillis() - startedAt).set("selector", selector).set("url", safeUrl(inst.page)));
}
long now = System.currentTimeMillis();
if (now >= deadline) {
RespBodyVo failure = RespBodyVo.fail("wait_for_count 失败:" + (timeout / 1000.0) + " 秒内 " + selector
+ " 的命中数没达到条件(实际 " + count + describeCountCondition(min, max, equals) + ")");
failure.setData(Kv.by("matched", false).set("count", count).set("waitedMs", now - startedAt)
.set("selector", selector).set("url", safeUrl(inst.page)));
return failure;
}
inst.page.waitForTimeout(120);
}
}
execute_js
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("execute_js", (svc, id, a) -> {
if (optStr(a, "body") == null && optStr(a, "bodyFile") == null) {
// 两个都没给:报「缺少参数 body」,与老版本的行为一致(老版本只认 body)
throw new IllegalArgumentException("缺少参数 body");
}
return svc.executeJs(id, a.getString("body"), optStr(a, "bodyFile"), a.getJSONObject("vars"),
optStr(a, "frame"));
});
展开 executeJs 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo executeJs(Long browserId, String body, String bodyFile, JSONObject vars, String frame) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return RespBodyVo.fail("没有找到对应的浏览器实例:" + browserId);
}
Frame target;
try {
target = frameOf(inst, frame);
} catch (IllegalArgumentException e) {
return RespBodyVo.fail("execute_js 失败:" + e.getMessage());
}
String raw = body;
if ((raw == null || raw.isBlank()) && bodyFile != null && !bodyFile.isBlank()) {
String fromFile = readScriptFile(bodyFile);
if (fromFile == null) {
return RespBodyVo.fail("execute_js 失败:读不到脚本文件 " + bodyFile
+ "(只允许读脚本目录下的文件,见 get_config 的 jsDir)");
}
raw = fromFile;
}
if (raw == null || raw.isBlank()) {
return RespBodyVo.fail("execute_js 需要 body 或 bodyFile");
}
String script = normalizeScript(applyVars(raw, vars));
try {
Object result = target.evaluate(script);
Kv data = Kv.by("result", result);
if (vars != null && !vars.isEmpty()) {
data.set("varsApplied", new ArrayList<>(vars.keySet()));
}
if (frame != null && !frame.isBlank()) {
data.set("frame", frame).set("frameUrl", safeFrameUrl(target));
}
// 「到底等没等 Promise」以前只能靠猜,于是有人退回同步 XHR 来规避。这里如实回报
data.set("awaited", true);
return RespBodyVo.ok(data);
} catch (PlaywrightException e) {
String message = briefMessage(e.getMessage());
log.error("execute_js 执行失败,id:{},script:{},error:{}", browserId, script, message, e);
RespBodyVo failure = RespBodyVo.fail("执行 JavaScript 失败:" + message);
Kv detail = Kv.by("error", scriptError(e)).set("scriptPreview", truncate(script, 400))
.set("scriptLength", script.length());
// 「脚本被截断」是 Windows 上最常见的一类假故障:多行脚本经 cmd/PowerShell 传参时被吃掉,
// 到服务端的只剩第一行,报错却是语法级的 "Unexpected end of input" —— 只说语法,人根本想不到
// 是传输层把脚本切了。这里直接说破,并给出传文件的写法
if (looksTruncatedScript(message)) {
detail.set("hint", "脚本像是被截断了(报的是 " + message + "):多行脚本走命令行时可能只到了第一行。"
+ "改用文件传:服务端把脚本放进 scripts/js 目录后传 bodyFile,或客户端用 js @脚本.js");
}
failure.setData(detail);
return failure;
}
}
