简介一份面向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 groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.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() 会一股脑儿全拼进来。比如td1sup2/sup3/tdtext() 得到的是 123而你期望的原始数值可能不是这样。所以清洗必须放在解析之后、落盘之前。下面是我在示例工程里用的解析循环Elements rows table.select(tr); ListListString data new ArrayList(); for (Element row : rows) { Elements cells row.select(th, td); // 同时取表头和表体单元格 ListString rowData new ArrayList(); for (Element cell : cells) { String text cell.text() .replace(\u00A0, ) // 把 nbsp; 换成普通空格 .replaceAll(\\s, ) // 多个空白字符合并成一个 .trim(); // 去掉首尾空白 rowData.add(text); } // 跳过完全空白的行 if (!rowData.isEmpty() rowData.stream().anyMatch(s - !s.isEmpty())) { data.add(rowData); } }逻辑拆开说先把行集合取出来再逐行取单元格th 和 td 都取是因为有些表格表头是 th有些是 td统一处理不用区分清洗分三步第一步用\u00A0是因为网页里常见的nbsp;解析后是不断行空格直接 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 (ListString row : data) { writer.write(String.join(,, row)); writer.write(\r\n); } }这段代码用 OutputStreamWriter 手动指定 UTF-8比 FileWriter 更可控。BOM 头\uFEFF写在第一行之前Excel 打开时就不会乱码。join 用英文逗号拼接但如果单元格内容本身包含逗号比如 北京, 上海 这种文本CSV 会错列要么在写入前把字段里的逗号替换成全角逗号要么用双引号包裹字段再转义示例里用的替换方式简单直接。为什么不直接写 ExcelPOI 的 xlsx 支持确实更专业但依赖重、代码量大对一个抓表格的示例来说性价比不高。如果数据量很大需要筛选排序CSV 导入 Excel 或数据库都方便如果你确实需要 xlsx可以把 CSV 作为中间产物再用 POI 转换成 Excel 文件职责分离出问题也好排查。这一步落盘完成后最简单的“抓取 → 解析 → 输出”闭环就跑通了。4. 避坑表格抓取常见的五个翻车场景与解法4.1 抓回来看全是乱码控制台和 CSV 都一样现象打印到控制台的中文变成 、锟斤拷之类的乱码生成的 CSV 用 Excel 打开也是同样的问题。原因目标页面的编码和 Jsoup 默认推断不一致。Jsoup 在响应头没有 charset 时会尝试从页面 meta 里读但如果页面 meta 写的是 charsetgb2312 而实际内容是 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 { ListListString newData crawAndParse(); writeCsv(newData); } catch (Exception e) { // 单次失败不能拖垮整个调度任务 System.err.println(抓取失败: e.getMessage()); } }, 0, 6, TimeUnit.HOURS);scheduleAtFixedRate 的第一个参数是初始延迟第二个是执行周期第三个是时间单位示例是每 6 小时抓一次。注意单次任务里必须 catch 所有异常否则一次网络抖动会让整个定时任务线程中断后续所有调度都失效。这个坑我不只一次踩过本地调试没问题挂到服务器上跑两天后任务悄悄停了原因就是某次请求异常没被捕获。调度之外一个好的习惯是保留最近一次的抓取日志包括抓取时间、行数、耗时输出到单独的文件。排查“某天数据为什么少了”这类问题时日志比代码更管用。做增量更新爬虫后最重要的一条验收标准是重复运行两次第二次不会报错也不会产生重复数据。这个标准验证的是你增量判断和去重逻辑是否正确。这套示例工程我是从一个实际的数据报表需求里拆出来的当时某业务方每天要手动复制网页报表里的几十行数据到 Excel耗时且容易出错。我第一次写的时候什么防护都没加直接连抓、直接按全量覆盖输出结果跑了三天就因为频率过高被目标站挡了半个下午。从那以后我每次做表格爬虫都会强制先走一遍三步验证第一步确认页面是服务端渲染还是动态加载第二步用浏览器检查编码和表格结构第三步把所有逻辑先在静态样本上跑通最后才带限速放出去跑真实抓取。这套流程已经帮我避开了不少无征兆的翻车现场希望帮到你。本文还有配套的精品资源点击获取