首页 / 资讯中心 / 文章详情

jsoup分页爬取实战:Java静态HTML解析与列表页数据抓取

jsoup分页爬取实战:Java静态HTML解析与列表页数据抓取 ★ FEATURED ARTICLE
简介面向Java开发者的一份jsoup分页爬虫实战工程解决多页网页数据批量抓取时涉及的URL翻页、HTML解析与内容提取等常见问题。工程内含可运行的Java源码及完整目录结构便于直接导入IDE学习或二次改造。资源共16个文件以jar依赖为主涵盖jsoup-1.9.2、json解析、slf4j日志及WebCollector相关组件同时附有源码、class配置与工程配置文件压缩包整体约3.08MB轻量易部署。已有1082人下载学习。内容围绕爬虫开发的核心链路展开包括通过connect()构造分页请求、用CSS选择器定位目标节点、attr()取值提取标题与链接以及循环翻页的遍历逻辑同时兼顾请求异常处理、数据清洗与存储、请求速率控制等生产环境关键问题并提示了jsoup不执行JavaScript的局限与应对思路。适合刚接触Java爬虫、希望快速搭建分页采集脚本的开发者参考。1. jsoup 分页爬取从列表页到结构化数据的一把快刀有次接了个小需求要把某技术社区前二十页的文章标题、链接和时间抓下来存到表格里。当时第一反应是用浏览器自动化去翻页结果发现光是等待页面渲染就耗掉大半时间。后来改用 jsoup 直接解析静态 HTML整个采集过程轻薄得像一层纸——它不依赖浏览器内核不发多余请求只做一件事把 HTML 字符串变成可查询的文档树。这次要聊的 jsoup 分页爬取就是把「解析」这件小事做到极致的典型方案。适合两类人一是想抓取列表页数据、又不想引入重型框架的 Java 开发者二是刚接触爬虫、想从最简单的静态页面入门的初学者。它的核心价值在于分页逻辑、节点定位、数据封装三件事都有清晰落点改一改就能变成自己的采集工具。2. 工程结构与技术选型用 Maven 三分钟搭出爬虫骨架2.1 为什么选 jsoup静态 HTML 解析的边界与优势选 jsoup 之前我把主流方案在脑子里过了一遍。Selenium 能处理动态渲染页面但每次启动浏览器都要消耗几十兆内存还要额外装驱动HttpClient 加正则做抽取也能跑但正则写起来又脆又难维护页面结构一调整就全线崩盘。jsoup 恰好站在两者中间它用一个Document对象把 HTML 解析成 DOM 树然后用 CSS 选择器语法定位节点代码读起来几乎就是 JQuery 风格。需要明确的是边界。jsoup 只认静态 HTML也就是服务端渲染后直接回到响应体里的内容。如果页面数据依赖 JavaScript 异步接口加载那Jsoup.connect()拿到的只是一个空壳页面。判断方法很简单浏览器右键查看源代码能看到列表内容的就能用 jsoup 抓看不到内容、只有一堆 script 标签的就别硬上。这次的项目场景里目标页面的列表数据全部由服务端模板渲染所以 jsoup 是恰到好处的选择。2.2 目录结构与依赖配置项目是一个标准的 Maven 工程核心只依赖一个 jsoup 包。整个工程只有三个 Java 文件加一个配置文件结构非常薄二次开发时不会在文件迷宫里绕晕。├── pom.xml └── src/main/java └── io/collector ├── App.java // 程序入口控制分页参数 ├── Article.java // 数据实体对应一条列表记录 └── PageCollector.java // 核心采集逻辑连接、解析、封装pom.xml 里的依赖只有一项版本选一个稳定的即可dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency这里建议用 Maven 管理依赖而不是手动下载 jar 包。jsoup 自身不传递其他依赖所以最终打包体积很小这也是它能保持轻量的原因之一。Java 版本要求不高JDK 8 以上就能跑项目里没有引入任何 lambda 之外的新语法换到旧环境也不会翻车。2.3 运行入口与参数控制App.java是入口把分页范围和目标 URL 都作为常量放在开头方便启动前修改public class App { public static void main(String[] args) throws Exception { // 目标列表页 URL 模板%d 会被页码替换 String baseUrl https://example-site.com/article?page%d; // 抓取前 10 页 ListArticle articles PageCollector.collect(baseUrl, 10); // 控制台输出一条条打印后续可改成写入文件 for (Article a : articles) { System.out.println(a); } } }这里的参数设计为后续改造留了空间baseUrl带一个%d占位符collect方法内部根据页码循环填充pageCount决定循环次数。如果想抓全部分页而不是固定前十页可以扩展成一个「检测最后一页」的逻辑这个后面在分页章节单独展开。3. 核心模块拆解连接、解析、抽取一条链路3.1 建立连接超时、UA、重试一个都不能少爬虫的第一步是拿到 HTML 字符串。Jsoup.connect()是同步阻塞请求返回值是一个Connection对象所有请求参数都在这个链路上配置。// 连接配置示例超时、User-Agent、请求头 Connection conn Jsoup.connect(url) .timeout(5000) // 连接和读取超时单位毫秒 .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36) .header(Accept-Language, zh-CN,zh;q0.9) .method(Connection.Method.GET); Document doc conn.execute().parse();逻辑说明timeout(5000)控制的是从建立 TCP 连接到读取响应体结束的整个等待窗口超过 5 秒没有响应就会抛出HttpTimeoutException。userAgent带上完整浏览器标识很多服务端会针对空 UA 直接返回 403这是一个非常常见却被新手反复踩的坑。.method(GET)指定请求方式jsoup 默认就是 GET这里写出来是为了让代码意图更明确。参数说明里有个细节值得注意execute()返回Response对象之后再调.parse()得到Document。如果你想先看响应状态码再决定是不是要解析可以用.statusCode()判断直接调.get()实际上就是execute().parse()的简写项目里多数情况用.get()就够。3.2 用选择器定位列表节点select 与 selectFirst 的分工HTML 解析是 jsoup 的看家本领。它的选择器语法和 CSS 几乎一致上手成本极低。以某个典型列表页为例页面结构是每个条目包在一个div.list-item节点里标题、链接、时间分别在不同层级div classlist-item h3a href/article/123jsoup 分页采集实践/a/h3 span classtime2025-01-18/span /div对应的解析代码长这样// 从 Document 中选出所有列表项节点 Elements items doc.select(div.list-item); for (Element item : items) { // 每一项内继续用选择器取子节点 Element titleLink item.selectFirst(h3 a); String title titleLink.text(); // 去掉标签纯文本 String link titleLink.absUrl(href); // 补全成绝对地址 String time item.selectFirst(span.time).text(); Article article new Article(title, link, time); articles.add(article); }逻辑说明第一行doc.select()返回一个Elements集合也就是匹配div.list-item的所有节点。循环体里再次用selectFirst()定位每一项内部的标题和时间——这里用selectFirst而不是select是因为每个列表项里只有一个标题、一个时间取第一个就够。absUrl(href)是 jsoup 很实用的方法当页面里写的是相对路径/article/123时它会自动拼上当前页面的域名省去手动拼接的麻烦。参数说明选择器h3 a表示「h3 元素下的 a 标签」中间的空格是后代选择器。如果页面结构再深一层比如 a 外面还套了一个 div那就写成h3 div a或者h3 a都能命中因为空格是穿透任意层级的。但要注意选择器越具体越不容易误伤同类节点建议优先按父节点 子标签的组合写。3.3 数据封装与去重输出Article类是个典型的数据载体用 Java 14 的 record 写法可以极简实现public record Article( String title, // 文章标题 String link, // 绝对地址链接 String time // 发布时间原文 ) { Override public String toString() { return String.join( | , title, link, time); } }把解析结果封装成实体后后续不管是写 CSV、写数据库还是打印日志都不需要关心原始 HTML 长什么样。这里有个我个人的习惯不要在实体里做数据清洗比如时间统一格式化、链接去除跟踪参数这类操作放到抽取之后再处理让实体保持「原始快照」状态。这样一旦目标页面的数据格式调整你可以先查看原始值再决定规则怎么改而不是连源头数据都看不见。4. 分页爬取的核心逻辑URL 模式与循环终止条件4.1 分页 URL 的两种形态静态列表页的分页方式绝大多数逃不出两种模式。第一种是查询参数分页URL 形态像?page2服务端从请求参数里读取页码第二种是路径分页URL 形态像/page/2更适用于伪静态或对 SEO 友好的站点。两者的抓取逻辑一样只是 URL 拼接方式不同我通常把它们比作「给地址填门牌号」——参数分页填的是 X 号路径分页填的是 X 栋。这个项目里按参数分页设计baseUrl里放一个%d占位符。乘着这个结构分页循环就是在做一件事把 page 变量的值填入占位符然后交给连接和解析流程。如果你想适配路径分页把占位符的位置从问号后面挪到斜杠后面就行。4.2 循环抓取与终止条件分页循环的写法直接决定了项目是优雅结束还是暴力拉满。最简单的思路是用for循环从 1 数到pageCountpublic static ListArticle collect(String baseUrl, int totalPages) throws IOException { ListArticle all new ArrayList(); for (int page 1; page totalPages; page) { // 1. 把页码填入 URL 模板 String pageUrl String.format(baseUrl, page); // 2. 连接并解析 Document doc Jsoup.connect(pageUrl) .timeout(8000) .userAgent(Mozilla/5.0 ...) .get(); // 3. 解析列表项逻辑见第三章 Elements items doc.select(div.list-item); for (Element item : items) { String title item.selectFirst(h3 a).text(); String link item.selectFirst(h3 a).absUrl(href); String time item.selectFirst(span.time).text(); all.add(new Article(title, link, time)); } // 4. 适当休眠避免请求频率过高 Thread.sleep(800); } return all; }这段代码有几个需要说明的点。String.format会把第一个%d替换成当前页码如果 URL 模板里还有别的数字占位符要注意顺序。循环内部的思路是「填 URL → 获取并解析 → 抽取封装 → 暂存」四步走完一页再继续下一页。终止条件的细节这里用固定totalPages做终止写起来简单但存在一个隐患——如果实际页面数少于你传的数量后面的请求会拿到重复的第一页或直接返回 404。更稳妥的做法是在循环体里检测「当前页是否有列表项」没有就break这算是最朴素的动态终止。再进阶一点可以从页面底部的「下一页」按钮里读 href 判断有没有下一页这个在 4.3 里单独说。参数说明Thread.sleep(800)是两页请求之间的间隔单位毫秒。别设成 0也别设成 100。间隔太短会让服务端的访问频率监控怀疑你在爆破太长又拖慢整体采集效率。800 到 1500 毫秒是静态页面采集的常见取值区间。另外 sleep 必须在循环内、请求结束之后调用才能有效降低瞬时请求密度。4.3 动态判断最后一页从「下一页」按钮读 href固定页码的方式在目标站删除了一些旧文章后就会失真。比如原本有 50 页内容后来只剩 48 页你传 50 进去最后两页会抓到空列表或残留数据。我一般会加一层动态判断抓完当前页后从页面节点里找「下一页」的链接能找到就继续找不到就停止。// 查找翻页区域中的下一链接 Element nextLink doc.selectFirst(a.next, a:contains(下一页)); if (nextLink null) { break; // 没有下一页终止循环 } // 如果 next 链接存在则用它的 href 作为下一个 URL pageUrl nextLink.absUrl(href);// 循环改写用 while 替代 for String pageUrl String.format(baseUrl, 1); while (pageUrl ! null !pageUrl.isEmpty()) { Document doc Jsoup.connect(pageUrl).get(); // ...解析逻辑同上... Element nextLink doc.selectFirst(a.next); pageUrl (nextLink null) ? null : nextLink.absUrl(href); }这里的设计思路是把「下一页的 URL」当作循环体内的状态变量。每一轮解析完去找翻页按钮找到了就把它的 href 赋给pageUrl找不到就赋null让 while 条件自然不成立。这样即使列表页中途新增或删除了若干篇文章爬虫也能顺着真实存在的翻页链路走到底不会因为固定页码而多抓或少抓。用这个方法要注意一点很多站点的「下一页」链接是相对路径所以必须用absUrl(href)补全否则第二次循环拿到的 URL 缺域名连接直接报错。4.4 多页数据汇总与去重循环抓完所有页面后数据汇总有两种常见方向直接存文件或者先做内存去重再入库。列表页在翻页过程中偶尔会出现重复条目——某些网站按「最新」排序时新文章插入会让相邻两页的数据重叠所以去重是必备步骤。// 使用 LinkedHashMap 按标题去重同时保留插入顺序 MapString, Article uniqueMap new LinkedHashMap(); for (Article a : articles) { // 后出现的重复标题覆盖先出现的 uniqueMap.put(a.title(), a); } ListArticle uniqueArticles new ArrayList(uniqueMap.values());用LinkedHashMap而不是HashSet是因为它既做到按 title 去重又保持「第一次出现」的顺序输出结果和页面顺序一致排查时对得上号。这里选的去重键是标题如果标题会变动可以换成链接。稍加改造还可以在put之前判断containsKey做到遇到重复就直接跳过、不覆盖保留更早的版本。5. 常见问题与避坑乱码、反爬、超时的血泪排查记录5.1 现象抓回来的标题全是乱码英文正常、中文变方块这在旧项目中很常见直接原因就是页面编码和 jsoup 默认解析编码不一致。jsoup 的parse()方法在解析字节流时会先看响应头里的 Content-Type 有没有 charset没有的话再看 HTML 里的 meta 标签都没有就默认按 UTF-8 处理。如果目标站是 GBK 编码且响应头里没声明 charset那中文必然乱码。解决在execute()之后、parse()之前手动指定编码从响应头或 meta 里读取原始 charset。最省事的做法是直接用Jsoup.parse(InputStream, charsetName, baseUrl)显式传入编码。我现在养成的习惯是抓之前先看一眼页面源码charsetgbk还是utf-8如果确认是 GBK代码里写死Charset.forName(GBK)比自动检测更稳。5.2 现象抓了近一百页后突然连续返回 403 或跳验证码页原因基本可以锁定是请求频率太高服务端已经把当前 IP 的访问特征识别为机器行为。jsoup 是同步阻塞请求单线程下即使每页睡 800 毫秒几十页爬下来请求间隔仍然稳定得过于规律容易触发阈值。解决一是调大间隔把 sleep 从 800 提到 1500 或 2000二是给每次请求换一个 User-Agent不要几十页都用同一个浏览器标识三是增加 jitter也就是随机睡眠时间。我写过一个小技巧Thread.sleep(800 new Random().nextInt(800))让间隔在 800~1600 毫秒之间随机波动更接近人的翻页节奏。如果站点要求登录才能翻页那还需要提前在代码里维护 Cookie否则爬完前三页就会被弹回登录页。5.3 现象选择器在浏览器里测得好好的代码跑出来却抓不到任何节点这是我见过最多人踩的坑。浏览器开发者工具里看到的 DOM和Jsoup.connect()拿到手的 HTML 可能不一样。原因有两类一是目标页面部分内容确实由 JS 渲染查看源代码发现列表节点是空的二是页面里有根据 User-Agent 返回不同 UA 版本的模板比如浏览器环境返回 PC 端页面、默认的 Java HTTP 客户端返回移动端精简页选择器自然对不上。解决先在代码里打印一段doc.html()看看实际拿到的 HTML 结构和浏览器里是否一致。把抓到的 HTML 存成文件用文本编辑器搜一下你要的类名搜到了再跑选择器搜不到就去排查是不是 UA 导致模板分叉。我建议把「先 dump 再解析」写成调试阶段的固定动作能省下大量玄学排查时间。5.4 现象循环没有终止进程一直不退出或者请求数远大于预期页数这个问题大多出在动态终止逻辑上。有些站点的「下一页」按钮在最后一页还会存在只是被加上disabled样式或者 href 指向当前页本身。如果代码只判断了nextLink null而没有判断 disabled 属性就会陷入死循环不停重复抓取同一页。解决取到nextLink后要加两道检查。先判断是否包含disabled类再判断absUrl(href)与当前 URL 是否相同只要命中其中一条就终止。我把这段写成一个hasNext(Document doc, String currentUrl)方法返回布尔值循环体里用while (hasNext(...))控制逻辑清晰也不容易漏判。6. 二次开发把写死的爬虫改成配置驱动项目做完了能跑但离「好用」还差一步。目前baseUrl、选择器、分页规则都是硬编码换一个目标站就得改代码重新编译。按我现在的习惯会把这三个要素抽成配置项用 Properties 或 YAML 管理让爬虫从「写死的工具」变成「可复用的采集器」。// 从配置文件读取采集规则 Properties prop new Properties(); try (var in Files.newInputStream(Paths.get(spider.properties))) { prop.load(in); } String baseUrl prop.getProperty(url.template); // 列表页 URL 模板 String itemSel prop.getProperty(selector.item); // 列表项选择器 String titleSel prop.getProperty(selector.title); // 标题链接选择器 String timeSel prop.getProperty(selector.time); // 时间选择器 int maxPages Integer.parseInt(prop.getProperty(page.max, 10)); // 最大页数 long sleepMs Long.parseLong(prop.getProperty(page.sleep, 800)); // 请求间隔改造的核心思路是「策略与数据分离」把每次可能变化的量都挪到配置里。爬虫的骨架不动连接、解析、循环、终止四个环节只是从固定常量换成prop读取的变量。以后要抓新的站点只需要新建一份 properties 文件填好 URL 模板和选择器就能在不改动 Java 代码的前提下切换采集目标。换成配置驱动之后有一个细节必须重新验证选择器写得对不对。我曾经把一条选择器从配置里读出来就跑结果生产环境宿主机访问页面时被重定向到了别的入口列表结构完全不一样抓回来的全是空对象。从那以后我每次改配置都会强制走一遍「先抓一页、打印前三条数据」的验证流程确认抽取结果里每个字段都有值、没有空标题和错位链接才放行全量抓取。这套流程也推荐给你先用单页验证通过再开分页循环别让整个批量任务为一条选择器陪跑。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站