简介:这份示例包面向具备Java基础、希望掌握网页结构化数据提取的初中级开发者与爬虫入门者,围绕在MyEclipse环境中抓取网页表格展开,涉及Jsoup解析HTML、HTTP请求、字符编码识别以及异常处理等基础环节,适合作为数据采集前的练手项目。压缩包共24个文件、约1.15MB,包含7个jar依赖库、7个java源码、7个class编译产物和IDE工程配置,依赖与源码配套较完整,导入后便于对照目录结构理解实现思路。已有710人学习,说明其在Java表格抓取入门场景中具有一定参考价值。读者可从中获得可运行的工程样例,观察网页连接、表格定位、行列数据提取与结果存储的组织方式,并借助异常处理代码了解网络波动或解析失败时的应对方法,为后续数据分析、市场研究等场景的数据获取打下基础。
1. 数据只长在网页表格里的时候
后台导不出、接口不对外开放,数据只存在于一张 HTML 表格里——这种场景写个 java 爬取网页表格 的例子就能解决,半小时收工,比一行行复制靠谱得多。例子的骨架很朴素:取页面、定位 table、遍历 tr/td、落成 CSV 或 Excel,运行环境是 MyEclipse,普通 Java 工程即可,不必起 Web 容器。它适合手头有重复性数据整理活得靠手工复制的人,也适合刚接触网页解析、想要一份能直接跑通的代码的人。真正的难点在于表格结构不规范:合并单元格、两行表头、翻页、编码错乱,抓取本身反而不难,后面几章把这些点逐个拆开。
2. 工程骨架与解析选型:为什么我用 Jsoup 而不是正则
2.1 三条解析路线的取舍
同样是从页面里拿表格,路线上有三条岔道,选错了后面全是返工。第一条是正则直接匹配<tr>和<td>,代码短,看起来爽,但表格里一旦嵌了子表格、属性顺序变了、标签换行位置不同,匹配就断在半截,且报错信息毫无指向性,排查起来像在黑匣子里摸。血泪经验是:正则只适合结构固定到你敢签字的单页,不适合当通用方案。
第二条是 DOM 解析,代表就是 Jsoup。它把 HTML 变成一棵可查询的树,用 CSS 选择器定位表格,select("table#dataList tbody tr")这种写法读起来就是人话,单元格取text()还能自动把<span>里包着的数字、 之类的脏东西抹掉。绝大多数静态渲染的表格页,这条路够用,也是我给这个例子定的默认方案。
第三条是渲染型抓取,比如 HtmlUnit 这类能跑 JS 的无头浏览器。只有一种情况必须走它:表格数据是页面加载后由异步请求填进 DOM 的,你在源码里看到的<tbody>是空的。代价是依赖重、启动慢、内存吃得凶。判断方法很简单,浏览器里右键查看网页源代码,如果源码里没有数据行、只有一行骨架,就属于这一类,先去找背后的数据请求地址,通常比硬渲染省力。
2.2 MyEclipse 里的工程结构与依赖
MyEclipse 新建 Java Project 之后,我习惯在工程根目录下建一个lib文件夹,把所有第三方 jar 丢进去,再用 Build Path → Configure Build Path → Add JARs 逐个挂上。这么做的原因是工程拷给别人时,jar 跟着源码走,不会出现「我这里能跑你那里 ClassNotFound」的扯皮。依赖清单大致如下,版本以你实际下载到的包名为准,别照抄字符串。
| 文件 | 作用 | 缺了会怎样 |
|---|---|---|
| jsoup-1.x.jar | HTML 解析与 HTTP 取页 | 编译期就报找不到org.jsoup |
| httpclient-4.x.jar + httpcore | 需要精细控制连接、Cookie 较多时用 | 用 Jsoup 自带连接也行,只是重试逻辑要自己包 |
| poi-4.x.jar + poi-ooxml | 导出 xlsx | 只能落 CSV |
| commons-csv(可选) | 规范写 CSV | 自己拼字符串,遇到逗号换行就翻车 |
工程编码这一项必须提前改,否则中文列名从第一步就乱。在 Window → Preferences → General → Workspace → Text file encoding 里选 UTF-8,右键工程 Properties → Resource 同样设成 UTF-8。MyEclipse 老版本默认是 GBK,控制台打印中文表头变成问号,八成就是这里没对齐。另外 JDK 编译级别和运行级别保持一致,避免在 IDE 里跑得好好的、打包出去报版本不匹配。
2.3 第一个能跑通的抓取类
先不管合并单元格和翻页,写一个最小闭环:拿到 Document、确认拿到的是列表页、数一下有几张表。这一步的意义在于把「网络层」和「解析层」分开验证,出错时能立刻判断是请求没通还是选择器写错。
package scrape; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; public class TableFetcher { // 换成自己要抓的那一页;先拿一页手工验证,别一上来就循环 private static final String URL = "http://example.com/list.html"; private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"; public static void main(String[] args) throws Exception { Document doc = Jsoup.connect(URL) .userAgent(UA) // 默认 UA 常被直接拒绝,返回一个空壳页 .header("Accept-Language", "zh-CN,zh;q=0.9") .timeout(15000) // 连接 + 读取总超时,单位毫秒 .ignoreHttpErrors(true) // 4xx/5xx 也把页面拿回来,便于看清错误内容 .get(); System.out.println("title = " + doc.title()); // 先看标题,确认不是拦截页 System.out.println("table count = " + doc.select("table").size()); System.out.println("row count = " + doc.select("table tr").size()); } }timeout(15000)这里给的是总时长,不是单次读超时,页面里有大图或广告脚本时容易被拖住,所以别设成 3000 这种激进值。ignoreHttpErrors(true)是排查期的后悔药:服务器返回 403 时,Jsoup 默认抛异常,你只能看到一行状态码;打开它之后能把返回的错误页正文打出来,往往直接写着「请求过于频繁」或者「缺少参数」,省掉半小时猜测。userAgent不是玄学,很多站点对空 UA 或 curl 类 UA 直接给一个精简页面,表格压根不在里面。
跑通之后,把doc.outerHtml()存成本地文件当离线样本,后面写选择器就对着样本调,不用每次请求线上环境,速度快,也不会把对方站点打出压力。这一步看着笨,实际是最省时间的习惯。
3. 表格定位与行列抽取:选择器、表头映射和合并单元格
3.1 把目标表格钉死
一页里可能有导航表格、布局表格、数据表格混在一起,用doc.select("table")直接取第一个,十有八九拿到的是布局表。定位顺序我一般按可靠性从高到低试:table#id最稳,其次是table.className,再其次是用表头文字反查、用父级容器限定范围,实在没有特征才用序号get(n)。
// 1) 优先按 id 定位,特征最唯一 Element table = doc.selectFirst("table#dataList"); // 2) 退一步:父容器 + class 限定,避免命中同名的布局表 if (table == null) { table = doc.selectFirst("div.table-wrap > table.list"); } // 3) 最后兜底:按表头文字反查,页面改版时最抗揍 if (table == null) { for (Element t : doc.select("table")) { if (t.text().contains("更新时间") && t.text().contains("单价")) { table = t; break; } } } if (table == null) { throw new IllegalStateException("没找到目标表格,先把页面存下来人工看一眼"); }三层兜底不是过度设计,是应对页面改版。选择器写死成一条链,运营哪天给表格加个包裹 div,整段逻辑就哑了,报错还是一片空指针。兜底写法至少能让程序多活几个版本。
还有一个容易忽略的点:tbody。浏览器渲染时会给没有 tbody 的表格自动补一个,但你抓的是服务端返回的原始 HTML,源码里可能根本没有这个标签。写table > tbody > tr在浏览器控制台验证通过、在 Jsoup 里却取不到行,原因就在这里。稳妥写法是用table.select("tr")全取,再自己过滤掉表头行。
3.2 表头映射与行数据组装
拿到表格之后,输出的目标结构我建议直接定成List<Map<String,String>>,键是表头文字,值是单元格文本。这样做的好处是后续写 CSV 或 Excel 时列顺序天然对齐,中间加一列字段也不用改下游代码。
public static List<Map<String, String>> parseTable(Element table) { List<String> head = new ArrayList<>(); // 表头可能在 thead,也可能就是第一个 tr 里的 th for (Element th : table.select("thead th")) { head.add(th.text().trim()); } if (head.isEmpty()) { Element first = table.selectFirst("tr"); if (first != null) { for (Element th : first.select("th")) head.add(th.text().trim()); } } List<Map<String, String>> data = new ArrayList<>(); for (Element tr : table.select("tr")) { Elements tds = tr.select("td"); if (tds.isEmpty()) continue; // 表头行、空行直接跳过 Map<String, String> row = new LinkedHashMap<>(); for (int i = 0; i < tds.size(); i++) { // 表头数量对不上时用 colN 兜底,别让数据直接丢列 String key = i < head.size() ? head.get(i) : ("col" + i); String val = tds.get(i).text() .replace('\u00a0', ' ') // 解出来是不间断空格 .replaceAll("\\s+", " ") .trim(); row.put(key, val); } data.add(row); } return data; }LinkedHashMap是为了保住列顺序,换成HashMap打印出来列序随机,导出时对不上号。\u00a0这个字符很阴,肉眼看着是空格,trim()却不认它,导进 Excel 后一串单元格末尾带个隐形字符,做 VLOOKUP 永远匹配不上,这类血泪经验只能靠预处理规避。表头数量与单元格数量不一致是常态,多出来的列用colN命名,宁可列名丑,也别丢数据。
3.3 colspan / rowspan 的补格算法
合并单元格是表格抓取里最容易翻车的地方:不处理,后面所有行的列都会错位一格,且错得毫无规律。思路其实不复杂——把表格铺成二维网格,维护一张「某列被上面的 rowspan 占到第几行」的占位表,遇到占据就填上一次的文本,遇到 colspan 就把同一个值铺满对应列数。
public static List<List<String>> toGrid(Element table) { List<List<String>> grid = new ArrayList<>(); Map<Integer, Integer> spanLeft = new HashMap<>(); // 列号 -> 还被占用的剩余行数 Map<Integer, String> spanText = new HashMap<>(); // 列号 -> 被占用时该填的值 for (Element tr : table.select("tr")) { List<String> line = new ArrayList<>(); int col = 0; for (Element cell : tr.children()) { // th 和 td 一起按顺序走 while (spanLeft.getOrDefault(col, 0) > 0) { // 这一列被上面的单元格纵向占着 line.add(spanText.get(col)); spanLeft.put(col, spanLeft.get(col) - 1); col++; } int cs = Math.max(1, parseIntSafe(cell.attr("colspan"))); int rs = Math.max(1, parseIntSafe(cell.attr("rowspan"))); String text = cell.text().trim(); for (int k = 0; k < cs; k++) { line.add(text); if (rs > 1) { // 纵向占位登记,交给后面几行消费 spanLeft.put(col, rs - 1); spanText.put(col, text); } col++; } } // 行尾还没消费完的纵向占位,继续补 while (spanLeft.getOrDefault(col, 0) > 0) { line.add(spanText.get(col)); spanLeft.put(col, spanLeft.get(col) - 1); col++; } if (!line.isEmpty()) grid.add(line); } return grid; } private static int parseIntSafe(String s) { try { return Integer.parseInt(s.trim()); } catch (Exception e) { return 1; } }参数上要注意rowspan="0",某些页面用它表示一直占到表尾,直接parseInt会得到 0,被Math.max(1, …)兜住后变成只占一行,结果行数会少。真遇上这种页面,得单独判 0 并把剩余行数设成一个足够大的值。铺完网格之后再做表头映射,比直接遍历 td 可靠得多:列号从此是绝对位置,不会因为某一行少个单元格就整体前移。
3.4 翻页与多表拼接
单页跑通之后就该处理翻页。翻页有三种常见形态:URL 里带页码参数、POST 提交页码、点「下一页」加载追加内容。前两种用循环即可,第三种得抓异步接口。
String base = "http://example.com/list.html?page="; List<List<String>> all = new ArrayList<>(); for (int page = 1; page <= 200; page++) { // 200 是个保险上限,防止死循环 Document d = Jsoup.connect(base + page).userAgent(UA).timeout(15000).get(); Element t = d.selectFirst("table#dataList"); if (t == null) break; // 表格都没了,认为到末页 List<List<String>> grid = toGrid(t); // 第一页保留表头,后面只取数据行 List<List<String>> part = (page == 1) ? grid : grid.subList(1, grid.size()); if (part.isEmpty()) break; // 空页也当结束,别硬跑满 200 次 all.addAll(part); Thread.sleep(800L + new Random().nextInt(400)); // 页间留间隔,别把对方当压测目标 }subList这里要留神:它返回的是原列表的视图,后续往all里加完之后如果还持有原列表并做修改,会抛并发修改异常。简单做法是new ArrayList<>(grid.subList(...))拷一份。上限 200 页是防呆,见过末页判断写错导致死循环跑了一夜的案例,日志刷到几个 G。
4. 请求层:编码、超时与状态保持
4.1 编码这件事,别赌默认值
中文页面乱码,九成出在两处:服务器返回的内容编码与 HTML 里 meta 声明的不一致,或者你解字节流时用了平台默认编码。Java 在 Windows 上默认是 GBK,Linux 上是 UTF-8,同一份代码换台机器就出鬼。稳妥做法是先按响应头的 charset 解,解出来是乱码再退回页面的 meta 声明。
Connection.Response resp = Jsoup.connect(URL) .userAgent(UA).timeout(15000).ignoreContentType(true) .method(Connection.Method.GET).execute(); String charset = resp.charset(); // 来自 Content-Type byte[] raw = resp.bodyAsBytes(); // 原始字节,先别急着转字符串 if (charset == null || charset.isEmpty()) { charset = "UTF-8"; // 头里没写时的默认猜测 } Document doc = Jsoup.parse(new String(raw, charset), URL); // 兜底:出现成片的替换符,说明猜错了,换 GBK 再试一次 if (doc.text().contains("\uFFFD")) { doc = Jsoup.parse(new String(raw, "GBK"), URL); }关键是保留bodyAsBytes()。很多示例直接resp.body()拿字符串,看上去省事,其实已经用了一次默认编码,之后再怎么换编码都救不回来。判断是否乱码,用\uFFFD(替换符)比看中文更准,它只会在解码失败时出现。
4.2 超时、重试与节奏
单次抓取失败太正常了:网络抖动、对方限流、偶发 502。不加重试,一万条数据里丢几十行,你还得靠肉眼比对发现。但重试不能写成紧密循环,那是把偶发故障升级成封禁。
public static Document fetchWithRetry(String url, int maxTry) { for (int i = 1; i <= maxTry; i++) { try { return Jsoup.connect(url) .userAgent(UA).timeout(15000) .ignoreHttpErrors(true) .get(); } catch (IOException e) { // 只重试网络类异常 System.out.println("第 " + i + " 次失败:" + e.getMessage()); try { Thread.sleep(1000L * i * i); // 1s、4s、9s,逐次拉长 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return null; } } } return null; // 返回空,由调用方记录失败清单 }maxTry我给 3 次,再多意义不大,反而拖长整体耗时。只捕获IOException,因为解析异常重试一百次结果一样。返回 null 而不是抛异常,是为了让外层循环能继续跑完并把失败 URL 记下来,跑完再单独补抓,比中途崩掉重头来强。
4.3 Cookie 与会话
有些列表页要先访问首页拿到会话,再带着 Cookie 请求数据页,否则返回一个空表。这种时候不用上完整的 HttpClient,Jsoup 的execute()已经能把响应 Cookie 取出来。
// 第一步:访问入口页,拿到服务端下发的 Cookie Map<String, String> cookies = Jsoup.connect("http://example.com/") .userAgent(UA).timeout(15000) .execute().cookies(); // 第二步:带着 Cookie 请求真正的列表页 Document list = Jsoup.connect("http://example.com/list.html") .userAgent(UA).cookies(cookies).timeout(15000).get();Cookie 有时效,批量抓取跑到一半失效是常事,表现为突然全是空行。稳妥做法是在循环里判断「连续 N 页拿不到数据行」,触发一次重新握手,而不是硬着头皮跑完全部页码。
5. 避坑与排查:五个真实翻车的现场
5.1 抓取阶段:拿回来的不是那张表
现象一:程序不报错,doc.title()正常,但select("table")只有一两个且行数是 0。原因是请求被识别成非浏览器流量,服务端返回了一个精简页或提示页,HTTP 状态还是 200。解决办法是先打开ignoreHttpErrors(true),把doc.outerHtml()存盘看正文,再补上userAgent、Accept-Language、Referer(从列表页跳详情页时对方常校验来源)。补完还不行,就去看浏览器开发者工具里那个真正返回数据的请求带了什么头,照着抄。
现象二:跑了几十页之后开始整片失败,隔一段时间又恢复。原因是请求间隔太短触发了频率限制。解决办法是把页间间隔从固定值改成固定值加随机抖动,并对连续失败做退避;同时把已抓页码记录到本地文件,中断后从这个页码续跑,不吃回头草。
现象三:本地跑得好,换到另一台机器上全是问号。原因是 JVM 默认字符集不同,而代码里某处用了new String(bytes)。解决办法是全链路显式指定StandardCharsets.UTF_8,并且在 MyEclipse 里把工程编码统一成 UTF-8,别依赖平台默认值。
5.2 解析阶段:数据看着对,其实错位了
现象四:导出的表格里,某些行的「单价」列出现了商品名,越往后错得越多。原因是那张表用了 rowspan,某些行没有对应该列的 td,按顺序遍历自然整体前移。解决办法是走第 3.3 节的网格铺平算法,用绝对列号定位;上线前抽三行手工比对源页面,重点看合并单元格附近的行。
现象五:明明页面上有 20 行,抓下来只有 19 行。原因是最后一行被当成表头跳过了,或者行里全是 被空值过滤掉。解决办法是判断表头时不要用「第一行即表头」这种硬规则,改成看这一行有没有th;过滤空行时只跳过td数量为 0 的行,不要因为文本为空就整行丢弃——空值本身也是信息。
5.3 环境与运行:MyEclipse 里的两个设置
现象六:代码没动,昨天能跑今天NoClassDefFoundError。原因是 Build Path 里引的是绝对路径的 jar,工程换目录或换机器就失效。解决办法是把 jar 放进工程内的lib目录,用相对路径挂载,并在 Order and Export 里勾上,否则打包后运行时找不到。
现象七:控制台日志里中文正常,写出来的 CSV 用 Excel 打开是乱码。原因是文件本身是 UTF-8,但 Excel 默认按本地编码读,没有 BOM 就不认。解决办法见下一节,写文件时先写\uFEFF。
6. 进阶:把抓取做成配置,加一道自动校验
写死 URL 和列名的代码,抓第二个站点就得复制一遍。我后来改成一张配置表驱动:一行描述一张表,包含地址、表格选择器、列名顺序、翻页参数名,主流程只读配置。
| 配置项 | 示例值 | 说明 |
|---|---|---|
| url | http://example.com/list.html | 列表页地址,翻页参数名单独配 |
| pageParam | page | 页码参数名,POST 提交时留空 |
| tableSelector | table#dataList | 定位不到时启用兜底链 |
| columns | 编号,名称,单价,更新时间 | 顺序即导出列序 |
| maxPage | 200 | 死循环保险 |
配置化之后,验证也有了着力点。我固定跑三项检查:抓到的行数与页面显示的总条数是否一致;关键列(编号、名称)的非空率是否达到 100%;随机抽三条记录回到源页面比对字段值。三项都过才算这次抓取有效,任何一项不过就先停下看日志,不要急着导出。这套检查加起来不到三十行代码,省下的返工时间远超写它的成本。
// 最小校验:关键列非空率 + 行数对账,不过就抛出来,别让脏数据流到下游 long blank = rows.stream().filter(r -> isBlank(r.get("编号"))).count(); if (blank > 0) throw new IllegalStateException("编号列为空的行数:" + blank); if (rows.size() != expectedTotal) { System.out.println("行数不一致,页面声明 " + expectedTotal + ",实际 " + rows.size()); }expectedTotal从页面上那句「共 N 条」里正则取出来,取不到就跳过这项,不要让校验本身成为新的失败点。从那以后我每次跑抓取任务,都强制走一遍「存原始 HTML → 校验三项 → 再导出」的流程,宁可多花两分钟,也不接受一份来源不明、列还错位的数据。希望帮到你。
本文还有配套的精品资源,点击获取