源码教程:文件上传、截图、PDF 与 OCR
这一组命令连接浏览器对象与服务端文件系统。路径属于服务端,不能把客户端路径直接当成服务器可以打开的文件。文件传输协议见第 12 篇,OCR 使用方式见第 16 篇。
1. upload_file 的两个入口分支
注册层先检查 contentBase64 或 url:存在时调用 uploadFileInline,先落盘,再进入页面上传;否则要求 path,调用 uploadFile。因此一次调用应明确选择一种来源,不要同时传互相矛盾的文件来源。
页面上传最终定位 input[type=file] 并使用浏览器文件输入能力设置文件。file input 常被隐藏,不能套用普通按钮的“只挑可见元素”策略。selector 与 index 至少选择一个,iframe 中的 selector 还需要 frame。
{"id":"1001","method":"upload_file","params":{"selector":"input[type=file]","path":"tmp/sample-logo.png"}}
path 需要按上传存储模块的解析规则使用;通过上传接口获得的相对路径按服务端上传目录解析,使用绝对路径时则指向服务端已有文件。不要假定所有命令的相对路径基准相同,OCR 文件路径的基准是进程工作目录。
实现不能只检查 setInputFiles 是否返回:框架可能消费文件后重置 input,甚至替换整个节点。回读应解释 filesLength、监听信息、consumed 和页面变化,避免因新 input 为空误判失败。上传后出现裁剪窗口时,需完成裁剪,不能直接再次上传。
2. screenshot 的目标选择
screenshot 优先处理 index/selector 指定的元素;没有元素定位时使用 Page.screenshot。只有 clipX、clipY、clipWidth、clipHeight 全部存在时才设置裁剪区域。fullPage 控制整页截图,不应同时把“元素截图”和“整页截图”当成两张产物。
默认不返回 Base64。未指定 path 且没有要求 inline 时,会生成任务目录中的 shot-N.png;指定 inline 才把图片编码放入结果。图像路径在任务 data 目录内时可生成 image URL,任意本地路径不会自动变成公开下载地址。
截图期间实现会隐藏交互高亮层,再恢复它,避免彩色编号挡住二维码或表单。截图失败应返回错误或降级说明,不凭页面文本声称已经取得图片。
{"id":"1001","method":"screenshot","params":{"fullPage":false,"inline":false}}
3. get_element_screenshot 与 pdf
get_element_screenshot 复用元素截图辅助逻辑,支持 frame。它通过浏览器截图获取像素,不是将跨域图片画到 canvas 后读取,所以不受同一种 canvas 污染限制。返回路径、URL 或显式请求的 Base64。
pdf 使用当前 Page 的 PDF 能力并保存文件,不是先做 OCR 再生成文档。该能力受浏览器引擎支持范围约束;不能向所有引擎承诺可用。导出前应等待字体、图片和业务内容完成加载。
4. ocr_image 的源码链
path 分支:解析文件路径并检查存在
元素分支:解析任务与 Frame → 元素截图
→ WindowsOcr.read
→ 将内置脚本写临时文件(处理 UTF-8 BOM)
→ Windows PowerShell 调用 Windows.Media.Ocr
→ 从输出文件读取文字与错误标识
→ data.ok/text/language/lineCount 等字段
→ 清理临时脚本和输出文件
PlaywrightService.ocrImage 即使识别失败,也可能以外层成功响应返回内部 OCR 结果。客户端必须检查 data.ok。语言缺失、平台不支持、文件不存在和文字识别为空是不同情况。
请求 language 不等于实际引擎语言;当前脚本会尝试用户语言回退,但未返回实际 engineLanguage。阅读源码时应核对返回语句,不能只照抄注释。
5. 验证方法
使用无个人信息的本地示例图片测试上传、节点替换、隐藏 input 和裁剪流程;验证默认截图只返回文件位置,inline 才返回 Base64,路径与文件实际一致。OCR 使用人为制作的示例文字,并分别检查语言包可用与不可用的返回。截图和 OCR 结果均应按敏感资料管理,默认留档不等于自动脱敏。
注册参数与 Java 入口
以下按 CommandTable 实际读取参数整理。* 表示注册层使用必填读取器;其余字段省略后由服务决定默认行为。带条件的入口仍需满足正文说明,例如上传文件来源、元素定位二选一。外层 id 不重复列出。
| 命令 | params 字段 | Java 入口 |
|---|---|---|
upload_file | contentBase64、url、index、selector、filename、contentType、timeoutMs、frame、path* | uploadFile / uploadFileInline |
screenshot | path、fullPage、index、selector、clipX、clipY、clipWidth、clipHeight、inline | screenshot |
get_element_screenshot | index、selector、path、inline、frame | getElementScreenshot |
pdf | path | pdf |
ocr_image | path、index、selector、frame、language | ocrImage |
当前源码:命令注册与执行
先在本章上半部分理解行为,再按命令展开实现。注册代码说明 JSON 参数如何传给 Java;服务方法展示实际浏览器操作。方法依赖共享类中的字段和辅助函数,不应脱离原类直接粘贴编译。
upload_file
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("upload_file", (svc, id, a) -> {
if (optStr(a, "contentBase64") != null || optStr(a, "url") != null) {
return svc.uploadFileInline(id, a.getInteger("index"), optStr(a, "selector"), optStr(a, "filename"),
optStr(a, "contentType"), optStr(a, "contentBase64"), optStr(a, "url"), a.getInteger("timeoutMs"),
optStr(a, "frame"));
}
return svc.uploadFile(id, a.getInteger("index"), optStr(a, "selector"), reqStr(a, "path"),
a.getInteger("timeoutMs"), optStr(a, "frame"));
});
展开 uploadFileInline 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo uploadFileInline(Long browserId, Integer index, String selector, String filename,
String contentType, String contentBase64, String url, Integer timeoutMs, String frame) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return RespBodyVo.fail("没有找到对应的浏览器实例:" + browserId);
}
if ((contentBase64 == null || contentBase64.isBlank()) && (url == null || url.isBlank())) {
return RespBodyVo.fail("upload_file 需要 path,或 contentBase64 / url 之一");
}
byte[] data;
String resolvedContentType = contentType;
try {
if (contentBase64 != null && !contentBase64.isBlank()) {
String payload = contentBase64.trim();
// 允许直接贴 data URI
int comma = payload.indexOf(',');
if (payload.startsWith("data:") && comma > 0) {
String header = payload.substring(5, comma);
int semicolon = header.indexOf(';');
if (resolvedContentType == null) {
resolvedContentType = semicolon > 0 ? header.substring(0, semicolon) : header;
}
payload = payload.substring(comma + 1);
}
data = Base64.getDecoder().decode(payload.replaceAll("\\s+", ""));
} else {
data = download(url, resolvedContentType);
if (resolvedContentType == null) {
resolvedContentType = contentTypeOf(url);
}
}
} catch (IllegalArgumentException e) {
return RespBodyVo.fail("upload_file 失败:contentBase64 不是合法的 base64(" + e.getMessage() + ")");
} catch (IOException e) {
return RespBodyVo.fail("upload_file 失败:下载 " + url + " 出错(" + briefMessage(e.getMessage()) + ")");
}
Kv saved;
try {
saved = UploadStore.save(filename, resolvedContentType, data);
} catch (IOException e) {
return RespBodyVo.fail("upload_file 失败:写入服务端暂存目录出错(" + briefMessage(e.getMessage()) + ")");
}
RespBodyVo uploaded = uploadFile(browserId, index, selector, saved.getStr("path"), timeoutMs, frame);
if (!uploaded.isOk()) {
return uploaded;
}
Kv data2 = uploaded.getData() instanceof Kv ? (Kv) uploaded.getData() : new Kv();
data2.set(saved);
data2.set("source", contentBase64 != null && !contentBase64.isBlank() ? "contentBase64" : "url");
uploaded.setData(data2);
return uploaded;
}
screenshot
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("screenshot",
(svc, id, a) -> svc.screenshot(id, optStr(a, "path"), a.getBoolean("fullPage"), a.getInteger("index"),
optStr(a, "selector"), a.getDouble("clipX"), a.getDouble("clipY"), a.getDouble("clipWidth"),
a.getDouble("clipHeight"), a.getBoolean("inline")));
展开 screenshot 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo screenshot(Long browserId, String path, Boolean fullPage, Integer index, String selector,
Double clipX, Double clipY, Double clipWidth, Double clipHeight, Boolean inline) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
if (index != null || (selector != null && !selector.isEmpty())) {
return elementScreenshot(inst, index, selector, path, inline);
}
boolean wantInline = inline != null && inline;
String target = path;
if ((target == null || target.isEmpty()) && !wantInline) {
target = defaultShotPath(inst);
}
Page.ScreenshotOptions options = new Page.ScreenshotOptions().setFullPage(fullPage != null && fullPage);
if (clipX != null && clipY != null && clipWidth != null && clipHeight != null) {
options.setClip(clipX, clipY, clipWidth, clipHeight);
}
if (target != null && !target.isEmpty()) {
ensureParent(target);
options.setPath(Paths.get(target));
}
try {
byte[] bytes = spuriousRetry(() -> withHighlightHidden(inst, () -> inst.page.screenshot(options)));
Kv data = Kv.by("size", bytes.length);
if (target != null && !target.isEmpty()) {
data.set("path", target);
data.set("url", shotUrl(inst, target));
}
if (wantInline) {
data.set("base64", Base64.getEncoder().encodeToString(bytes));
data.set("inline", true);
} else {
data.set("inline", false).set("base64Omitted", true).set("note",
"默认不返回 base64(会把整张图塞进上下文):要看图请 GET data.url,确实需要内联再传 inline=true");
}
return RespBodyVo.ok(data);
} catch (PlaywrightException e) {
return RespBodyVo.fail("screenshot 失败:" + briefMessage(e.getMessage()));
}
}
get_element_screenshot
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("get_element_screenshot", (svc, id, a) -> svc.getElementScreenshot(id, a.getInteger("index"),
optStr(a, "selector"), optStr(a, "path"), a.getBoolean("inline"), optStr(a, "frame")));
展开 getElementScreenshot 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo getElementScreenshot(Long browserId, Integer index, String selector, String path,
Boolean inline, String frame) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
return elementScreenshot(inst, index, selector, path, inline, frame);
}
pdf
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("pdf", (svc, id, a) -> svc.pdf(id, optStr(a, "path")));
展开 pdf 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo pdf(Long browserId, String path) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
SharedBrowser browser = sharedBrowser;
if (browser != null && browser.engine.isFirefox()) {
// 与其让 Playwright 抛一句英文的「only supported in Chromium」,不如直接说清怎么改
return RespBodyVo.fail("pdf 失败:PDF 导出只有 Chromium 支持,当前引擎是 firefox"
+ "(browser=firefox 或 browser.engine=firefox),需要 PDF 时请换成 Chromium 系的浏览器再 start:"
+ "browser=chrome / browser=chromium / browser=edge");
}
String target = (path == null || path.isEmpty()) ? defaultPdfPath() : path;
try {
ensureParent(target);
inst.page.pdf(new Page.PdfOptions().setPath(Paths.get(target)));
return RespBodyVo.ok(Kv.by("path", target));
} catch (PlaywrightException e) {
return RespBodyVo.fail("pdf 失败:" + briefMessage(e.getMessage()));
}
}
ocr_image
展开参数注册
源码:playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
put("ocr_image", (svc, id, a) -> svc.ocrImage(id, optStr(a, "path"), a.getInteger("index"),
optStr(a, "selector"), optStr(a, "frame"), optStr(a, "language")));
展开 ocrImage 实现
源码:playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java。以下为当前实现,可放回原类中阅读;依赖同类字段和辅助方法,并非独立编译单元。
public RespBodyVo ocrImage(Long browserId, String path, Integer index, String selector, String frame,
String language) {
Path image = null;
String imageUrl = null;
String target;
if (path != null && !path.isBlank()) {
image = Paths.get(path).toAbsolutePath().normalize();
target = "path=" + path;
BrowserInstance inst = INSTANCES.get(browserId);
if (inst != null) {
// 落盘在 data/<id>/ 下的图可以直接 GET,贴给用户看
imageUrl = shotUrl(inst, image.toString());
}
} else if (index != null || (selector != null && !selector.isBlank())) {
BrowserInstance inst = INSTANCES.get(browserId);
if (inst == null) {
return notFound(browserId);
}
String shotPath = defaultShotPath(inst);
RespBodyVo shot = elementScreenshot(inst, index, selector, shotPath, Boolean.FALSE, frame);
if (!shot.isOk()) {
return shot;
}
Kv shotData = shot.getData() instanceof Kv ? (Kv) shot.getData() : new Kv();
image = shotData.getStr("path") == null ? null : Paths.get(shotData.getStr("path"));
imageUrl = shotData.getStr("url");
target = String.valueOf(shotData.get("target"));
} else {
return RespBodyVo.fail("ocr_image 需要 path,或 index / selector 之一");
}
if (image == null || !Files.isRegularFile(image)) {
return RespBodyVo.fail("ocr_image 找不到图片:" + image);
}
Kv read = WindowsOcr.read(image, language);
Kv data = new Kv();
data.putAll(read);
data.set("target", target).set("imagePath", image.toAbsolutePath().toString());
if (imageUrl != null) {
data.set("imageUrl", imageUrl);
}
if (!Boolean.TRUE.equals(read.get("ok"))) {
data.set("ocrSupported", WindowsOcr.available());
}
return RespBodyVo.ok(data);
}
