Java爬取网页表格:HttpClient+Jsoup解析与CSV导出
简介:这是一份面向Java初学者与数据采集入门者的网页表格抓取示例,基于MyEclipse开发环境,围绕静态HTML表格的解析与结构化提取展开,也可为动态页面采集方案提供对照思路。压缩包共24个文件,约1.15MB,包含7个java源码、7个class编译文件、7个jar依赖及工程配置文件,覆盖Jsoup解析、HTTP请求与项目运行所需组件,导入后便于查看完整工程结构。资源已有712人学习,价值在于提供可运行的实践样例:从建立连接、定位表格元素到遍历行列、保存结果与异常处理均有对应代码;Java源码可直接对照调试,jar依赖补齐运行环境,便于在MyEclipse中复现和二次修改。读者可借此理解网页表格数据的提取流程,掌握表格行列数据的整理方式,并作为课程实验、小型采集脚本或数据分析前置练习的参考。
1. 一个 main 方法抓下整张表格:这个 Java 采集例子能干什么
后台导出的对账单、公示页面上的价格清单、教务系统里那张课程表——这些数据在浏览器里看得见、摸得着,可真要拿去用的时候,Ctrl+C 再 Ctrl+V 粘进 Excel 就全乱了:合并单元格塌了,列序错位,分页还得手动翻十几次。这个 java 爬取网页表格 例子盯的就是这类活:一个能在 MyEclipse 里直接跑起来的 Java 工程,用纯 HTTP 请求把页面 HTML 拿回来,再交给 HTML 解析器把<table>拆成规整的行列,最后落成 CSV 或者一批 POJO 对象。
它没有浏览器自动化那套重装备,不装驱动、不起无头浏览器,一个 main 方法跑完就出文件,特别适合手里只有 JDK 和一个老 IDE、想先把「抓下来—解析—存下来」这条链路走通的开发者。页面是静态渲染的、表格结构相对固定的场景,用它最省事;要是目标站点的数据全靠前端异步渲染出来,这套路子会翻车,后面第 4 章会讲清楚怎么判断。
2. 选型与工程骨架:HttpClient 取页面、Jsoup 拆表格,在 MyEclipse 里搭起来
这个例子的骨架其实只有两件事:把 HTML 文本弄到手,把文本里的表格结构还原成行列。前者归请求层,后者归解析层,两层之间只传一个字符串,耦合度极低,所以你在 MyEclipse 里改任何一层都不会牵动另一层。
2.1 请求层为什么不用 JDK 自带的 HttpURLConnection
java.net.HttpURLConnection是零依赖的,一个 jar 都不用加,这是它最大的优势。但它有几个地方在采集场景里特别难受:默认不维护连接池,每抓一页都要重新握手;超时只能靠setConnectTimeout/setReadTimeout两个方法硬设;遇到 302 跳转得自己写循环;Header 的拼装全靠setRequestProperty一行行敲。抓个一两页无所谓,一旦涉及到翻页几十次、加个失败重试,代码就会迅速膨胀成一坨。
Apache HttpClient 4.x(4.5 这条线比较稳)把这些都封好了:PoolingHttpClientConnectionManager管连接复用,RequestConfig一次性把连接超时、读取超时、重试次数配齐,CloseableHttpResponse拿到的状态码、Header、实体流一应俱全。我一般会选它,代价是 lib 目录下要多躺几个 jar。
至于 Jsoup,它是这个例子里真正干活的那把刀。它支持 CSS 选择器,table.list tbody tr一行就能定位到目标行;Element.text()会自动把标签剥掉只留文本,顺手做了空白归一化;最关键的是它容错,HTML 里少个</td>这种脏结构它也能把 DOM 补全,不会像 XML 解析器那样直接抛异常。请求层用 HttpClient、解析层用 Jsoup,是这个组合最常见的分工;如果你图省事,Jsoup 自己也能发请求(Jsoup.connect(url).get()),只是连接管理这块弱一些,页数一多就明显。
2.2 工程目录与依赖清单
在 MyEclipse 里建一个普通 Java Project 就行,不需要 Dynamic Web Project,这个例子不涉及 Servlet 容器。目录保持下面这个样子,后面所有代码都往对应位置塞:
TableSpider/ ├── src/ │ └── com/example/spider/ │ ├── HttpFetcher.java # 请求层:拿 HTML 字符串 │ ├── TableParser.java # 解析层:table -> List<List<String>> │ ├── Row.java # 实体:一行业务数据 │ ├── CsvWriter.java # 出口:写 UTF-8 BOM 的 CSV │ └── Main.java # 入口:串流程 ├── lib/ # 手动放入的第三方 jar └── out/ # 编译输出(MyEclipse 默认)依赖一共五个 jar,全部丢进lib/目录,然后在工程上右键 Build Path → Configure Build Path → Add JARs 全部挂上:
| jar 包 | 作用 | 是否必需 |
|---|---|---|
| httpclient | 发请求、连接池、超时控制 | 必需 |
| httpcore | HttpClient 的底层协议实现 | 必需 |
| commons-logging | HttpClient 的日志门面,缺了启动就报 NoClassDefFoundError | 必需 |
| commons-codec | 处理 Basic 认证与编码工具 | 必需 |
| jsoup | HTML 解析与 CSS 选择器 | 必需 |
提示:
commons-logging和commons-codec这两个传递依赖最容易被漏掉,因为你在代码里从来没 import 过它们。现象是编译完全通过、一运行就抛java.lang.NoClassDefFoundError,新手很容易在这里卡半天。
2.3 MyEclipse 里三个必须动一下的设置
老 IDE 的默认值对中文相当不友好,这三处不改,后面必踩坑。
第一处是文本编码。Window → Preferences → General → Workspace → Text file encoding 改成 UTF-8;单个工程上右键 Properties → Resource 也确认一遍是 UTF-8。源码里写了中文表头、中文正则,编码错了就是一堆问号。
第二处是编译级别。工程右键 Properties → Java Compiler,把 Compiler compliance level 设成 1.8;如果你图省事用了 JDK 8 的 lambda 或者try-with-resources,级别停在 1.6 会直接报语法错,而且报错位置看着莫名其妙。
第三处是运行参数。Run Configurations → Arguments → VM arguments 里加一句:
-Dfile.encoding=UTF-8控制台输出的中文才不会变成乱码。这三处都是纯配置,一次改完以后新工程记得照抄,我自己是把这个 VM 参数直接写进了 IDE 的全局模板里。
3. 核心代码拆解:一次请求从建立连接到表格落成 CSV 的完整链路
流程本身很直白:拼 URL → 发 GET → 拿 HTML → 选中目标 table → 逐行逐格抽文本 → 组装实体 → 写文件。真正花时间的是每一步的边界情况,下面按层拆。
3.1 请求层:把超时、UA、重试收进一个工具类
public class HttpFetcher { // 连接池全局复用,别每次 new 一个 client,否则翻页时性能极差 private static final CloseableHttpClient CLIENT; static { PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(20); cm.setDefaultMaxPerRoute(10); CLIENT = HttpClients.custom() .setConnectionManager(cm) .setRetryHandler(new DefaultHttpRequestRetryHandler(2, true)) .build(); } public static String get(String url, String charset) throws IOException { // 超时三件套:连接 5s、取连接 5s、读数据 15s RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) .setConnectionRequestTimeout(5000) .setSocketTimeout(15000) .build(); HttpGet get = new HttpGet(url); get.setConfig(config); // 不带 UA 的请求最容易被网关直接掐掉,伪装成一个普通浏览器 get.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"); get.setHeader("Accept", "text/html,application/xhtml+xml"); get.setHeader("Accept-Language", "zh-CN,zh;q=0.9"); try (CloseableHttpResponse resp = CLIENT.execute(get)) { int code = resp.getStatusLine().getStatusCode(); if (code != 200) { throw new IOException("HTTP " + code + " -> " + url); } // 用 EntityUtils 拿到字节,再按指定编码转字符串,避免流读一半 byte[] bytes = EntityUtils.toByteArray(resp.getEntity()); return new String(bytes, charset); } } }setMaxTotal和setDefaultMaxPerRoute控制的是连接池上限,抓单站时每路由 10 个足够了,设得太大反而容易触发对端的并发限制。DefaultHttpRequestRetryHandler(2, true)的第二个参数表示「遇到 IO 异常也重试」,对于偶发的连接重置挺管用,但对 4xx、5xx 不会重试——这是对的,403 重试一百次还是 403。
EntityUtils.toByteArray这一步别换成EntityUtils.toString(entity, charset)之后就完事,因为很多老站点的响应头里 charset 写的是ISO-8859-1,实际页面却是 GBK。先取字节、再自己决定编码,等于把编码的决策权收回到自己手里,这个习惯能省下大量排查时间。
3.2 解析层:table → tr → td 的定位与合并单元格补格
先解决「选哪张表」。一个页面上常常有好几张 table,导航栏一张、页脚一张、目标数据一张。最稳的做法是先按业务特征缩范围:
Document doc = Jsoup.parse(html); // 优先用 id 或 class 定位,其次退化成「取行数最多的那张表」 Element table = doc.selectFirst("table#dataList"); if (table == null) { for (Element t : doc.select("table")) { if (t.select("tr").size() > (table == null ? 0 : table.select("tr").size())) { table = t; } } }用「行数最多」兜底是个土办法,但在结构未知的页面上命中率意外地高。当然它也只是兜底,真要长期跑还是老老实实写选择器。
接着是补格。网页表格里rowspan和colspan是家常便饭,你的td数量跟表头对不上,绝大多数时候就是它们造成的。下面这段用一个待填充的 Map 记录被上方单元格「占用」的位置:
public static List<List<String>> parseTable(Element table) { List<List<String>> grid = new ArrayList<>(); Map<String, String> pending = new HashMap<>(); // "行,列" -> 上方 rowspan 留下的文本 Elements rows = table.select("tr"); for (int r = 0; r < rows.size(); r++) { List<String> line = new ArrayList<>(); int c = 0; for (Element cell : rows.get(r).children()) { // th 和 td 一起处理 // 先把上一行 rowspan 占住的位置填掉 while (pending.containsKey(r + "," + c)) { line.add(pending.remove(r + "," + c)); c++; } String text = cell.text().trim(); int colspan = attr(cell, "colspan"); int rowspan = attr(cell, "rowspan"); for (int i = 0; i < colspan; i++) { line.add(text); c++; for (int k = 1; k < rowspan; k++) { pending.put((r + k) + "," + (c - 1), text); } } } // 行尾可能还有上方 rowspan 延续下来的格子 while (pending.containsKey(r + "," + c)) { line.add(pending.remove(r + "," + c)); c++; } grid.add(line); } return grid; } private static int attr(Element e, String name) { String v = e.attr(name); try { return v.isEmpty() ? 1 : Integer.parseInt(v); } catch (NumberFormatException ex) { return 1; // 脏数据写成 rowspan="abc" 时按 1 处理,别让整张表挂掉 } }pending的 key 用行号,列号拼字符串,虽然看着不优雅,但省掉了一个二维数组的初始化麻烦,几十行几百列的量级完全无压力。attr方法里那个 catch 是血泪经验——真实页面里rowspan=""、rowspan="0"、甚至带空格的情况都出现过,Integer.parseInt一崩整个解析就中断,宁可退化处理也不能让程序挂。
补格逻辑只能保证「列数对齐」,不能保证语义完全正确。有些站点的合并单元格语义是分组的,展开之后重复值看着别扭,这时候在输出前做一次「同值向上合并」的清洗就好,别在解析层硬改。
3.3 数据出口:表头映射成 POJO 再落 CSV
拿到List<List<String>>之后别急着按下标取值,grid.get(i).get(3)这种写法,页面列序一变全盘皆错。先做一张表头到下标的映射:
public static Map<String, Integer> headerIndex(Element table) { Map<String, Integer> map = new HashMap<>(); Element head = table.selectFirst("thead tr"); if (head == null) head = table.selectFirst("tr"); // 没有 thead 的站不少 Elements ths = head.select("th, td"); for (int i = 0; i < ths.size(); i++) { map.put(ths.get(i).text().trim(), i); // 中文表头直接当 key } return map; } // 按名字取值,取不到返回空串,避免 IndexOutOfBounds public static String cell(List<String> line, Map<String, Integer> header, String name) { Integer idx = header.get(name); if (idx == null || idx >= line.size()) return ""; return line.get(idx); }有了这层,业务代码就变成「按中文列名取数」,页面多一列少一列都不影响。接着组装实体、写 CSV:
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter( new FileOutputStream("out/result.csv"), "UTF-8")); bw.write('\uFEFF'); // 写 UTF-8 BOM,否则 Excel 双击打开必乱码 bw.write("名称,单价,数量,小计\n"); for (Row row : rows) { bw.write(String.join(",", escape(row.getName()), row.getPrice(), row.getCount(), row.getTotal())); bw.write("\n"); } bw.close();那个\uFEFF是 BOM 头,不加的话 Excel 会按本机 ANSI 解码,中文表头直接变问号;加了之后大部分工具都正常。字段里出现逗号、换行、双引号时要做转义,标准做法是用双引号包裹并把内部双引号写成两个,这个小函数几行就能写完,别省略,否则一条带逗号的商品名就能把整列错开。
3.4 翻页:URL 规律和 POST 表单两种情形
翻页分两类,先看页码是不是在地址栏里。?page=1、?pageno=2、/list_3.html都属于这一类,把数字抽出来循环即可:
for (int page = 1; page <= maxPage; page++) { String url = String.format("https://example.com/list?page=%d", page); String html = HttpFetcher.get(url, "UTF-8"); // ... 解析、追加写入 Thread.sleep(800); // 别打太猛,800ms 是个温和的间隔 }maxPage别硬编码成 100,去页面上找「下一页」按钮或者总数文案,找不到就靠一个「本页解析结果为空就 break」的兜底条件退出。
第二类是 POST 表单翻页,页面地址不变,页码藏在请求体里。这种情况直接拼 URL 是拿不到第二页的,必须构造表单:
HttpPost post = new HttpPost("https://example.com/query.do"); List<NameValuePair> form = new ArrayList<>(); form.add(new BasicNameValuePair("pageNo", String.valueOf(page))); form.add(new BasicNameValuePair("pageSize", "20")); post.setEntity(new UrlEncodedFormEntity(form, "UTF-8")); // 其余 Header、超时配置与 GET 一致判断该用哪种,最简单的办法是浏览器 F12 打开网络面板点一次「下一页」,看请求方法。如果是 POST,还要顺手把 Referer 带上,不少站点会校验它。
4. 避坑与排查:五类把结果搞成乱码和空表的现场问题
这套代码跑通不难,难的是跑稳。下面五条都是我在真实页面上撞过的,按「现象 → 原因 → 解决」记,照着对号入座能省不少时间。
4.1 请求与编码类:403、乱码、读超时
第一类,请求发出去直接被拒。
现象:本地跑得好好的,换台机器或者隔天再跑,状态码直接 403,返回一个「访问被拒绝」的静态页。
原因:只带了默认 Header。HttpClient 默认的 User-Agent 是Apache-HttpClient/x.x,网关一眼就认出来;另外部分站点会看 Referer 和 Accept-Language,缺了也拒。
解决:把 UA、Accept、Accept-Language、Referer 补齐,Referer 填目标站点自己的域名;如果加了 Header 还是 403,检查是不是漏了 Cookie——先手动在浏览器里登录一次,把 Cookie 串复制到代码里试通,再考虑后续。别一上来就上代理池,先确认是不是 Header 的问题,不然方向就错了。
第二类,中文全变问号或者「锟斤拷」。
现象:控制台输出一堆????,或者 CSV 打开是一串锟斤拷。
原因:三处编码任意一处不对都会这样——源码文件编码、HTTP 响应的实际编码、输出流的编码。老站点尤其阴,响应头写charset=ISO-8859-1,页面 meta 里写gb2312,两者互相打架。
解决:先按 3.1 的方式取字节数组,然后按优先级判定编码:响应头 Content-Type 里的 charset → HTML 前 1024 字节里的<meta charset>→ 手动指定。三者都没有就按 UTF-8 试,出乱码再换 GBK。源码编码和 IDE 的 Workspace 编码统一到 UTF-8,运行参数带上-Dfile.encoding=UTF-8。
第三类,读超时。
现象:程序卡在某个页面几十秒,然后抛SocketTimeoutException。
原因:setSocketTimeout设得太短,或者目标页面本身响应慢(报表类页面几秒出结果很正常),也可能是连接池被占满后拿不到连接。
解决:读超时从 15 秒放到 30 秒,连接请求超时单独设。如果是连接池耗尽,看setDefaultMaxPerRoute是不是设得太小,同时确认有没有忘记close()响应对象——try-with-resources能兜住这个。
4.2 结构与数据类:抓到空表、列错位、脏数据
第四类,解析出来是空表。
现象:table.select("tr").size()返回 0,或者只拿到表头一行。
原因:三种可能。一是数据根本不在初始 HTML 里,靠前端 JS 异步拉接口渲染,你抓到的只是一个空壳;二是选择器写错了,目标表在一个iframe里,或者 class 名带了动态哈希;三是页面结构是「先加载骨架再填数据」,而你的请求没带必要的 Cookie,服务端返回了简化版。
解决:先看抓下来的原始 HTML(直接把它写到文件里肉眼扫一遍,这一步别省)。里面有数据就是选择器问题,调整即可;没数据就是渲染方式问题,去 Network 面板找那个真正返回 JSON 的接口,改抓接口,比解析 HTML 省事十倍。iframe 里的内容要单独按 iframe 的 src 再请求一次。
第五类,列错位和脏数据。
现象:某些行比表头多一列或少一列,取出来的「单价」位置上是「备注」。
原因:rowspan/colspan没处理干净;或者页面里嵌了嵌套表格,table.select("tr")把内层表格的行也一起选中了。
解决:补格逻辑按 3.2 那套走;嵌套表格的问题改用table.select("> tbody > tr")或者先取直接子节点再遍历,避免跨层选择。另外Element.text()会把<br>变成空格、把多个空白压成一个,如果业务需要保留换行,改用html()自己处理。
注意:任何一次「解析结果看起来差不多对」都要抽三五行跟浏览器里显示的原值逐字比对。列错位最阴的地方在于它不会报错,数据看着都是字符串,等你把 CSV 交给业务方才发现整列偏移,那时候回溯成本就高了。
5. 二次开发:把 demo 改成配置驱动的定时采集器
这个例子的代码量不大,直接拿去改业务逻辑很快就能用。但如果要给别的同事复用、或者每周跑一次,硬编码的 URL、编码、列名就会变成负担。我的做法是把「变的部分」抽出去,代码只留「不变的部分」。
5.1 把 URL、编码、列映射抽成一份配置文件
在src下放一个spider.properties,把每个采集任务写成一个前缀:
task1.url=https://example.com/list?page={page} task1.charset=GBK task1.table=table#dataList task1.columns=名称,单价,数量,小计 task1.maxPage=20读取端用Properties加载,{page}用String.replace占位替换。这样新增一个站点只改配置文件,Main里按前缀循环即可。columns这一行同时承担两个职责:声明要哪几列、以及 CSV 的表头顺序,一举两得。改造之后Main大概长这样:
Properties cfg = new Properties(); cfg.load(new FileInputStream("src/spider.properties")); for (String key : cfg.stringPropertyNames()) { if (!key.startsWith("task") || !key.endsWith(".url")) continue; String prefix = key.substring(0, key.indexOf(".url")); new TaskRunner(cfg, prefix).run(); // 每个任务独立跑,互不影响 }TaskRunner里放的就是第 3 章那套流程,参数从配置里取。任务之间互不影响这点很重要——某个站点挂了不该拖垮整批。
5.2 验证方法与增量落库
改完一定要验证,别凭感觉。我一般做两层校验:第一层是行数,把页面上标注的总条数和抓到的行数对一下,差得离谱说明翻页逻辑有问题;第二层是抽样,随机抽五条,人工去页面上核对原文。这两步加起来不到五分钟,但能挡掉八成低级错误。
增量落库这块,最简单可靠的做法是给每条记录算一个业务主键的哈希,写进数据库前先查一下存不存在,存在就跳过。表结构里加一个uk_hash唯一索引,重复插入直接靠数据库拦截,比在 Java 里维护一个Set靠谱得多,程序重启也不丢状态。如果只是导 CSV,就在文件名里带日期,别覆盖上一次的结果——后悔药这种东西,平时不用,用的时候真救命。
还有一个容易被忽略的点:定时跑的时候把日志落到文件里,至少记下每次的页码、状态码、解析行数。出问题的时候这三样一对,就知道是没抓到还是没解析对,不用重跑一遍去猜。
这个例子我改过好几版,最早那版把 URL 和表头全写死在Main里,结果页面列序一调整,整个 CSV 的表头跟数据全错位,业务方拿着文件来问的时候我才发现。从那以后我每次接一个新的表格页面,都强制先把表头映射和原始 HTML 落盘看一眼,再动手写解析逻辑,这个习惯帮我挡掉了很多次类似的翻车。希望帮到你。
本文还有配套的精品资源,点击获取