Java 程序员的浏览器自动化利器:Jvppeteer 从入门到实战

先说个事实:在浏览器自动化这个领域,Node.js 的 Puppeteer 已经统治了好几年。Python 有 Playwright 做替代,Go 有 chromedp,唯独 Java 这边一直缺一个"用起来顺手"的方案。Selenium?太重。HtmlUnit?功能不够。直到 Jvppeteer 出现,Java 开发者终于有了一个基于 Chrome DevTools Protocol 的、API 设计跟 Puppeteer 几乎一致的浏览器控制库。
老实说,我第一次接触 Jvppeteer 的时候,并没有太当回事——又是一个 Puppeteer 的 Java 翻译版嘛,能有啥新东西?但真正用起来才发现,这东西解决的是一个非常具体但又非常普遍的痛点:Java 后端程序员需要操控浏览器,但不想碰 Node.js。
这篇文章,我会先聊聊 Jvppeteer 的核心能力,再结合当下的 AI Agent 热潮看看它能玩出什么花样,最后用一个"京东金融积存金下限价自动下单"的真实场景,把代码跑一遍——不是玩具 demo,是真正在生产环境跑的代码。
先搞清楚 Jvppeteer 是什么
Jvppeteer 的灵感来自 Google 官方的 Puppeteer,API 设计基本保持一致。它通过 Chrome DevTools Protocol 直接操控 Chromium 或 Chrome 浏览器,默认以 headless(无头)模式运行,也支持有头模式方便调试。
GitHub 地址:github.com/fanyong920/jvppeteer
Maven 依赖:
<dependency>
<groupId>io.github.fanyong920</groupId>
<artifactId>jvppeteer</artifactId>
<version>3.6.2</version>
</dependency>
注意是 3.6.2,最新版。跟早期版本相比,API 有变化——比如启动入口从 com.ruiyun.jvppeteer.core.Puppeteer 改成了 com.ruiyun.jvppeteer.cdp.core.Puppeteer,LaunchOptions 也改用了 Builder 模式。网上很多旧教程还在用 1.x 的写法,别被坑了。
核心能力包括:
- 页面导航:打开 URL、前进后退、等待页面加载
- 元素操作:点击、输入、选择下拉框、上传文件
- JavaScript 注入:在页面上下文中执行任意 JS,这是最强的能力
- 截图与 PDF:全页截图、元素截图、生成 PDF
- 网络拦截:监听请求、修改请求头、拦截响应
- Cookie 管理:读取、设置、清除 Cookie
- 多标签页:创建、切换、关闭标签页
- 移动端模拟:自定义 User-Agent + Viewport,模拟手机浏览器
说白了,你在 Chrome 里能手动做的操作,Jvppeteer 基本都能帮你用代码实现。

当 AI Agent 遇上浏览器自动化
2025 年,AI Agent 已经不是什么新鲜概念了。大家都在聊"让 AI 替你干活",但有一个很现实的问题:AI 的"手"在哪里?
大语言模型能做推理、能做决策、能生成内容,但它没法直接帮你打开一个网页、填写表单、点击按钮。这就是"最后一公里"的问题——AI 需要一个能操作真实软件界面的工具。
Jvppeteer 恰好能补上这块拼图。
想象一下这个链路:AI Agent 根据你的需求做出决策(比如"今天金价低于 1002 元/克就买入 1200 元"),然后把决策翻译成 Jvppeteer 的操作序列,自动打开交易页面、填入价格、提交订单。整个过程不需要人工干预。
这种模式现在有个很火的叫法:Computer Use Agent(CUA)。Anthropic 的 Claude 推出了 Computer Use 功能,OpenAI 也在搞 Operator,本质上都是让 AI 模型通过"看屏幕 + 操控鼠标键盘"来完成任务。
而 Jvppeteer 给 Java 生态提供了一个更底层、更可控的选择——你不需要依赖某个 AI 厂商的黑盒方案,自己写代码就能搭一个。

实战:京东金融积存金下限价自动买卖
聊了这么多理论,来点真正的干货。
京东金融 App 里支持积存金交易,这东西跟炒股票差不多——看准价格低点买入,高点卖出。但问题是,你得盯盘。金价一天波动好几轮,手动盯?太累了。作为一个程序员,这种又费心又重复的事情,交给程序去做就好了。
第一个想法肯定是:行情数据我抓个包拿到接口,直接调 API 不就完了?行情接口确实可以这么干,但一旦涉及到下单、支付,画风就完全不一样了——参数加密、签名验证、风控校验、设备指纹,安全措施一堆。真要去逆向破解这些,光签名算法就够你喝一壶的,而且它还会不定期更新,你破解的速度赶不上人家改的速度。
所以我的策略很简单:行情数据用接口拿,下单操作用浏览器模拟。浏览器操作走的是正常的用户流程,跟你自己手动点一模一样,不需要破解任何加密,不需要关心接口签名。京东看到的就是"一个正常用户在用手机浏览器操作",风控也挑不出毛病。
下面我把整套方案的核心设计思路和关键代码拆开讲。
先看下效果图:

整体架构:配置驱动 + 分层设计
先看下整体结构,不是上来就写一坨 main 方法:
public class GoldAutomation {
// ==================== 常量 ====================
private static final String DATA_DIR = "browser-data";
private static final DateTimeFormatter LOG_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter FILE_FMT =
DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
// ==================== 配置 ====================
private Properties cfg = new Properties();
private String chromePath;
private boolean headless;
private int vw, vh;
private double scale;
private String targetUrl;
private String phone;
private String buyPrice;
private String buyAmount;
private String deadline;
private String password;
private int timeoutPage;
private int timeoutElement;
private int retryKb;
private int delayStep;
private String screenshotDir;
private String logFile;
private boolean screenshotEveryStep;
private String runId;
}
所有参数都外置到 config.properties,改配置不用重新编译:
# ========== 浏览器设置 ==========
chrome.executable=/usr/bin/google-chrome
chrome.headless=false
viewport.width=390
viewport.height=844
viewport.scale=3.0
# ========== 条件单设置 ==========
order.price=1002.1
order.amount=1200
order.deadline=1日
order.password=123456
# ========== 超时与重试 ==========
timeout.pageLoad=10000
timeout.element=5000
retry.keyboard=3
delay.step=1200
# ========== 输出设置 ==========
output.screenshotDir=browser-data/screenshots
output.logFile=browser-data/automation.log
output.screenshotEveryStep=true
命令行也可以直接覆盖参数:
java -jar gold-automation.jar --price 980 --amount 1500
java -jar gold-automation.jar --headless --price 975
这种设计的好处是:同样的代码,开发时用有头模式调试,部署到服务器上加 --headless 就行,其他一行不用改。
启动浏览器:移动端模拟 + 状态持久化
京东金融的 Web 版走的是移动端 H5,所以不是简单地打开一个桌面浏览器就行,需要模拟手机:
public void run() {
// 用户数据目录,持久化 cookies/session,避免每次重新登录
String userDataDir = System.getProperty("user.home")
+ File.separator + ".browser-gold-data";
// 启动浏览器
LaunchOptions.Builder builder = LaunchOptions.builder();
builder
.args(List.of(
"--no-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage", "--disable-gpu",
"--lang=zh-CN", "--disable-extensions"))
.headless(headless)
.userDataDir(userDataDir);
if (StringUtils.isNotBlank(chromePath)) {
builder.executablePath(chromePath);
}
LaunchOptions opts = builder.build();
Browser browser = Puppeteer.launch(opts);
Page page = browser.pages().get(0);
// 设置移动端模拟:iPhone 14 Pro
Viewport vp = new Viewport(vw, vh, scale, true, false, false);
page.setViewport(vp);
page.setUserAgent(
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) " +
"Version/16.0 Mobile/15E148 Safari/604.1");
}
几个关键点:
userDataDir:把浏览器状态存在本地目录,下次启动自动恢复 Cookie,不用重新扫码登录Viewport(390, 844, 3.0):模拟 iPhone 14 Pro 的屏幕尺寸和 DPR- 注册了 JVM 关闭钩子(ShutdownHook),
Ctrl+C时能正常关闭浏览器
登录与状态恢复
登录是自动化里最麻烦的环节。京东金融需要短信验证码,没法完全自动化。我的策略是:
// 恢复/执行登录
boolean loggedIn = false;
if (Files.exists(Paths.get(userDataDir))) {
log("发现已保存状态,尝试恢复...");
safeGoTo(page, targetUrl);
waitForLoad(page);
loggedIn = checkLoginStatus(page);
log(loggedIn ? "✓ 登录状态有效!" : "⚠ 已过期,需重新登录");
}
if (!loggedIn) {
safeGoTo(page, targetUrl);
waitForLoad(page);
doLogin(page);
log("✓ 登录完成,状态已保存");
}
登录检测逻辑很实用——通过检查页面上是否有"条件单"文字来判断登录态:
private boolean checkLoginStatus(Page page) {
try {
Thread.sleep(2000);
// 有手机号输入框 = 未登录
ElementHandle tel = page.$("input[type='tel']");
if (tel != null) return false;
// 有"条件单"等已登录特征 = 已登录
Object r = page.evaluate("() => { " +
"let els = document.querySelectorAll('*'); " +
"for (let e of els) { " +
" if (e.offsetParent && e.textContent.includes('条件单')) " +
" return true; } return false; }");
return Boolean.TRUE.equals(r);
} catch (Exception e) {
return false;
}
}
首次登录时需要手动扫码/输入验证码,脚本会暂停等你操作:
log("");
log(">>> 请在浏览器中手动完成登录(如输入验证码)<<<");
log(">>> 完成后按 Enter 继续...");
new Scanner(System.in).nextLine();
虚拟键盘操作:这才是真功夫
京东金融的移动端页面用了自定义虚拟键盘,不是标准 HTML input。你以为用 page.type() 就能输入?天真了。那个键盘是一堆 <div class="itemW"> 按钮,得用 JS 模拟点击。
先判断键盘是否出现:
private boolean waitForKeyboard(Page page) throws Exception {
for (int i = 0; i < 10; i++) {
Object r = page.evaluate("""
() => {
const exists = document.querySelector(
".jr-aks-sec-keyboard-panel");
if (exists) { return true; }
return false;
}
""");
if (Boolean.TRUE.equals(r)) return true;
Thread.sleep(300);
}
return false;
}
然后按虚拟键盘上的按钮:
private boolean pressKeyboard(Page page, String key) throws Exception {
for (int attempt = 0; attempt < 3; attempt++) {
try {
Object result = page.evaluate("""
(keyContent) => {
const keyboardContainer = document.querySelector(
'.jr-aks-sec-keyboard.num.itemKH');
if (!keyboardContainer) return false;
const buttons = keyboardContainer.querySelectorAll(
'.itemW, [aria-label]');
for (let button of buttons) {
const buttonText = button.textContent.trim();
const ariaLabel = button.getAttribute('aria-label');
if (buttonText === keyContent
|| ariaLabel === keyContent) {
button.click();
button.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true }));
setTimeout(() => button.dispatchEvent(
new MouseEvent('mouseup', { bubbles: true })),
50);
return true;
}
}
return false;
}
""", key);
if ("true".equals(Objects.toString(result))) {
return true;
}
} catch (Exception e) {
System.err.println("第 " + (attempt + 1) + " 次尝试失败: "
+ e.getMessage());
}
Thread.sleep(300);
}
return false;
}
整个输入流程是:点击输入框弹出键盘 → 删除旧值 → 逐字符输入新值 → 点确定 → 校验结果。而且带重试——第一次失败会自动再来,最多 3 次:
private void inputByKeyboard(Page page, String label,
String value, Predicate predicate) throws Exception {
for (int attempt = 1; attempt <= retryKb; attempt++) {
log(" 第 " + attempt + " 次尝试设置 " + label + "...");
// 点击输入框弹出键盘
jsClick(page, label);
Thread.sleep(1500);
// 等待虚拟键盘出现
boolean kbVisible = waitForKeyboard(page);
if (!kbVisible) { continue; }
// 删除旧值
for (int i = 0; i < 8; i++) {
pressKeyboard(page, "删除");
Thread.sleep(150);
}
// 输入新值
for (char c : value.toCharArray()) {
pressKeyboard(page, String.valueOf(c));
Thread.sleep(200);
}
// 点击确定
pressKeyboard(page, "确定");
Thread.sleep(1000);
// 校验结果
if (predicate.test(page)) {
log(" ✓ " + label + " = " + value + " 设置成功");
return;
}
log(" ✗ 校验失败,准备重试...");
}
takeScreenshot(page, "error_" + label + "_failed");
log(" ❌ " + label + " 设置失败,已达最大重试次数");
}
这种"输入 → 校验 → 重试"的模式,是我踩了无数坑之后才总结出来的。网页操作太脆弱了——网络慢一点、渲染差一帧、键盘没弹出来,任何一个小问题都会导致整个流程挂掉。没有重试机制的自动化脚本,在生产环境根本跑不住。
条件单主流程:8 步走
整个条件单操作拆成了 8 步,每步都有截图:
private void executeConditionOrder(Page page) throws Exception {
log("========== 条件单操作开始 ==========");
// [1/8] 点击"条件单"
jsClick(page, "条件单");
Thread.sleep(delayStep);
// [2/8] 切换到"限价买入"
jsClick(page, "限价买入");
Thread.sleep(delayStep);
// [3/8] 预约买入金价
inputByKeyboard(page, "需小于实时金价", buyPrice,
(o) -> verifyValue(page, "需小于实时金价", buyPrice));
// [4/8] 起购金额 — 先切换到"按克重"模式
jsClick(page, "按克重");
inputByKeyboard(page, "起购", buyAmount,
(o) -> verifyValueBySelector(page, ".money-input", buyAmount));
// [5/8] 截止时间
selectDeadline(page);
// [6/8] 勾选同意协议
checkAgreement(page);
// [7/8] 点击"预约买入"
jsClickElement(page, ".fixed-bottom__btn");
Thread.sleep(delayStep);
// 确认下单
jsClickElement(page, ".verify-half-new-btns");
Thread.sleep(delayStep);
// [8/8] 输入交易密码
inputPassword(page);
log("========== 条件单操作完成!==========");
}
密码输入也是走虚拟键盘,跟价格输入一样的逻辑,只是不需要清空旧值:
private void inputPassword(Page page) throws Exception {
log(" 通过虚拟键盘输入密码...");
if (waitForKeyboard(page)) {
for (char c : password.toCharArray()) {
pressKeyboard(page, String.valueOf(c));
Thread.sleep(250);
}
pressKeyboard(page, "确定");
log(" ✓ 密码已通过键盘输入");
} else {
takeScreenshot(page, "error_no_password_input");
log(" ❌ 未找到密码输入方式");
}
}
元素定位:两种方案
整个脚本用了两种元素定位方式,各有所长:
方案一:按文本内容点击——适用于按钮、链接等有明确文字的元素:
private void jsClick(Page page, String text) throws Exception {
page.evaluate("""
function findSmallestElementByTextAndClick(text, options) {
const allElements = document.querySelectorAll('*');
const matchedElements = [];
for (let el of allElements) {
const elementText = el.textContent;
let isMatch = elementText.toLowerCase()
.includes(text.toLowerCase());
// 也检查 placeholder 属性
const placeholder = el.getAttribute('placeholder');
if (placeholder &&
placeholder.toLowerCase()
.includes(text.toLowerCase())) {
isMatch = true;
}
if (isMatch) matchedElements.push(el);
}
// 过滤出最小范围的元素(没有子元素也包含该文本)
const smallest = matchedElements.filter(el => {
for (let child of el.children) {
if (matchedElements.includes(child)) return false;
}
return true;
});
if (smallest[0]) {
smallest[0].click();
return true;
}
return false;
}
""", text);
}
这个函数的精髓在于"找最小元素"——如果一个 <div> 里有文字"条件单",它的子 <span> 也有,就点击最里层的那个。不然可能点到外层容器,啥反应没有。
方案二:CSS 选择器直接定位——适用于有明确 class 的元素:
private boolean jsClickElement(Page page, String selector) throws Exception {
Object r = page.evaluate("""
function clickElement(selector) {
const element = document.querySelector(selector);
if (element && typeof element.click === 'function') {
element.click();
return true;
}
return false;
}
""", selector);
return Boolean.TRUE.equals(r);
}
实际操作中,"条件单"、"限价买入" 这种用方案一,.fixed-bottom__btn、.verify-half-new-btns 这种用方案二。
截图和日志:出了问题能回溯
每一步操作都自动截图,文件名带时间戳和步骤编号:
private void takeScreenshot(Page page, String name) {
try {
String filename = runId + "_" + name + ".png";
Path path = Paths.get(screenshotDir, filename);
ScreenshotOptions opts = new ScreenshotOptions();
opts.setPath(path.toString());
opts.setType(ImageType.PNG);
page.screenshot(opts);
log("📸 截图: " + filename);
} catch (Exception e) {
logError("截图失败: " + name, e);
}
}
截图目录结构长这样:
browser-data/screenshots/
├── 20250410_165230_01_login_page.png
├── 20250410_165235_02_phone_entered.png
├── 20250410_165240_03_login_clicked.png
├── 20250410_165245_04_after_login.png
├── 20250410_165250_06_condition_order_page.png
├── 20250410_165255_07_limit_buy_tab.png
├── 20250410_165300_08_price_set.png
├── 20250410_165305_09_amount_set.png
├── 20250410_165310_10_deadline_set.png
├── 20250410_165315_11_agreement_checked.png
├── 20250410_165320_12_submit_clicked.png
└── 20250410_165325_13_password_done.png
出问题的时候,看截图和日志就能定位到底哪一步出了问题,不用重新跑一遍。

踩坑经验
写自动化脚本,写代码只占 30% 的时间,剩下 70% 都在调试和踩坑。分享几个最典型的:
虚拟键盘比你想象的难搞:京东金融的键盘不是标准 HTML input 弹出的系统键盘,是自己用 div 画的。page.type() 根本没用,必须用 JS 找到键盘按钮然后 .click()。而且键盘的 DOM 结构在不同页面还不一样,我至少改了三版才稳定。
evaluate 返回值的坑:page.evaluate() 返回的是 Object,实际可能是 Boolean、String 或 null。直接 (Boolean)r 可能抛 ClassCastException。用 Boolean.TRUE.equals(r) 比较安全。
登录态管理是个永恒难题:京东的 Cookie有效期还是非常长的,不过过期后 userDataDir 里的状态就没用了。我的做法是检测到过期就弹有头浏览器让人手动扫码,不会傻等。
操作间隔不能太短:每个步骤之间至少等 1-2 秒。页面 DOM 渲染、网络请求、动画都需要时间。不等就操作,十次有八次要失败。我设的 delay.step=1200(毫秒)是比较稳妥的值。
Jvppeteer 还能干什么
我这里只进行抛砖引玉,除了金融自动下单,Jvppeteer 的应用场景非常广:
数据采集:抓取需要登录才能看到的页面数据,比 HttpClient 靠谱得多,因为它是"真实浏览器"访问,JS 渲染的内容也能拿到。
UI 自动化测试:替代 Selenium 做前端 E2E 测试,Jvppeteer 的 API 比 Selenium 简洁得多,不需要 WebDriver,不需要配置各种 driver。
RPA(机器人流程自动化):在企业内部系统里自动填表、提交、审批,把重复的办公流程变成脚本。很多企业的内部 OA 系统没有 API,只能从浏览器操作。
竞品监控:定时截图某个页面,或者监控价格变动、库存变化,有变化就通知你。
AI Agent 的"手":接上大语言模型,让 AI 直接操控浏览器完成复杂任务——这才是最有想象力的方向。从"写脚本自动化"到"AI 自动化",中间差的就是一个浏览器操控的能力。
写在最后
Jvppeteer 不是什么革命性的东西,它就是一个把 Puppeteer 搬到 Java 世界的工具。但就是这么一个"翻译过来"的库,解决了一个真实的、普遍的问题:Java 程序员需要操控浏览器,但不想为此引入 Node.js 技术栈。
而当你把它跟 AI Agent 结合起来,可能性就完全不一样了。Jvppeteer 补上了 Java 生态里这块缺失的拼图。
如果你手头正好有重复性的浏览器操作需求,不妨试试。代码不复杂,收益很直接。上面的积存金脚本就是最好的证明——行情接口抓包拿到数据,下单操作交给浏览器自动化,不用破解任何加密,不用逆向任何接口,每天的盯盘和手动下单全交给程序,自己该干嘛干嘛。

#Jvppeteer #Java #Puppeteer #浏览器自动化 #ChromeDevTools #AI #AIAgent #自动化运维 #RPA #爬虫 #京东金融 #积存金 #金融科技 #Java开发 #开源工具
Q.E.D.


