简介:一份面向Java初学者的网页表格抓取示例,运行环境为MyEclipse,演示如何借助Jsoup等第三方库实现网页连接、HTML解析、表格行列遍历与数据提取,适合希望入门网络爬虫、掌握静态页面数据抓取流程的开发者。资源采用zip压缩包格式,共24个文件,大小约1.15MB,包含7个Java源码文件、7个编译好的class文件、7个依赖jar包(如jsoup-1.7.2.jar、httpclient/commons-logging等),以及.project、.classpath等MyEclipse工程配置,导入IDE后即可直接查看和运行。目前已有710人学习下载。通过研究这份源码,可以理解Jsoup.connect()如何发起请求、select()如何按表格ID定位元素、遍历tr/td如何提取单元格数据,还能学习针对网络超时、解析异常使用try-catch进行容错处理,为后续将数据持久化到文件或数据库打下基础,是一份实用性较强的Java爬虫入门参考。
1. Java 爬取网页表格:一个给 MyEclipse 用的抓表小工具
用 Java 爬取网页表格,听起来是个老需求,但真动手的人都知道坑不少:页面编码乱码、表格嵌套定位错、浏览器里能看到数据 Java 却抓不到。这篇笔记要拆的就是一个能直接在 MyEclipse 里跑通的完整例子,目标是把一个公开页面里的 table 抓下来、清洗干净、输出成 CSV。适合刚接触爬虫的 Java 开发,也适合要批量收集行业数据但不想手动复制粘贴的运维和数据分析同学。整个方案不依赖重型框架,一个 main 方法就能跑,看完你能直接照着重现。
2. 抓表前的装备:选型原因、依赖引入与 MyEclipse 工程搭建
2.1 为什么选 Jsoup 而不是 HttpClient 加正则
先回答一个很多人纠结过的问题:抓网页表格到底用什么?常见做法是 HttpClient 负责发请求拿到 HTML,然后用正则去抠数据。这个方案对简单页面能跑,但一旦遇到表格嵌套、属性带引号变体、单元格里有富文本标签,正则写起来就是一场灾难。网页表格本质上是结构化的 DOM 节点,用解析器去按节点读取,远比用正则匹配靠谱。Jsoup 就是 Java 生态里最常用的 HTML 解析库,它能容忍不闭合的标签、能自动修正残缺的 DOM 树,还提供了一套类似 CSS 的选择器语法,定位表格极其顺手。
选择 Jsoup 还有一个现实理由:它自带一个简易的 HTTP 客户端,connect 方法可以直接发请求拿 Document,省去了 HttpClient 和解析器之间的拼接工作。对于“单个页面抓表格”这种场景,依赖越少越不容易翻车。需要注意的是,Jsoup 拿回来的是静态 HTML 源码,如果目标表格是 JavaScript 动态渲染出来的,后面第 4 章会专门讲怎么处理这种情况。这个例子里的目标页面是服务端直接输出的 HTML 表格,所以用 Jsoup 正好。
依赖层面,Jsoup 没有任何第三方依赖,jar 体积也就几百 KB,丢进工程就能用。这一点对 MyEclipse 这种老牌 IDE 很友好,不需要折腾 Maven 仓库镜像,也不需要处理传递依赖冲突。如果你的工程恰好已经接了 Maven,那加一个依赖坐标就行;如果是裸的 Java Project,手动引 jar 也完全可行。下一步就分别说这两种方式。
2.2 MyEclipse 里建工程并引入解析库
打开 MyEclipse,新建一个 Java Project,名字随意,比如 TableCrawler。建好之后最关键的一步就是把 Jsoup 的 jar 引进来。直接下载 jsoup 的当前稳定版 jar 包,在工程根目录建一个 lib 文件夹,把 jar 拖进去,然后右键工程 → Build Path → Add to Build Path。这样编译和运行都能识别。MyEclipse 自带 Tomcat 和一大堆 Java EE 组件,但爬虫只需要标准 Java 环境,别去碰 Web 项目类型,普通 Java Project 就够了,否则启动慢还容易带上多余依赖。
如果你的工程已经整理成了 Maven 结构,那就在 pom.xml 里加一段最简依赖:
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.15.3</version> </dependency>版本号建议以 Maven 仓库里的最新稳定版为准,不要盲目追新;旧版本对 HTML5 标签的支持差异比较大,尽量选 1.14 之后的版本。加完之后右键工程 → Maven → Update Project,等依赖下载完成。这里有个小提醒:MyEclipse 自带的 Maven 插件比较老,如果 Update 后报错,先看本地仓库路径是不是配好了,再检查 settings.xml 里有没有镜像地址。
引入依赖后先做一个最朴素的验证:写一个只抓取页面标题的类,跑通“请求 → 解析 → 输出”这条链路,再进入表格解析。这一步能提前排查掉网络代理、IDE 编译级别、JDK 版本这些环境问题,不然等写完完整代码才发现连不上网,排查起来会混在一起。
2.3 第一个连通性测试:把页面抓回来
新建一个类叫 FetchPage,写一段最简代码验证环境:
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; public class FetchPage { public static void main(String[] args) throws Exception { // connect 返回 Connection 对象,可以链式设置请求参数 Document doc = Jsoup.connect("https://example.com/stats.html") .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") // 模拟浏览器 UA .timeout(10000) // 连接超时时间,单位毫秒 .get(); // 发起 GET 请求 System.out.println(doc.title()); System.out.println(doc.charset()); } }这段代码的作用是请求目标页面并把响应解析成 Document 对象。userAgent 这个参数很关键,不少站点会拦截默认的 Java UA,返回 403 或者空页面;timeout 设成 10 秒是兼顾慢网络和快速失败。doc.charset() 输出的是 Jsoup 根据响应头或页面 meta 推断出的编码,第一次运行时一定要看这一步,它能帮你提前发现编码问题。
跑起来如果正常打印出页面标题,说明链路通了。如果抛 UnknownHostException,先检查本机能不能解析域名,跟代码无关;如果抛 HttpStatusException,多半是页面本身返回了 4xx 或 5xx,可以先在浏览器里访问确认页面存活。常见做法是把目标 URL 换成你自己的数据页面,但注意测试阶段不要用高频请求去打别人的服务器,先手动访问一次确认页面结构再说。
3. 解析并输出表格:定位、清洗与落盘的三步套路
3.1 定位表格节点:选择器优先级与多表格页面
页面抓回来之后,第一步是在 Document 里找到目标 table。Jsoup 提供了一整套 CSS 选择器语法,定位方式按优先级可以这样排:id 选择器最快,class 选择器其次,标签加属性组合最灵活,纯标签选择器最慢也最容易误命中。比如目标页面里只有一个表格,直接写:
Element table = doc.selectFirst("table");这行代码只取页面上第一个 table 节点。但实际页面往往不止一个表格,可能是导航菜单用 table 布局,也可能有多个数据块。更稳妥的做法是优先用带 id 或 class 的选择器,比如:
Element table = doc.selectFirst("table#mainTable");如果目标表格没有 id,但 class 是固定的,就用:
Element table = doc.selectFirst("table.data-grid");还有一种更保险的思路:先拿到所有 table,再根据表头文本判断哪一个是目标表。这种方式在页面结构偶尔调整但不改表头文字时很抗揍:
Elements tables = doc.select("table"); for (Element t : tables) { String headerText = t.selectFirst("tr").text(); // 第一行文本作为表头特征 if (headerText.contains("发布日期")) { table = t; break; } }这段循环的逻辑是逐张表检查第一行内容是否包含预期的表头关键词,匹配上了就认定为目标表。注意 t.selectFirst("tr") 如果表是空的会返回 null,调用前要做好判空。定位这一个环节决定了解析的成败,宁可多写两步判断,也不要贪图代码短。常见误用是直接 select("table").get(0),如果页面结构稍微调整,表格顺序一变,数据就是错的了。
3.2 行列遍历与单元格清洗:处理空格、换行和富文本
表格节点拿到后,解析套路很固定:外层遍历 tr 行,内层遍历 th 或 td 单元格。但这里有一个容易翻车的细节——Jsoup 的 text() 方法拿到的是单元格内所有文本的拼接,如果单元格里有 em、strong、span 这类内联标签,text() 会一股脑儿全拼进来。比如<td>1<sup>2</sup>3</td>,text() 得到的是 "123",而你期望的原始数值可能不是这样。所以清洗必须放在解析之后、落盘之前。
下面是我在示例工程里用的解析循环:
Elements rows = table.select("tr"); List<List<String>> data = new ArrayList<>(); for (Element row : rows) { Elements cells = row.select("th, td"); // 同时取表头和表体单元格 List<String> rowData = new ArrayList<>(); for (Element cell : cells) { String text = cell.text() .replace("\u00A0", " ") // 把 换成普通空格 .replaceAll("\\s+", " ") // 多个空白字符合并成一个 .trim(); // 去掉首尾空白 rowData.add(text); } // 跳过完全空白的行 if (!rowData.isEmpty() && rowData.stream().anyMatch(s -> !s.isEmpty())) { data.add(rowData); } }逻辑拆开说:先把行集合取出来,再逐行取单元格,th 和 td 都取是因为有些表格表头是 th,有些是 td,统一处理不用区分;清洗分三步,第一步用\u00A0是因为网页里常见的 解析后是不断行空格,直接 trim 不掉,必须先替换成普通空格,第二步把所有连续空白压缩成一个,第三步 trim 掉首尾。清洗之后再判断这一行是不是真的空行,避免把空行落进 CSV。
这里有个重要提醒:数据行的列数最好保持一致,如果你在 Excel 里打开 CSV 发现某一列整体错位,大概率是某一行多了或少了单元格。遇到这种情况,不要在解析时强行补列,先回到页面源码确认是不是存在 colspan 或 rowspan 合并单元格,这是第 4 章要专门讲的一个坑。
3.3 输出 CSV 与写 Excel 的取舍:编码和兼容性
数据清洗干净后,输出格式主要看下游怎么用。示例工程默认输出 CSV,因为 CSV 是纯文本,任何环境都能打开,MyEclipse 控制台和记事本都能直接看。但 CSV 有一个经典坑:Excel 直接打开 UTF-8 编码的 CSV 会乱码,原因不是编码错,而是 Excel 默认按 ANSI 解读没有 BOM 的文件。解决办法是在写文件时先输出一个 BOM 头:
try (Writer writer = new OutputStreamWriter( new FileOutputStream("output.csv"), StandardCharsets.UTF_8)) { writer.write('\uFEFF'); // UTF-8 BOM,帮助 Excel 识别编码 for (List<String> row : data) { writer.write(String.join(",", row)); writer.write("\r\n"); } }这段代码用 OutputStreamWriter 手动指定 UTF-8,比 FileWriter 更可控。BOM 头\uFEFF写在第一行之前,Excel 打开时就不会乱码。join 用英文逗号拼接,但如果单元格内容本身包含逗号,比如 "北京, 上海" 这种文本,CSV 会错列,要么在写入前把字段里的逗号替换成全角逗号,要么用双引号包裹字段再转义,示例里用的替换方式,简单直接。
为什么不直接写 Excel?POI 的 xlsx 支持确实更专业,但依赖重、代码量大,对一个抓表格的示例来说性价比不高。如果数据量很大需要筛选排序,CSV 导入 Excel 或数据库都方便;如果你确实需要 xlsx,可以把 CSV 作为中间产物,再用 POI 转换成 Excel 文件,职责分离,出问题也好排查。这一步落盘完成后,最简单的“抓取 → 解析 → 输出”闭环就跑通了。
4. 避坑:表格抓取常见的五个翻车场景与解法
4.1 抓回来看全是乱码,控制台和 CSV 都一样
现象:打印到控制台的中文变成 �、锟斤拷之类的乱码,生成的 CSV 用 Excel 打开也是同样的问题。
原因:目标页面的编码和 Jsoup 默认推断不一致。Jsoup 在响应头没有 charset 时会尝试从页面 meta 里读,但如果页面 meta 写的是 charset=gb2312 而实际内容是 GBK 扩展字符,或者响应头根本没带编码信息,最终拿到的 Document 就会按 ISO-8859-1 去解码,中文自然就废了。
解决:在 connect 时手动指定编码,代码改成.charset("GBK")或.charset("UTF-8"),以实际截图确认的页面编码为准。我的习惯是先跑一遍 2.3 节的测试代码看 doc.charset() 输出,再决定手动指定什么编码。另外,CSV 落盘时统一转成 UTF-8 加 BOM,不要在输出环节继续用页面原始编码,避免 Excel 和数据库两边标准不一致。编码这个问题属于一次定位、终身受益的类型,在工程里最好写一个常量配置。
4.2 页面有多个表格,selectFirst 选错了对象
现象:程序没有报错,但抓出来的数据和浏览器里看到的完全对不上,字段数量一样但内容明显是另一张表。
原因:页面上的第一个 table 不是目标表,可能是侧边栏菜单、页脚版权表或者广告位。select("table").first() 拿到的是 DOM 顺序上的第一张,而目标数据表排在后面。
解决:回到浏览器按 F12 查看目标 table 的 id 或 class,改用table#mainTable或table.data-grid这种限定选择器。如果页面结构里没有可用属性,就用表头文本匹配的方式,先取所有表再遍历判断第一行是否包含目标表头关键词。这种写法虽然多几行代码,但能扛住页面板块顺序调整。我在模拟项目 X 里就遇到过页面改版后菜单少了一个 table、数据表排位提前的情况,用表头匹配后一次命中,没有再维护过选择器。
4.3 浏览器里有数据,Java 抓回来是空的
现象:Jsoup 成功拿到了页面,doc.select("table") 也能找到节点,但 rows 数量是 0,或者表格里只有表头没有数据行。
原因:目标表格的内容是 JavaScript 异步加载渲染的,比如用 jQuery 发起 Ajax 请求拿 JSON 再拼成表格。Jsoup 不执行 JS,拿到的只是没有填充数据的空骨架。另一个变种是需要登录态,未登录时表格区域直接不渲染。
解决:先按 F12 切到 Network 面板,刷新页面找到渲染表格的 XHR 请求,复制它的 URL 直接用 Jsoup 去请求这个接口。很多时候接口返回的是 JSON,解析起来比 HTML 更简单;如果接口需要 Cookie,就在 connect 时用.cookie("sessionId", "xxx")携带登录凭证。如果 XHR 请求参数带签名不好模拟,那就考虑改用手边浏览器自动化工具,不过那就超出这个轻量示例的范围了。记住一句话:Jsoup 只适合抓“服务端渲染”的页面,动态渲染走数据接口才是正道。
4.4 抓取频率太高,IP 被目标站点拉黑
现象:连续抓取几个页面后,突然间请求返回 503,或者页面跳到一个验证码页,控制台打印的 doc.title() 变成了“安全验证”之类的标题。
原因:脚本在短时间内对同一站点发起密集请求,触发了服务端的访问频率限制策略。这属于资源生产方最常见的封禁诱因,凡是做爬虫早晚会踩一次。
解决:在每次请求之间强制加间隔,最简单的方式是Thread.sleep(2000 + new Random().nextInt(3000)),把间隔控制在 2 到 5 秒之间随机波动,避免固定间隔被发现。同时做好重试机制,遇到 503 时退避等待更长时间再重试,最多重试 3 次就放弃,不要无限循环。对目标站点的表结构观察要一次到位,不要在调试过程中反复全量抓取同一页面,先把解析代码写在静态 HTML 样本上调试好,再放出去跑真实请求,这是减少请求量的最佳策略。说到底,爬虫只是获取公开数据的手段,别给自己的 IP 找麻烦。
4.5 合并单元格导致行数据错位
现象:CSV 里从某一行开始,所有列的内容整体向前或向后偏移一位,后面全错。
原因:目标表格使用了 colspan 或 rowspan 来合并单元格,DOM 里这一行实际的 td 数量比表头少,按顺序直接取值就会和表头对不上。
解决:解析时检测 colspan 属性并主动补全占位单元格。在遍历单元格的循环里加一段扩展逻辑:
int colSpan = Integer.parseInt(cell.attr("colspan").isEmpty() ? "1" : cell.attr("colspan")); for (int i = 0; i < colSpan; i++) { rowData.add(text); // 合并的每一列都塞同一个值 }这段代码先把 colspan 属性的值取出来,没有该属性时按 1 处理,然后循环向 rowData 中添加对应次数的相同值。处理之后行内列数就和表头严格一致,错位问题不再出现。rowspan 的补齐逻辑更复杂,需要在跨行期间记录状态,示例里只处理了 colspan,因为实际场景中 colspan 出现频率远高于 rowspan。遇到 rowspan 时我的建议是调整策略:要么放弃那几行的完整性,要么改用专门的表格识别工具,Java 里手写 rowspan 状态机性价比太低。这个问题属于表格抓取里的玄学命题,但从原理上想通后就再也不慌。
5. 进阶:把抓表工具改成能增量更新的小爬虫
基础版本跑通后,下一步最常见需求是定时更新,而不是每次手动运行。一个简单的增量更新思路是这样:给数据行定义一个唯一键,通常是表格第一列编号或 ID,抓取时先读本地已经存过的键集合,新行写入,旧行如果内容变了就更新,没变就跳过。这样既避免全量覆盖,又能降低对目标站点的请求频次,服务器也不会因为你频繁跑全量抓取而拉黑你。键的提取可以在解析循环里完成,也可以用整行文本做 MD5 指纹,指纹变了就说明这行数据有更新。两种方式各有适用场景,键值法适合有明确主键的表格,指纹法适合没有可靠主键的自由报表。
调度器不需要引入 Quartz 这种重框架,JDK 自带的 ScheduledExecutorService 就够用。下面这段是我常用的调度骨架:
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { try { List<List<String>> newData = crawAndParse(); writeCsv(newData); } catch (Exception e) { // 单次失败不能拖垮整个调度任务 System.err.println("抓取失败: " + e.getMessage()); } }, 0, 6, TimeUnit.HOURS);scheduleAtFixedRate 的第一个参数是初始延迟,第二个是执行周期,第三个是时间单位,示例是每 6 小时抓一次。注意单次任务里必须 catch 所有异常,否则一次网络抖动会让整个定时任务线程中断,后续所有调度都失效。这个坑我不只一次踩过,本地调试没问题,挂到服务器上跑两天后任务悄悄停了,原因就是某次请求异常没被捕获。
调度之外,一个好的习惯是保留最近一次的抓取日志,包括抓取时间、行数、耗时,输出到单独的文件。排查“某天数据为什么少了”这类问题时,日志比代码更管用。做增量更新爬虫后,最重要的一条验收标准是:重复运行两次,第二次不会报错,也不会产生重复数据。这个标准验证的是你增量判断和去重逻辑是否正确。
这套示例工程我是从一个实际的数据报表需求里拆出来的,当时某业务方每天要手动复制网页报表里的几十行数据到 Excel,耗时且容易出错。我第一次写的时候什么防护都没加,直接连抓、直接按全量覆盖输出,结果跑了三天就因为频率过高被目标站挡了半个下午。从那以后我每次做表格爬虫都会强制先走一遍三步验证:第一步确认页面是服务端渲染还是动态加载,第二步用浏览器检查编码和表格结构,第三步把所有逻辑先在静态样本上跑通,最后才带限速放出去跑真实抓取。这套流程已经帮我避开了不少无征兆的翻车现场,希望帮到你。
本文还有配套的精品资源,点击获取