
简介这是一份面向Java初学者与数据采集入门者的网页表格抓取示例工程运行环境为MyEclipse重点解决静态HTML表格的请求、解析与字段提取问题也可作为网络爬虫基础练习的参考案例。压缩包共24个文件约1.15MB包含7个java源码、7个class编译产物、7个依赖jar以及MyEclipse工程配置与首选项文件其中jar涵盖Jsoup、HttpClient、编码检测等组件源码与编译结果配套齐全。已有712人学习适合对照理解网页请求、DOM选择器定位表格、行列遍历取值及异常处理等环节。工程目录保留src与bin结构便于在IDE中查看类与依赖的对应关系也能帮助读者梳理从抓取到结构化存储的实现思路。代码中体现表格元素筛选、单元格数据读取、结果集合保存与try-catch异常处理适合边看边改为后续数据分析前的数据获取打基础。1. 抓一张网页表格为什么值得单独拆一遍数据岗的同事甩过来一个内网地址说每天要把页面上那张表抄进 Excel半小时起步还容易抄错行。这类活儿的解法不复杂Java 爬取网页表格本质是把 HTML 里 table 的 tr/td 结构还原成二维数组再落成 Excel 或写进数据库。这份例子工程跑在 MyEclipse 里主流程就四个类发请求、解析表格、写 Excel、启动入口。它不追求通用爬虫框架那套调度和反爬对抗胜在链路完整、代码短改两行选择器就能套到自己的目标页面上。适合手上有一批结构稳定的列表页、需要周期性导出数据又不想为了一个脚本引入整套框架的人。下面按工程骨架、抓取链路、数据落盘、跑通排查、二次开发依次拆开。2. 工程骨架与技术选型从 MyEclipse 的目录结构看起2.1 目录骨架与三份依赖老工程在 MyEclipse 里通常是普通 Java Project也有人建成 Web Project跑的时候直接 Run As Java Application。目录不像 Maven 那样规整但职责分得很清楚看一眼就知道该去哪个包里改TableCrawler/ ├── .project / .classpath # MyEclipse 工程描述导入时靠它识别 ├── src/ │ └── com/example/crawler/ │ ├── Main.java # 入口读配置、串流程 │ ├── HttpFetcher.java # 发 HTTP 请求拿回 HTML 字符串 │ ├── TableParser.java # Jsoup 定位 table转 ListListString │ ├── ExcelWriter.java # POI 写出 xlsx │ ├── DbWriter.java # JDBC 批量入库可选 │ └── model/RowData.java # 表格行映射成的实体 ├── lib/ # 手动放的第三方 jar │ ├── jsoup-1.x.jar │ ├── poi-5.x.jar │ ├── poi-ooxml-5.x.jar │ └── mysql-connector-java-8.x.jar └── output/ # 导出的 xlsx 落地目录三个依赖各管一段jsoup 负责把 HTML 文本变成可查询的 DOM省掉自己写状态机匹配标签的麻烦POI 负责 xlsx 读写注意poi-ooxml和poi要一起放只放前者启动时会报NoClassDefFoundErrorJDBC 驱动只有在需要入库时才加纯导出 Excel 的版本根本用不上。MyEclipse 里 jar 不会自动进 classpath右键工程 → Properties → Java Build Path → Libraries → Add External JARs把 lib 下四个包全勾上否则编译期就红。编译器版本建议统一到 1.8。老工程常见的一个坑是 Project Facets 写的是 1.6、JDK 装的是 17Run As时冒出一堆Unsupported class file major version。改法是把 Java Compiler 的 Compiler compliance level 和 JRE 都对齐到同一个版本不要一半 1.6 一半 17。2.2 三条解析路线的取舍字符串截取、正则、DOM抓表格这件事代码量从少到多有三种写法选错了后期维护会很痛苦。先把结论摆出来方式典型代码优点什么时候翻车字符串截取html.indexOf(table)到/table零依赖几行搞定页面有嵌套 table、属性顺序变了、空白字符不同就断正则tr[^]*(.*?)/tr比截取灵活一点跨行内容、注释、script里的字符串都会误匹配回溯性能差DOM 解析Jsoup.parse(html).select(table#list tr)结构稳、容错强、选择器可读需要引一个 jar页面若是 JS 渲染仍然拿不到我一般直接用 DOM。原因很实在一次性的小脚本怎么写都行但表格抓取这种需求页面改版是迟早的事选择器改一个字符串就能修正则得从头捋。这份例子也是走 jsoup 路线。正则那条路也不是没人用但血泪经验是别用贪婪匹配。曾经有个页面表格后面跟着一大段script里面拼了一串 HTML 字符串正则一路吃到脚本末尾解析出来的行数比实际多了几十行排查半天才发现是匹配范围的问题。DOM 解析器会把 script 内容当文本节点不会当成标签结构天然避开这类问题。3. 抓取主链路从 URL 到二维表格的四步3.1 发请求UA、超时、编码三件套第一步永远是把页面原样拿回来。Jsoup.connect(url).get()一行也能跑但工程里建议自己封一层HttpURLConnection把超时、请求头、异常出口都控制住出问题时能定位到是哪一步断的public static String fetch(String url) throws IOException { HttpURLConnection conn (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(8000); // 建连超时网络抖动时别干等 conn.setReadTimeout(15000); // 读超时服务端慢查询时兜底 // 不带 UA 的请求不少站点直接回 403 或者一个空壳页 conn.setRequestProperty(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0 Safari/537.36); conn.setRequestProperty(Accept, text/html,application/xhtmlxml); conn.setRequestProperty(Accept-Language, zh-CN,zh;q0.9); // 有些站点校验来源页抓详情/分页时补上列表页地址 conn.setRequestProperty(Referer, http://example.com/list); int code conn.getResponseCode(); if (code ! 200) { throw new IOException(HTTP code - url); } try (InputStream in conn.getInputStream(); ByteArrayOutputStream bos new ByteArrayOutputStream()) { byte[] buf new byte[8192]; int n; while ((n in.read(buf)) ! -1) { bos.write(buf, 0, n); } // 关键用字节流收完再按指定编码解码不要沿途 new String() return new String(bos.toByteArray(), UTF-8); } finally { conn.disconnect(); } }几个参数值得单独说。connectTimeout和readTimeout必须分开设前者管 TCP 握手后者管服务端响应合在一起设会出现“连上了但一直不返回数据、程序假死”的情况。User-Agent不是可选项默认的Java/1.x在很多站点的日志里就是明显的爬虫特征直接被挡。Referer只在对方做了来源校验时才需要不确定就先不加报 403 后再补。编码这一段是最容易埋雷的地方。把InputStream直接包成InputStreamReader再用默认编码读开发机是 UTF-8、部署机是 GBK同一份代码在两台机器上跑出两种乱码。稳妥做法是先把字节收全再用页面meta charset里声明的编码解码懒得解析就固定 UTF-8抓之前用浏览器看一眼源码里的 charset 确认一下。另外注意 gzip如果响应头带Content-Encoding: gzip上面这段直接读会得到二进制垃圾需要套一层GZIPInputStream或者干脆不主动发Accept-Encoding。3.2 用选择器定位表格select 语法与容错HTML 到手之后第一件事是把目标 table 找出来。一个页面可能有三四个 table——导航栏是一个、布局是一个、真正要抓的数据表是第三个。选择器写得越具体越稳// html 是上一步抓回来的字符串baseUri 用于补全相对链接 Document doc Jsoup.parse(html, http://example.com/); // 优先级id class 结构位置 全量兜底 Elements tables doc.select(table#dataList); if (tables.isEmpty()) { tables doc.select(table.tb, table.grid, table.list); // 备选 class } if (tables.isEmpty()) { tables doc.select(div.content table); // 限定父容器 } if (tables.isEmpty()) { tables doc.select(table); // 最后兜底取第一个 } Element target tables.first(); Elements rows target.select(tr);select里几个常用写法记住就够用了#id按 id.cls按 classtable.data tr是后代关系table tbody tr是直接子元素tr:has(td)过滤掉全是 th 的表头行td:eq(2)取第三列。属性匹配写a[href^http]前缀匹配在翻页链接上很好使。这里有个细节target.select(tr)会把嵌套表格里的 tr 一起选出来。表格里再套一张排版用的 table 是老页面的常见做法结果就是数据行里混进了无关内容。解决办法是用target.select( tbody tr, tr)只取直接子行或者解析时判断 tr 的父节点是不是目标 table。判断父节点的写法略啰嗦选择器方案更省事。还有一层容错tables.first()前必须判空。页面改版、被拦截、返回登录页这几种情况下 select 结果都是空集合直接.first()会抛IndexOutOfBoundsException日志里只会看到一行空指针完全看不出是“没抓到”还是“解析错了”。空集合时明确抛一个带 URL 的异常排查效率差好几倍。3.3 行与列的提取跳过表头、处理 rowspan/colspan拿到 tr 列表之后标准做法是逐行取 tdcell.text().trim()拿文本。简单表格到这就算完了。但真实页面里的表格常带合并单元格rowspan3意味着一格占三行后面两行的 td 数量会少直接按索引取列就会整体错位——第一列的名字跑到了第二列上导出的 Excel 看着像对的其实每行都偏了一格。处理思路是维护一个“占位”表记录哪些列在当前行被上方的 rowspan 占着public static ListListString parseTable(Element table) { ListListString grid new ArrayList(); // pending 记录某列还被上方 rowspan 占着几行pendingText 存占位文本 MapInteger, Integer pending new HashMap(); MapInteger, String pendingText new HashMap(); for (Element tr : table.select( tbody tr, tr)) { ListString row new ArrayList(); int col 0; for (Element cell : tr.children()) { // th 和 td 都要顺序不能乱 // 先把被上方跨行占住的位置填掉 while (pending.getOrDefault(col, 0) 0) { row.add(pendingText.getOrDefault(col, )); pending.put(col, pending.get(col) - 1); col; } String text cell.text().trim(); int rowspan parseInt(cell.attr(rowspan), 1); int colspan parseInt(cell.attr(colspan), 1); for (int i 0; i colspan; i) { row.add(text); if (rowspan 1) { pending.put(col, rowspan - 1); // 减 1当前行已经填过了 pendingText.put(col, text); } col; } } // 行尾若还有被跨行占住的位置补上 while (pending.getOrDefault(col, 0) 0) { row.add(pendingText.getOrDefault(col, )); pending.put(col, pending.get(col) - 1); col; } grid.add(row); } return grid; }parseInt是个小工具方法属性缺失或写成空串时返回默认值 1避免NumberFormatException打断整个解析。tr.children()而不是tr.select(td)是因为表头用的是 th用 td 选择器会把表头整行丢掉——而表头恰恰是后面映射字段名的依据最好保留在第 0 行。表头和数据行的分离放在解析之后做不塞进解析里。取grid.get(0)当表头subList(1, size)当数据好处是能用表头文本反查列索引而不是硬编码“第 3 列是金额”。页面加了一列硬编码版本整体错位表头反查版本只需要在字段映射里加一条。4. 数据落盘Excel 导出与数据库入库4.1 POI 写 xlsx单元格类型与日期格式解析出来的ListListString直接往 Excel 里怼代码短但导出的文件会有几个讨人嫌的毛病数字存成文本单元格左上角一个绿三角求和结果是 0长数字订单号、身份证超过 15 位被科学计数法吃掉精度日期是一串字符串排序按字典序排。public static void writeXlsx(ListListString grid, String outPath) throws IOException { try (XSSFWorkbook wb new XSSFWorkbook(); OutputStream out new FileOutputStream(outPath)) { Sheet sheet wb.createSheet(data); for (int r 0; r grid.size(); r) { Row row sheet.createRow(r); ListString cols grid.get(r); for (int c 0; c cols.size(); c) { Cell cell row.createCell(c); String v cols.get(c); // 纯数字且长度小于 15 位才写数值型超长编号必须留文本 if (v.matches(^-?\\d(\\.\\d)?$) v.length() 15) { cell.setCellValue(Double.parseDouble(v)); } else { cell.setCellValue(v); } } } for (int c 0; c 20; c) { sheet.setColumnWidth(c, 4000); // 宽度按字符数算4000 约等于 20 字符 } wb.write(out); // 写到流里别用 File 直接构造流由 try-with-resources 关 } }XSSFWorkbook对应 xlsxHSSFWorkbook对应老版 xls。行数超过几万时用SXSSFWorkbook它把已完成的行刷到磁盘临时文件内存占用能压下来一大截。setColumnWidth的单位是 1/256 个字符宽度写 4000 是个经验值中文列可能要更大。真正把数据从字符串变成结构化对象的一步在 Main 里拿表头行建一个MapString, Integer的列索引再按字段名取值。这样字段映射集中在一处页面列顺序变了不用改解析逻辑。4.2 JDBC 批量插入批处理与唯一索引去重需要天天跑的场景Excel 只是中间产物最终还是要进库。逐条executeUpdate一天几百行还行上万行就是几分钟起步而且中途失败会留下半截数据。String sql insert into t_data(day, name, amount, src_url) values(?,?,?,?) on duplicate key update amount values(amount); try (Connection conn DriverManager.getConnection(url, user, pwd); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); // 关自动提交按批 commit int batch 0; for (RowData d : list) { ps.setString(1, d.getDay()); ps.setString(2, d.getName()); ps.setBigDecimal(3, d.getAmount()); // 金额用 BigDecimal别用 double ps.setString(4, d.getUrl()); ps.addBatch(); if (batch % 500 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); }去重靠表上的唯一索引不靠代码里查一遍再插。unique key (day, name)建好SQL 用on duplicate key update或insert ignore重复抓同一天的数据不会堆出重复行。批量大小 500 是折中值太大容易撞max_allowed_packet太小收益不明显。事务按批提交而不是全程一个事务是为了失败时已经成功的批次不回滚重跑时靠唯一索引跳过。金额字段用BigDecimal这个不是洁癖。double在累加时会出现0.1 0.2 0.30000000000000004这种事导出的对账单差几分钱财务会来找你。4.3 分页抓取与增量判断列表页一般分页翻页参数可能是?page2也可能是 POST 表单里的pageNo。抓取循环里两件事必须做判断到底了没有以及别把对方站点当压测目标。for (int page 1; page maxPage; page) { String url String.format(%s?page%dsize50, base, page); ListListString grid TableParser.parseTable( Jsoup.parse(HttpFetcher.fetch(url), base)); if (grid.size() 1) break; // 只剩表头说明到底了 handle(grid.subList(1, grid.size())); // 跳过表头写库/写文件 Thread.sleep(800 new Random().nextInt(700)); // 随机间隔别定频 }结束条件不能只靠page maxPage。很多站点的总页数是 JS 算的服务端 HTML 里根本没有这个值翻到第 50 页返回的仍是一个空表格。用“本次抓到 0 条数据”作为终止条件更可靠。随机 sleep 是礼貌也是自保固定间隔的请求序列在访问日志里非常扎眼。增量判断最简单的做法是记一个上次抓到的最大日期或最大 id本次遇到比它旧的数据就停止翻页。这比“每次都全量抓一遍再靠唯一索引去重”省太多请求。文件里存一行时间戳就能实现不必上 Redis。5. 在 MyEclipse 里跑通与踩坑排查5.1 导入工程到跑出第一行数据的五个动作拿到工程包之后按顺序做完下面这几步基本就能看到控制台打印出解析结果File → Import → General → Existing Projects into Workspace选到含.project的工程根目录。如果导入后项目图标上没有 Java 标识说明.project的 natures 不对右键 →Configure → Convert to Java Project补一下。右键工程 →Properties → Java Build Path → Libraries确认 lib 下四个 jar 都在列表里。MyEclipse 导入外部工程后这一步经常是空的jar 文件在磁盘上躺着classpath 里没有编译直接报package org.jsoup does not exist。Properties → Java Compiler把 Compiler compliance level 设成 1.8同时勾上Use default compliance settings让 JRE 和编译器版本一致。Window → Preferences → General → Workspace → Text file encoding改成 UTF-8。已经乱码的源文件要重新保存一次否则注释和字符串里的中文会一直是问号。打开Main.javaRun As → Java Application。启动参数里加上-Dfile.encodingUTF-8Run Configurations → Arguments → VM arguments防止控制台输出在 GBK 环境下乱码。跑之前把 Main 里的目标 URL 换成自己的地址输出路径output/确认存在——FileOutputStream不会自动创建父目录路径不存在时报的是FileNotFoundException看着像权限问题其实是目录没建。5.2 六条踩坑记录现象、原因、解决现象一控制台打出来的中文全是问号或方块。原因通常是三处编码不一致源文件编码、JVM 默认编码、HTTP 响应解码编码。MyEclipse 工作区是 GBK代码里用 UTF-8 解字节流双重转换就乱了。 解决工作区编码统一 UTF-8VM 参数加-Dfile.encodingUTF-8fetch()里显式指定new String(bytes, UTF-8)三步对齐基本不会再犯。现象二浏览器打开正常程序抓回来是登录页或者 403。原因是请求特征被识别或者站点需要会话。默认 UA、缺少 Cookie、缺少 Referer 都会触发。 解决先补 UA 和 Referer仍然不行就把浏览器里登录后的 Cookie 整串粘到请求头里试一次。能通就说明是会话问题后续用CookieManager或手动维护 Cookie 串。这一步是排查手段不建议在别人的站点上长期高频跑。现象三select 拿到 table 了但 td 内容是空的。两种可能一是数据由 JS 异步加载服务端返回的 HTML 里本来就没有这些行二是内容在 iframe 里。 解决先用curl或者浏览器的“查看源代码”不是审查元素确认服务端 HTML 里到底有没有数据。没有就说明这条路走不通需要找对应的数据接口。源码里有、程序里没有多半是选择器命中了错误的 table 或者嵌套结构把target.html()打出来看一小段就能确认。现象四导出的 Excel 每行都错位一列。合并单元格导致的 td 数量不一致前面说的 rowspan 问题。 解决把 3.3 的 pending 逻辑补上补完再核对第一行和最后一行看行宽是否一致。现象五java.lang.NoClassDefFoundError: org/apache/poi/xssf/usermodel/XSSFWorkbook。编译期没报错运行期才炸说明 classpath 缺poi-ooxml或其传递依赖。 解决poi和poi-ooxml两个包必须同时存在。还报的话检查是不是 MyEclipse 的Web App Libraries里有同名旧版本 jar 覆盖了新版本删掉旧的。现象六抓了三千行程序卡住不动也不报错。大概率是readTimeout没设或者对方限速后连接挂着。也可能是executeBatch攒了太大一批没提交。 解决超时必须显式设置控制台在循环里打印页码和已处理行数卡在哪一页一眼能看出来。加日志的成本远低于事后猜。6. 二次开发从“跑一次的例子”到“能天天跑的采集器”例子代码最大的问题不是功能少是所有参数都硬编码在 Main 里。目标 URL 写死、输出路径写死、字段映射写死换一个页面就得改代码重新编译。二次开发第一步我一般做配置外置URL、翻页参数、表格选择器、字段映射、输出路径全部挪到config.propertiesMain 只负责读配置和串流程。target.urlhttp://example.com/list target.tableSelectortable#dataList target.rowSelector tbody tr, tr paging.parampage paging.maxPage50 output.diroutput/读的时候用一个Config单例包一层缺失项给默认值。改完之后换页面只改 properties不碰 Java 代码这一点在多页面复用时价值最大。第二步是加重试和断点。网络请求偶发超时太正常一次失败就中断整轮抓取很浪费。简单做法是在fetch()外面套一个三次重试每次间隔递增更省事的做法是把“已完成的页码”写到output/.progress文件里启动时读出来从下一页继续。这套东西不需要引入任何框架二十行代码搞定。第三步是验证改造没把老逻辑改坏。我的习惯是留一份上次跑出来的 xlsx 当基准改完代码后重跑同一批 URL用下面这段比对行数和首尾行内容// 简易回归行数一致 首尾行内容一致认为解析逻辑没被改坏 ListListString oldGrid readBack(baselineFile); ListListString newGrid TableParser.parseTable(target); if (oldGrid.size() ! newGrid.size()) { System.out.println(行数变了: oldGrid.size() - newGrid.size()); } if (!oldGrid.get(0).equals(newGrid.get(0))) { System.out.println(表头变了选择器可能要跟着调); }行数一致但内容不同也要看一眼可能是对方页面真的更新了也可能是字段映射顺序错了。这个比对脚本挂在改完代码之后跑一遍两分钟能挡掉大部分低级错误。还有个容易被忽略的点日志。System.out 打一堆只适合本地调试真跑起来至少要落到文件按天切带时间戳和页码。出问题时翻日志比重新跑一遍快得多。从那以后我每次动解析逻辑前都会先存一份当前输出当基准改完必跑一遍比对。这个习惯救过我好几次——有一次只是把td换成tr.children()忘了 th 的存在表头整行没了就是因为比对的表头不一致才发现。希望帮到你。本文还有配套的精品资源点击获取