简介这份Java网页表格抓取示例面向具备一定Java基础、希望入门数据采集的开发者围绕在MyEclipse中解析HTML表格并提取结构化数据展开涉及Jsoup连接页面、按选择器定位表格、遍历行列与单元格、抽取文本以及异常处理等要点也可作为数据分析前的采集练习参考。压缩包共24个文件约1.15MB以jar依赖库、java源码、class编译文件和MyEclipse工程配置为主依赖部分涵盖Jsoup与配套HTTP组件源码、编译结果和工程配置相互配合便于读者对照理解项目组织、类结构与运行方式。已有710人学习说明该示例在入门爬虫练习中具有较好的参考价值。读者可从中观察静态网页表格抓取的完整实现过程并在此基础上练习字段抽取、结果存储与网络异常处理积累可复用的采集思路。1. 后台只给了个网页数据还得自己搬上个月接了个活合作方的数据只在一个后台页面里展示没有导出按钮也没有接口文档几十页表格只能靠人眼复制。翻了一圈最省事的办法是用 Java 写个小程序把网页里的 table 直接分析成 Excel。这就是“java 爬取网页表格”最典型的实际场景不讲通用爬虫框架那一套专注解决一件事——网页表格变成结构化数据。这份例子跑在 MyEclipse 里纯 Java SE 工程不需要 Tomcat一个 main 方法就能从头到尾跑通。适合两类人手上有只能看不能导出的网页表格、又不想额外装 Python 环境的 Java 后端以及刚接触 HTML 解析、想找一个完整链路练手的人。代码量不大但编码、分页、合并单元格这些容易出问题的地方一个不落啃完基本能自己改。2. 工程骨架在 MyEclipse 里把例子跑起来2.1 目录结构与四个包的职责这份例子没有做成分层很重的企业工程包结构按“抓、解、出、公”四段切一个包一件事出了问题一眼能定位到哪一层。包路径职责关键类com.demo.crawler.fetch发请求、拿 HTML、处理编码与重试PageFetcher、RetryPolicycom.demo.crawler.parseJsoup 定位表格、表头映射、合并单元格补齐TableParser、CellSpancom.demo.crawler.export写 xlsx / csvExcelWriter、CsvWritercom.demo.crawler.util字符串清洗、URL 拼接、日志TextUtils、UrlUtils目录长这样crawler-table-demo ├── src │ └── com/demo/crawler │ ├── fetch/PageFetcher.java │ ├── parse/TableParser.java │ ├── parse/CellSpan.java │ ├── export/ExcelWriter.java │ ├── util/TextUtils.java │ └── Main.java ├── lib # 手动放 jar 时用 ├── output # 导出的 xlsx 落这儿 └── config.properties把抓取和解析分成两个包不是为了好看。实际改需求时八成改的是选择器和字段映射抓取层几乎不动反过来对方站点换域名、加 Cookie 校验只改 fetch 层。分开以后改一处不会牵连另一处这是我踩过几次“全塞一个类里、改三行崩一片”之后形成的习惯。Main只做编排不写业务逻辑三段调用清清楚楚后面任何一段想换实现都不用动它。2.2 依赖Maven 和手动放 jar 两条路MyEclipse 用户两极分化有人用 Maven有人习惯把 jar 拖进 lib 再 Build Path。两条路都给出来选一条走。Maven 的 pom 核心依赖就三块HTTP 客户端、HTML 解析器、Excel 写出。dependencies !-- HTTP 客户端负责把页面原样拿回来 -- dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.13/version /dependency !-- HTML 解析CSS 选择器定位 table比正则稳得多 -- dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.15.3/version /dependency !-- Excel 写出xlsx 格式 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version /dependency /dependencies参数说明httpclient 4.5.x 是 JDK 7/8 环境兼容性最好的一版jsoup 1.15 之后 API 基本稳定select、selectFirst都能用poi 4.x 比 3.x 少了很多NoSuchMethodError但注意 poi-ooxml 会带入commons-compress、xmlbeans、commons-collections4这几个间接依赖MyEclipse 手动配 jar 时最容易漏。手动配的路子把 httpclient、httpcore、commons-logging、commons-codec、jsoup、poi、poi-ooxml、poi-ooxml-schemas、xmlbeans、commons-compress、commons-collections4 全下下来扔进lib右键工程 Build Path → Configure Build Path → Libraries → Add JARs。少一个就在运行时报NoClassDefFoundError报错类名就是缺的 jar对着补。导入工程时还有个小细节MyEclipse 默认编译级别可能是 1.6 或 1.7代码里用了 try-with-resources 和 lambda 就会报红。右键 Properties → Java Compiler把 Compiler compliance level 调到 1.8同时 Project Facets 里的 Java 版本也改成 1.8两处不一致照样报错。2.3 主流程入口一个 main 串起三段Main的写法刻意保持线性方便新手照着断点单步。public class Main { public static void main(String[] args) throws Exception { // 1. 目标地址分页用 {page} 占位实际替换成页码 String urlTpl http://example-host/data/list?page{page}; int totalPage 5; // 先手动填后面改成自动探测末页 PageFetcher fetcher new PageFetcher(); TableParser parser new TableParser(); ExcelWriter writer new ExcelWriter(); ListString headers null; ListListString allRows new ArrayList(); for (int page 1; page totalPage; page) { String url urlTpl.replace({page}, String.valueOf(page)); String html fetcher.get(url, null); // 第二参数是 Referer可空 TableParser.Result r parser.parse(html, url); if (headers null) headers r.getHeaders(); // 表头只取第一页 allRows.addAll(r.getRows()); System.out.println(第 page 页拿到 r.getRows().size() 行); sleep(1500); // 限速别把对方打挂 } writer.write(headers, allRows, output/result.xlsx); System.out.println(总计 allRows.size() 行已写入 output/result.xlsx); } private static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }逻辑说明外层循环管分页内层三段分别是“拿 HTML”“解表格”“攒数据”最后一次性写出。表头只在第一页取因为后面几页表头结构一样重复取反而容易因为某页渲染差异导致列数不一致。参数说明totalPage这里写死 5 是故意的——例子要能一次跑通自动探测末页放在后面章节讲。sleep(1500)是限速真实项目里我一般设 1 到 3 秒对方站点有风控的话还得随机化。Referer传 null 表示不带有些站点校验来源那时候要把上一页 URL 传进去。跑之前记得在工程根目录建output文件夹POI 的FileOutputStream不会自动创建父目录路径不存在直接抛FileNotFoundException。3. 抓取层HttpClient 把页面完整拿回来3.1 请求封装UA、超时、连接池三件套用URL.openStream()也能拿页面但线上跑两天就会遇到卡死、301 不跳转、服务器直接拒绝。HttpClient 多写二十行省掉后面一堆麻烦。public class PageFetcher { private static final int TIMEOUT_MS 15000; private final CloseableHttpClient client; public PageFetcher() { RequestConfig cfg RequestConfig.custom() .setConnectTimeout(TIMEOUT_MS) // 建连超时 .setSocketTimeout(TIMEOUT_MS) // 读数据超时 .setConnectionRequestTimeout(TIMEOUT_MS) // 从池里拿连接的超时 .setRedirectsEnabled(true) // 跟随 301/302 .build(); this.client HttpClients.custom() .setDefaultRequestConfig(cfg) .setMaxConnTotal(20) .setMaxConnPerRoute(10) .build(); } public String get(String url, String referer) throws IOException { HttpGet req new HttpGet(url); req.setHeader(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36); req.setHeader(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8); req.setHeader(Accept-Language, zh-CN,zh;q0.9); if (referer ! null) req.setHeader(Referer, referer); try (CloseableHttpResponse resp client.execute(req)) { int code resp.getStatusLine().getStatusCode(); if (code ! 200) { throw new IOException(HTTP code - url); } byte[] body EntityUtils.toByteArray(resp.getEntity()); return decode(body, resp); } } }逻辑说明三个超时必须全设。只设 socketTimeout遇到 DNS 卡住或者 TCP 握手无响应照样会永远阻塞。连接池设 20/10 是为了后面并行抓多页留的口子串行跑其实 2 个就够。参数说明UA 不设的话很多站点会返回一个精简版页面或者直接 403这是最常见的 403 来源。Accept-Language影响服务端返回的语言版本想要中文页面就加上。EntityUtils.toByteArray一次性读完页面几百 KB 完全扛得住比流式读省心。3.2 编码Content-Type 和 meta 冲突时听谁的中文乱码是这类项目翻车率最高的一环现象五花八门一会儿“锟斤拷”一会儿满屏问号一会儿又正常。根源是编码判断的顺序错了。正确的优先级是响应头Content-Type里的 charset → HTML 里meta charset→ 默认 UTF-8。private String decode(byte[] body, HttpResponse resp) { String charset null; // 1. 优先看响应头 Header ct resp.getFirstHeader(Content-Type); if (ct ! null) { String v ct.getValue().toLowerCase(); int i v.indexOf(charset); if (i 0) { charset v.substring(i 8).replace(\, ).replace(;, ).trim(); } } // 2. 响应头没写从 HTML 前 2KB 里嗅探 meta if (charset null) { String head new String(body, 0, Math.min(body.length, 2048), StandardCharsets.ISO_8859_1); Matcher m Pattern.compile(charset[\]?([\\w-]), Pattern.CASE_INSENSITIVE).matcher(head); if (m.find()) charset m.group(1); } // 3. 兜底 if (charset null) charset UTF-8; // GB2312 是 GBK 的子集统一升级避免生僻字丢字 if (gb2312.equalsIgnoreCase(charset)) charset GBK; try { return new String(body, charset); } catch (UnsupportedEncodingException e) { return new String(body, StandardCharsets.UTF_8); } }逻辑说明关键在于先拿字节数组、后决定编码。很多人图省事用EntityUtils.toString(entity)那是按响应头里的 charset 解的响应头没写就默认 ISO-8859-1中文必然全乱而且乱完就救不回来了。参数说明嗅探那一步必须用 ISO-8859-1 解码——它是单字节映射不会破坏原始字节只是把字节“看”成字符用来找 charset 关键字足够了。gb2312升级成GBK是个血泪经验某些站点的表格里带“镕”“堃”这类字GB2312 解出来就是问号GBK 能覆盖。3.3 分页从写死页码到自动探测末页例子里的totalPage 5只是让流程先跑通真用起来页数会变。两种探测方式看页面结构选。第一种页面底部有“共 N 页”。直接正则从 HTML 里抠private int detectTotalPage(String firstPageHtml) { Matcher m Pattern.compile(共\\s*(\\d)\\s*页).matcher(firstPageHtml); if (m.find()) return Integer.parseInt(m.group(1)); // 兜底数下一页链接没有就当只有一页 Document doc Jsoup.parse(firstPageHtml); Element next doc.selectFirst(a.next, a[relnext]); return next null ? 1 : -1; // -1 表示未知交给调用方处理 }第二种翻页链接里带页码参数抓最后一条的页码最大值。这种更稳因为不受文案改动影响但要求 URL 规律。不管是哪种抓之前先做一件事把第一页 HTML 存到本地Files.write(Paths.get(debug/page1.html), html.getBytes(StandardCharsets.UTF_8))。选择器调不通的时候打开这个文件用浏览器看比对着日志猜快十倍。这个习惯我保持了几年救过至少三次时间。限速这块Thread.sleep用固定值容易被识别成机器人。简单做法是1000 new Random().nextInt(1500)随机区间内抖动。对方站点有明确频率要求的宁可慢一半也别冒险封一次 IP 又要换环境验证成本高得多。4. 解析层Jsoup 把 table 拆成一行行记录4.1 定位表格CSS 选择器的写法与降级策略一个页面里可能有七八个 table布局用的、导航用的、真正装数据的。选错一个后面全白搭。public Result parse(String html, String baseUri) { Document doc Jsoup.parse(html, baseUri); // 策略一靠 id/class 精确命中最优先 Element table doc.selectFirst(table#dataList); if (table null) table doc.selectFirst(table.grid); // 策略二兜底找行数最多的那个 table if (table null) { for (Element t : doc.select(table)) { if (t.select(tr).size() 2) { table t; break; } } } if (table null) throw new IllegalStateException(页面里没找到数据表格); return extract(table); }逻辑说明三级降级是有意为之。对方改版把 id 去掉了第一级失效但第二级还有 classclass 也改了第三级按“行数最多”猜八成也能猜中。纯靠单一选择器的脚本改版当天就报废。参数说明Jsoup.parse(html, baseUri)第二参数传原页面 URL作用是让相对路径的href、src自动补全成绝对地址。表格里如果有链接列后面导出时能直接点开不传就是空的。选择器别写成doc.select(table)然后取第一个布局表格往往是第一个抓出来全是空的。4.2 表头映射别用下标硬编码新手容易这么写tds.get(2).text()表示第三列。对方加一列整个导出的数据就集体错位而且不报错最难查。正经做法是先读表头建立“列名 → 下标”的映射取值时按名字拿。private MapString, Integer buildHeaderIndex(Element headRow) { MapString, Integer idx new LinkedHashMap(); int i 0; for (Element th : headRow.select(th, td)) { String name th.text().trim().replaceAll(\\s, ); if (name.isEmpty()) name col_ i; // 重名列加后缀避免覆盖 String key idx.containsKey(name) ? name # i : name; idx.put(key, i); i 1 colspanOf(th) - 1; // colspan 会占多个列位 } return idx; } private String get(MapString, Integer idx, ListString row, String colName) { Integer p idx.get(colName); if (p null || p row.size()) return ; return row.get(p); }逻辑说明LinkedHashMap保证顺序和表头一致导出时列序不会乱。重名列加后缀是为了应对“数量”这种在表头里出现两次的情况例如“库存数量”和“订单数量”如果不处理后一个会覆盖前一个。参数说明replaceAll(\\s, )去掉表头里的换行和空格很多表头是两行渲染的“商品\n名称”这样拼起来不去空格永远匹配不上。colspanOf处理表头自身跨列的情况跨几列下标就往后推几位。字段映射改配置文件更彻底后面二次开发那一章会说。4.3 合并单元格rowspan 补齐算法这是整个项目里最费脑子的一段。表格里第一行的“华北区”跨了 3 行如果直接用tr.select(td)遍历第二行、第三行的 td 只有 2 个跟表头 5 列对不上导出的 Excel 全是错位。解法是维护一张“待补”的表按列记录上方 rowspan 还没走完的单元格。private static class Pending { String value; int remain; // 还差几行填完 Pending(String v, int r) { this.value v; this.remain r; } } private ListListString extractRows(Element table, int colCount) { ListListString rows new ArrayList(); MapInteger, Pending carry new HashMap(); // 列下标 - 待补单元格 for (Element tr : table.select( tbody tr, tr)) { ListString line new ArrayList(); IteratorElement cells tr.select( td, th).iterator(); int col 0; while (col colCount) { // 该列被上一行的 rowspan 占着直接填值 if (carry.containsKey(col)) { Pending p carry.get(col); line.add(p.value); if (--p.remain 0) carry.remove(col); col; continue; } if (!cells.hasNext()) { line.add(); col; continue; } Element td cells.next(); String text td.text().trim(); int rowspan attr(td, rowspan); int colspan attr(td, colspan); for (int k 0; k colspan col colCount; k) { line.add(text); if (rowspan 1) carry.put(col, new Pending(text, rowspan - 1)); col; } } rows.add(line); } return rows; } private int attr(Element e, String name) { try { return Integer.parseInt(e.attr(name).trim()); } catch (NumberFormatException ex) { return 1; } // 没写或写错都当 1 }逻辑说明carry是核心。每遇到一个rowspan3的单元格就把它登记到当前列remain 2表示后面两行还要补。下一行处理到这一列时先查carry命中就填值并减一减到 0 移除。colspan则是在原地连续填 k 个相同的值同时往下推 k 列。参数说明attr里NumberFormatException兜底返回 1 很重要有些页面写rowspan或者rowspanabc不兜底直接抛异常整个任务中断。tr.select( td, th)里的是子选择器只取直接子元素。不写这个表格里嵌套表格的 td 会被一起抓出来行内单元格数量翻倍这是另一个高频坑。table.select( tbody tr, tr)同理兼容浏览器自动补 tbody 和不补两种情况。4.4 写出 ExcelPOI 的 XSSFWorkbook 与一行一行的写数据攒在ListListString里导出的部分就简单了。public void write(ListString headers, ListListString rows, String path) throws IOException { Files.createDirectories(Paths.get(path).getParent()); // 父目录不存在先建 try (XSSFWorkbook wb new XSSFWorkbook(); OutputStream os new FileOutputStream(path)) { Sheet sheet wb.createSheet(data); Row headRow sheet.createRow(0); for (int i 0; i headers.size(); i) { headRow.createCell(i).setCellValue(headers.get(i)); } int r 1; for (ListString line : rows) { Row row sheet.createRow(r); for (int c 0; c line.size(); c) { String v line.get(c); Cell cell row.createCell(c); // 纯数字且长度合理按数值写方便后续求和 if (v.matches(-?\\d(\\.\\d)?) v.length() 15) { cell.setCellValue(Double.parseDouble(v)); } else { cell.setCellValue(v); } } } wb.write(os); } }逻辑说明Files.createDirectories解决输出目录不存在的问题比让用户手动建目录友好。数字判断是为了让 Excel 里能直接求和但长度限制在 15 位以内——超过 15 位的数字比如订单号、身份证号用 double 存会丢精度变成1.23456789012345E17这类必须按字符串写。参数说明sheet.createRow(r)从 1 开始0 行留给表头。XSSFWorkbook是 xlsx 格式写 10 万行以内没问题数据量再大要改用SXSSFWorkbook它会把已写的行刷到磁盘内存占用稳定在几百行。CSV 兜底更简单Files.write一行一行拼逗号注意字段里有逗号或换行要用双引号包起来并且内部引号转义。5. 避坑与排查六个当场翻车的点5.1 抓到的是空壳页面表格一行都没有现象HTTP 200 正常返回但doc.select(table)是空的或者只有一个 loading 占位。 原因页面数据是 JS 异步渲染的服务端返回的 HTML 里本来就没有 table。HttpClient 不执行 JS所以永远拿不到。 解决先用浏览器开发者工具看 Network找 XHR 里返回真实数据的那个请求直接抓接口返回的多半是 JSON比解 HTML 还简单。实在找不到接口才考虑上无头浏览器但那属于另一个技术栈了不建议为了一个表格就引入。5.2 中文全是问号或“锟斤拷”现象英文数字正常中文全乱text()出来是????或链。 原因解码用的 charset 和页面实际编码不一致常见于响应头写charsetISO-8859-1但页面实际是 GBK。 解决按 3.2 的顺序重写decode先拿字节数组再决定编码。已经拿成 String 的救不回来必须回到toByteArray那一步。另外 MyEclipse 控制台本身的编码也要设成 UTF-8否则是控制台显示问题数据其实是好的这个区分方法很简单把结果写进文件再看。5.3 跑几页就 403 或者返回验证码现象第一页正常第二三页开始返回 403、或者一段很短的 HTML。 原因请求频率太固定、没有 UA、没有 Cookie被风控识别成脚本。 解决加 UA 和 Accept-Language限速改成随机区间必要时把第一次请求拿到的 Cookie 带进后续请求。还有一个容易忽略的点——分页请求有时候必须带上一页的 Referer不带就拒。5.4 导出的行数对不上列数也不对现象页面显示 50 行导出来 120 行或者每行少一列。 原因tr.select(td)把嵌套表格的 td 也抓进来了或者没处理 rowspan/colspan导致后面的行整体前移。 解决选择器加限定直接子元素table.select( tbody tr)、tr.select( td, th)合并单元格按 4.3 补齐。排查方法是在extractRows里打印每行line.size()正常应该等于表头列数不等的那一行就是问题所在。5.5 MyEclipse 里编译通过运行报 NoClassDefFoundError现象java.lang.NoClassDefFoundError: org/apache/xmlbeans/XmlObject或org/apache/commons/collections4/...。 原因poi-ooxml 的间接依赖没配齐Maven 会自动拉手动放 jar 全靠自己补。 解决看报错里的类名反查是哪个包。常用对照XmlObject在 xmlbeansListValuedMap在 commons-collections4ZipArchiveInputStream在 commons-compress。补完 jar 记得 Build Path 刷新。用 Maven 的话在命令行跑一次mvn dependency:tree把依赖树贴出来一个个对。5.6 表头文字匹配不上字段全是空现象映射建好了但get(idx, row, 单价)一直返回空。 原因页面表头里混了全角空格、换行、nbsp;text()出来是“单 价”或者“单价 ”跟代码里的“单价”不相等。 解决映射和取值两头都做归一化——去掉所有空白字符、把全角空格\u00A0、\u3000替换成半角再 trim。调试时把idx整个打印出来键值一目了然。6. 二次开发把单页脚本改成能长期跑的采集器例子能跑通但直接用在实际项目里还是脚本味太浓选择器写死在 Java 里、页码写死、中断了得从头再来。我一般做三处改造投入不大但收益明显。第一处把选择器和字段映射外置到config.properties。改版的时候改配置不改代码不用重新编译。table.selectortable#dataList row.selector tbody tr cell.selector td, th field.mapping名称:商品名称,价格:单价,数量:库存数量Properties p new Properties(); try (InputStream in Main.class.getClassLoader().getResourceAsStream(config.properties)) { p.load(new InputStreamReader(in, StandardCharsets.UTF_8)); } // 名称:商品名称 表示 Excel 列叫名称页面上叫商品名称 for (String pair : p.getProperty(field.mapping).split(,)) { String[] kv pair.split(:); renameMap.put(kv[0], kv[1]); }第二处加断点续跑。每抓完一页把已抓的行数和页号写进一个progress.txt启动时先读非 0 就跳过前面的页。这招在抓几十页的数据时价值极大网络一抖动重来一遍能烦死人。第三处加去重和唯一键。表格里通常有“编号”这一列导出前用SetString过滤一遍避免翻页过程中对方数据变动导致的重复行。改造点改动量解决什么问题选择器外置半天站点改版不用改代码断点续跑两小时中断后不用从头抓唯一键去重一小时翻页期间数据变动导致重复日志落文件半小时出问题能回看是哪一页哪一行验证方法也简单拿一页数据手工数一遍行数和某几列的值和导出的 Excel 逐行对对不上先看line.size()再打日志看原始 HTML。别嫌土这比盯着代码猜快得多。说个自己的教训。早几年做一个类似的东西为了省事把EntityUtils.toString(entity)直接用上了本地跑好好的换到另一台机器上中文全乱查了整整一个下午才定位到是响应头没写 charset、不同 JDK 版本默认编码不一样。从那以后我每次写抓取第一件事就是把 HTML 原样存一份到本地再解析编码判断也一律走“先字节后编码”那条路再没在这上面栽过。上面这些改造和排查方法照着抄一遍基本就能把这套例子用起来希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?