简介:这份资料面向Java初学者和需要采集网页数据的开发者,演示在MyEclipse环境下抓取网页表格并整理为结构化数据的完整思路,涉及HttpClient、Jsoup等常用库的组合使用,适合作为数据挖掘前的数据获取练习或课程设计参考。压缩包共24个文件,约1.15MB,其中7个jar为jsoup、httpclient、httpcore、cpdetector等依赖库;7个java与对应的7个class构成可直接运行、调试的源码与编译产物;另有prefs、project、classpath等Eclipse工程配置,导入IDE即可还原项目结构。已有710人学习下载。读者可参照源码理解连接网页、定位表格元素、遍历行列、异常处理与结果存储各环节的具体写法,并借此改造成自己的采集小工具,对入门爬虫与数据分析练习较为实用。
1. 一份成绩表引出的需求:这个 Java 网页表格抓取例子能解决什么
给一个网址,程序把网页里那张 table 抓下来存成 CSV——这是很多人对 Java 爬取网页表格的第一印象,也是最落地的场景。前阵子帮人处理一件事:某高校教务系统里的成绩只能一页页翻,一学期十几门课、几百号人,手工复制粘贴到 Excel 要耗掉整个下午。他想要的就是一个能在 MyEclipse 里直接跑起来的 Java 爬取网页表格的例子:HttpClient 发请求拿 HTML,Jsoup 解析 table 节点,逐行逐列抽出来写文件。
它不适合当通用爬虫框架,但对「结构固定的表格 + 周期性导出」这类活,够用、好改、门槛低。后面从选型、目录结构、核心代码一路拆到怎么在多页采集里做二次改造。
2. 技术选型与工程结构:HttpClient 加 Jsoup 在 MyEclipse 里怎么摆
2.1 为什么这套老组合至今还够用
先说结论:抓表格这件事,90% 的成本不在「发请求」,而在「从 HTML 里稳定地取出那几列」。所以选型的第一优先级是解析层的可维护性,请求层只要能控制超时、Cookie 和连接复用就行。
正则表达式是最先被排除的。写一个<tr>.*?</tr>看着简单,真跑起来一遇到<td>里嵌套<span>、属性顺序变化、换行缩进不同,立即全线崩塌;更别说 colspan 合并单元格,正则基本没法处理。血泪经验是:用正则解析 HTML 的人,最后都会回来重写一遍。
Jsoup 的定位是「HTML 界的 jQuery」,支持 CSS 选择器,doc.select("table#scoreTable tbody tr")一行就能拿到行集合。它内置了容错解析器,标签没闭合、属性没引号这些脏 HTML 都能修成合法 DOM,正好对付教务系统、后台管理页这类常年不规范的页面。它的parse还支持传null让解析器自己从<meta charset>里嗅探编码,这一点在处理 GBK 页面的场景里非常实用。
请求层有两个选择。Jsoup.connect(url).get()看着最省事,但它把连接和解析绑在一起,超时只能设timeout(),连接池、Cookie 复用、重试策略都不好控。HttpClient 4.x 则把这些都暴露出来:RequestConfig管超时与重定向,PoolingHttpClientConnectionManager管连接复用,CookieStore管会话。翻页采集十几个页面时,复用同一个 client 能省掉大量 TCP 握手,也不会因为频繁建连被目标站点的限流策略盯上。
| 方案 | 上手成本 | 编码处理 | 连接控制 | 适合场景 |
|---|---|---|---|---|
| Jsoup.connect 单干 | 最低 | 自动嗅探 | 弱 | 单页、临时验证 |
| HttpClient + Jsoup | 中等 | 手动交字节给 Jsoup | 强 | 多页、需要登录、要稳定跑 |
| HttpClient + 正则 | 看着低 | 手动 | 强 | 不推荐,维护成本高 |
实际项目里还有一种情况:页面由 JS 动态渲染,原始 HTML 里只有<div id="app"></div>。这时候任何解析器都救不了你,只能去找页面背后的 XHR 接口,直接请求 JSON。判断方法很简单——浏览器里右键「查看网页源代码」,如果搜不到表头文字,就是动态渲染,赶紧换思路。
2.2 目录结构与 jar 包清单
MyEclipse 时代的工程基本不是 Maven 项目,依赖 jar 全靠手工放lib目录再 Add JARs。结构建议按「请求层 / 解析层 / 输出层 / 模型」四块切开,后面改成多站点采集时不用动主干。
TableCrawler/ ├── src/ │ └── com/demo/crawler/ │ ├── Main.java # 入口,串起请求→解析→写盘 │ ├── TableFetcher.java # 请求层:连接池、超时、Header │ ├── TableParser.java # 解析层:Jsoup 选择器、行列抽取 │ ├── CsvWriter.java # 输出层:BOM、转义、追加写 │ └── model/ │ └── ScoreRow.java # 数据模型 ├── lib/ │ ├── httpclient-4.5.13.jar │ ├── httpcore-4.4.13.jar │ ├── commons-logging-1.2.jar │ ├── commons-codec-1.11.jar │ └── jsoup-1.11.3.jar └── output/ # 运行后生成的 CSV 落这里关于 jar 清单,几个容易漏的点值得单独说。httpclient不是孤立的,它编译期依赖httpcore,运行期还需要commons-logging(HttpClient 用 JCL 打日志,缺了会在第一次执行时抛NoClassDefFoundError: org/apache/commons/logging/LogFactory),commons-codec则是处理 NTLM 认证和 Base64 时才需要,但加上没坏处。版本上,HttpClient 4.5.x 配 httpcore 4.4.x 是官方对得上的组合,别把 4.3 的 httpclient 和 4.4 的 httpcore 混着用,那种NoSuchMethodError排查起来非常磨人。
Jsoup 只出一个 jar,没有传递依赖,版本 1.11.x 之后selectFirst这个便捷方法就有了,写起来更顺手。
注意:Add External JARs 引用的是本机绝对路径,工程拷给别人就跑不起来。一律先把 jar 复制进
lib目录,再用 Add JARs 引用工程内的相对路径。
3. 抓取链路拆解:从 URL 到一行表格数据的完整代码
3.1 请求层:超时、Header 和字节流的处理
请求层最核心的一个决定是:不要在请求层决定编码。很多示例代码在这里写死EntityUtils.toString(entity, "UTF-8"),一遇到 GBK 页面就是满屏问号。正确做法是拿到原始字节数组,把编码判断交给 Jsoup 的嗅探器。
public class TableFetcher { private final CloseableHttpClient client; public TableFetcher() { // 连接池在整个采集任务里复用,别每抓一页 new 一个 this.client = HttpClients.custom() .setMaxConnTotal(20) .setMaxConnPerRoute(10) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(5000) // 建连超时 .setSocketTimeout(10000) // 读数据超时 .setConnectionRequestTimeout(2000) // 从池里取连接的超时 .setRedirectsEnabled(true) .build()) .build(); } public byte[] getBytes(String url) throws IOException { HttpGet get = new HttpGet(url); get.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0 Safari/537.36"); get.setHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); get.setHeader("Accept-Language", "zh-CN,zh;q=0.9"); // 部分站点会校验 Referer,缺了直接返回空表或跳登录页 get.setHeader("Referer", baseUrlOf(url)); try (CloseableHttpResponse resp = client.execute(get)) { int status = resp.getStatusLine().getStatusCode(); if (status >= 400) { throw new IOException("HTTP " + status + " -> " + url); } return EntityUtils.toByteArray(resp.getEntity()); } } public void close() throws IOException { client.close(); } }这段的逻辑是「建池 → 建请求 → 设头 → 执行 → 拿字节」,逐项说明几个参数为什么这么定。setConnectTimeout(5000)控制 TCP 握手,DNS 解析慢或者目标站点挂掉时就靠它及时失败;setSocketTimeout(10000)控制两次数据包之间的最大间隔,表格页通常几十 KB,10 秒足够,设太大只会让卡死时白等。setConnectionRequestTimeout(2000)只在连接池满时生效,翻页并发采集时有意义。
User-Agent 的作用不是「伪装」,而是避开一些后台系统对空 UA 的直接拒绝。Referer 同理——不少站点的表格列表接口会校验来源页,直接从地址栏访问 URL 反而拿不到数据。EntityUtils.toByteArray拿到的字节流,交给下游解析层,编码问题在这一层不做任何假设。
3.2 解析层:用 CSS 选择器定位 table 并逐行遍历
解析层的第一步是定位表格。理想情况是table上有 id 或 class,比如table#scoreTable、table.data-list。没 id 的话退而求其次:遍历所有 table,按表头文字匹配。
public List<ScoreRow> parse(byte[] html, String baseUri) throws IOException { // charsetName 传 null,Jsoup 会读 <meta charset> 或 http-equiv 自动嗅探 Document doc = Jsoup.parse(new ByteArrayInputStream(html), null, baseUri); Element table = doc.selectFirst("table#scoreTable"); if (table == null) { // 兜底:找第一个同时含「姓名」「成绩」表头的表格 table = doc.select("table").stream() .filter(t -> t.text().contains("姓名") && t.text().contains("成绩")) .findFirst() .orElse(null); } if (table == null) { throw new IllegalStateException("页面里没找到成绩表,先确认是不是 JS 动态渲染"); } List<ScoreRow> list = new ArrayList<>(); Elements rows = table.select("tr"); for (int i = 1; i < rows.size(); i++) { // i=0 是表头,跳过 Elements cells = rows.get(i).children(); // 只取直接子元素,避开嵌套表格 if (cells.size() < 4) { continue; // 空行、分隔行、合计行 } String name = cells.get(0).text().trim(); String course = cells.get(1).text().trim(); String score = cells.get(2).text().trim(); if (name.isEmpty() || !score.matches("\\d+(\\.\\d+)?")) { continue; // 过滤掉「合计」「平均分」这类行 } list.add(new ScoreRow(name, course, Double.parseDouble(score), now())); } return list; }关键点在rows.get(i).children()而不是select("td")。children()只返回直接子元素,能自动剪掉单元格内部再嵌一层 table 的情况——教务系统里「课程名 + 子表格」的排版很常见,用select("td")会把内层 td 一起捞出来,列就错位了。
第二个关键点是text().trim()。Jsoup 的text()会把元素内所有文本节点拼起来并压缩空白, 也会被还原成一个普通空格,所以拿到就是干净的一行字,不用再做 HTML 实体反转义。
charsetName传null是这套代码里最有价值的一行。Jsoup 的嗅探顺序是:HTTP 响应头(如果传了 Connection)→<meta charset>→<meta http-equiv="Content-Type">→ UTF-8 兜底。老教务系统多数是 GBK,<meta http-equiv="Content-Type" content="text/html; charset=gb2312">这类声明能正确命中,省掉手写编码判断表。
baseUri参数用于把页面里的相对链接转成绝对链接,做翻页、抓详情页时会用到;纯表格抽取传不传都行,但养成传的习惯没坏处。
注意:如果表格行使用了
rowspan合并,比如同一个学生多门课只写一次姓名,那么后续行的cells.size()会少一格,列索引整体前移。这种情况得在循环外维护一个「当前姓名」变量,遇到少列就沿用上一行的值。
3.3 输出层:CSV 落盘、BOM 与字段转义
输出最容易翻车的地方是 Excel 打开乱码。UTF-8 编码的 CSV 双击用 Excel 打开,中文会显示成乱码,原因是 Excel 在没有 BOM 的情况下默认按本地编码(GBK)解读。解决办法是在文件开头写一个\uFEFF。
public class CsvWriter implements Closeable { private final Writer writer; public CsvWriter(File file) throws IOException { // 追加写,便于一天多次采集累积到同一个文件 this.writer = new OutputStreamWriter(new FileOutputStream(file, true), "UTF-8"); if (file.length() == 0) { writer.write('\uFEFF'); // BOM,Excel 才认 UTF-8 writer.write("姓名,课程,成绩,抓取时间\r\n"); } } public void write(ScoreRow row) throws IOException { writer.write(String.join(",", esc(row.getName()), esc(row.getCourse()), String.valueOf(row.getScore()), row.getFetchTime())); writer.write("\r\n"); writer.flush(); // 边采边落,进程被杀也不丢已抓数据 } private String esc(String v) { if (v == null) return ""; if (v.contains(",") || v.contains("\"") || v.contains("\n")) { return "\"" + v.replace("\"", "\"\"") + "\""; } return v; } public void close() throws IOException { writer.close(); } }逐项说明:new FileOutputStream(file, true)的第二个参数是追加模式,配合文件长度判断,第一次写才补表头,后续追加不会重复。\r\n是 CSV 的规范换行,某些 Windows 工具(尤其老版本 Excel)只认 CRLF,用\n会出现整张表挤在一行的情况。
esc方法处理的是 CSV 的经典问题:字段里本身含逗号(比如课程名「数据结构,实验」)会导致列错位,含双引号需要转义成两个双引号,含换行会把一行拆成两行。规则是——只要命中这三种字符,整个字段用双引号包起来,内部的双引号替换成""。
writer.flush()每行调用一次看着有点浪费,但在采集场景下是值得的:几百行数据写到一半程序崩了,已经 flush 的部分还在,不用从头再跑。
4. 运行与调试:在 MyEclipse 里把 main 方法跑通并核对数据
4.1 导入工程与 Build Path 配置
拿到工程目录先看根下有没有.project和.classpath。有的话直接 File → Import → General → Existing Projects into Workspace,选中目录即可。没有的话就 File → New → Java Project 建个空工程,再 File → Import → General → File System,把src和lib整个拷进去,然后在工程上右键 Refresh。
配 Build Path 的顺序是:工程右键 → Properties → Java Build Path → Libraries → Add JARs,选中工程内lib目录下全部 5 个 jar。这一步必须用 Add JARs 而不是 Add External JARs,前者记录的是工程相对路径,工程整个拷走还能用;后者记的是本机绝对路径,换个机器就一堆红叉。
然后是编码。MyEclipse 默认工作空间编码经常是 GBK,源码里写了中文注释和中文表头,编译出来可能变乱码。改 Window → Preferences → General → Workspace → Text file encoding → Other → UTF-8,再在工程上右键 Properties → Resource,把 Inherit from container 取消,同样选 UTF-8。改完记得 Clean 一次工程,让所有 class 重新编译。
Java 版本方面,如果用的是 jsoup 1.11.x,JDK 1.8 最稳;工程右键 Properties → Java Compiler,把 Compiler compliance level 设成 1.8,同时确认 Project Facets 里的 Java 版本一致。两个地方版本不一致会出现「项目报错但代码看不出问题」的玄学现象,本质是编译器和运行时对不上。
4.2 断点调试与数据核对
跑之前先确认 Main 里的相对路径。new File("output/result.csv")这种写法,相对的是 Run Configuration 里设置的 Working directory,默认是工程根目录,但有时候会跟着启动配置跑到别处去。稳妥做法是 Run Configurations → Arguments → Working directory 里显式设成${workspace_loc:TableCrawler},避免文件写到不知道哪个角落。
调试的核心手段是断点加表达式求值。在Document doc = Jsoup.parse(...)这一行打断点,F6 单步过去,然后在 Variables 视图里展开doc,或者用 Display 视图(Ctrl+Shift+D)直接敲表达式:
doc.select("table").size() // 页面里一共有几张表 doc.select("table#scoreTable tr").size() // 目标表的行数 doc.select("table#scoreTable tr").first().text() // 表头文字,验证选对了表 doc.select("table#scoreTable tr").last().text() // 最后一行,看有没有合计行混进来这几个值跟浏览器里 F12 看到的结果对一遍,基本就能定位问题在哪一层。行数一致但内容错位,问题在列索引;行数是 0 但页面明明有表,问题在选择器或者动态渲染;行数对但中文是问号,问题在编码嗅探。
如果 Console 里输出中文乱码,别急着怀疑代码,先看 Run Configurations → Common → Encoding,设成 UTF-8。MyEclipse 的 Console 编码跟工程编码是两套配置,很多人在这里被绕进去半天。
还有一个实用的核对方法:先在浏览器里把目标页另存为 HTML 文件,用解析器直接读本地文件跑一遍,排除网络因素。本地能跑通、线上跑不通,那问题一定在请求层(Header、Cookie、跳转),范围一下就收窄了。
5. 避坑与排查:表格抓取最容易翻车的五个现场
5.1 编码与选择器:三条静默失败的坑
坑一:CSV 里中文全是问号,程序不报错。
现象是控制台打印正常,但打开 CSV 一看,姓名列全是???。原因通常是请求层提前把字节转成了字符串,EntityUtils.toString(entity, "UTF-8")遇到 GBK 页面,非法字节被替换成问号,这一步是有损的,后面再怎么处理都救不回来。解决办法是请求层只返回byte[],解析层用Jsoup.parse(new ByteArrayInputStream(bytes), null, baseUri)让嗅探器自己判断。如果页面<meta>里压根没写编码,那就先抓一次原始字节,用new String(bytes, "GBK")和"UTF-8"各试一遍,看哪个不像乱码,然后写死。
坑二:doc.select("table#xxx")返回空,但页面里明明有这张表。
先做一件事:打印doc.html().length(),如果只有几百个字符,说明拿到的不是真正的内容页。常见原因是登录跳转(返回了登录页)或者 JS 动态渲染(返回了只有<div id="app">的空壳)。判断方法是在浏览器里右键「查看网页源代码」,搜索表头文字,搜不到就是动态渲染,只能去 Network 面板找 XHR 接口直接请求 JSON。如果长度正常但选择器还是空,那就是 id 或 class 写错了,用doc.select("table").size()先确认页面有几张表,再逐个打印table.attr("id")和table.className()对。
坑三:抓下来的行数比页面上少几行。
两种可能。一种是分页——页面只渲染当前页的 20 行,剩下的靠 URL 参数翻页,得在 URL 上做循环。另一种是tbody的问题:浏览器会自动给<table>补一个<tbody>,但有些老页面源码里<tr>直接挂在<table>下,Jsoup 的解析规则和浏览器不完全一致,table > tbody > tr这种写法可能命中不到。稳妥的写法是table.select("tr"),让 Jsoup 自己找所有后代 tr,然后再用children()过滤直接子元素。
还有一种更隐蔽的情况:表格里混了rowspan合并单元格,导致某些行的cells.size()少一格。程序不会报错,只是从某一行开始所有列都往前错一位。排查时打印前 5 行和后 5 行的cells.size(),数字突变的地方就是合并单元格出现的位置。
5.2 请求与运行环境:两条要命的坑
坑四:本地跑得好好的,部署到另一台机器上全是超时或 403。
现象是同一份代码,开发机上能抓到数据,换台机器就SocketTimeoutException或者返回 403。原因通常有两类。一类是网络出口不同,目标站点对某些网段做了访问限制,这种情况看响应体就能判断——403 一般会带一段提示页。另一类是 Header 不全,开发机上可能因为浏览器缓存或者 Cookie 带上了会话,换机器就没了。解决办法是把 Cookie 显式管理起来:用BasicCookieStore配合HttpClientContext,登录一次拿到 Cookie 后,后续所有请求都复用同一个 context;同时在请求之间加Thread.sleep(500)到1000毫秒的间隔,别让目标站点的频率限制策略把你当成异常流量。
坑五:MyEclipse 里编译通过,一运行就NoClassDefFoundError。
现象是代码里 import 全绿,Ctrl+S 也不报错,但 Run As → Java Application 之后抛异常,提示找不到某个类。原因是 Build Path 里加的 jar 只影响编译期,运行时的 classpath 是另一套。检查顺序是:Project → Properties → Java Build Path → Order and Export,确认所有 jar 都勾上;然后在 Run Configurations → Classpath 里看 User Entries 是否包含工程本身。还有一种情况是版本冲突,比如lib下同时存在 httpclient 4.3 和 4.5,编译器挑了其中一个,运行时加载了另一个,报NoSuchMethodError。解决办法很简单——lib目录里同一个库只留一个版本,删干净再 Clean 一次工程。
6. 二次开发:把写死的单表抓取改成可配置的多页采集
原始例子最典型的问题是所有东西都写死在代码里:URL 是常量、选择器是字符串、只抓一页。真要拿去做日常采集,第一件事是把这些抽成配置。
我会在工程根目录放一个crawler.properties,用Properties加载,注意用InputStreamReader指定 UTF-8,否则中文选择器会乱码:
Properties props = new Properties(); try (Reader r = new InputStreamReader( new FileInputStream("crawler.properties"), "UTF-8")) { props.load(r); } String listUrl = props.getProperty("list.url"); // 带 {page} 占位符 String rowSel = props.getProperty("table.row.selector", "table#scoreTable tr"); int maxPage = Integer.parseInt(props.getProperty("page.max", "20")); long interval = Long.parseLong(props.getProperty("request.interval", "800"));翻页的逻辑就是替换占位符加循环,注意三点:一是每页之间必须 sleep,二是遇到空结果要break而不是硬跑满 maxPage,三是写盘用追加模式,翻页过程中断了重跑也不会产生重复表头。
try (TableFetcher fetcher = new TableFetcher(); CsvWriter writer = new CsvWriter(new File("output/result.csv"))) { for (int page = 1; page <= maxPage; page++) { String url = listUrl.replace("{page}", String.valueOf(page)); byte[] html = fetcher.getBytes(url); List<ScoreRow> rows = parser.parse(html, url); if (rows.isEmpty()) { break; // 翻到底了 } for (ScoreRow row : rows) { writer.write(row); } Thread.sleep(interval); } }再往上一层可以做去重。最简单的办法是用「姓名 + 课程」拼一个 key 塞进HashSet,写盘前判断一下,避免同一页被重复抓两次导致 CSV 里出现重复行。数据量大了就换成 SQLite,建个唯一索引,靠数据库自己兜底。
| 改造点 | 现状 | 改法 | 验证方式 |
|---|---|---|---|
| URL 与选择器 | 硬编码在类里 | 抽到 properties | 改配置不重编译即可生效 |
| 单页 → 多页 | 只抓第一页 | {page}占位 + 循环 | 打印每页行数,观察是否递减到 0 |
| 重复数据 | 无处理 | HashSet 或唯一索引 | 连续跑两次比对行数 |
| 定时执行 | 手工点运行 | 挪到独立线程 + Timer | 观察 output 文件是否按周期增长 |
改造时最容易忽略的是「解析层和请求层的解耦」。TableParser.parse只接收byte[]和baseUri,不关心数据从哪来——这个签名让后面加本地文件回放、加缓存、加单元测试都变得很自然。反过来,如果解析方法里直接调Jsoup.connect(url),那想离线复现一次 bug 都得联网。
从那以后我每次接这类表格抓取,都先把「响应字节数 → 页面编码 → 选择器命中行数」这三个中间结果打印出来,再动手写解析。宁可多打三行日志,也不愿意对着一个只有表头的 CSV 猜半小时。希望帮到你。
本文还有配套的精品资源,点击获取