Java爬虫实战:HttpClient+HtmlParser实现网页数据采集
2026/9/9 7:05:26 网站建设 项目流程

简介:一套利用Java HttpClient与HtmlParser实现的简单搜索引擎爬虫项目源码,面向具备基础Java语法、希望入门网络爬虫的开发者,也适合作为课程设计或毕业设计的参考模板。压缩包共42个文件,其中包含5个Java源文件、8个编译后的class文件、16个常用依赖jar包(如httpclient、htmlparser)以及4个抓取的HTML页面样本,总大小仅3.1MB,结构精简便于阅读。项目围绕SearchEngine场景,展示从构造GET请求、发送请求、解析HTML文档到提取搜索结果标题与链接的完整流程,覆盖初始化客户端、请求页面、解析响应、提取数据、存储结果与异常处理等关键步骤,并附带了Eclipse工程配置和第三方库,导入后即可运行调试。已有262人学习,适合想快速掌握HttpClient与HtmlParser核心用法,并动手实践简单爬虫的Java学习者,也便于在此基础上扩展多页抓取、数据持久化等功能。 做 Java 后端的人,多半会被问到一个问题:写爬虫不是 Python 的事吗?用 HttpClient、HtmlParser 这些 Java 技术栈是不是绕远了?其实我在实际项目里抓过不少公开数据,体验是:如果目标网站只要求服务端渲染、公开访问、无需复杂登录,用 Java 做一条简单爬虫反而方便,因为它能直接嵌进现有业务系统,登录态、数据落库、定时任务都可以复用同一套体系。这个项目就是一个很典型的例子:HttpClient 负责“拿到网页”,HtmlParser 负责“抽走数据”,两者配合,几十行代码就能实现对目标站点的内容采集。

这篇文章会从环境准备讲到完整实现,再把我踩过的坑一并列出来。适合刚接触爬虫的 Java 同学,也适合准备 Java 面试时想拿一两个实操亮点的人参考。毕竟面试官常问“你写过爬虫吗”,你如果能把这套流程讲清楚,比背八股文有用得多。我自己刚接触的时候也纠结过要不要直接上 Spring Boot 全家桶,后来发现一个干净的命令行工程就够验证思路;等你真想把它工业化,再往数据层和调度层扩展不迟。

1. 准备阶段:为什么选这组库与项目基础搭建

1.1 为什么是 HttpClient + HtmlParser

一说到爬虫,大多数人第一反应是 Python 的 requests 加 BeautifulSoup,脚本写起来确实快。但放到 Java 工程里,这个组合会多一层跨语言维护成本:你要么用 Java 调 Python 脚本,要么单独跑一个采集服务,两边还得定义接口来回传数据。如果公司内部已经有一套 Java 后端,最自然的方式就是把采集逻辑写成一个普通 Maven 模块,和业务代码放在同一个工程里,依赖管理、日志、监控全部复用。

当然,Java 生态里也有一堆现成方案,比如 Jsoup 本身就能解析 HTML,它的 CSS 选择器比 HtmlParser 好用得多。但“HttpClient + HtmlParser”是很多老项目里真实存在的组合,也是 Java 面试八股里经常被拿出来问的技术栈。拆成两层还有一个好处:你能更清楚地理解爬虫的本质,无非是“发请求拿响应”和“从响应里提取数据”两个阶段,混在一个库时反而容易搞混。为了学习原理,这个经典组合非常合适。

方案优点缺点
Python requests + BeautifulSoup上手快、代码短、生态强与 Java 系统集成困难,需要跨语言维护
HttpClient + HtmlParser纯 Java,能嵌入现有工程,连接池管理成熟HtmlParser 较老,对 HTML5 兼容一般
Jsoup 单库方案选择器好用,容错强HTTP 能力弱,通常还得配合 HttpClient

1.2 Maven 依赖引入和项目结构

先用 Maven 建一个最简单的 Java 工程,把两个依赖加进去。HttpClient 我选的是 4.5.14,这是 4.x 系列的收尾版本,长期使用下来很稳定;HtmlParser 版本停在 2.1,虽然老,但 API 简单、没有复杂传递依赖,能跑就行。

<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <dependency> <groupId>org.htmlparser</groupId> <artifactId>htmlparser</artifactId> <version>2.1</version> </dependency>

如果你不是 Maven 工程,直接下载这两个 jar 包丢进 lib 目录也可以。这里提醒一句:最省事的运行环境是 JDK 8。HtmlParser 发布得太早,在高版本 JDK 上偶尔会因为模块限制报错,我在第 5 章会专门讲这个坑。项目结构也不复杂,就四个类:

src/main/java/ ├── com/example/crawler/ │ ├── HttpUtil.java // 负责发送 HTTP 请求 │ ├── News.java // 数据模型 │ ├── NewsParser.java // 负责解析 HTML │ └── Main.java // 串联整个流程

2. 用 HttpClient 封装网页抓取器(附完整代码)

2.1 HttpClient 的基本用法和超时配置

HttpClient 的用法很固定:创建一个全局的 CloseableHttpClient,然后用 HttpGet 包装请求地址,执行后从 response 里取出实体内容。下面这个工具类是我在项目里常用的简化版,保留了超时设置、User-Agent 伪装和资源自动释放:

import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import java.io.IOException; /** * 最简单的 Http 抓取工具,只保留够用的功能。 */ public class HttpUtil { private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; private static final int TIMEOUT = 5000; private final CloseableHttpClient httpClient; public HttpUtil() { RequestConfig config = RequestConfig.custom() .setConnectTimeout(TIMEOUT) .setSocketTimeout(TIMEOUT) .setConnectionRequestTimeout(TIMEOUT) .build(); httpClient = HttpClients.custom() .setDefaultRequestConfig(config) .build(); } public String getHtml(String url) throws IOException { HttpGet httpGet = new HttpGet(url); httpGet.setHeader("User-Agent", USER_AGENT); try (CloseableHttpResponse response = httpClient.execute(httpGet)) { int status = response.getStatusLine().getStatusCode(); if (status != 200) { throw new IOException("请求失败,HTTP 状态码:" + status); } return EntityUtils.toString(response.getEntity(), "UTF-8"); } } public void close() throws IOException { httpClient.close(); } }

三个超时参数别嫌多,它们管的事不一样:连接超时是建立 TCP 连接的最长等待时间,Socket 超时是服务端返回数据包之间的最大间隔,连接请求超时是从连接池里拿连接的最长等待时间。我曾经用默认配置去访问一个响应很慢的服务,结果线程卡在原地快 90 秒才报错,把这三个值统一设成 5 秒后,问题立刻消失。

2.2 模拟浏览器请求头与连接复用

还有一个关键点是 User-Agent。HttpClient 默认会带一串“Apache-HttpClient/4.5.14”的标识,很多网站看到就直接返回 403,换上浏览器的 User-Agent 后成功率会高很多。如果目标是需要登录才能访问的页面,可以把 Cookie 串也塞进请求头:

httpGet.setHeader("Accept", "text/html,application/xhtml+xml"); httpGet.setHeader("Referer", "https://example.com/"); httpGet.setHeader("Cookie", "sessionid=xxx; token=yyy");

在爬虫里,连接复用特别重要。新手最容易犯的一个错误是每次请求都 new 一个 HttpClient,用完再 close,这样底层连接池完全没生效,每次都要重新握一次手。更好的做法是像上面的代码一样,把 httpClient 做成成员变量,同一个实例多次执行 getHtml,连接会自动复用。等你想加并发量了,再用 PoolingHttpClientConnectionManager 调整连接池大小:

PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(50); cm.setDefaultMaxPerRoute(20); httpClient = HttpClients.custom().setConnectionManager(cm).build();

3. 用 HtmlParser 精准抽取页面数据(附完整代码)

3.1 HtmlParser 的 NodeFilter 过滤与提取

抓完 HTML 之后,下一步就是从一堆标签里把目标数据抠出来。HtmlParser 的工作机制很直观:先把 HTML 解析成一棵节点树,然后用 Filter 按条件筛选节点。你可以把 Filter 想象成一道筛子,比如TagNameFilter("A")等价于 CSS 选择器里的aHasAttributeFilter("class", "news-item")等价于[class=news-item],多个条件可以用AndFilter拼在一起。

下面这个例子演示了最基础的用法:解析一段 HTML 字符串,提取所有链接和文字。注意extractAllNodesThatMatch的第二个参数传true,表示递归查找所有后代节点,否则只会扫描当前层级,很容易漏数据。

import org.htmlparser.Node; import org.htmlparser.NodeFilter; import org.htmlparser.Parser; import org.htmlparser.filters.AndFilter; import org.htmlparser.filters.HasAttributeFilter; import org.htmlparser.filters.TagNameFilter; import org.htmlparser.tags.LinkTag; import org.htmlparser.util.NodeList; public class HtmlParserDemo { public static void main(String[] args) throws Exception { String html = "<div class='list'>" + "<div class='item'><a href='/news/1.html'>标题一</a></div>" + "<div class='item'><a href='/news/2.html'>标题二</a></div>" + "</div>"; Parser parser = new Parser(html); parser.setEncoding("UTF-8"); NodeFilter itemFilter = new AndFilter( new TagNameFilter("DIV"), new HasAttributeFilter("class", "item")); NodeList items = parser.parse(itemFilter); NodeList links = items.extractAllNodesThatMatch(new TagNameFilter("A"), true); for (int i = 0; i < links.size(); i++) { LinkTag link = (LinkTag) links.elementAt(i); System.out.println(link.getStringText().trim() + " -> " + link.getLink()); } } }

很多初学者不知道Parser.parse(filter)内部其实是两件事:先完整解析 HTML 成 NodeList,再在结果上执行过滤。理解这一点后,你就不会纠结“为什么 parse 一次之后还能继续 extract”。实际项目里,我通常先解析出外层容器,再在容器内继续提取,这样比一次性写一个超级复杂的 Filter 可读性好得多,也方便单条数据单独做异常兜底。

3.2 用 NodeVisitor 处理复杂页面结构

如果只是“挑同一类节点”,Filter 是首选。但遇到要在遍历过程中统计上下文、或者同时处理多种标签的情况,可以用 NodeVisitor。它相当于给解析树挂了个监听器,每访问到一个 Tag 就回调一次 visitTag:

Parser parser = new Parser(html); parser.setEncoding("UTF-8"); parser.visitAllNodesWith(new NodeVisitor() { @Override public void visitTag(Tag tag) { if ("A".equalsIgnoreCase(tag.getTagName())) { LinkTag link = (LinkTag) tag; System.out.println(link.getStringText() + " -> " + link.getLink()); } } });

说句实话,平时写简单爬虫用 NodeVisitor 的场景不算多,它更适合解析表格、嵌套列表这类结构复杂的页面。但了解这种写法还是值得的,因为很多老项目的代码里就是用 Visitor 遍历的,面试时能随口说出两种解析方式,比只背一个“用 Filter 提取节点”要扎实得多。

4. 完整案例:新闻标题列表爬虫从零跑通

4.1 目标页面分析与数据结构设计

纸上谈兵没意思,我直接假设一个新闻列表页,结构如下。这类服务端渲染的页面是最适合初级爬虫练习的,数据都老老实实写在 HTML 里:

<div class="news-list"> <div class="news-item"> <span class="date">2025-06-01</span> <a href="/news/1001.html">某地发布重要通知</a> </div> <div class="news-item"> <span class="date">2025-06-01</span> <a href="/news/1002.html">城市交通新规本周实施</a> </div> </div>

解析策略很明确:先用AndFilter匹配所有 class 为 news-item 的 div,再从每个 div 内部提取 a 标签的文本和 href,以及 span 的文本。定义一个最简单的 News 类来保存结果:

public class News { private String title; private String url; private String publishDate; public News(String title, String url, String publishDate) { this.title = title; this.url = url; this.publishDate = publishDate; } @Override public String toString() { return "News{title='" + title + "', url='" + url + "', publishDate='" + publishDate + "'}"; } }

4.2 串起主流程:抓取、解析、落地

下面这个 NewsParser 是核心解析逻辑。注意我在单条数据解析外面加了个 try-catch,这样即使某一条 HTML 结构不对,也不会拖垮整个采集任务,最多丢掉一条数据。这个习惯在批量爬取时非常重要,目标页面里偶尔出现一两个不规范标签太常见了。

import org.htmlparser.Node; import org.htmlparser.NodeFilter; import org.htmlparser.Parser; import org.htmlparser.filters.AndFilter; import org.htmlparser.filters.HasAttributeFilter; import org.htmlparser.filters.TagNameFilter; import org.htmlparser.tags.LinkTag; import org.htmlparser.tags.Tag; import org.htmlparser.util.NodeList; import java.util.ArrayList; import java.util.List; public class NewsParser { public List<News> parseList(String html) throws Exception { List<News> newsList = new ArrayList<>(); Parser parser = new Parser(html); parser.setEncoding("UTF-8"); NodeFilter itemFilter = new AndFilter( new TagNameFilter("DIV"), new HasAttributeFilter("class", "news-item")); NodeList items = parser.parse(itemFilter); for (int i = 0; i < items.size(); i++) { try { Node item = items.elementAt(i); NodeList links = item.getChildren() .extractAllNodesThatMatch(new TagNameFilter("A"), true); if (links.size() == 0) { continue; } LinkTag aTag = (LinkTag) links.elementAt(0); String title = aTag.getStringText().trim(); String url = aTag.getLink(); NodeList dateNodes = item.getChildren() .extractAllNodesThatMatch(new AndFilter( new TagNameFilter("SPAN"), new HasAttributeFilter("class", "date")), true); String date = dateNodes.size() > 0 ? ((Tag) dateNodes.elementAt(0)).toPlainTextString().trim() : ""; newsList.add(new News(title, url, date)); } catch (Exception e) { System.err.println("解析单条数据失败:" + e.getMessage()); } } return newsList; } }

最后用 Main 把抓取和解析串起来。下面例子里的 URL 是我虚构的,自己练习时替换成实际目标地址就行:

public class Main { public static void main(String[] args) { HttpUtil httpUtil = new HttpUtil(); String listUrl = "https://example.com/news"; try { String html = httpUtil.getHtml(listUrl); NewsParser parser = new NewsParser(); for (News news : parser.parseList(html)) { System.out.println(news); } } catch (Exception e) { e.printStackTrace(); } finally { try { httpUtil.close(); } catch (Exception ignored) { } } } }

跑起来后,控制台会打印类似这样的结果:

News{title='某地发布重要通知', url='/news/1001.html', publishDate='2025-06-01'} News{title='城市交通新规本周实施', url='/news/1002.html', publishDate='2025-06-01'}

到这一步,一个最基础的爬虫已经能正常工作了。如果你要抓更多页,在外面套一层循环,遍历分页 URL 就行:

for (int page = 1; page <= 5; page++) { String url = "https://example.com/news?page=" + page; // 抓取 + 解析 Thread.sleep(2000 + new Random().nextInt(1000)); }

这里特意加个随机休眠,不是教你去对抗反爬,而是让采集频率尽量接近正常访客,不给目标站点增加压力。

5. 实操中常见的坑与排查方法

5.1 最常见:编码不一致导致的乱码

乱码排第一,因为十个人里有八个会踩。大多数页面是 UTF-8,但有些老站还用 GBK,如果抓回来以后直接按 UTF-8 解析,中文全变问号。判断方法很简单:看 HTTP 响应头里的 charset,或者看 HTML 的 meta 标签。HttpClient 在EntityUtils.toString时手动指定编码就行。还有一种情况是编码已经错了,字符串本身是乱的,可以试着用下面这行代码救回来:

String correct = new String(garbled.getBytes("ISO-8859-1"), "UTF-8");

这个技巧的原理是:HttpClient 如果不指定编码,默认按 ISO-8859-1 把字节还原成字符串,导致 UTF-8 的中文字节被“错误翻译”。把乱码字符串重新按 ISO-8859-1 转会为原始字节,再用 UTF-8 解码,往往就恢复原样了。另外记得让 HtmlParser 的 setEncoding 和页面编码保持一致,否则解析出来的文本还是乱码。

5.2 被识别成爬虫:User-Agent、Cookie 与频率

如果你发现程序能正常访问浏览器,但用 HttpClient 一抓就是 403 或 418,大概率是请求头暴露了“爬虫身份”。排查时先看 User-Agent 是不是浏览器那串,再确认有没有带 Referer,最后检查访问频率。有些网站的防火墙会统计单 IP 的请求数,短时间请求过多就会临时封禁。

这里要说得明白一点:反爬对抗是一个动态过程,我不会教你去搞验证码识别、代理池轮换这类灰色操作。一个体面的爬虫应该尊重网站的 robots.txt 和用户协议,只抓公开数据,控制频率,出现问题先停下来。技术归技术,分寸感也很重要。

5.3 HtmlParser 的兼容性问题

这个库太老了,用的时候要降低预期。第一,HTML5 新增的标签像 main、section、article,它不一定能正确建模,过滤时可能漏节点;第二,很多不规范的自闭合标签和不闭合标签会直接让解析抛异常,所以我在第 4 章强调单条解析要加 try-catch;第三,高版本 JDK 下运行时,偶尔会出现类加载或模块访问的报错,最简单的处理办法是切回 JDK 8,别在高版本上死磕。如果项目允许,我会建议把解析引擎换成 Jsoup,写起来更顺手,但“HttpClient + HtmlParser”这套思路完全可以迁移过去,因为换的只是解析层,抓取层的设计和代码都不用动。

5.4 常见问题速查表

现象可能原因处理建议
返回乱码页面编码与解析编码不一致检查响应头/meta,统一 setEncoding
403/418UA 被识别或访问频率过高设置浏览器 UA、降低抓取频率
拿不到数据动态渲染页面,数据由 JS 加载优先找后端 JSON 接口,或换无头浏览器
连接卡死超时未设置设置连接、Socket、连接请求三类超时
HtmlParser 解析抛异常HTML 不规范或 HTML5 标签不支持单条 try-catch,考虑换 Jsoup

最后再分享一个小技巧:日常调试解析代码时,先把抓回来的 HTML 保存到本地文件,解析器直接从文件读,这样不会因为网络波动反复请求目标网站。等解析逻辑稳定了,再改成实时抓取。这个习惯帮我节省了大量时间,也避免了对目标站点造成不必要的压力。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询