抓取静态页面很简单,用 HttpClient 发个请求、Jsoup 解析一下 HTML 就完事了。但现在越来越多的网站是 Vue、React 这类前端框架渲染的单页应用,接口返回的 HTML 只有一个空的 <div id="app"> 占位节点,真正的数据要等浏览器执行完 JavaScript 之后才会出现。这种页面想靠纯 HTTP 请求抓取,基本走不通。解决办法是让程序真正驱动一个浏览器去渲染页面,等 DOM 渲染完成后再提取内容,Puppeteer 就是干这件事的利器。

为什么选择 Puppeteer 而不是 Selenium
Puppeteer 是 Google 推出的 Node.js 库,直接基于 Chrome DevTools Protocol(CDP)与 Chrome 通信,省去了 WebDriver 这一中间层,启动速度快、API 简洁,对 Headless 模式的支持也是最原生的。相比之下,Selenium 需要单独维护 chromedriver,且驱动版本必须和浏览器版本严格匹配,版本一旦错位就会报 SessionNotCreatedException,运维成本明显更高。
在 Java 技术栈下使用 Puppeteer 有两条路:一是通过 Java 进程调用 Node.js 脚本,两个进程之间用消息队列或 HTTP 通信;二是直接使用 Java 版的 Puppeteer 客户端,比如社区维护的 puppeteer-java 项目,它实现了 CDP 协议的大部分能力,API 风格与官方 Node 版几乎一致。对于 Spring Boot 项目来说,第二种方式集成成本最低,不需要额外部署 Node 环境,本文以 puppeteer-java 为例讲解。
还有一点值得注意:Puppeteer 控制的是真实的 Chromium 内核,所以它抓到的页面和用户在浏览器里看到的一模一样,包括懒加载的图片、异步请求填充的数据,甚至可以绕过一部分基于 Cookie 或指纹的前置校验。这也是它在动态渲染抓取场景中流行的根本原因。
环境准备与依赖引入
首先确认服务器环境。Headless Chrome 对内存有要求,建议单实例至少分配 512MB,如果并发开 5 个页面,2GB 内存是起步配置。CentOS 或 Ubuntu 服务器需要安装 Chromium 的依赖库,最省事的方式是直接下载官方的 Chrome Headless Shell,解压即用,不依赖图形界面。
在 Spring Boot 项目的 pom.xml 中加入依赖:
<dependency>
<groupId>com.ruiyun</groupId>
<artifactId>jvppeteer</artifactId>
<version>1.1.5</version>
</dependency>jvppeteer 是 puppeteer-java 生态中维护较活跃的一个实现,支持页面导航、元素选择、截图、请求拦截、PDF 导出等核心能力。如果没有指定 executablePath,它会在首次启动时自动下载对应平台的 Chromium,生产环境建议提前下载好浏览器并显式指定路径,避免部署时联网下载失败。
浏览器启动参数也很关键。生产服务器通常没有沙箱权限,需要加上 --no-sandbox;为了降低资源占用,可以加上 --disable-gpu 和 --disable-dev-shm-usage,后者能避免容器环境下因 /dev/shm 太小导致的浏览器崩溃。示例代码如下:
ArrayList<String> args = new ArrayList<>();
args.add("--no-sandbox");
args.add("--disable-gpu");
args.add("--disable-dev-shm-usage");
LaunchOptions options = new LaunchOptions.Builder()
.args(args)
.executablePath("/opt/chrome/chrome-headless-shell")
.headless(true)
.build();
Browser browser = Puppeteer.launch(options);核心抓取逻辑的实现
浏览器启动后,抓取流程一般是:新建页面、设置视口与 UA、导航到目标 URL、等待渲染完成、提取 HTML 或特定元素文本。其中最容易被忽视的是等待策略。页面 load 事件触发不代表渲染完成,SPA 应用往往在 load 之后才发起数据请求。稳妥的做法是组合使用 waitUntil 选项和 waitForSelector,即等待某个必然出现的业务元素渲染出来后再提取内容。
Page page = browser.newPage();
page.setViewport(1280, 800);
page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
PageNavigateOptions navOptions = new PageNavigateOptions.Builder()
.setWaitUntil(Collections.singletonList("networkidle2"))
.setTimeout(30000)
.build();
page.goTo("https://ippipp.com/list", navOptions);
// 等待列表容器渲染完成,最多等 10 秒
page.waitForSelector(".data-list .item", 10000);
String html = page.content();
System.out.println("抓取到的 HTML 长度: " + html.length());networkidle2 表示网络连接数降到 2 个以下且持续 500ms,比 load 更适合动态页面。如果目标数据依赖滚动触发,还需要在抓取前执行 page.evaluate 模拟滚动到底部,把懒加载内容逼出来。需要截图留证的场景,调用 page.screenshot 即可,支持全页截图和指定区域截图。
在 Spring Boot 中,建议把 Browser 实例封装成单例 Bean,由容器管理生命周期,应用关闭时在 @PreDestroy 中调用 browser.close(),避免浏览器进程残留。每次抓取任务复用 Browser 但新建 Page,用完立即 page.close(),这样既节省了启动浏览器的开销,又不会让页面上下文互相污染。
并发控制与常见坑
并发是这类服务最容易出问题的地方。一个 Browser 实例可以开多个 Page,但 Page 数量要严格限制,建议用信号量或线程池把并发页面数控制在 CPU 核数的 1 到 2 倍以内。超过限制后,Chrome 的渲染进程会互相争抢内存,表现为抓取耗时飙升甚至 OOM。更彻底的做法是部署多个独立的服务实例,每个实例跑一个 Browser,前面用负载均衡分发任务。
@Service
public class CrawlerService {
private final Semaphore semaphore = new Semaphore(5); // 最多 5 个并发页面
public String crawl(String url) throws InterruptedException {
semaphore.acquire();
Page page = null;
try {
page = BrowserHolder.getBrowser().newPage();
page.goTo(url);
page.waitForSelector(".content", 10000);
return page.content();
} catch (Exception e) {
log.error("抓取失败: {}", url, e);
return null;
} finally {
if (page != null) {
page.close();
}
semaphore.release();
}
}
}几个高频踩坑点总结一下。一是进程残留:浏览器进程异常退出后可能留下僵尸进程,定时执行 ps aux | grep chrome 检查,必要时用 pid 文件强杀;二是内存持续上涨:长时间运行的 Browser 会有内存泄漏倾向,可以设置定时任务每几小时重启一次浏览器实例;三是目标站点反爬:频繁请求会触发验证码,合理设置请求间隔、轮换 UA 和代理 IP 是基本操作,同时尽量拦截图片、字体等静态资源请求,能显著降低加载时间,Puppeteer 的请求拦截 API 通过 page.setRequestInterception 开启后可以按资源类型直接 abort 掉媒体请求。
整体来看,Spring Boot 整合 Puppeteer 的方案结构清晰:jvppeteer 负责驱动浏览器,Spring 容器管理 Browser 生命周期,信号量控制并发,再加上资源拦截和定期重启兜底稳定性。这套架构跑每天几万次的动态页面抓取任务没有压力,如果规模再往上走,可以考虑把抓取逻辑抽成独立的 Node.js 微服务,Java 侧只做任务调度,各取所长。
Spring BootPuppeteer动态渲染抓取修改时间:2026-09-07 20:40:42