Skip to content

源码教程:等待条件与 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;这时应等待业务文字、具体元素数量或某个数据区域稳定,而不是无限增加超时。

json
{"id":"1001","method":"wait_for_count","params":{"selector":"#results tbody tr","min":1,"timeoutSeconds":10}}
json
{"id":"1001","method":"wait_for_stable","params":{"selector":"#results","quietMs":500,"timeoutSeconds":10}}

列表稳定不代表它已经从旧结果刷新到新结果。先观察加载标记、请求完成或查询条件对应的结果标识,再等待稳定,能避免读取上一次的数据。

稳定等待的循环如何写 ​

waitForStable 使用 contentProbe 获取内容指纹,指纹改变就重置稳定起点。下面省略响应构造,保留源码的判断顺序:

java
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。

json
{"id":"1001","method":"execute_js","params":{"body":"() => ({title: document.title, count: document.querySelectorAll('article').length})","retryOnSpurious":true}}

bodyFile 只能从配置允许的脚本目录读取;不能把任意服务器路径暴露成脚本执行入口。vars 用于替换模板变量:applyVars 将值 JSON 编码,再替换 或双引号包裹的 ""。模板变量应占据完整的 JavaScript 值位置,不用于拼接标识符、属性名或半截字符串。

json
{"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。导航、对象释放与脚本语法错误也必须区别处理。

自动重试的起因不止「事件泵伪故障」一种:页面正在导航/重建(主 frame 为 null)时,安全名单里的只读命令同样会被服务端有限重试,回执用 pageNavigatingRetry 与 spuriousRetry 区分这次重发到底是哪一种起因;动作类命令两种起因都不自动重发。

retryOnSpurious 是按调用点声明的开关,对任何命令都有效(不只是 execute_js):判据里「命令名是否在安全名单」只决定默认行为,调用方的显式声明可以放行动作类命令的一次有限重发。两者的取舍相同 —— 只读、或确认上一次没生效时才声明。

Windows 命令行报 Unexpected end of input 时,检查脚本是否在传输中被截断,优先将参数写 UTF-8 JSON 文件,或使用 bodyFile;不要反复修改一段本来正确的 JavaScript。

4. 本地验证 ​

fixture 在延时后增加一行表格,再在稍后修改单元格。分别验证 count、stable 和 function 的触发时机;用持续网络轮询验证 idle 能超时而不是误判成功。脚本测试覆盖字符串、对象、Promise、语法错误和只读脚本的有限重试。等待失败后先读取当前状态,不自动再次点击“查询”。

注册参数与 Java 入口 ​

以下按 CommandTable 实际读取参数整理。* 表示注册层使用必填读取器;其余字段省略后由服务决定默认行为。带条件的入口仍需满足正文说明,例如上传文件来源、元素定位二选一。外层 id 不重复列出。

命令params 字段Java 入口
waitseconds*waitSeconds
wait_for_elementselector*、timeoutSeconds、framewaitForElement
wait_for_texttext*、timeoutSecondswaitForText
wait_for_urlurl*、timeoutSecondswaitForUrl
wait_for_loadstate、timeoutSecondswaitForLoad
wait_for_functionexpression*、timeoutSecondswaitForFunction
wait_for_idlequietMs、timeoutSeconds、selector、textwaitForIdle
wait_for_stableselector、quietMs、timeoutSecondswaitForStable
wait_for_countselector*、min、max、equals、timeoutSecondswaitForCount
execute_jsbody、bodyFile、vars、frame、retryOnSpuriousexecuteJs

当前源码:命令注册与执行 ​

先在本章上半部分理解行为,再按命令展开实现。注册代码说明 JSON 参数如何传给 Java;服务方法展示实际浏览器操作。方法依赖共享类中的字段和辅助函数,不应脱离原类直接粘贴编译。

wait ​

展开参数注册

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

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。

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;
    }
  }