☰
jsoup分页爬取实战:从HTML解析到数据采集的完整指南
2026/10/21 13:15:25 网站建设 项目流程

简介:这是一份基于jsoup实现静态网页分页抓取的Java示例工程,面向Java初学者及需要快速获取结构化页面数据的开发者,完整演示了从建立连接、解析HTML、使用CSS选择器提取信息到循环遍历多页数据的实现路径。压缩包共16个文件、约3.08MB,以jsoup、WebCollector、json、slf4j、log4j等7个jar依赖为主,分别负责HTML解析、网页采集、JSON处理、日志记录与缓存存储;同时包含核心java源码、编译后的class文件、Eclipse工程配置及IDE状态文件,src与lib目录划分清晰,导入开发环境即可直接运行调试。工程重点覆盖分页URL参数构造、connect方法建立请求、parse方法解析文档、select与attr方法定位并读取节点、循环翻页及异常重试等高频操作,数据抓取后还可结合json与log4j完成数据持久化、格式转换与日志追踪,便于后续复用与排错。已有1082人学习下载,整体代码量适中、注释直观,适合作为Java Web爬虫入门或课堂教学的参考模板。

1. jsoup分页爬取网页:先看清拉取边界再动手

在Java项目里,jsoup分页爬取网页是我被问到最多的入门需求。某个资讯站、某个商品列表、某个公告分页,核心套路都一样:用jsoup建立连接、解析HTML、定位列表行,再构造页码URL循环抓取。先说结论:jsoup只解析静态HTML,不执行JavaScript,凡是依赖JS渲染的分页它都无能为力;但绝大多数传统站点的列表页,分页信息都藏在静态DOM里——页码序列、下一页链接、总数标签,总能找到一个能用来终止循环的信号。这篇笔记围绕模拟项目X展开,目标是把一个分页资讯列表完整抓下来并做数据完整性校验,适合刚把jsoup装进项目、正准备从单页爬取推进到多页采集的开发者。

2. 环境与解析基础:jsoup引入、连接参数与选择器定位

2.1 Maven引入与版本选型

在Maven项目里加依赖是最省事的引入方式,没有之一。jsoup没有把自身拆成一堆子模块,一个jar包自带解析、选择器、网络连接三块功能,配好就能用。版本上我一般选当前最新的稳定版,不刻意追新,也不抱着老版本不换。1.15之前的版本对HTML5新增标签的解析有细微瑕疵,遇到页面结构里塞了<main>、<section>这类标签,容易在层级匹配上出问题;升到1.17.2之后,这类问题基本没再遇到。

<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>

这段坐标加进pom.xml的<dependencies>里即可。groupId对应组织名org.jsoup,artifactId是项目名jsoup,版本号固定写死,避免构建时漂移到不兼容的新版。如果你的环境是离线仓库,需要先从中央仓库把jar下载到本地~/.m2/repository,jar本身没有传递依赖,落地成本很低。如果你不是Maven项目,直接把jsoup的jar放进lib目录并在IDE里加到Build Path也可以,后续代码写法完全一致,不影响任何API。

2.2 连接参数:超时、UA与重试

很多第一次写jsoup爬虫的人会直接写Jsoup.connect(url).get(),这在本地Demo里能跑通,放到真实站点上大概率第一页就翻车。对方服务器没有义务给一个看不出出处的请求返回正常页面,常见的表现是返回403、302重定向到验证页,或者干脆超时。我在连接阶段就把UA、超时、请求头一套配齐,页面返回后再单独解析,这样控制力更强。

Connection.Response resp = Jsoup.connect(url) .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") .timeout(8000) .header("Accept", "text/html,application/xhtml+xml") .header("Accept-Language", "zh-CN,zh;q=0.9") .method(Connection.Method.GET) .execute(); Document doc = resp.parse();

userAgent()里填一个完整的浏览器UA,尽量别用网上到处抄的简陋版“Mozilla/5.0”,字段不全的UA很容易被目标站识别为脚本请求。timeout(8000)表示连接建立和读取数据的总超时是8秒,调到10秒也行,但超过15秒在网络抖动时会让整个分页任务卡死。.method(Connection.Method.GET)显式声明请求方式,防止某些链接默认走POST逻辑。.execute()拿到原始响应对象,resp.parse()才把HTML转成Document。如果服务器返回的是重定向页,resp.header("Location")里能看到跳转目标,这一步在排查“为什么抓到的是登录页”时非常有用。

网络请求不可能每次都成功,尤其是分页爬到后半段,服务器开始不耐烦。我给连接包了一层带重试的封装,用指数退避代替一遍遍死磕。

public Document fetchWithRetry(String url, int maxRetry) { for (int attempt = 1; attempt <= maxRetry; attempt++) { try { return Jsoup.connect(url) .userAgent(UA) .timeout(8000) .execute() .parse(); } catch (IOException e) { if (attempt == maxRetry) { throw new RuntimeException("重试" + maxRetry + "次仍失败: " + url, e); } try { Thread.sleep(1000L * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } return null; }

maxRetry我通常传3,爬取量大且网络不稳定时传5。注意sleep(1000L * attempt)让重试间隔随次数线性增长,第一次失败等1秒,第二次等2秒,给服务器一个恢复窗口。这里有一个细节:Thread.sleep抛出的InterruptedException要主动响应,不能直接吞掉,否则线程池场景下任务取消会失效。

2.3 用选择器定位列表行与分页控件

拿到Document之后,所有信息都靠CSS选择器提取。模拟项目X的列表页结构很规矩,每一条资讯都在ul.news-list > li里,标题放在h3 > a,发布时间在span.date。先把列表行全找出来,再逐行抽取字段。

Elements listItems = doc.select("ul.news-list > li"); if (listItems.isEmpty()) { // 当前页没有任何列表数据,视为到达最后一页 return; } for (Element li : listItems) { String title = li.select("h3 a").text(); String relativeUrl = li.select("h3 a").attr("href"); String absoluteUrl = li.select("h3 a").absUrl("href"); String dateText = li.select("span.date").text(); }

doc.select("ul.news-list > li")是CSS选择器语法,>代表直接子元素,只匹配ul.news-list下面一层的li,不会误伤嵌套在其他容器里的条目。.text()拿到的是去掉HTML标签后的纯文本,比.html()更适合标题和日期这种字段。.attr("href")拿到的通常是相对地址,比如/article/123.html,而.absUrl("href")会基于当前页面的URL自动拼成完整地址,这个API是jsoup里比较贴心的设计,省去了手动拼接域名的工作。

分页控件的定位思路类似。常见的分页条是div.pagination里一组a标签,其中有页码数字,也有“下一页”这样的文字链接。我通常先找出“下一页”按钮,如果能拿到就说明后面还有内容;拿不到再考虑用页码序列判断边界。

Elements pageLinks = doc.select("div.pagination a[href]"); String nextUrl = null; for (Element a : pageLinks) { String text = a.text().trim(); if ("下一页".equals(text) || ">".equals(text) || "Next".equalsIgnoreCase(text)) { nextUrl = a.absUrl("href"); break; } }

这里div.pagination a[href]表示只筛选带href属性的链接,排除掉禁用的灰色“下一页”按钮。匹配文字时把中文“下一页”、符号“>”、英文“Next”都列出来,不同站点写法差异很大,宁可多匹配几种也不能漏。拿到nextUrl后,循环的下一步就用它作为新URL,而不是自己机械拼接页码——这个选择会在下一章展开讲。

3. 分页爬取核心实现:URL构造、终止条件与数据入库

3.1 三种常见分页格式与URL构造策略

分页爬虫不能只会照抄一种URL格式。模拟项目X的任务书里写了三个候选站点,分页格式各有不同,我实际看下来发现市面上静态列表页的分页格式基本可以归成三类。先把它们列清楚,后面构造URL才不会写死。

分页格式URL示例构造方式
查询参数页码https://site.example.com/list?page=3拼接page参数
路径页码https://site.example.com/list/3.html替换路径中的页码占位符
偏移量https://site.example.com/list?offset=40每页条数乘以页码

查询参数页码是最常见的,后端通常用page或p接收页码,从1开始。构造逻辑简单,但要注意某些站点页码从0开始,第一页是page=0而不是page=1,判断起始值前先把第一页URL请求一遍看规律。路径页码常见于内容型站点,URL对搜索引擎更友好,构造时用字符串替换占位符。偏移量格式比较隐蔽,它不区分页码,而是直接把“跳过的记录数”作为参数,假设每页20条,第3页就是offset=40。

String buildPageUrl(int page, int pageSize) { if (PAGE_FORMAT == PAGE_FORMAT_OFFSET) { return BASE_LIST_URL + "?offset=" + (page - 1) * pageSize; } return BASE_LIST_URL + "?page=" + page; }

这个简易构造器把偏移量换算放在显眼处,避免在循环里反复计算。pageSize从哪里来?从第一页的每一页条目数量统计出来,模拟项目X每页固定20条,所以offset以20递增。如果页面底部有“共N条/共N页”的统计文本,优先用总数去除页大小,能提前拿到总页数,爬取范围就不靠猜了。

3.2 翻页终止条件:从页码列表到“下一页”按钮

把URL拼出来只是完成了三分之一,真正让分页爬虫区别于“爬几页就停”的关键是终止条件。写错终止条件的后果很隐蔽,要么多爬一页返回空列表白白浪费请求,要么漏掉最后一页数据,入库后对不上总数。

我常用的终止判断有两层。第一层是“列表为空即停止”,这是最保险的兜底。任何站点在超过末页后,通常要么返回空列表,要么返回别的推荐内容,为空判断能拦截绝大部分异常。第二层是“没有下一页链接即停止”,这更主动,不用等请求发出空列表再回头。

public void crawlByNextLink(String startUrl, int maxPages) { String currentUrl = startUrl; int pageCount = 0; while (currentUrl != null && pageCount < maxPages) { Document doc = fetchWithRetry(currentUrl, 3); List<NewsItem> pageData = parseListPage(doc); if (pageData == null || pageData.isEmpty()) { // 本页没有数据,说明已经越过末页,终止循环 break; } pageCount++; // 本页数据处理逻辑,去重后入库 saveItems(pageData); currentUrl = findNextPageUrl(doc); } }

maxPages是人为设置的安全上限,防止站点分页控件异常时无限翻下去。currentUrl每次都在循环末尾更新,如果findNextPageUrl返回null,循环自然结束。这条链路里有两个容易漏的细节:一是findNextPageUrl不能只匹配文字,有些站点的下一页是图标,没有文字内容,需要用rel="next"属性来识别;二是页面如果有两套分页控件(顶部一套、底部一套),可能出现重复的下一页链接,取第一个就行,不必去重。下面这个实现把文字和rel属性都纳入匹配。

private String findNextPageUrl(Document doc) { Elements nextLinks = doc.select("a[rel=next], a:contains(下一页), a:contains(Next)"); if (nextLinks.isEmpty()) { return null; } return nextLinks.first().absUrl("href"); }

:contains(下一页)是jsoup选择器里比较少用但很有效的写法,它匹配文本中包含指定字符串的标签。这里注意,:contains匹配的是子文本节点内容,如果“下一页”文字里面还包了一层span,需要用a:containsOwn(下一页)来只匹配直接文字。

3.3 数据封装与入库模板

拿到每页的数据之后,直接塞进实体类再批量入库是比逐条insert更合理的做法。实体类字段和页面结构一一对应,别偷懒用Map。模拟项目X的资讯页包含标题、链接、发布日期三个核心字段,我按这个粒度封装。

public class NewsItem { private String title; private String url; private String publishDate; // getter / setter 省略 }

解析时要注意日期格式。页面里的日期经常是“2025-05-20 14:30”这种带时间的文本,入库时我一般只留日期部分,用正则把多余内容剥掉。

String dateText = li.select("span.date").text(); String cleanedDate = dateText.replaceAll("(\\d{4}-\\d{2}-\\d{2}).*", "$1");

入库用PreparedStatement的批量执行,减少数据库交互次数。下面给出一个JDBC批量的骨架,连接池用的是常见Druid/HikariCP那一套,DataSource已经在项目里注入好了。

public void batchInsert(List<NewsItem> items) { String sql = "INSERT INTO news(title, url, publish_date) VALUES (?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (NewsItem item : items) { ps.setString(1, item.getTitle()); ps.setString(2, item.getUrl()); ps.setDate(3, java.sql.Date.valueOf(item.getPublishDate())); ps.addBatch(); } ps.executeBatch(); } catch (SQLException e) { throw new RuntimeException("批量入库失败", e); } }

addBatch()把SQL参数暂存在驱动层,executeBatch()一次性发给数据库,比逐条执行快一个量级。这里有两个前置条件需要确认:第一是连接没有开启自动提交,业务上要求“要么整批进、要么整批不进”,可以在finally里对conn.rollback()做补偿;第二是publishDate的格式必须统一成yyyy-MM-dd,Date.valueOf()对格式挑剔,多一个空格都会抛异常。

4. jsoup分页爬取避坑指南:乱码、超时与JS渲染页

4.1 乱码:页面编码GBK被当UTF-8解析

现象:抓回来的标题全是“�”,或者中文变成一堆Unicode转义字符,日期字段倒是正常的。模拟项目X的某个列表页用的是GBK编码,而jsoup从HTTP响应头读不到charset时,默认按UTF-8来解析。

原因:目标页面在<head>里写了<meta charset="GBK">,但响应头的Content-Type字段没有带字符集信息,jsoup的编码探测没能走到读meta这一步,或者探测逻辑在非UTF-8场景下确实容易漏。

解决:在execute()拿到响应对象之后,手动指定字符集再解析。正确写法是resp.charset("GBK"),然后resp.parse()。注意时机要在parse()之前执行。如果拿到的页面还带BOM头,需要先用resp.body()读字节流,把BOM剥掉再转字符串。

Connection.Response resp = Jsoup.connect(url).execute(); resp.charset("GBK"); Document doc = resp.parse();

还有一种少见情况:页面里两个meta标签,一个写UTF-8一个写GBK,jsoup可能会读到第一个就停。这类页面先用浏览器打开看“查看源代码”里到底哪个生效,如果浏览器显示正常而jsoup乱码,直接手动指定,别依赖探测。

4.2 连接超时与HTTP 403

现象:单页爬取正常,分页爬到第5页、第10页突然抛SocketTimeoutException,或者某几页稳定返回403。

原因:短时间密集请求触发了服务器限流。403不一定代表封IP,很多限流是“每分钟超过N次就拒绝”,过一会儿自己恢复。另外UA不完整、缺少Accept请求头也会让对方把请求判定为脚本行为。

解决:第一优先级是拉长请求间隔,把Thread.sleep从500毫秒改到1500毫秒以上,看是否缓解。第二是给请求补上更完整的浏览器头,包括Accept-Language和Referer。第三是把之前的重试机制用起来,对403和超时都做指数退避。这里最难排查的是403返回的是自定义错误页,页面结构和正常页一样,但内容为空,如果只判断“列表为空就停止”,会把爬虫引向错误结论。处理办法是记录响应状态码,resp.statusCode() != 200时跳过当前页并重试。

4.3 漏数据与重复数据

现象:程序跑完,统计入库总数比预期少了几十行;或者同一标题出现两条记录,url完全相同。

原因:终止条件判断过快。有的站点末页不是空列表,而是把前面页的尾部数据再展示一遍,如果只靠“下一页按钮消失”判断结束还好,靠“列表为空”判断就永远多爬一次。重复数据的根源更常见:分页链接里混着排序参数,同一批数据在两个页面里以不同顺序出现,按页码推进时无法感知,只能靠库里去重。

解决:入库前对url做唯一性约束,模拟项目X的资讯详情链接是唯一Key,直接在表上加唯一索引,executeBatch()时对违反约束的批次做拆分重试。漏数的排查路径是打印每一页实际拿到的条数,和页面底部“共N条”做减法,第一轮就能定位是哪一页开始少数据。

4.4 JS渲染的分页控件抓不到

现象:doc.select("div.pagination a")结果为空,但浏览器里分页条显示得清清楚楚。

原因:分页条是前端JS动态渲染的,静态HTML里只有一个空的div容器,页码和链接是Ajax接口返回后由脚本拼进去的。jsoup作为解析库不执行JavaScript,拿不到运行时才生成的DOM节点。

解决:先确认这一点。打开浏览器开发者工具查看网络面板,如果翻页请求走的是XHR接口,直接找到接口地址和参数,用jsoup请求那个接口解析JSON,效率更高。如果接口返回的就是HTML片段,也可以直接喂给jsoup解析。只有在这两种方案都失效、服务器要求完整浏览器环境时才考虑引入无头浏览器方案,但那个方案不是jsoup的范畴了。判断方法是看页面源码,按Ctrl+U查看HTML里能不能找到分页标签,找不到就是JS渲染;找得到却解析不出来,再回来看选择器写错没写错。

5. 爬取提速与稳定:并发请求、限速与断点续爬

5.1 多线程并发爬取

单线程按页循环在100页以内的任务里够用,页面超过200页后耗时明显,每一页从发起请求到解析完成大约1~2秒,200页就是5分钟以上,还没算重试。用固定线程池把页码分给多个线程,能把耗时压缩三分之一到一半。模拟项目X的资讯站请求响应稳定,我用4个线程跑,额外加一个内存队列收集解析结果,避免多线程同时写库把连接池打满。

ExecutorService pool = Executors.newFixedThreadPool(4); BlockingQueue<List<NewsItem>> resultQueue = new LinkedBlockingQueue<>(); for (int page = 1; page <= 30; page++) { final int pageNo = page; pool.execute(() -> { Document doc = fetchWithRetry(buildPageUrl(pageNo), 3); if (doc != null) { List<NewsItem> items = parseListPage(doc); if (!items.isEmpty()) { resultQueue.add(items); } } }); } pool.shutdown(); pool.awaitTermination(10, TimeUnit.MINUTES);

线程池大小设置为4而不是8,因为jsoup请求属于IO密集型,线程数超过连接池上限只会增加上下文切换,不会带来线性提速。awaitTermination的10分钟是超时保护,任务真的卡死时避免主线程无边等待。这里有一个并发环境下的细节:parseListPage里用到的时间格式化不是线程安全的,要么每次解析时新建SimpleDateFormat,要么用DateTimeFormatter,后者线程安全可以直接放在静态字段里。

5.2 限速策略

并发提上去之后,服务器那边的压力也上去了,限速不是可选项。我的习惯是最低间隔用信号量控制,不让线程池里所有线程同时冲出去。

private static final Semaphore rateLimiter = new Semaphore(3); private void acquirePermit() { try { rateLimiter.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }

Semaphore(3)允许最多3个线程同时持许可证,配合4个线程的线程池,等于最多只有3个并发请求同时在线。需要精确控制每秒请求数时,可以换成令牌桶思路:用ScheduledExecutorService每秒往桶里放一个令牌,请求前先取令牌,拿不到就等。限速参数没有通用值,目标站是静态资讯站我一般设每秒1~2个请求;如果是数据接口,可以放宽到每秒5个。关键是先跑50页观察有没有出现403和超时,没有再把参数收紧。

5.3 断点续爬

分页爬到一半进程挂掉是正常现象,重头跑一遍又浪费前面的请求。断点续爬只需要记录两样东西:已经处理过的页码和已经入库的URL。页码记录适合跨次运行,URL记录适合防止同一进程内重复入库。

private final Set<String> visitedUrls = ConcurrentHashMap.newKeySet(); private void saveCheckPoint(int page, String filePath) { try { Files.writeString(Paths.get(filePath), String.valueOf(page), StandardCharsets.UTF_8); } catch (IOException e) { logger.warn("写入断点失败: {}", filePath, e); } }

ConcurrentHashMap.newKeySet()返回一个线程安全的Set,多线程并发时add不丢数据。断点文件每次只写页码,不写整个集合,因为文件名本身已经携带了目标信息。重启后读取断点文件,从checkPoint + 1页继续跑。这里我不用数据库记录断点,一是文件更直观,二是分页爬虫的断点粒度只在页码级别,不需要更细的状态管理。每次跑完一页就写一次文件,就算进程被强杀,损失的也只有正在跑的那一页。

6. 收尾这一环:用日志与断言锁住抓取质量

爬虫跑完不代表任务结束,数据能不能用还得过一道校验关。我习惯在爬取结束前打印统计报表,再用几个硬性断言卡住质量,不合格就直接抛错,而不是让脏数据静默入库。

int expectedPages = 15; int actualPages = successfulPageCount.get(); if (actualPages < expectedPages) { throw new IllegalStateException("期望爬取" + expectedPages + "页,实际只有" + actualPages + "页"); }

expectedPages来自页面底部的总数标签,如果目标页没有总数,就从列表空转的次数推算。这个断言能捕获“分页控件样式改版导致漏页”和“中间某页连续超时被跳过”两类问题。断言之外,我还打印一个简单的日志摘要,标题数量、URL去重数量、日期格式非法数量,一眼就能判断数据是否干净。

logger.info("抓取完成,成功页: {}, 新条目: {}, 去重跳过: {}", successfulPageCount.get(), totalNewItems.get(), totalDuplicateItems.get());

我从第一次写分页爬虫到现在,最深的教训就是:翻页逻辑写得再顺,也要在最后一页做一次人工核对——打开浏览器看末页最后一行的数据,和数据库里最后一条记录比一比。有一些边界状况是代码无法预判的,比如末页在改版中途被截断了,或者服务器在末页返回了缓存数据。从那以后我每次跑分页爬虫,都会强制走一遍三件事:确认终止条件是“下一页消失”还是“列表为空”、确认页面编码不是靠猜、确认去重字段是数据库唯一索引。这三张牌打出去,分页爬取任务基本就能收得住。希望这篇笔记能帮你在jsoup上少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询