
简介这是一份面向Java初学者与数据采集入门者的网页表格抓取示例包围绕在MyEclipse环境下使用Java获取网页表格数据展开适合需要理解静态HTML解析、HTTP请求与结构化数据提取的读者。压缩包共24个文件约1.15MB包含7个java源码、7个class编译产物和7个jar依赖另有工程配置与偏好设置文件整体可直接作为MyEclipse工程参考依赖涵盖Jsoup、HttpClient、编码识别等常用组件。源码展示了网页连接、表格定位、行列遍历、单元格数据提取、结果存储及异常处理等环节便于读者对照理解网页表格抓取从请求到存储的完整链路。已有712人学习适合用来熟悉Java爬虫基础与项目依赖组织方式并在此基础上改造成自己的数据采集小工具。1. MyEclipse 里跑通一个 Java 表格抓取例子先看清它到底解决什么问题手上有一份现成的 Java 抓取网页表格的例子压缩包解开是能在 MyEclipse 里直接运行的那种工程不是零散几段代码片段。它值不值得下载取决于你正在做什么如果目标是“把某个业务系统页面上那张 HTML 表格原样搬到 Excel 或数据库里”这份例子给到的是一条最小可运行链路——HTTP 取页面、解析表格行与单元格、落成结构化数据跑起来五分钟改造成自己的目标页面也就半天。难点不在代码本身而在 HTML 的脏、编码的乱、分页的坑。下面按 MyEclipse 导入、DOM 解析、表格提取、异常排查、批量与导出依次拆新手照着能跑通熟手能看到参数和边界。2. 环境与工程结构在 MyEclipse 里把例子跑起来先确认一件事这份例子的运行环境写的是 MyEclipse意味着它大概率是普通 Java 工程Java Project或早期 Web Project而不是 Maven 标准工程。这类工程最典型的翻车点是 JDK 版本和 jar 包路径——导入后满屏红叉十有八九不是代码错是 Build Path 没对上。先把环境钉死再谈抓取逻辑。2.1 JDK 与工程导入的版本对齐MyEclipse 本身对 JDK 版本的宽容度不如新版 IDEA尤其是 20142017 那一批版本默认编译级别常停在 1.6/1.7。例子里的代码如果用了try-with-resources或 lambda编译级别低于 1.8 就会报语法错。我的习惯是先把工程属性里的三处对齐Java Build Path 的 JRE、Java Compiler 的 compliance level、Project Facets 里的 Java 版本三者必须同为 1.8或你本机装的版本。# 导入前先确认本机 JDKMyEclipse 的 Window Preferences Java Installed JREs 要指向同一个 java -version javac -version # 若工程带 .classpath 和 .project直接 File Import Existing Projects into Workspace # 若只有 src 目录走 File New Java Project再把 src 拷进去避免 .project 冲突上面这段的重点是“先看本机、再导工程”。java -version和javac -version不一致是极常见的环境问题运行期报UnsupportedClassVersionError就是它。导入方式上带.project文件的走 Existing Projects没有的走新建工程再拷src——后者更稳因为老工程的.classpath里常写着绝对路径的 jar换台机器必挂。2.2 依赖 jar 与目录约定这类例子通常依赖两三个解析库加一个 HTTP 客户端。原生HttpURLConnection也能取页面但处理 gzip、重定向、Cookie 很啰嗦常见做法是用 HttpClient 或 Jsoup 自带的连接能力表格解析则交给 Jsoup 的 CSS 选择器比手写正则稳得多。目录上我一般固定成下面这样后面的代码路径都按这个来。WebTableCrawler/ ├── src/ │ └── com/example/crawler/ │ ├── FetchPage.java # 取页面统一编码 │ ├── ParseTable.java # 解析 table/tr/td │ └── ExportCsv.java # 落 CSV ├── lib/ │ ├── jsoup-1.17.2.jar │ └── commons-csv-1.10.0.jar └── output/lib下放 jar右键 Add to Build Pathoutput目录单独建别把抓取结果写进src否则下次 clean 就可能被清掉。这里必须提醒一句工程里如果已经带了旧版 jsoup有些例子打包的是 1.7.x不要再往lib里塞新版两个版本同时在 Build Path 上会出现NoSuchMethodError现象是编译通过、运行到选择器就崩。2.3 一个最小可运行的取页面方法先跑通“能拿到 HTML 且中文不乱码”再谈解析。这一步是整个链路的地基编码错了后面选择器写得再准也是徒劳。public static String fetch(String url) throws IOException { // 老版本 jsoup 用 Jsoup.connect超时单位毫秒 Document doc Jsoup.connect(url) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64)) // 不带 UA 有些站点直接返回空 .timeout(15000) // 15 秒内网页面调小到 5000 .ignoreContentType(true) // 目标不是标准 HTML 时别让它抛异常 .get(); // 强制按 UTF-8 输出避免 GBK 页面解析后成乱码 doc.outputSettings().charset(UTF-8); return doc.html(); }逻辑说明userAgent不是伪装是很多站点对空 UA 直接返回 403 或空 body这是血泪经验timeout必须显式设默认值在跨网段访问时会让你以为程序卡死ignoreContentType用于目标是.do、.action这类后缀的页面jsoup 默认只认text/*和application/xml。参数怎么改内网系统 5000 毫秒够公网页面给到 15000如果目标站要求登录还要加.cookie(JSESSIONID, xxx)这部分放到第 5 章讲。3. 表格解析从 table 标签到二维数据的稳定提取页面到手之后真正花时间的是“表格不规整”。教科书式的tabletrtd在真实系统里几乎见不到表头用th、合并单元格用rowspan/colspan、表里套表、甚至整张表是 JS 渲染出来的。这一章把提取逻辑拆成可复用的三层定位表、遍历行、拍平单元格。3.1 用 CSS 选择器定位目标表格而不是靠下标新手最常见的写法是doc.select(table).get(0)取页面第一张表。这在你本地测试页上永远是对的到目标站就翻车——导航栏、布局、统计小卡片全可能是 table。稳的做法是先看页面结构用“特征选择器”锚定。// 优先用 id 或稳定的 class例如目标页表格外层是 table iddataList Elements tables doc.select(table#dataList); if (tables.isEmpty()) { // 退一步找表头含“序号”“名称”的那张表比下标可靠 tables doc.select(table:has(th:contains(序号))); } if (tables.isEmpty()) { throw new IllegalStateException(未定位到目标表格页面结构可能已变更); } Element table tables.first();table#dataList是最优解改动成本最低。table:has(th:contains(序号))是兜底策略jsoup 的:has加:contains组合能表达“包含某表头文字的表”中文表头识别率高缺点是全表扫描略慢几百张表的页面要几百毫秒。抛异常而不是返回 null是让问题在日志里显式暴露别让它悄悄产出空 CSV——空文件比报错更难查。3.2 遍历 tr 与 td处理表头与数据行分离定位到表之后行和单元格的遍历看起来简单但表头行、汇总行、空行混在一起时数据会错位。我的做法是先分离表头再按表头列数对齐数据行列数对不上的行直接记日志丢弃。ListString headers new ArrayList(); ListListString rows new ArrayList(); for (Element tr : table.select(tr)) { Elements cells tr.select(th, td); if (cells.isEmpty()) continue; // 跳过纯布局空行 ListString line new ArrayList(); for (Element c : cells) { // 合并单元格会撑出空白这里先做 trim 与全角空格清理 line.add(c.text().replace(\u00A0, ).trim()); } if (headers.isEmpty() !tr.select(th).isEmpty()) { headers.addAll(line); // 第一行有 th 视为表头 continue; } if (line.size() ! headers.size()) { System.out.println(列数不匹配已跳过: line); // 通常是汇总行或误抓 continue; } rows.add(line); }逻辑说明tr.select(th, td)一次拿到该行所有单元格顺序即文档顺序这是 jsoup 保证的。c.text()会自动去掉内部标签并合并空白比html()干净。参数上replace(\u00A0, )处理的是不间断空格中文页面里大量存在不做这一步后面按空格切分或去重都会出错。至于列数校验宁可丢几行汇总数据也别让整张表错位——错位数据导入数据库后再回溯成本远高于重新抓一次。3.3 合并单元格的两种补全策略rowspan和colspan是表格抓取的经典难点。jsoup 不会帮你展开c.text()只返回当前 td 自己的内容合并区域的其他格子在你遍历时根本不存在。两种常见做法各有取舍。策略做法优点代价占位补空遇到 colspan/rowspan 时插入等量空字符串实现简单列号对齐数据稀疏下游要自己回填向下向右填充缓存合并值按跨度补到后续行/列数据完整可直接入库需维护网格状态逻辑易错// 占位补空只处理 colspanrowspan 需额外维护行缓存 ListString line new ArrayList(); for (Element c : tr.select(th, td)) { line.add(c.text().trim()); int colspan Integer.parseInt(c.attr(colspan).isEmpty() ? 1 : c.attr(colspan)); for (int i 1; i colspan; i) line.add(); // 补空占位保证列号不偏移 }这段只解决colspanrowspan需要按行维护一个MapInteger, String记录“某列当前应填充的值”每行遍历时先查缓存再解析实际 td。我的建议是如果目标表的合并只出现在表头用占位补空即可如果数据区本身就有纵向合并比如按部门分组的报表老老实实写网格填充并且一定要打日志记录每次填充否则出错时你无法定位是哪一行开始偏的。3.4 输出结构化结果CSV 与内存对象解析完得到的是ListListString落盘前先想清楚下游怎么用。如果只是人工核对CSV 足够如果要进数据库建议先转成一个 POJO 列表字段名取表头这样后续做类型转换和校验有抓手。try (BufferedWriter w Files.newBufferedWriter(Paths.get(output/table.csv), StandardCharsets.UTF_8)) { w.write(String.join(,, headers)); w.newLine(); for (ListString r : rows) { // 简单转义含逗号或引号的字段整体加引号内部引号翻倍 ListString esc r.stream() .map(v - v.contains(,) || v.contains(\) ? \ v.replace(\, \\) \ : v) .collect(Collectors.toList()); w.write(String.join(,, esc)); w.newLine(); } }这里用Files.newBufferedWriter显式指定 UTF-8绕开平台默认编码。用 Excel 直接双击打开 UTF-8 CSV 会乱码这是 Excel 的老问题不是文件错——要用“数据 从文本导入”并选 UTF-8或者写成带 BOM 的 UTF-8。字段转义也别省很多系统导出的单元格内容里带逗号地址、备注不转义直接拼列会整体错乱。4. 避坑与排查五类让例子跑不通的真实问题同一份代码别人机器上五分钟出结果你这边可能卡一下午。下面五条是按发生频率排的每条都按“现象—原因—解决”写遇到问题按顺序对号入座基本能覆盖八成情况。4.1 中文乱码页面是 GBK你按 UTF-8 解现象是抓回来的表格里全是问号或方块但浏览器打开同一页面完全正常。原因通常是目标站响应头里没写 charset或者写了 GBK/GB2312而 jsoup 默认按 UTF-8 解字节流一错后面全错。解决方式是先读Content-Type头取不到再读页面里的meta charset两者都没有就按 GBK 试。Connection conn Jsoup.connect(url).userAgent(Mozilla/5.0).timeout(15000); Connection.Response resp conn.execute(); // 先拿响应不解析 String charset resp.charset(); // jsoup 会尝试从头和 meta 推断 Document doc resp.parse(); // 用推断结果解析 // 若仍乱码强制覆盖Jsoup.parse(new String(resp.bodyAsBytes(), GBK))判断依据很简单resp.charset()返回的是推断值把它打出来看一眼如果和浏览器里页面编码不一致就强制指定。不要用“打开不乱就是对的”来验证Java 侧和浏览器侧的默认编码经常不同。4.2 只抓到一半数据表格是 JS 异步渲染的现象是doc.html()里能看到页面框架但目标表格区域是空的或者只有一个div占位。原因是表格数据由前端脚本请求接口后动态插入HTTP 直接取到的 HTML 里根本没有这些节点jsoup 解析自然拿不到。解决路径有两条一是找页面背后的数据接口浏览器开发者工具的 Network 面板筛选 XHR看返回 JSON 的那个请求直接用 Java 请求接口拿 JSON这比解析 HTML 稳一个量级二是放弃纯 HTTP 方案换能执行 JS 的运行时但那就超出这份例子的范围了成本和维护量都高。我的排序永远是先找接口找不到再考虑别的。4.3 ClassNotFound / NoSuchMethodErrorjar 版本冲突现象是编译期一切正常运行到Jsoup.connect或select时报NoSuchMethodError或者启动就ClassNotFoundException。原因基本是两个版本的同名 jar 同时在 Build Path 上编译器用了 A 版本的签名运行时加载了 B 版本。解决办法是打开工程属性里的 Java Build Path Libraries逐个核对再检查WebContent/WEB-INF/lib或lib目录把旧版删掉只留一份。体检命令不用写代码肉眼核对文件名里的版本号即可但一定要连同Referenced Libraries一起看很多人只删了lib里的忘了 Build Path 里还挂着一个绝对路径的旧包。4.4 请求被拦403、空响应与频控现象是本地浏览器能打开Java 请求返回 403、302 跳登录页或者 body 长度为零。原因有三类缺 UA、缺 Referer、请求过快触发频控。解决上UA 和 Referer 按浏览器里那条真实请求抄频控则在循环里加间隔别用固定 sleep做成随机区间更像人也更不容易被判定为机器。for (int i 0; i urls.size(); i) { String html fetch(urls.get(i)); // 200~600ms 随机间隔别写成固定 1000固定节奏反而更显眼 Thread.sleep(200 new Random().nextInt(400)); }4.5 表格结构轻微变动就全盘失效现象是上个月跑得好好的这个月列全错位或抓不到表。原因是选择器写得过于依赖位置或完整 class 名目标系统一次前端改版就全废。解决是把选择器做成可配置的并且加“结构断言”——抓到之后校验表头是否包含预期关键词不包含就报错退出并输出当时的 HTML 片段。if (!headers.contains(序号) || !headers.contains(名称)) { Files.write(Paths.get(output/debug.html), doc.html().getBytes(StandardCharsets.UTF_8)); throw new IllegalStateException(表头校验失败已保存现场 HTML); }把现场 HTML 落盘这一步是我踩过最多次坑之后加的后悔药没有现场你只能凭记忆猜页面长什么样有了它五分钟就能定位是选择器问题还是页面改版。5. 从单页到批量分页、登录态与结果校验例子跑通单页之后真实需求几乎一定是“抓全部页”。这一步的复杂度不在代码量在会话状态和边界控制分页参数怎么翻、登录 Cookie 怎么带、抓到第几页算结束、结果怎么验证没缺没重。做完这层这个例子才算真正能用在日常工作里。5.1 分页参数的识别与循环终止条件分页常见三种形态URL 里带page1、表单 POST 提交页码、以及“下一页”是 JS 点击。第一种最好办直接把页码拼进 URL 循环第二种要构造 POST 参数第三种回到第 4.2 节的接口思路。循环终止不能只靠“抓不到数据就停”要设三重保险页码上限、连续空页计数、以及总条数比对页面常显示“共 N 条”。int page 1, emptyStreak 0; while (page 200) { // 保险一硬上限防死循环 String url baseUrl ?page page; ListListString part parseTable(fetch(url)); if (part.isEmpty()) { if (emptyStreak 2) break; // 保险二连续两页空才停容忍偶发空页 page; continue; } emptyStreak 0; rows.addAll(part); page; Thread.sleep(200 new Random().nextInt(400)); }page 200这个上限不是随便写的是防止分页参数被服务端忽略导致无限循环——有些系统对超范围页码不报错直接返回第一页你不设上限就会一直抓第一页的内容最后得到几千条重复数据还以为是页面有重复行。连续两页空才停是为了容忍中间某页因数据被删而临时为空。5.2 登录态与 Cookie 的携带方式内网系统、业务后台的表格页大多要登录。例子本身多半没带这部分需要自己补。思路是先用代码模拟一次登录拿到会话 Cookie再带着 Cookie 去抓表格页。关键是登录请求的参数名要和表单一致以及处理登录后的重定向。// 第一步登录拿 Cookie Connection.Response login Jsoup.connect(loginUrl) .method(Connection.Method.POST) .data(username, user) .data(password, pass) .userAgent(Mozilla/5.0) .followRedirects(true) .execute(); MapString, String cookies login.cookies(); // 关键会话在这里 // 第二步带 Cookie 抓表格 Document doc Jsoup.connect(tableUrl) .cookies(cookies) .userAgent(Mozilla/5.0) .timeout(15000) .get();参数说明.data()的键名必须和目标表单的name属性完全一致差一个字符就是登录失败但返回 200 的页面followRedirects(true)让登录后的跳转自动完成Cookie 也会随跳转更新这一步不能关。如果目标站有验证码纯代码方案就走不通了别硬刚换导出功能或找系统自带的导出入口更实际。另外 Cookie 有有效期长任务里要判断响应是否又跳回登录页是的话重新登录一次再续跑。5.3 结果校验条数、去重与抽样比对抓完不等于抓对。我的固定动作是三件事条数比对、主键去重、随机抽样人工核对。条数比对看页面上的“共 N 条”和实际行数是否一致差得多说明分页漏了去重按业务主键一般是第一列的编号做重复率高说明分页参数没生效参考 5.1 的硬上限抽样则是随机挑五行拿浏览器里的原始页面逐字段对一遍这一步最土但最有效能抓出编码、合并单元格、字段错位这三类隐蔽问题。校验项做法异常含义总条数页面“共 N 条” vs 实际行数差值为整页多为分页漏抓主键去重按编号列 group by 计数大量重复分页参数被忽略字段抽样随机 5 行对原始页面错位、乱码、空值异常空值率统计每列空值占比某列全空多为选择器写错跑完这套校验再入库能省掉后面大量返工。我见过最典型的一次翻车是分页参数名写错pageNo写成page服务端忽略后一直返回第一页跑了两千行全是重复数据直到去重那步才发现。从那以后我每次做批量抓取都强制先把page1和page2的返回分别落盘对比一次确认内容不同再开循环这个习惯比任何校验脚本都省事。5.4 大规模抓取时的资源与合规边界数据量上去之后两个问题会浮出来一是内存ListListString全量堆在内存里几十万行就吃力改成边解析边写文件、只在内存里留校验用的主键集合二是对方系统的承受能力请求间隔、并发数都要克制单线程加随机间隔是最稳的起步配置需要提速时优先加间隔优化而不是加并发。另外抓取前确认目标数据的使用范围只取业务需要的字段别整页照搬——这既是技术要求也是基本的工程习惯。这份例子的价值在于给你一条能改的最小链路剩下的边界靠上面这些参数和校验一点点补齐。希望帮到你。本文还有配套的精品资源点击获取