2026/10/23 20:11:46

Java网页表格抓取实战:HttpClient+Jsoup导出CSV

Java网页表格抓取实战:HttpClient+Jsoup导出CSV 简介这份资料面向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 发请求拿 HTMLJsoup 解析 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 idapp/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-loggingHttpClient 用 JCL 打日志缺了会在第一次执行时抛NoClassDefFoundError: org/apache/commons/logging/LogFactorycommons-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/xhtmlxml,application/xml;q0.9,*/*;q0.8); get.setHeader(Accept-Language, zh-CN,zh;q0.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)控制两次数据包之间的最大间隔表格页通常几十 KB10 秒足够设太大只会让卡死时白等。setConnectionRequestTimeout(2000)只在连接池满时生效翻页并发采集时有意义。User-Agent 的作用不是「伪装」而是避开一些后台系统对空 UA 的直接拒绝。Referer 同理——不少站点的表格列表接口会校验来源页直接从地址栏访问 URL 反而拿不到数据。EntityUtils.toByteArray拿到的字节流交给下游解析层编码问题在这一层不做任何假设。3.2 解析层用 CSS 选择器定位 table 并逐行遍历解析层的第一步是定位表格。理想情况是table上有 id 或 class比如table#scoreTable、table.data-list。没 id 的话退而求其次遍历所有 table按表头文字匹配。public ListScoreRow parse(byte[] html, String baseUri) throws IOException { // charsetName 传 nullJsoup 会读 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 动态渲染); } ListScoreRow list new ArrayList(); Elements rows table.select(tr); for (int i 1; i rows.size(); i) { // i0 是表头跳过 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()会把元素内所有文本节点拼起来并压缩空白nbsp;也会被还原成一个普通空格所以拿到就是干净的一行字不用再做 HTML 实体反转义。charsetName传null是这套代码里最有价值的一行。Jsoup 的嗅探顺序是HTTP 响应头如果传了 Connection→meta charset→meta http-equivContent-Type→ UTF-8 兜底。老教务系统多数是 GBKmeta http-equivContent-Type contenttext/html; charsetgb2312这类声明能正确命中省掉手写编码判断表。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); // BOMExcel 才认 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.xJDK 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 视图CtrlShiftD直接敲表达式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 idapp的空壳。判断方法是在浏览器里右键「查看网页源代码」搜索表头文字搜不到就是动态渲染只能去 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 全绿CtrlS 也不报错但 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); ListScoreRow 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 猜半小时。希望帮到你。本文还有配套的精品资源点击获取