简介:这是一份面向Java初学者与爬虫入门开发者的实战型资源,围绕HttpClient与HtmlParser两大核心库,演示如何构建一个可运行的简单网络爬虫,并落地到SearchEngine搜索场景中。资源包共42个文件,以16个jar依赖库、8个class编译文件、5个java源码、4个html页面为主,另含classpath与project工程配置,压缩包约3.1MB,导入IDE后即可直接编译运行。内容覆盖HttpClient发送GET请求、读取响应实体、关闭连接资源,以及Jsoup加载文档、select选择器查询、提取文本与属性、遍历结果等关键环节,并延伸出初始化、请求页面、解析响应、提取数据、存储数据、异常处理与多页循环的完整爬虫流程。目前已有264人学习下载,适合希望快速理解Java爬虫请求与解析链路、对照源码动手实践的读者参考。
1. 一个 Java 爬虫的最小闭环:HttpClient 抓页面,HtmlParser 拆标签
很多人第一次写 Java 爬虫,上来就搬整套框架,结果光依赖冲突就耗掉半天。其实如果目标只是抓静态页面、提取标题和链接,JDK 自带的java.net.http.HttpClient加一个轻量 HTML 解析库就够了,整个工程不超过五个文件。我最近拆了一个用 HttpClient 和 HtmlParser 搭的简单爬虫项目,它没有反爬对抗、没有分布式调度,就是把「发请求 → 拿 HTML → 解析出结构化数据 → 落盘」这条链路走通。适合谁?适合刚接触 Java 网络编程、想先跑通一个能看见结果的爬虫,而不是一上来就被框架配置淹没的人。下面按我实际复现的顺序,把选型理由、代码、参数和踩过的坑一次讲清。
2. 选型先想清楚:为什么是 HttpClient 加 HtmlParser,而不是别的组合
2.1 HttpClient 在 JDK 11 之后到底解决了什么
JDK 11 引入的java.net.http.HttpClient把以前用HttpURLConnection时要手写的连接复用、超时、重定向、异步请求都收进了标准库。以前写一个 GET 请求,得自己设setConnectTimeout、setReadTimeout、判断响应码、手动读流;现在一行HttpClient.newBuilder().connectTimeout(...)就把连接层参数定了。它支持 HTTP/2、支持CompletableFuture异步、支持自定义Executor,对爬虫这种「大量短请求」的场景,连接池复用能明显减少握手开销。
但要注意,HttpClient 默认不跟随重定向,需要显式.followRedirects(Redirect.NORMAL)。另外它默认不带 Cookie 管理,如果目标站点靠 Cookie 维持会话,得自己挂CookieManager。这个项目里目标页面是公开静态页,所以这两点没配,但你在改造成多页抓取时一定会遇到。
2.2 HtmlParser 的定位:只做解析,不做请求
HtmlParser 是一个纯解析库,输入是 HTML 字符串或流,输出是节点树。它和 Jsoup 最大的区别是:Jsoup 既能发请求又能解析,API 更现代;HtmlParser 更老、更底层,但胜在轻、依赖少,解析逻辑靠NodeFilter和NodeVisitor两个接口驱动。这个项目选它,我猜是因为它不绑定任何网络层,解析部分可以单独测试——你喂一段写死的 HTML 进去就能验证提取逻辑,不用起服务。
它的核心用法是:用Parser拿到根节点,然后写一个NodeFilter决定「哪些节点我要」,再写一个NodeVisitor决定「拿到节点后干什么」。这种访问者模式对新手不算友好,但一旦理解,提取规则会写得很干净。
2.3 两种组合的取舍对比
| 维度 | HttpClient + HtmlParser | HttpClient + Jsoup |
|---|---|---|
| 依赖数量 | 极少,解析库单 jar | 单 jar,但体积更大 |
| API 友好度 | 偏低,需理解 Visitor 模式 | 高,CSS 选择器直接选 |
| 解析可控性 | 高,逐节点控制 | 中,选择器表达力强但黑盒 |
| 适合场景 | 学习解析原理、轻量定制 | 快速开发、复杂选择 |
如果你只是想把活干完,Jsoup 更省事;如果你想搞懂「HTML 是怎么被遍历成节点的」,HtmlParser 更值得拆。这个项目的价值偏后者。
3. 把请求发出去:HttpClient 的配置与三个必调参数
3.1 构建一个可复用的 HttpClient 实例
import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.time.Duration; public class Fetcher { // HttpClient 是线程安全的,全局建一个即可,不要每次请求都 new private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) // 建立 TCP 连接的上限 .followRedirects(HttpClient.Redirect.NORMAL) // 跟随 301/302 .version(HttpClient.Version.HTTP_1_1) // 兼容性优先,HTTP/2 视站点而定 .build(); public static String fetch(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) // 整个请求-响应的上限 .header("User-Agent", "Mozilla/5.0 (compatible; SimpleCrawler/1.0)") .GET() .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); // 只有 2xx 才认为拿到有效页面 if (response.statusCode() / 100 != 2) { throw new IllegalStateException("状态码异常: " + response.statusCode()); } return response.body(); } }逻辑说明:CLIENT声明为static final,因为 HttpClient 内部维护连接池,反复创建会丢掉复用收益,这是新手最常见的性能翻车点。connectTimeout管的是 TCP 握手,HttpRequest.timeout管的是从发出到收完的整段,两个都要设,只设一个在慢站点上照样卡死。
参数说明:User-Agent一定要带,很多站点对空 UA 直接返回 403;BodyHandlers.ofString()默认按响应头字符集解码,如果页面是 GBK 而响应头没写对,中文会乱码,这时要换成ofString(Charset.forName("GBK"))。状态码判断用statusCode()/100 != 2而不是== 200,因为 204、206 也算成功。
3.2 超时与重试的边界
超时不是设了就越小越好。connectTimeout设 5 秒、请求超时设 10 秒,是我在多数公开站点上比较稳的取值。设太短,网络抖动就误判失败;设太长,一个坏链接拖住整个循环。重试不要无脑加,只对「连接超时」和「5xx」重试,对「404」重试纯属浪费。常见做法是包一层循环,最多重试两次,每次间隔递增。
提示:
HttpClient.send是同步阻塞的,循环抓多个页面时它天然串行。想并发就用sendAsync返回CompletableFuture,但并发数要自己控,别把对方站点打挂。
4. 把标签拆出来:HtmlParser 的 Filter 与 Visitor 写法
4.1 解析入口与节点遍历
import org.htmlparser.Parser; import org.htmlparser.Node; import org.htmlparser.NodeFilter; import org.htmlparser.filters.AndFilter; import org.htmlparser.filters.TagNameFilter; import org.htmlparser.filters.HasAttributeFilter; import org.htmlparser.util.NodeList; import org.htmlparser.visitors.NodeVisitor; public class Extractor { public static void parse(String html) throws Exception { Parser parser = Parser.createParser(html, "UTF-8"); // 只要 <a> 标签,且必须带 href 属性 NodeFilter linkFilter = new AndFilter( new TagNameFilter("a"), new HasAttributeFilter("href")); NodeList nodes = parser.extractAllNodesThatMatch(linkFilter); for (int i = 0; i < nodes.size(); i++) { Node node = nodes.elementAt(i); String href = node.getText(); // 这里拿到的是原始标签文本 System.out.println(href); } } }逻辑说明:Parser.createParser(html, "UTF-8")的第二个参数是解析时用的字符集,必须和页面实际编码一致,否则提取出的中文属性值会变成乱码。AndFilter把两个条件叠加,等价于「标签名是 a 且带 href」。extractAllNodesThatMatch返回的是匹配节点列表,顺序是文档流顺序。
参数说明:TagNameFilter大小写不敏感,写 "a" 或 "A" 都行;HasAttributeFilter("href")只判断属性存在,不判断值是否为空,所以后面还得自己过滤掉href="#"或javascript:开头的无效链接。
4.2 用 Visitor 提取文本而不是原始标签
上面node.getText()拿到的是<a href="...">文字</a>整段,想只要链接和锚文本,得往下钻:
import org.htmlparser.tags.LinkTag; for (int i = 0; i < nodes.size(); i++) { if (nodes.elementAt(i) instanceof LinkTag) { LinkTag link = (LinkTag) nodes.elementAt(i); String url = link.getLink(); // 纯 href 值 String text = link.getLinkText(); // 锚文本 if (url != null && !url.startsWith("javascript:")) { System.out.println(text + " -> " + url); } } }逻辑说明:HtmlParser 为常见标签提供了专用类,LinkTag就是<a>的封装,getLink()和getLinkText()直接给出干净结果,比手动切字符串可靠得多。这也是选 HtmlParser 的一个隐性好处——它对常用标签做了语义封装。
参数说明:getLink()返回的是原始 href,可能是相对路径,落盘前要用URI.resolve转成绝对路径,否则存下来的链接没法直接访问。
4.3 相对链接转绝对链接
import java.net.URI; URI base = URI.create("https://example.com/list/"); URI abs = base.resolve(link.getLink()); // 自动处理 ../ 和 / 开头 System.out.println(abs.toString());逻辑说明:URI.resolve按 RFC 3986 规则拼接,比手写字符串拼接稳,能正确处理../、./、以/开头这三种情况。参数说明:base 必须是带路径的完整 URI,如果只写域名不带结尾斜杠,相对路径拼接结果会差一级。
5. 避坑与排查:我复现时踩到的五个真实问题
5.1 中文乱码,页面明明声明了 UTF-8
现象:提取出的锚文本全是问号或方块。原因:Parser.createParser的字符集参数写死成 UTF-8,但目标页面实际是 GBK,或者响应体解码那一步就已经错了。解决:先从响应头Content-Type里读 charset,读不到再用<meta charset>兜底,两处都没有才默认 UTF-8。别在解析层硬编码。
5.2 抓到的链接一半是javascript:void(0)
现象:落盘的链接点不开。原因:HasAttributeFilter("href")只保证属性存在,不保证值可用。解决:在 Visitor 里加白名单判断,只保留http、https开头或能resolve成绝对路径的,其余丢弃。这个过滤必须在落盘前做,否则脏数据混进结果集很难回头清。
5.3 循环抓取时越来越慢,最后卡死
现象:前几十个请求正常,之后每个都等超时。原因:每次请求都HttpClient.newHttpClient(),连接池反复创建销毁,加上没设请求超时,坏连接一直挂着。解决:HttpClient 提成静态单例,两个超时都设上,并且对失败请求做有限重试而不是无限等待。
5.4 状态码 200 但内容是空壳
现象:请求成功,解析出来零个节点。原因:目标站点对无 UA 或异常 UA 返回了一个「正常但无内容」的页面,或者页面内容靠 JS 渲染,HttpClient 拿到的只是骨架。解决:先打印response.body().length()确认拿到东西,再确认页面是否静态。纯前端渲染的页面,这个组合抓不到,得换带渲染能力的方案,别在这上面死磕。
5.5 解析大页面时内存飙升
现象:抓一个几百 KB 的页面,堆内存涨得明显。原因:extractAllNodesThatMatch会把整棵节点树建在内存里,页面越大树越大。解决:对超大页面改用流式NodeVisitor边遍历边处理,不要先extractAllNodesThatMatch再遍历;或者限制单页大小,超过阈值直接跳过。
6. 进阶一点:把抓取和解析拆开,加一层可验证的落盘
写到能跑通之后,我习惯做一件事:把「抓取」和「解析」彻底解耦,中间落一份原始 HTML 到本地。这样解析逻辑出问题时,不用重新发请求,直接读本地文件调试,省时且不打扰对方站点。
import java.nio.file.*; public class Pipeline { public static void run(String url, String saveDir) throws Exception { String html = Fetcher.fetch(url); // 用 URL 的哈希做文件名,避免非法字符 String name = Integer.toHexString(url.hashCode()) + ".html"; Path raw = Paths.get(saveDir, name); Files.createDirectories(raw.getParent()); Files.writeString(raw, html); // 原始页面存档 Extractor.parse(html); // 解析同一份内容 } }逻辑说明:先落盘再解析,保证「解析用的就是抓到的」,不会出现两次请求拿到不同内容导致结果对不上。参数说明:文件名用hashCode的十六进制,简单但可能碰撞,正式用可以换成 URL 的 SHA-256 前 16 位。Files.writeString默认 UTF-8,和解析字符集保持一致。
验证方法:跑完之后去saveDir里随便打开一个 html 文件,用浏览器看渲染结果,再对比程序提取出的链接,两边对得上就说明解析规则没跑偏。这个「原始存档 + 结果对照」的习惯,是我从几次解析规则改错、结果却已经落库的教训里养成的。从那以后我每次改提取逻辑,都强制先跑一遍本地存档验证,确认无误再重新抓取。希望帮到你。
本文还有配套的精品资源,点击获取