Java爬虫入门:HttpClient与HtmlParser实现网页抓取与解析
简介这是一份面向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 HtmlParserHttpClient 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(); HttpResponseString 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 直接返回 403BodyHandlers.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 文件用浏览器看渲染结果再对比程序提取出的链接两边对得上就说明解析规则没跑偏。这个「原始存档 结果对照」的习惯是我从几次解析规则改错、结果却已经落库的教训里养成的。从那以后我每次改提取逻辑都强制先跑一遍本地存档验证确认无误再重新抓取。希望帮到你。本文还有配套的精品资源点击获取