抓取器从一个你已经有 URL 的页面上提取字段。爬虫则是首先去找到那些 URL 的东西:它从一个种子页面出发,跟随它发现的链接,并一直走下去,直到覆盖了你所关心的那部分站点。如果你想绘制一棵文档树、收集某个类别下的每个产品页面,或者给一个搜索索引喂数据,你需要的是第二类程序。
本指南将向你展示如何从零开始用 Java 构建一个网络爬虫。你将使用现代的 HttpClient(Java 11+)来获取页面,用 Jsoup 解析它们并提取链接,并用一个待爬队列加上一个已访问集合来驱动一次广度优先的爬取。我们会加上那些把玩具和你真正能运行的东西区分开来的控制:深度限制、同域限制和礼貌延迟。然后我们会讲到每一次真实爬取都会遇到的那部分,即 JavaScript 渲染和 IP 封锁,以及如何在不重写爬虫的情况下应对它。
爬虫与抓取器:究竟有什么不同
这两个词常被互换使用,但这两种程序有着不同的形态。抓取器拿一个已知的 URL,获取它一次,并提取数据。爬虫拿一个种子 URL,获取它,提取链接,并把新发现的那些加进一个仍待访问页面的队列。它重复那个循环,因此它触及的页面集合会随着它的运行而增长。提取仍然是工作的一部分,但其标志性特征是链接发现和遍历。
那个区别驱动了下面的每一个设计决策。因为爬虫会发现它自己的工作,它需要一个队列(待爬队列)来保存待处理的 URL,一个集合来记住它已经见过什么,以免永远循环下去,以及一些边界,以免它游荡进整个互联网。把这三样做对,剩下的就是获取和解析了。
你将构建什么
一个单一的可运行 Java 类,它拿一个种子 URL,并在一个域内向外爬取。它做广度优先遍历,在一个可配置的深度停下,跳过它已经访问过的页面,并在请求之间礼貌地暂停。对每个页面它都打印 URL 和标题,你可以把它换成你需要的任何提取。
- 待爬队列一个等待被获取的 URL 的 FIFO 队列,与每个 URL 被发现时所处的深度配成对。
-
已访问集合一个已处理 URL 的
Set,这样爬虫永远不会两次获取同一个页面。 - 深度限制对爬虫将从种子跟随多少次链接跳转设一个硬上限。
- 同域守卫一项检查,让爬取保持在你出发的那个主机上。
- 礼貌延迟请求之间的一次暂停,这样你才不会猛敲目标。
先决条件
在编写任何代码之前,你需要准备好几样东西,它们都不会花太长时间。
Java 11 或更高版本。这个爬虫使用 Java 11 引入的 java.net.http.HttpClient API,因此用 java -version 确认你的版本。任何 JDK 11+ 的发行版都行。
Jsoup。Jsoup 是用于解析 HTML 并用 CSS 选择器选取元素的标准 Java 库。用 Maven 时,添加下面的依赖;用 Gradle 或一个普通的类路径时,拉取等效的 jar。
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>
基础 Java。你应该能够轻松地编译和运行一个类,并读懂一段堆栈跟踪。这里没有任何东西用到 JDK 和 Jsoup 之外的框架。
第 1 步:用 HttpClient 获取一个页面
从最小的有用一块开始:一个拿 URL 并把 HTML 作为字符串返回的方法。HttpClient API 使用一种构建器模式,默认支持 HTTP/2,并暴露一个同步的 send 调用,它会阻塞直到响应到达。一个请求超时能让一个缓慢或死掉的主机不至于挂起整次爬取。
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); static String fetch(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(20)) .header("User-Agent", "MyJavaCrawler/1.0") .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { return response.body(); } System.out.println("Skipping " + url + " -> " + response.statusCode()); return null; }
那个单一的静态客户端是有意为之的:HttpClient 是不可变且线程安全的,因此你创建一个并为每个请求复用它,而不是每次获取都构建一个。设置一个 User-Agent 是礼貌之举,而且往往是必需的,而在返回 body 之前检查状态码能让失败保持可见,而不是把空 HTML 喂给解析器。
第 2 步:用 Jsoup 解析链接
获取给你一串 HTML。要爬取,你需要它里面的链接。Jsoup 把 HTML 解析成一个文档,并让你用 CSS 选择器选取元素。a[href] 选择器抓取每一个带 href 的锚点,而 Jsoup 的 absUrl 会把像 /about 这样的相对链接,相对于它们被发现的那个页面,解析成绝对 URL,而这恰恰是待爬队列所需要的。
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.util.ArrayList; import java.util.List; static List<String> extractLinks(String html, String baseUrl) { List<String> links = new ArrayList<>(); Document doc = Jsoup.parse(html, baseUrl); System.out.println("Title: " + doc.title()); Elements anchors = doc.select("a[href]"); for (Element a : anchors) { String absolute = a.absUrl("href"); if (!absolute.isBlank()) { links.add(absolute); } } return links; }
把 baseUrl 传给 Jsoup.parse 正是让 absUrl 起作用的关键;没有它,相对的 href 会解析成空。同一个文档也是你做真正提取的地方,用像 doc.select("h1") 或 doc.select(".price") 这样的选择器。这里我们打印标题,这样你就能看着爬取一页接一页地推进。如果你想更深入地了解在 Java 中选取和提取字段,用 Java 进行网络抓取的指南详细讲解了解析这一侧。
第 3 步:用待爬队列和已访问集合驱动爬取
现在是核心。广度优先爬取意味着你一波一波地访问页面:先是种子,然后是一跳之外的一切,然后是两跳,依此类推。一个 FIFO 队列免费给你那种排序。每个队列条目把一个 URL 与它被发现时所处的深度配成对,这样你就知道何时停止跟随它的子链接。一个已访问 URL 的 HashSet 防止环路,而一项同域检查把爬取限定在一个主机上。
import java.net.URI; import java.util.ArrayDeque; import java.util.HashSet; import java.util.Queue; import java.util.Set; static class Task { final String url; final int depth; Task(String url, int depth) { this.url = url; this.depth = depth; } } static boolean sameHost(String url, String host) { try { return host.equalsIgnoreCase(URI.create(url).getHost()); } catch (Exception e) { return false; } } static void crawl(String seed, int maxDepth, long delayMs) throws Exception { String host = URI.create(seed).getHost(); Queue<Task> frontier = new ArrayDeque<>(); Set<String> visited = new HashSet<>(); frontier.add(new Task(seed, 0)); visited.add(seed); while (!frontier.isEmpty()) { Task task = frontier.poll(); System.out.println("[" + task.depth + "] " + task.url); String html = fetch(task.url); if (html == null || task.depth >= maxDepth) continue; for (String link : extractLinks(html, task.url)) { if (sameHost(link, host) && visited.add(link)) { frontier.add(new Task(link, task.depth + 1)); } } Thread.sleep(delayMs); } }
这里有几个细节各有分量。visited.add(link) 在 URL 已经存在时返回 false,因此一次调用既检查成员资格又记录 URL,这就是为什么那个守卫写作 sameHost(link, host) && visited.add(link)。深度检查发生在获取之后但在把子链接入队之前,因此处于 maxDepth 的页面仍会被获取和解析,它们只是不再贡献新链接。而 Thread.sleep(delayMs) 是那次礼貌暂停:它是一个站点能容忍的爬虫和一个被它封锁的爬虫之间的区别。
第 4 步:一个完整、可运行的爬虫
把这些片段接进一个带 main 方法的类。在类路径上带着 Jsoup 编译它,并针对一个种子 URL、一个深度和一个延迟运行它。
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.*; public class WebCrawler { private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); record Task(String url, int depth) {} static String fetch(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(20)) .header("User-Agent", "MyJavaCrawler/1.0") .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return response.statusCode() == 200 ? response.body() : null; } static List<String> extractLinks(String html, String baseUrl) { List<String> links = new ArrayList<>(); Document doc = Jsoup.parse(html, baseUrl); System.out.println(" Title: " + doc.title()); Elements anchors = doc.select("a[href]"); for (Element a : anchors) { String absolute = a.absUrl("href"); if (!absolute.isBlank()) links.add(absolute); } return links; } static boolean sameHost(String url, String host) { try { return host.equalsIgnoreCase(URI.create(url).getHost()); } catch (Exception e) { return false; } } static void crawl(String seed, int maxDepth, long delayMs) throws Exception { String host = URI.create(seed).getHost(); Queue<Task> frontier = new ArrayDeque<>(); Set<String> visited = new HashSet<>(); frontier.add(new Task(seed, 0)); visited.add(seed); while (!frontier.isEmpty()) { Task task = frontier.poll(); System.out.println("[" + task.depth() + "] " + task.url()); String html = fetch(task.url()); if (html == null || task.depth() >= maxDepth) continue; for (String link : extractLinks(html, task.url())) { if (sameHost(link, host) && visited.add(link)) { frontier.add(new Task(link, task.depth() + 1)); } } Thread.sleep(delayMs); } } public static void main(String[] args) throws Exception { crawl("https://books.toscrape.com/", 2, 1000); } }
那就是一个可工作的广度优先爬虫,远不到 100 行。它用一个 Java record 表示任务对,用一个复用的客户端获取,用 Jsoup 解析,保持在一个主机上,用一个集合去重,在深度 2 停下,并在请求之间等待一秒。先把它指向一个像 books.toscrape.com 这样的沙盒,然后换上你自己的种子和提取。
在爬取一个你并不拥有的站点之前,阅读它的 robots.txt 和服务条款,并把它们当作边界。遵守 crawl-delay 指令,跳过被禁止的路径,并把你的请求速率保持得足够低,以免给服务器造成压力。一个会退让的礼貌爬虫是一个能持续工作的爬虫;一个激进的爬虫则会被封锁,并且可能造成真正的伤害。
手工搭建的爬虫在哪里撞墙
上面的爬虫是正确的,在一个静态、规规矩矩的站点上它会运行得很好。有两样东西会在现代网络上让它崩溃,而两者都常见到你会很快遇到它们。
JavaScript 渲染。你的获取返回服务器发送的原始 HTML。许多站点用 React、Angular 或 Vue 在浏览器中构建其内容,因此那个原始 HTML 是一个近乎空壳的东西。Jsoup 只解析它被给到的东西,并且不能运行 JavaScript,因此在那些页面上 a[href] 找到很少或根本没有链接,爬取就在种子处死掉。你会需要一个无头浏览器先把页面渲染出来。爬取那些依赖客户端渲染的站点本身就是一个话题,如何爬取重 JavaScript 的网站的指南讲解了它。
IP 封锁。一个爬虫在短时间窗口内从一个地址发出许多请求,而这恰恰是反机器人系统所寻找的模式。数据中心 IP 会被质询、限流或封禁,而一旦你的地址被标记,无论你的延迟多么礼貌,爬取都会停摆。把请求分散到一个住宅 IP 池上能避免那种单一地址的签名,但构建和维护那个池是工作的大头。更全面的操作手册在如何抓取网站而不被封锁里。
转向规模化:在服务端渲染并轮换
你可以在不让爬虫复杂化的情况下解决这两个问题。不要直接获取目标 URL,而是把获取路由通过一个 API 端点,它在一个真实浏览器中渲染页面,并从一个轮换的住宅 IP 提供它,然后交给你完成的 HTML。你的爬虫逻辑,即待爬队列、已访问集合、深度限制、同域守卫,保持完全不变。只有 fetch 方法改变。
Crawling API 是一个普通的 HTTP GET:你把你的 token 和目标 URL 作为查询参数传入,加一个标志来请求 JavaScript 渲染,并读取响应 body。因为它只是一个 GET,它能直接放进你已经写好的 HttpClient 代码里。
import java.net.URLEncoder; import java.nio.charset.StandardCharsets; private static final String TOKEN = "YOUR_CRAWLBASE_JS_TOKEN"; static String fetch(String url) throws Exception { String target = URLEncoder.encode(url, StandardCharsets.UTF_8); String endpoint = "https://api.crawlbase.com/?token=" + TOKEN + "&page_wait=3000&url=" + target; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .timeout(Duration.ofSeconds(90)) .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return response.statusCode() == 200 ? response.body() : null; }
两处改动承担了主要工作。目标 URL 在进入查询字符串之前用 URLEncoder 编码,因为 Crawlbase 要求内层 URL 被编码,这样它自己的参数才能干净地解析。而 page_wait=3000 告诉 API 在加载后等待三秒以待迟到渲染的内容,然后再捕获 HTML,而这正是把一个 JavaScript 空壳变成你的解析器所需要的完整构建页面的关键。JS token 启用渲染;轮换发生在服务端,因此目标看到的地址是一个真实的住宅 IP,而不是你爬虫的。一个更长的客户端超时是为了应对渲染步骤比一次原始获取耗时更长。
让你的 Java 爬虫保持简单,并让渲染和 IP 轮换发生在服务端。Crawling API 通过一个普通 GET 拿一个 token 和一个目标 URL,在一个真实浏览器中运行页面,轮换住宅 IP,并返回完成的 HTML,于是你的待爬队列、已访问集合和解析器永不改变。把它接进 fetch 方法,就能爬取重 JavaScript 的站点,而无需自己运行一支无头浏览器集群或一个代理池。
为真实运行加固爬虫
少数几处补充能把这个演示推向生产。它们都不改变核心循环;它们让它在与杂乱站点的接触中存活下来。
-
在去重之前规范化 URL。剥掉片段(
#section)和尾部斜杠,这样/page和/page#top就算作一个 URL。没有这一步,你的已访问集合会膨胀,而你会重新抓取同样的内容。 -
过滤非 HTML 链接。在入队之前跳过以
.pdf、.jpg、.zip及类似后缀结尾的 href,这样爬虫就不会试图把二进制文件当作 HTML 来解析。 - 给页面总数设上限。深度限定了广度优先的触及范围,但一个宽阔的站点仍可能把数千个页面排进队列。给已访问规模设一个硬上限是一个简单的安全阀。
- 逐页处理错误。把每次获取和解析包在一个 try/catch 里,这样一个坏 URL 会被记录并继续,而不是杀死整次爬取。
- 持久化待爬队列。对于长时间爬取,用一个文件或数据库来支撑队列和已访问集合,这样一次崩溃就不会丢失进度,而你也能恢复。
如果你想要真正的并行,同样的结构可以扩展到一个线程池:把 HashSet 换成 ConcurrentHashMap.newKeySet(),把 ArrayDeque 换成 ConcurrentLinkedQueue,然后运行几个从待爬队列拉取的工作线程。把礼貌延迟保持在每个主机上,这样并发就不会变成洪流。对于你提交许多 URL 并在它们完成时收集结果而不是在每个上阻塞的工作负载,异步 Crawler 为你处理排队和回调。
核心要点
- 爬虫发现它自己的 URL。其标志性部分是一个待爬队列、一个已访问集合和一些边界,而不是提取。
-
HttpClient 加 Jsoup 是核心栈。Java 11 的
HttpClient负责获取,Jsoup 解析 HTML 并用absUrl解析链接。 -
广度优先需要三个守卫。一个带深度的 FIFO 待爬队列、一个已访问 URL 的
HashSet和一项同域检查,让爬取保持有界且无环。 -
要礼貌。在请求之间延迟,设置一个真实的
User-Agent,并尊重robots.txt和服务条款。 - JS 渲染和 IP 封锁是 DIY 崩溃之处。把获取路由通过 Crawling API 能在服务端处理两者,并让爬虫逻辑保持不动。
常见问题
网络爬虫和网络抓取器有什么区别?
抓取器从你已经有的 URL 中提取数据。爬虫从一个种子 URL 出发,跟随它找到的链接,并在它运行时发现新页面,因此它访问的页面集合随时间增长。提取仍然是爬取的一部分,但其标志性特征是链接发现和遍历,这就是为什么爬虫需要一个待爬队列和一个已访问集合,而单纯的抓取器不需要。
在 Java 中我该用 HttpClient 还是 Jsoup 来获取页面?
各用其所长,两者都用。Java 11 的 HttpClient 给你对请求的精细控制:超时、请求头、重定向策略,以及同步或异步发送。Jsoup 则是为解析返回的 HTML 并用 CSS 选择器选取元素而生的。Jsoup 自己也能获取,但把一个专用的 HTTP 客户端与作为解析器的 Jsoup 配对,能让这两件关注点保持分离,并且更易于扩展,比如之后把获取路由通过一个 API。
我如何让我的爬虫不至于永远循环?
维护一个你已经入队过的 URL 的 Set,并在添加任何新链接之前检查它。在这个例子里,visited.add(link) 在 URL 已经存在时返回 false,因此一次调用既检验成员资格又记录 URL。结合一个深度限制和一项同域守卫,这能防止环路,并阻止爬取在整个网络上游荡。
为什么我的 Java 爬虫返回空的或缺失的链接?
最可能的原因是 JavaScript 渲染。你的获取返回服务器发送的原始 HTML,而许多站点用像 React 或 Angular 这样的框架在浏览器中构建其内容,因此那个原始 HTML 是一个近乎空壳的东西,带着很少或根本没有锚点。Jsoup 不能运行 JavaScript,因此它找不到任何可跟随的东西。先用一个无头浏览器渲染页面,或者把获取路由通过启用了渲染的 Crawling API,这样在你解析之前 HTML 就已经完整构建好了。
在爬取时我如何避免被封锁?
用一个延迟给你的请求设定节奏,设置一个诚实的 User-Agent,尊重 robots.txt 和 crawl-delay 指令,并把每个主机的量保持在合理范围。更难的问题是 IP 声誉:来自一个数据中心地址的许多请求会很快被标记。把请求分散到轮换的住宅 IP 上能避免那种签名。Crawling API 为你处理轮换;如果你自建技术栈,那就是要投入的那部分。
我能让一个 Java 爬虫并行运行吗?
能。广度优先结构可以扩展到多个线程:把 HashSet 换成 ConcurrentHashMap.newKeySet(),把 ArrayDeque 换成一个像 ConcurrentLinkedQueue 这样的线程安全队列,然后运行几个从待爬队列拉取的工作线程。把礼貌延迟保持在每个主机上,这样并发就不会变成洪流,并且对于那种你提交许多 URL 并在结果完成时收集它们的“发射后收集”工作负载,可以考虑异步 Crawler。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
