Java网络爬虫与jsoup:获取、解析和渲染
Expert in Web Scraping Technologies
TL;DR:
- jsoup 是一个带有 CSS 选择器引擎的 Java HTML 解析器,它自带一个 HTTP 客户端,因此一个有效的抓取器只需依赖一个库和大约二十行代码。
- jsoup 默认情况下只保留响应的前 2 MiB。在一个 2,235,648 字节的页面上,它恰好保留了 2,097,152 字节,丢弃了 255 个引用条目中的 68 个,失去了一个完整的部分,没有抛出任何内容。
attr("href")返回与 HTML 中写的完全相同的 href。attr("abs:href")则将其解析为文档的基本 URI。- jsoup 不会执行 JavaScript。在一个客户端渲染的页面上,相同的选择器代码直接找到 0 个项,而在 HTML 预渲染到达时找到 10 个项。
- 通过 Scrapeless Universal Scraping API 进行获取路由仅改变运输——解析方法未被触及。
- Scrapeless 免费计划足以运行本指南中的每个请求。
Java 是许多抓取数据的最终归宿。如果消耗数据的服务是 Spring 或 Quarkus 应用程序,将提取保持在同一 JVM 中可以消除语言边界、序列化步骤和第二个部署目标。
实现这一切的库是 jsoup。它以浏览器的方式解析真实世界的 HTML——闭合未闭合的标签、修复嵌套以及规范化属性,遵循 HTML 解析规范 中的错误处理规则——然后通过 CSS 选择器 API 暴露结果。
本指南构建了一个针对两个实时站点的有效抓取器,并测量了决定抓取器是否正确的两个行为:jsoup 在大型页面上默默丢弃了什么,以及在浏览器中呈现的页面返回了什么。
jsoup 提供的功能
jsoup 首先是一个解析器,其次是一个 HTTP 客户端。Jsoup.connect(url) 返回一个流式请求构建器;.get() 执行请求并返回一个你可以用选择器查询的 Document。
java
String url = "https://books.toscrape.com/";
Document doc = Jsoup.connect(url).get();
String title = doc.selectFirst("h1").text();
该 Document 是一个解析后的树,而不是字符串,因此格式错误的页面仍然会产生可查询的结构。它还携带了其获取的基本 URI,这使得后续绝对 URL 解析成为可能。
jsoup 不做的是运行脚本。它没有 JavaScript 引擎,也没有 DOM 事件循环。服务器发送的任何内容都是你要解析的内容。
设置项目
一个依赖项覆盖解析。Gson 仅用于读取最后一节中的 JSON 封装;如果你跳过该节,可以去掉它。
xml
<dependencies>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.21.1</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.14.0</version>
</dependency>
</dependencies>
显式锁定编译器插件。Maven 3.8.7 默认情况下仍绑定 maven-compiler-plugin 3.1,这会忽略 maven.compiler.release 并报错 Source option 5 is no longer supported:
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>com.example.Scraper</mainClass>
</configuration>
</plugin>
</plugins>
</build>
将 maven.compiler.release 设置为 17,该代码在任何当前 JDK 上编译。验证运行使用的是 OpenJDK 21.0.11。
获取页面并解析
在每个请求上设置用户代理和超时。jsoup 的默认代理将自己标识为 jsoup,而许多网站的响应则仅在此基础上发生变化。
java
static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
static Document fetch(String url) throws IOException {
return Jsoup.connect(url)
.userAgent(USER_AGENT)
.timeout(30_000)
.get();
}
timeout 为毫秒,并适用于连接和读取。超时的 get() 将引发 SocketTimeoutException;非 2xx 状态将引发 HttpStatusException,并携带代码。
选择数据
选择器是标准 CSS。select 返回每个匹配项作为 Elements 集合;selectFirst 返回一个 Element 或 null。
java
record Book(String title, String price, String url) {}
static List<Book> books(Document doc) {
List<Book> found = new ArrayList<>();
for (Element card : doc.select("article.product_pod")) {
Element link = card.selectFirst("h3 > a");
found.add(new Book(
link.attr("title"),
card.selectFirst("p.price_color").text(),
对实时目录,这返回了20本书,第一本是《阁楼里的光》,售价为51.77英镑。
最后一行的 "abs:" 前缀确实在发挥作用。源中的锚点为:
```text
catalogue/a-light-in-the-attic_1000/index.html
attr("href") 会逐字返回该字符串。在属性名称前加上 abs: 会根据文档的基本 URI 使用 WHATWG URL 标准 中的算法解析它,产生:
text
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
一个存储原始值的抓取器在某天其他东西尝试取用它时会出现问题。
会让你失去数据的限制
jsoup 对它将读取的响应体做了限制。默认限制为 2 MiB,这在 jsoup 请求接口 上有文档说明。超出该点后,它会停止读取并解析它所拥有的内容。它不会引发异常、记录警告,也不会在 Document 上留下任何标志来说明主体被截断。
当你想在解析之前查看响应时,使用 execute() 而不是 get():
java
Connection.Response capped = Jsoup.connect(big)
.userAgent(USER_AGENT).timeout(60_000).execute();
Connection.Response whole = Jsoup.connect(big)
.userAgent(USER_AGENT).timeout(60_000).maxBodySize(0).execute();
在对一个 2,235,648 字节的 Wikipedia 比较页面进行操作时,这两个响应的差异正好是限制值:
text
http 状态,默认限制:200
接收的字节数,默认限制:2097152
接收的字节数,maxBodySize(0):2235648
表格行,默认限制:544
表格行,maxBodySize(0):544
参考条目,默认限制:187
参考条目,maxBodySize(0):255
最后部分,默认限制:References
最后部分,maxBodySize(0):External links
两次运行都返回了 HTTP 200。两者都生成了 Document。该页面存在的比较表格内容一模一样,都是 544 行,因为它们位于文档的顶部附近。
所有在截断以下的内容则是简单缺失。参考列表失去了 255 个条目中的 68 个,而最终的 External links 部分在截断的树中根本不存在。一个读取表格的抓取器不会注意到这一点;而一个收集引用的抓取器则会安静地少报四分之一的数据,仍然看起来正常。
maxBodySize(0) 移除了限制。请有意识地设置,而不是条件反射——在意外响应上进行无界读取本身就是一个问题——但要有意识地设置,并与服务器报告的 Content-Length 进行比较,后者被 HTTP 语义规范 定义为主体的八位字节数。
当页面在浏览器中呈现时
https://quotes.toscrape.com/js/ 标志着边界。它将其引用作为 JavaScript 数组提供,并在客户端构建 DOM,而 jsoup 成功获取了它:
text
直接获取的 html 字节:5479
jsoup 找到的引用:0
5,479 字节,HTTP 200,零结果。jsoup 接收到的标记确实不包含任何 div.quote 元素——它们是在脚本运行后创建的,而 jsoup 没有脚本引擎。
大多数指南通过切换到浏览器自动化工具来回答这个问题,这意味着提取代码也要重写。这种权衡在 Node 中 Cheerio 和 Puppeteer 分别处于两侧,在 Java 中也需要付出同样的代价。更狭窄的解决方案是仅仅改变 HTML 到达的方式。这个工作属于 Scrapeless Universal Scraping API。它呈现页面并将生成的 HTML 作为字符串返回,你可以将其传递给相同的解析器。
JDK 内置的 HttpClient 足够用了——无需 HTTP 依赖:
java
static String render(String url) throws IOException, InterruptedException {
JsonObject input = new JsonObject();
input.addProperty("url", url);
input.addProperty("proxy_country", "US");
input.addProperty("js_render", true);
JsonObject payload = new JsonObject();
payload.addProperty("actor", "unlocker.webunlocker");
payload.add("input", input);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.scrapeless.com/api/v2/unlocker/request"))
.header("Content-Type", "application/json")
.header("x-api-token", System.getenv("SCRAPELESS_API_KEY"))
.timeout(Duration.ofSeconds(180))
.POST(HttpRequest.BodyPublishers.ofString(payload.toString(), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("unlocker 返回 HTTP " + response.statusCode());
}
return JsonParser.parseString(response.body())
.getAsJsonObject().get("data").getAsString();
}
响应包为 `{"code": ..., "data": "<渲染的 html>"}`。使用 `Jsoup.parse(html, url)` 解析该字符串 — 传递 URL 设置基本 URI,因此 `abs:href` 继续有效 — 然后运行相同的选择器方法:
```java
static List<String> quotes(Document doc) {
List<String> found = new ArrayList<>();
for (Element quote : doc.select("div.quote")) {
found.add(quote.selectFirst("span.text").text()
+ " -- " + quote.selectFirst("small.author").text());
}
return found;
}
text
渲染的 html 字节数: 8940
相同解析器找到的引用: 10
相同的方法,相同的选择器,相同的 Document API。唯一改变的是这 8,940 字节的来源。将你的 API 密钥保存在环境中,如上面的 SCRAPELESS_API_KEY,而不是源码中。Universal Scraping API 快速入门指南 涵盖了其余请求参数。
入门只需要一分钟 — 创建一个免费的 Scrapeless 账户,免费计划涵盖本指南中的所有内容。
运行它
组装好类后,一个命令即可编译并运行它:
bash
export SCRAPELESS_API_KEY="你的-api-key"
mvn -q -B compile exec:java
验证运行的全部输出:
text
jsoup 1.21.1 | java 21.0.11
--- 静态页面,直接解析 ---
页面 1 上的书籍: 20
第一个标题: A Light in the Attic
第一个价格: £51.77
第一个 URL: https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
HTML 中书写的 href: catalogue/a-light-in-the-attic_1000/index.html
--- 默认体大小限制 ---
http 状态,默认限制: 200
接收字节数,默认限制: 2097152
接收字节数,最大体大小(0): 2235648
表格行,默认限制: 544
表格行,最大体大小(0): 544
参考条目,默认限制: 187
参考条目,最大体大小(0): 255
最后一节,默认限制: References
最后一节,最大体大小(0): External links
--- JavaScript 渲染页面 ---
直接获取的 HTML 字节数: 5479
通过 jsoup 找到的引用: 0
--- 通过 Universal Scraping API 获取的相同页面 ---
渲染的 HTML 字节数: 8940
相同解析器找到的引用: 10
故障排除
Source option 5 is no longer supported. Maven 绑定了其默认的 maven-compiler-plugin 而不是你的。按上述方式在<build><plugins>中固定插件版本。
selectFirst 返回 null,下一行抛出 NullPointerException. 选择器没有匹配到任何东西。检查与 jsoup 实际接收到的 HTML — doc.html() — 而不是与浏览器检查器显示的内容比较,这个是后脚本 DOM。
计数低于页面显示。 将 response.bodyAsBytes().length 与服务器的 Content-Length 进行比较。如果它们在 2,097,152 时匹配,则体大小限制是原因。
HttpStatusException: 403. 服务器拒绝了请求而不是解析。首先设置一个合理的用户代理;如果页面也需要渲染,则上述转变适用。
提取文本中的 Mojibake。 jsoup 从 Content-Type 头读取字符集,然后是 meta 标签。当服务器都没有声明时,明确将编码传递给 Jsoup.parse(InputStream, String, String)。
结论
对于静态 HTML,jsoup 和 JDK 是整个工具链:一个依赖关系,一个流式请求,一个 CSS 选择器 API,以及一个能承受糟糕标记的解析树。决定结果是否可信的两种行为默认都是不可见的 — 返回一个看似健康的部分文档的 2 MiB 体积限制,以及在客户端渲染页面时的空结果集。
这两者都很便宜检查。将接收的字节与 Content-Length 进行比较,将选择器的计数与页面显示的进行比较。当第二个检查失败,因为标记为空时,解决方法是更改传输而保持解析器不变。
准备好尝试了吗?从Scrapeless免费计划开始,查看当前定价以获取更高的使用量。
常见问题
问:jsoup单独使用够吗,还是需要Selenium?
对于服务器渲染的HTML,单独使用jsoup就足够了,并且速度明显更快,因为它不会启动浏览器。只有当数据是由JavaScript生成时,你才需要渲染步骤——正如上面的测量所示,这一步可以是返回渲染HTML的HTTP请求,而不是你过程中的完整浏览器。
问:我怎么知道一个页面是否需要JavaScript才能编写选择器?
从纯jsoup获取中打印doc.html()并在其中搜索一个你可以在页面上看到的值。如果该值在字符串中缺失但在浏览器中可见,则它是客户端渲染的。比较选择器计数和可见计数可以捕捉到相同的情况。
问:改变maxBodySize的实际原因是什么?
默认情况下为2 MiB,这比许多列表、档案和比较页面要小。如果你的目标超过了这个限制,超过部分将从解析树中消失而不会引发错误。当你需要整个文档时,设置maxBodySize(0),并将接收到的字节与Content-Length进行比较,以知道何时需要。
问:jsoup能处理格式错误的HTML吗?
可以。它应用浏览器所遵循的相同错误恢复规则,因此未闭合的标签和嵌套错误的元素仍然会生成可查询的树。这是将其优先于严格的XML解析器处理现实世界页面的主要原因。
问:使用Java进行抓取合法吗?
语言与法律问题无关。重要的是你收集的数据、网站的条款、你遵循的爬虫指令以及你所处的管辖区。将收集限制在公开页面,保持请求量适中,对于任何涉及个人数据的情况寻求法律建议。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



