☰
Java爬虫解析HTML表格:HttpClient+Jsoup处理合并单元格
2026/10/24 1:36:11 网站建设 项目流程

简介:这份面向Java初学者与数据采集入门者的示例包,围绕在MyEclipse环境下抓取网页表格数据展开,涵盖连接目标页面、按ID定位表格元素、遍历行列提取单元格内容、整理存储及异常处理等关键环节,适合想用Jsoup完成静态页面结构化数据抽取的开发者参考。压缩包共24个文件、约1.15MB,以7个java源码、7个class编译文件与7个jar依赖为主,另有Eclipse工程配置文件用于直接导入IDE;依赖中包含jsoup、httpclient、cpdetector、commons-lang等,分别支撑HTML解析、网络请求与编码识别。已有710人学习,说明该示例在爬虫入门场景中具备一定参考价值。读者可通过源码对照理解表格解析流程,借助依赖配置快速复现运行,并从异常处理与数据存储写法中积累排错思路,为后续数据分析与信息采集实践打下基础。

1. 台账表格复制到手软,不如写 40 行 Java

Java 爬取网页表格这件事,触发点往往很朴素:某公司内部台账系统每天刷出十几页 HTML 表格,人工复制进 Excel,合并单元格一粘就散架,列错位是常态。我见过有人花两周手抄,也见过有人直接开浏览器自动化一页页截。两种都不划算——页面是服务端渲染的静态 table,用 HttpClient 取 HTML、Jsoup 抽 tr/td,几十行代码就能闭环。这套方案适合会一点 Java、在 MyEclipse 里干活、不想为了几十行数据引入整套浏览器环境的人;不适合页面数据全靠前端接口渲染、或者要登录后点七八层菜单才出表的场景。下面把我拆这个例子的顺序摆出来:先搭最小闭环,再啃合并单元格,最后处理翻页和一堆恶心人的报错。

2. 工程骨架:HttpClient 拿页面,Jsoup 拆表格

2.1 两件工具的分工边界

HttpClient 管协议层:连接复用、超时、请求头、Cookie、gzip 解压、重定向。Jsoup 管 DOM 层:把一段 HTML 字符串解析成文档树,用类似 CSS 选择器的语法定位节点,而且它对残缺 HTML 的容错比标准 XML 解析器强得多——网页里少个闭合标签是家常便饭,用 DOM4J 那种严格解析器经常直接抛异常。

有人会问,Jsoup 自己带Jsoup.connect(url).get(),为什么还要多引一个 HttpClient?单页调试确实够用,它底层走的还是 HttpURLConnection。但抓十几页表格时问题就出来了:每次 connect 都是新建连接,没有连接池;超时只能在单次连接上设;想统一加 Referer、统一重试,代码会散得到处都是。我一般把两件事分开,HttpClient 只负责「给我一段 HTML 字符串」,Jsoup 只负责「从字符串里把表抠出来」,中间用一个方法隔开,以后换请求库不影响解析逻辑。

方案适合场景主要代价
Jsoup.connect 单干单页、临时脚本、验证选择器无连接池,多页抓取慢
HttpClient + Jsoup静态 HTML 表格、分页抓取要管 jar 依赖和资源关闭
HtmlUnit页面有少量 JS 渲染内存占用高,CSS/JS 报错多
浏览器自动化强 JS 渲染、需要点击交互环境重,跑久了容易挂

判断标准很简单:F12 打开 Network,刷新页面,如果表格数据直接出现在 HTML 响应体里,走第二行;如果响应体里只有一个空的 div,数据是后面 XHR 拉回来的,那就先去翻 XHR 的请求地址,直接请求那个接口拿 JSON,往往比解析 HTML 更省事。

2.2 MyEclipse 里把工程立起来

新建 Java Project 时 JRE 选 1.8 以上,Jsoup 1.x 和 HttpClient 4.x 都跑得动。工程建好后第一件事是改编码:右键项目 → Properties → Resource → Text file encoding 选 Other → UTF-8。这一步不做,源码里的中文表头字符串在 GBK 工程里会变成问号,后面按表头名取列会全部对不上,而且报错信息里看不出任何异常,属于典型的玄学现场。

依赖不要用 Add External JARs,那个记的是绝对路径,换台机器或者把项目拷给同事,全部变红叉。正确做法是在项目根目录建一个lib文件夹,把 jar 拷进去,然后 Build Path → Configure Build Path → Libraries → Add JARs,选项目内的文件。需要的 jar 大致是这些:

jar作用是否必需
jsoup-1.x.jarHTML 解析与选择器必需
httpclient-4.x.jarHTTP 请求主体必需
httpcore-4.x.jarHttpClient 底层协议实现必需
commons-logging-1.x.jarHttpClient 日志门面必需
commons-codec-1.x.jar认证与编码工具必需
poi / poi-ooxml导出 xlsx按需,主线用 CSV 可不引

前五个是传递依赖关系,少一个就在启动时报NoClassDefFoundError,报错指向的类名和你写的代码八竿子打不着,第一次遇到很容易怀疑人生。

2.3 最小闭环:一次 GET,一次 parse

先把「能拿到 HTML」这件事跑通,别急着写解析。下面这个类只做一件事:给定 URL 和编码,返回响应体字符串。

package com.demo.table; import org.apache.http.HttpEntity; 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; public class HttpFetcher { // 三个超时都要给:只设 connectTimeout 是最常见的半吊子配置 private static final RequestConfig CONFIG = RequestConfig.custom() .setConnectTimeout(5000) // 建立 TCP 连接的上限 .setSocketTimeout(15000) // 两次数据包之间的等待上限 .setConnectionRequestTimeout(3000) // 从连接池借连接的上限 .build(); public static String get(String url, String charset) throws Exception { try (CloseableHttpClient client = HttpClients.custom() .setDefaultRequestConfig(CONFIG) // 不带 UA 的请求有相当比例会被网关直接挡掉 .setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") .build()) { HttpGet get = new HttpGet(url); 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 IllegalStateException("HTTP " + code + " -> " + url); } HttpEntity entity = resp.getEntity(); if (entity == null) { return ""; } // 编码必须显式传,无参版本默认 ISO-8859-1,中文必乱 return EntityUtils.toString(entity, charset); } } } }

这段代码里有三个地方值得单独说。setSocketTimeout管的是「连接已建立但服务端迟迟不吐数据」,抓分页时最容易卡在这里,不给它就会一直挂着。EntityUtils.toString(entity, charset)的第二个参数决定中文是否正常,页面是 GBK 就传 GBK,不确定就先按 UTF-8 试,出现锟斤拷再换。try-with-resources把 client 和 response 都包住,少写一个 close 就可能出现连接泄漏,跑几百页后进程卡死。

拿到字符串之后,解析只有两行:

Document doc = Jsoup.parse(html, baseUrl); // baseUrl 用于补全相对链接 Element table = doc.selectFirst("table#dataList"); System.out.println(table.select("tr").size());

Jsoup.parse的第二个参数是基准 URL,页面里有相对路径的链接或图片时,Jsoup 会自动补全。selectFirst找不到目标时返回 null,所以生产代码里必须判空,不能直接链式调用。

3. 表格解析深水区:定位、表头与合并单元格

3.1 目标表格怎么定位才不飘

优先级从高到低:table#id、table.className、按表头文字反查、按序号。前两个最稳,但内部系统经常连 id 都不给。按序号取(比如doc.select("table").get(3))是最脆的写法,页面顶部加一条公告栏,表格序号就整体后移一位,第二天跑出来一堆空行。

我一般用表头文字反查,逻辑是遍历所有 table,看它的文本里是否同时包含几个标志性列名:

private static Element findTable(Document doc, String... mustContain) { for (Element t : doc.select("table")) { String text = t.text(); boolean hit = true; for (String key : mustContain) { if (!text.contains(key)) { hit = false; break; } } if (hit) return t; } return null; } // 调用:找同时含「设备编号」和「责任人」的那张表 Element target = findTable(doc, "设备编号", "责任人");

t.text()会把表格内所有单元格的文字拼成一串,用来做包含判断足够了。这种写法的好处是页面加公告、加侧边栏都不影响,只要表头列名不变就能定位到。代价是如果页面里有两张表都含这两个词(比如上面是汇总、下面是明细),会命中第一张,这时候再加一个特征词区分。

3.2 表头归一化:中文列名到字段下标

不要用硬编码下标取列。表头顺序一变,row.get(2)拿到的就是别的字段,而且不会报错,数据静默错位,这种坑最难查。正确做法是先建一张「列名 → 下标」的映射表,取值时按名字查。

// header 是解析出来的第一行 Map<String, Integer> index = new HashMap<>(); for (int i = 0; i < header.size(); i++) { // 去空格、去全角空格、去换行,网页表头里这些字符很常见 String name = header.get(i).replace('\u00a0', ' ') .replaceAll("\\s+", "") .trim(); if (!name.isEmpty()) { index.put(name, i); } } // 取值的封装,列不存在时立刻抛错,别让它静默返回 null private static String cell(List<String> row, Map<String, Integer> index, String col) { Integer i = index.get(col); if (i == null) { throw new IllegalStateException("表头缺少列: " + col + ",实际表头=" + index.keySet()); } return i < row.size() ? row.get(i) : ""; }

replaceAll("\\s+", "")是为了处理表头里的换行和制表符——很多系统为了让长列名换行显示,会在文字中间插入<br>,Jsoup 的text()会把它变成空格,不清理就匹配不上。异常信息里把实际表头打出来,下次页面改版时一眼就能看出是哪个列名变了。

3.3 rowspan/colspan:不还原就等着列错位

这是整个解析里唯一需要动点脑子的地方。HTML 表格里一个rowspan="3"的单元格只出现一次,后面两行在源码里根本没有这个 td。如果直接按每行tr.children()逐个取,第一行 8 列、第二行 6 列,导出 CSV 后 Excel 里就是阶梯状错位。

正确的做法是维护一个二维网格:按行推进,遇到被上方 rowspan 占住的位置就跳过并填入之前的值。

public static List<List<String>> parseTable(Element table) { // 先把所有 tr 按文档顺序收集起来,注意别把嵌套表格的行混进来 List<Element> rows = new ArrayList<>(); for (Element child : table.children()) { String tag = child.tagName(); if ("thead".equals(tag) || "tbody".equals(tag) || "tfoot".equals(tag)) { for (Element tr : child.children()) { if ("tr".equals(tr.tagName())) rows.add(tr); } } else if ("tr".equals(tag)) { rows.add(child); // 没有分组的裸 tr } } List<List<String>> grid = new ArrayList<>(); // key = "行号,列号",value = 被 rowspan 占住的值 Map<String, String> occupied = new HashMap<>(); for (int r = 0; r < rows.size(); r++) { List<String> line = new ArrayList<>(); int c = 0; for (Element cell : rows.get(r).children()) { if (!"td".equals(cell.tagName()) && !"th".equals(cell.tagName())) continue; // 进入每个单元格前,先把被上方占住的列补上 while (occupied.containsKey(r + "," + c)) { line.add(occupied.get(r + "," + c)); c++; } int rowspan = parseInt(cell.attr("rowspan"), 1); int colspan = parseInt(cell.attr("colspan"), 1); String value = cell.text().replace('\u00a0', ' ').trim(); for (int k = 0; k < colspan; k++) { line.add(value); // 当前格子向下覆盖的行,登记到 occupied for (int d = 1; d < rowspan; d++) { occupied.put((r + d) + "," + c, value); } c++; } } // 行尾可能还有被占住但没补的列 while (occupied.containsKey(r + "," + c)) { line.add(occupied.get(r + "," + c)); c++; } grid.add(line); } return grid; } private static int parseInt(String s, int def) { try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; } }

occupied这个 Map 是整个算法的黑匣子,理解它只需记住一点:它记录的是「未来某一行某一列应该填什么」。外层循环每进一行,内层循环每处理一个 td 之前,都要先把当前位置上已被占住的格子填掉,否则c会错位。parseInt兜底返回 1,是因为有些页面写rowspan=""或者rowspan="abc",直接Integer.parseInt会抛异常导致整页解析中断。

注意:table.children()而不是table.select("tr")。后者会把表格内部嵌套表格的 tr 也选进来,遇到「表格里套表格」的排版,行数会翻倍。

3.4 噪音行过滤:合计行、分页行、空行

原文表格末尾经常带一行「合计」「本页小计」,直接入库会污染统计。判断方式看首列文字:

List<List<String>> clean = new ArrayList<>(); for (List<String> line : grid) { if (line.isEmpty()) continue; String first = line.get(0).trim(); if (first.isEmpty()) continue; if (first.contains("合计") || first.contains("小计") || first.contains("总计")) continue; // 列数和表头差太多的行也不要,多半是分页控件被错误地包进了 table if (line.size() < header.size()) continue; clean.add(line); }

最后一条判断看着粗糙,但很有效。有些老系统把分页条放在<tfoot>里,parseTable会把它当成数据行收进来,列数通常只有一两列,用列数卡一道就能滤掉。

4. 翻页、限速与并发:把整张表拉全

4.1 翻页的三种形态先分清

第一种是 URL 参数翻页,形如?pageNo=2&pageSize=50,改参数重发 GET 就行,最省事。第二种是 POST 表单翻页,页码藏在表单字段里,得构造HttpPost带上NameValuePair。第三种是前端 JS 渲染,页码按钮不产生新请求,数据是 XHR 异步加载的,这种情况要么找到 XHR 接口直接请求,要么放弃 HttpClient 路线。

第二种的写法:

HttpPost post = new HttpPost(baseUrl); List<NameValuePair> form = new ArrayList<>(); form.add(new BasicNameValuePair("pageNo", String.valueOf(page))); form.add(new BasicNameValuePair("pageSize", "50")); post.setEntity(new UrlEncodedFormEntity(form, "UTF-8")); // 表单提交通常要求带上 Referer,否则服务端校验来源会返回 403 post.setHeader("Referer", baseUrl);

UrlEncodedFormEntity的编码参数必须和页面表单的accept-charset一致,老系统很多是 GBK,传 UTF-8 会导致中文查询条件变成乱码,服务端返回空结果——现象是「第一页有数据,翻到第二页突然空了」,很容易误判成翻页结束。

4.2 限速与重试:sleep 放哪、重试重什么

sleep放在每页请求之后,不是之前。放前面会让第一页也白等,放后面才是真正的「抓完一页歇一下」。间隔取 500 到 1000 毫秒比较稳妥,太小容易触发风控,太大十几页要跑半分钟,自己调试也难受。

重试只对两类情况做:网络层异常(IOException、SocketTimeoutException)和 5xx。4xx 一律不重试,尤其 403 和 404,重试一百次结果也一样,白白浪费时间和触发封禁。退避时间要递增:

for (int attempt = 1; attempt <= 3; attempt++) { try { html = HttpFetcher.get(url, "UTF-8"); break; } catch (Exception e) { if (attempt == 3) throw e; // 第三次还失败就往外抛 Thread.sleep(1000L * attempt); // 1s、2s 递增退避 } }

判断「抓完了没有」不能只看页面上有没有「下一页」按钮,那个按钮有时候到最后一页还是可点的。可靠的判断是:解析出来的数据行数为 0,或者和上一页内容完全一致。后者能防住「页码超界后服务端默认返回第一页」这种设计,这种站点不少,光看行数会陷入死循环。

4.3 并发抓取:共享 client,独立任务

单线程抓 50 页、每页 800 毫秒间隔,要 40 秒,能接受就先别并发。真到了几百页的量级,用固定线程池,但要避开一个常见错误:每个任务里new一个CloseableHttpClient。那样连接池形同虚设,而且每个 client 都持有资源,线程一多就 OOM。

CloseableHttpClient本身是线程安全的,全局建一个共享即可:

CloseableHttpClient shared = HttpClients.custom() .setDefaultRequestConfig(CONFIG) .setMaxConnTotal(20) // 总连接数上限 .setMaxConnPerRoute(8) // 单个目标主机的连接数上限 .build(); ExecutorService pool = Executors.newFixedThreadPool(4); List<Future<List<List<String>>>> futures = new ArrayList<>(); for (int p = 1; p <= totalPage; p++) { final int page = p; futures.add(pool.submit(() -> { String html = fetchWithRetry(shared, baseUrl + "?pageNo=" + page, "UTF-8"); return parseTable(Jsoup.parse(html, baseUrl).selectFirst("table#dataList")); })); } pool.shutdown();

线程数别超过 8,setMaxConnPerRoute要和线程数匹配,否则线程会卡在借连接那一步,表现为「并发开了但速度没提升」。并发抓完还要解决顺序问题:Future是按提交顺序取的,四页并发返回的顺序可能是乱的,入库前按页码排一次序,或者干脆每页解析完立刻按页号写入独立文件,最后合并。

5. 排查清单:乱码、403、空表与连接泄漏

5.1 中文全是问号或锟斤拷

现象:抓下来打印到控制台是????,或者锟斤拷,或者䏿–‡这种。

原因有三层,得逐层排。第一层是 HTTP 响应编码,EntityUtils.toString(entity)无参版本默认 ISO-8859-1,页面实际是 UTF-8,必须显式传。第二层是 HTML 内部的meta charset,如果响应头没给 charset,就得从 HTML 前 1024 字节里正则抠出来。第三层是 MyEclipse 工程编码,源文件里的中文字符串常量在 GBK 工程里编译出来就是坏的,这个最隐蔽,因为代码逻辑完全正确。

解决顺序:先把EntityUtils.toString(entity, "UTF-8")写死试一次,正常了就说明是编码参数问题;还乱就System.out.println(resp.getFirstHeader("Content-Type"))看响应头;都不行再检查项目编码和文件编码是否一致。

5.2 第一页正常,第二页开始 403

现象:?pageNo=1返回 200,?pageNo=2直接 403 或返回一个验证页。

原因是服务端在会话维度做了频率或来源校验。常见触发点:缺 Referer、Cookie 没带、请求间隔太短、UA 是 Java 默认的Java/1.8.0。

解决:给每个请求补上 Referer(值设成列表页 URL)、用同一个 client 保持 Cookie、把间隔拉到一秒以上。如果服务端要求先访问列表页拿 Cookie,就先 GET 一次首页,让 client 自然接收Set-Cookie,再发翻页请求。HttpClients.custom().setDefaultCookieStore(new BasicCookieStore())一行就能让整个 client 共享会话。

5.3 拿到 HTML 了,但 select("table") 是空

现象:html.length()有几十 K,doc.select("table").size()返回 0。

原因通常三种:表格在 iframe 里、表格是 JS 渲染的、响应是 gzip 压缩但没解。第三种在 HttpClient 里一般不会出现,因为它默认带Accept-Encoding: gzip并自动解压,但如果你手动设了Accept-Encoding头又没配解压器,拿到的就是二进制乱码。

排查方法:把 html 存成本地文件用浏览器打开,能看到表格说明是解析问题,看不到就是渲染问题。iframe 的话,先用选择器取到 iframe 的 src,再发一次请求。JS 渲染的话,去 Network 面板找返回 JSON 的那个 XHR 请求,直接请求接口,比解析 HTML 更稳。

5.4 翻页越抓重复越多,或者缺页

现象:导出的 CSV 里同一个设备编号出现七八次,或者第 5 页的数据凭空消失。

原因一:页码基址不一致。有的系统第一页是pageNo=0,有的是pageNo=1,代码写死从 1 开始,第一页实际是第二页的内容,最后一页则超界返回首页,形成循环。判断方法是打印每页第一条记录的某个唯一字段,看是否重复。

原因二:抓取过程中数据在变。台账类系统如果有别人同时在编辑,翻页时排序会抖动,同一条记录可能出现在两页。这种没法根治,只能在入库前按主键去重。

解决:翻页前先探测总页数(页面里通常有「共 N 页」),用总页数做循环上界,而不是靠「下一页按钮是否可点」判断;入库前按业务主键做一次LinkedHashMap去重,保留首次出现的那条。

5.5 跑了几百页之后进程卡死

现象:前几十页正常,跑到中途不动了,CPU 不高,线程 dump 出来一堆WAITING。

原因是连接没释放。CloseableHttpResponse不关,连接就一直被占着,连接池满了之后新请求全在connectionRequestTimeout上排队,等到超时抛异常,看起来像「卡住」。另一个来源是EntityUtils.toString之后没EntityUtils.consume(entity),实体流没读完,连接无法归还。

解决:所有 response 必须放在 try-with-resources 里;用连接池的话,抓完记得client.close();跑批的脚本加个定时打印「已完成 N 页」的日志,卡住时能立刻定位到第几页出问题。

6. 导出对账:CSV 的三个细节和一次行列自检

导出 CSV 比导出 xlsx 省依赖,但有三处细节不处理,Excel 打开就是灾难。第一是 BOM,UTF-8 无 BOM 的 CSV 在中文版 Excel 里打开是乱码,开头写一个\ufeff就正常。第二是逗号转义,单元格里本身含逗号必须用双引号包起来,含双引号则要写成两个双引号。第三是换行符,用\r\n而不是\n,否则某些版本的 Excel 会把整份文件读成一行。

private static String escape(String s) { if (s == null || s.isEmpty()) return ""; if (s.contains(",") || s.contains("\"") || s.contains("\r") || s.contains("\n")) { return "\"" + s.replace("\"", "\"\"") + "\""; } return s; }

导完之后别急着交付,跑一次自检:总行数是否等于「总页数 × 每页条数」减去末页空位,每行的列数是否和表头列数一致。后者能抓出 90% 的合并单元格问题——只要line.size() != header.size(),就说明那一行的 rowspan 没还原对,回去看occupied的 key 是不是行列写反了。

还有一个反直觉的经验:解析器写完,别拿真实系统直接跑。先手工造三五个 HTML 片段,分别覆盖「无合并单元格」「纵向合并」「横向合并」「混合合并」四种情况,写成单元测试。我头一回就是跳过了这一步,直接对着线上页面调,一改代码就得重新请求一次,被限速卡得怀疑人生。从那以后每次写表格解析,都先把这四段 HTML 固化进src/test/resources里跑一遍,再上真实地址。希望帮到你。

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

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

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

立即咨询