
简介使用 jsoup 处理分页爬取的 Java 工程示例面向 Java 初学者与爬虫开发者解决多页 HTML 数据抓取、解析与结构化提取等问题。工程完整覆盖 URL 分页参数构造、jsoup connect() 建立连接、CSS 选择器定位元素、循环遍历页码、异常重试与数据清洗等关键环节并兼顾请求速率控制与 Robots 协议等注意事项适合作为入门到进阶的参考项目。资源共 16 个文件RAR 压缩包大小 3.08MB包含 Java 源文件、编译后的 class、Eclipse 配置文件以及 .project/.classpath 工程描述文件另附 jsoup-1.9.2.jar、json-20140107.jar、WebCollector-2.52.jar、log4j、slf4j 等依赖库导入后即可完整运行。已有 1080 人学习下载。代码采用 Eclipse 标准目录结构src 与 lib 分置便于直接查阅和调试通过测试类可快速验证分页抓取逻辑并积累应对动态加载或异常网页的替代方案是一份实用的小型爬虫 starter 工程。若需扩展还可结合多线程或自定义持久化方式实现更高效的抓取。1. jsoup分页爬取是什么轻量爬虫的常见起点jsoup分页爬取就是用 Java 的 jsoup 库把网站的列表页一页一页请求回来再用 CSS 选择器从 HTML 里抽出结构化数据最终落到文件或数据库。做后端开发的人迟早会碰到这个需求系统里缺一批外部数据对方没有开放 API 和导出接口唯一路径就是爬网页而网页列表几乎全是分页的。jsoup 在这个场景里的优势很明确依赖小、API 直接、解析 HTML 像写 jQuery 一样顺手不需要启动浏览器内核普通定时任务的内存占用可以压得很低。它的边界也同样明确对纯前端渲染的页面无能为力典型表现是列表数据通过 AJAX 异步加载直接用 Jsoup.connect 拿到的是空壳 HTML。这篇文章面向 Java 基础扎实、但第一次把分页爬虫写完整的开发者也适合想把分页边界条件和反爬应对梳理清楚的熟手。读完你能跑通一个最小完整的采集流程也明白哪些参数值得调、哪些坑必须躲。2. 分页 URL 的构造与循环从第一页爬到最后一页的关键写法分页爬取的第一步不是写解析逻辑而是先把手里的 URL 摸清楚。很多第一次写爬虫的人上来就写Jsoup.connect(url).get()然后发现只有第一页数据原因就是分页参数压根没拼进 URL。分页的 URL 构造方式直接决定循环怎么写这一章先把三种常见的分页形式捋清楚再给出最小可跑的循环代码和退出条件。2.1 三种常见分页模式URL 参数、路径分段、POST 表单第一种是查询参数分页最常见。URL 大概是list?page1、list?page2这种页码放在 query string 里有些站点用pageNo、pagenum、pageIndex这些名字本质一样。循环时只需要改参数值字符串拼接或者String.format都能处理。第二种是路径分段分页URL 长成/list/1.html、/list/2.html的样子历史悠久的站点尤其爱用这种结构对搜索引擎更友好但对爬虫来说只是多一个拼接点。第三种是 POST 表单分页列表数据不在 URL 里而是通过 POST 请求体里的page字段控制的常见于一些后台管理系统和旧式搜索页。这种用Jsoup.connect(url).data(page, page).post()来发请求注意是post()而不是get()。还有一种需要单独提一下很多站点把总页数藏在列表底部的分页组件里爬之前需要先解析出总页数再决定循环上限。拿不到总页数也没关系可以在循环里判断是否还有“下一页”链接或者观察当前页是否返回了空列表这两种方式在后面会展开。先说结论构造 URL 之前先把目标页面在浏览器里打开开发者工具手动翻一页看请求 URL 里哪个参数在变。这一步看着基础但能避免后面整个循环白写。2.2 用 Jsoup.connect 构造请求最小可跑通的循环代码下面是最小可跑通的循环代码以查询参数分页为例。代码里我用 example.com 占位真实场景替换成目标站点的 URL 和参数名。String baseUrl https://example.com/list; int maxPage 5; // 先写死页数后面再改成动态判断 for (int page 1; page maxPage; page) { // 构造当前页URLpage参数从1开始 String url String.format(%s?page%d, baseUrl, page); try { // 设置UA、超时和最大body大小避免拿到一半就断 Document doc Jsoup.connect(url) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64)) .timeout(10_000) .maxBodySize(0) .get(); System.out.println(page page size doc.select(ul.list li).size()); // 解析代码在这里第三章详细展开 } catch (IOException e) { System.err.println(page page failed: e.getMessage()); // 单页失败先记录不要中断整体循环 } }这段代码的逻辑是先用String.format拼出当前页 URLpage 从 1 递增到 maxPage每次请求都设置一个常规浏览器的 User-Agent超时设 10 秒maxBodySize(0)表示不限制 HTML 大小防止某些页面 body 特别大时被默认上限截断。catch 块只打印错误不中断原因是分页采集里单页失败很常见应该等循环结束后统一处理而不是因为一页报错就丢掉了后面所有页。这里有个参数值得注意timeout 的单位是毫秒某些响应较慢的站点可能需要调到 15 秒或 20 秒但不要为了等一个页面无限加大后面第 5 章会讲超时与限流的关系。实际写代码时我一般会把 maxPage 先手动确认一遍。做法是在浏览器里翻到最后一页看分页条上的总数先写死跑通再改成动态判断。直接写死的好处是调试阶段不会因为退出条件写错而无限抓下去代价是换站点时要改代码。动态判断的写法在 2.3 节给出。2.3 判断末页的三种退出条件别把空页当正常页动态判断总页数一般有三种做法。第一种是解析分页条里的“下一页”按钮如果页面里找不到 next 链接就说明已经到末页。第二种是从分页组件的页码序列里解析出最大页码通常是最后一个数字链接。第三种是根据当前页实际返回的列表项数量判断如果数量为 0 就停止。三种方式我实际都用过前两种更稳第三种容易在页面结构调整时误判。// 方式一以“下一页”是否存在作为循环条件 String url String.format(https://example.com/list?page%d, page); Document doc Jsoup.connect(url).userAgent(UA).get(); Elements items doc.select(ul.list li); if (items.isEmpty()) { System.out.println(page page empty, stop); break; } Elements nextBtn doc.select(a.next); if (nextBtn.isEmpty()) { System.out.println(no next button, reach last page); break; }// 方式二从分页组件里解析总页数先获取再循环 Document firstDoc Jsoup.connect(https://example.com/list?page1).get(); Elements pageLinks firstDoc.select(ul.pagination a[href]); int totalPage 1; for (Element link : pageLinks) { String text link.text().trim(); // 取数字文本中最大的一个作为总页数 if (text.matches(\\d)) { totalPage Math.max(totalPage, Integer.parseInt(text)); } } System.out.println(totalPage totalPage);方式一的逻辑比较直观空页或没有 next 链接都视为到达末页然后退出。但这里有个陷阱有些站点的分页条长年显示“下一页”按钮翻到最后一页后按钮变成灰色、链接消失但元素还在此时用a.next仍然能选中就会在最后一页继续请求不存在的页需要额外判断禁用样式比如link.hasClass(disabled)再决定是否退出。方式二的坑在于分页组件里的页码文本可能包含省略号和当前页两个重复数字所以用正则先过滤数字再取最大值。单页失败时把错误记录到日志继续下一轮不要因为某一页解析不到页码就 break。3. 用 select 抽取列表页数据选择器写法与实体映射写完循环只是拿到 HTML真正有价值的动作是从 HTML 里把目标字段抽出来。jsoup 的 select 支持 CSS 选择器对写过 jQuery 的人来说几乎没有学习成本。但选择器写得好不好直接决定这段爬虫能活多久。3.1 列表页的三种常见结构li 列表、表格、卡片大多数分页列表页可以归成三类。第一类是 ul/li 列表最经典每条数据一个 li字段散落在 li 内部的标题、摘要、时间、价格等节点上选择器写起来最简单。第二类是 table 表格常见于后台管理页面和传统企业站每行 tr 是一条记录每列 td 对应一个字段缺点是字段顺序固定页面稍微调整列就会错位。第三类是 div 卡片流电商和内容社区最爱用每条数据是一个div.content-item之类的块内部结构通常比 li 列表更复杂需要逐层定位。判断页面属于哪种结构不需要看源码用浏览器开发者工具点一下元素就行。如果列表是 table直接看 tr 数量如果是 div 卡片看重复出现的 class 名。这个判断影响的是 3.2 节选择器的根节点选择根节点选错了后面所有字段选择器都会失效。我见过一个翻车案例某开发者把列表根节点选成div.list结果页面实际结构是div.list ul li因为根选择器选在了外层容器上导致每个字段都匹配到了多个节点数据全串行。3.2 select 从容器到字段的抽取代码与选择器说明以一个典型的内容列表页为例。假设每条数据是 li里面有标题 h2、链接 a、发布时间 span.time、阅读数 span.count下面代码展示完整的字段抽取流程。String url String.format(https://example.com/list?page%d, page); Document doc Jsoup.connect(url).userAgent(UA).get(); // 第一步定位列表容器ul.list 是每一页的列表根节点 Elements items doc.select(ul.list li); System.out.println(page page items items.size()); // 第二步遍历每条数据抽取字段 for (Element item : items) { // 标题取第一个h2里的纯文本 String title item.select(h2 a).text(); // 链接abs:href 自动拼接成绝对地址 String link item.select(h2 a).attr(abs:href); // 发布时间与阅读数 String publishTime item.select(span.time).text(); String readCount item.select(span.count).text(); System.out.println(title | link | publishTime | readCount); }这段代码里最值得说明的是三个细节。select(ul.list li)用的是子选择器只匹配 ul.list 直接子级的 li不会误抓到嵌套更深的多余元素如果页面里还有其他 ul.list可以改成在更精确的容器范围内查找比如doc.select(div.main-content ul.list li)。attr(abs:href)是 jsoup 处理相对链接的便利手段目标站点的链接如果写成/detail/123.html用这条能自动变成https://example.com/detail/123.html省掉手动拼接。.text()返回的是节点内的纯文本已经去掉了标签和空白比用.html()再正则清洗省事得多。字段抽取的另一个要点是空值处理。有些列表项可能缺发布时间或阅读数select 选不到节点时.text()返回空字符串而不是报错。这本身不算问题但数据落到库里会有大量空值需要在后期清洗。更稳妥的做法是对每个字段做个备选选择器比如时间字段既有span.time也有span.date时先判断第一个是否为空再取第二个这个在 3.3 节的实体映射里一起处理。3.3 把数据装进实体类清洗、去重与空值处理解析出来的字段还是散装的 String真正要落库之前最好先装进实体类。实体类的好处是字段类型明确、后续改表结构只动一处、写文件或数据库的代码可以复用。public class Article { private String title; private String link; private String publishTime; private long readCount; // 省略getter/setter }// 抽取出的一条数据装进实体 Article article new Article(); article.setTitle(item.select(h2 a).text()); article.setLink(item.select(h2 a).attr(abs:href)); article.setPublishTime(normalizeTime(item.select(span.time).text())); String countText item.select(span.count).text(); article.setReadCount(countText.matches(\\d) ? Long.parseLong(countText) : 0L);代码背后的逻辑是文本型字段直接存 String数值字段在转换前先用正则校验转换失败给默认值避免parseLong抛 NumberFormatException。到这里一条记录的抽取就完成了。去重通常有两种策略以链接为主键做 URL 去重或者以标题做文本去重。URL 去重更精准因为同一篇文章可能被分到多个栏目标题会变但 URL 不变。实现上用一个 HashSet 存见过的链接每次新记录先检查 set或者更正规一点落库时靠数据库唯一索引扛重复。SetString seenLinks new HashSet(); // 每次拿到实体后 if (seenLinks.contains(article.getLink())) { continue; // 已处理过跳过 } seenLinks.add(article.getLink());这段逻辑要放在整个分页循环的外层因为同一个链接可能出现在不同页码里只放在单页循环内部是防不住跨页重复的。去重时机越早越好最好在进入存储之前过滤不然数据量一大重复记录会成倍消耗存储空间和后期清洗时间。4. 数据落地与断点续抓中断一次也不必从头再来数据抽出来之后就该考虑存储。存文件最简单存数据库方便查询存 Excel 组里其他人也能看。但比存储方式更重要的是断点续抓不然抓了一半网络抖动导致进程退出就得从头再跑一次。4.1 落文件还是落数据库任务量决定技术选型先给一个实际判断标准数据量在万级以下、一次性任务、不需要复杂查询直接落文件。CSV 格式最通用Excel 打开不识别编码就加一个 UTF-8 BOM或者直接用 JSON Lines 每行一条 JSON后续处理也方便。数据量到十万以上、需要增量更新、要做去重和关联查询就应该落数据库。爬虫任务的数据量增长往往比预想快一次看起来小批量的采集跑起来可能每天新增几万条所以我的习惯是凡是任务要跑多次的一律先落库。// 最简单的文件落盘UTF-8编码写CSV try (FileWriter fw new FileWriter(articles.csv, true); BufferedWriter bw new BufferedWriter(fw)) { for (Article a : articles) { // 转义处理放在后面这里只做追加 bw.write(String.format(%s,%s,%s%n, escapeCsv(a.getTitle()), escapeCsv(a.getLink()), escapeCsv(a.getPublishTime()))); } } // 转义标题里可能带逗号、引号、换行不处理CSV会裂列 private static String escapeCsv(String value) { if (value.contains(,) || value.contains(\) || value.contains(\n)) { return \ value.replace(\, \\) \; } return value; }这段代码有两个要注意的点。FileWriter 的第二个参数 true 表示追加模式配合断点续抓每次只写新抓到的数据而不是覆盖整个文件。escapeCsv 是一个必要的转义方法处理标题里可能出现的逗号、引号、换行符否则 CSV 列会错位。数据库落库的代码就不展开了但 JDBC 连接参数里一定要加上useUnicodetruecharacterEncodingutf8这是字符集问题的头号来源。4.2 记录已完成页码从断点继续的接入方式断点续抓的核心思想很朴素每成功处理完一页就把页码记到本地文件里下次启动时先读这个文件从记录的页码 1 开始。这个做法比记录 URL 更可靠因为 URL 可能因为站点改版而失效页码是稳定的。String stateFile crawl-state.txt; // 启动时读取上次进度 int startPage 1; File f new File(stateFile); if (f.exists()) { startPage Integer.parseInt(Files.readString(f.toPath()).trim()) 1; } for (int page startPage; page totalPage; page) { boolean success crawlPage(page); // 内部包含下载和解析 if (success) { // 只有整页成功才算完成写入进度 Files.writeString(f.toPath(), String.valueOf(page)); } else { // 失败页重试3次仍失败记录错误页码后继续 } }这里的关键点是“完成”的判定范围。不能把页码写进状态文件就算成功必须等这一页的所有记录都解析并落库完成后再写。如果解析到一半进程崩溃下一轮从记录的页码 1 开始会漏掉崩溃时正在处理的那一页。所以更稳的写法是把状态更新放在单页的 try 块最后所有子步骤成功才执行。错误页码单独记到 error-pages.txt等整体跑完后单独重跑这些页比当场死循环要合理。Files.readString要求 Java 11 以上老项目用new String(Files.readAllBytes(...), StandardCharsets.UTF_8)替代。4.3 异常分级与重试策略哪些失败值得等哪些该放弃分页爬虫常见的异常分三类。第一类是网络层异常比如连接超时、SocketTimeoutException这类通常是临时性的值得重试。第二类是 HTTP 状态异常比如 403、429、503这是目标站点的反爬或过载信号盲目重试只会加重被封风险。第三类是解析层异常HTML 下载成功但页面结构不是预期结构比如列表选择器一个节点都没匹配到这种情况重试多少次都没用应该记录错误并人工介入。重试策略我一般用指数退避第一次失败等 2 秒第二次等 4 秒第三次等 8 秒最多重试 3 次。退避的时间间隔根据目标站点的响应速度调整站点本来就慢就要加大初始值。private boolean fetchWithRetry(String url, int maxRetries) { int retry 0; while (retry maxRetries) { try { Document doc Jsoup.connect(url) .userAgent(UA) .timeout(10_000) .get(); return parseAndSave(doc); // 解析也放在try内 } catch (IOException e) { retry; if (retry maxRetries) { System.err.println(give up url url); return false; } try { // 指数退避retry1时等2秒retry2时等4秒 Thread.sleep(1000L * (1L retry)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return false; } } } return false; }这个方法的逻辑是把所有可能失败的动作都放在同一个 try 块里包括下载和解析任一步抛异常都视为本次失败。重试间隔用移位算1 1是 2 秒1 2是 4 秒。看起来简单但很多人写的重试只包了连接不包解析步骤结果下载成功了但每次都在解析处失败无限重试还是抓不到数据。还有一点catch 里不要吞掉 InterruptedException中断标志要恢复不然线程池环境下会出现中断状态被污染的问题。5. jsoup 分页爬取的避坑手册五个我踩过的真实问题这一章是血泪经验汇总。前几章的代码跑通只是开始投入生产之后坑才一个个浮出来。我挑五个最高频的按“现象、原因、解决”的格式写方便你对照排查。5.1 页面乱码jsoup 不是解析乱码是解析前编码就错了现象页面在浏览器里显示正常用 jsoup 抓出来的中文全是问号。 原因jsoup 默认根据 HTTP 响应头里 Content-Type 的 charset 来解码如果目标页面没有正确声明字符集或者声明的是 Windows-1252 这类编码jsoup 就会解码错。有些站点的 HTML 里明明有meta charsetutf-8但 HTTP 响应头里没带 charsetjsoup 这时不会读 meta 标签直接用默认编码。 解决下载后用 jsoup 提供的 parse 方法重新指定编码。// 先以字节流读取再用明确的字符集解析 Connection.Response resp Jsoup.connect(url).execute(); Document doc resp.charset(StandardCharsets.UTF_8).parse();这个写法先拿到原始响应再通过 charset 方法指定 UTF-8 解析比直接.get()多了可控性。如果还是乱码把resp.charset()打出来看看八成是头部声明和 meta 声明不一致。极端情况某些页面是 GBK 但写成了 GB2312个别字符还是会乱这时要查看实际字节内容确认编码。还有一个常见误用是拿到 HTML 后自己转一次码实际上只要在 parse 前指定正确 charset 就行转码是多此一举。5.2 遇到 403 与验证码先查头信息再考虑降速现象前几页抓得好好的某页开始返回 HTTP 403或者页面里出现验证码图片。 原因403 说明目标站点已经识别到非浏览器的访问特征。最常见的是 User-Agent 缺失或太过明显其次是请求频率过高触发限流还有一种是你带了 cookies 访问了需要登录才能看的页面。 解决先把 User-Agent 换成真实浏览器的完整 UA再加上 Referer 和 Accept-Language 头部很多静态页站点这样就能过。如果 403 依旧把请求间隔从 1 秒拉大到 3 到 5 秒单线程跑。还不行就需要维护 cookies。Document doc Jsoup.connect(url) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) .header(Referer, https://example.com/) .header(Accept-Language, zh-CN,zh;q0.9) .timeout(10_000) .get();这段代码展示的是最常规的头部伪装。Referer 填目标站点的首页即可有些站点会校验来源页。如果加了这些仍然被 403就别再和站点较劲了大概率是 IP 维度被限制换策略从接口和速率入手或者换采集时间段。这里要提醒的是不要为了过验证码去搞识别模块既越界又容易被封得更狠jsoup 场景下遇到验证码最合理的做法是停掉任务人工介入。5.3 选择器因页面改版失效日志里必须预留原样 HTML现象爬虫稳定运行一周某天突然抓到的全是空数据但页面能正常打开。 原因目标站点改了页面结构class 名变了或者 DOM 层级调整了选择器匹配不到节点。 解决预先在日志里输出一页原始 HTML改版时能快速定位差异。// 重要把抓到的第一页HTML保存下来留底 Files.writeString(Path.of(debug-page1.html), doc.outerHtml(), StandardCharsets.UTF_8);单独看这段代码没有技术含量但它能救你于水火。页面改版后第一件事是打开保存的旧 HTML 和当前新页面做对比class 名、层级结构一目了然省去重复猜测的时间。日志里还应该记录每次解析命中数量比如原来正常抓到 30 条某天只抓到 0 条日志里立刻能看出来不用等到数据报表反馈才察觉。选择器本身也要有意识写稳一点能用结构关系定位就不要依赖过于具体的 class 名但这和易读性有冲突属于权衡题。5.4 退出条件写错抓空页、死循环与重复数据的来源现象脚本运行特别久不结束或者日志里出现大量空页记录。 原因退出条件写错。常见的有三种第一用items.isEmpty()判断末页但页面列表为空时响应码仍是 200条件成立退出没问题问题出在首页本身为空但站点返回了“暂无数据”的提示页这个页面上还有其他列表结构导致误判第二种下一页按钮判断选择器没匹配禁用状态在最后一页反复请求不存在的页码第三种解析总页数时把当前页码也算进去导致多抓一页。 解决组合使用两个退出条件同时监听响应内容长度。// 退出条件列表为空或者页面里出现结束提示语 String bodyText doc.body().text(); boolean reachEnd items.isEmpty() || bodyText.contains(没有更多数据); if (reachEnd) { // 写日志记录退出时的page和响应长度 System.out.println(stop at page page htmlLen doc.html().length()); break; }这段代码把“列表为空”和“页面包含结束提示语”两个条件组合起来。原因在于只用 items.isEmpty() 时一旦站点改版把列表节点改名items 永远为空爬虫会在第一页就退出反而暴露了选择器失效的问题这个不算太坏最坏的情况是 items 一直不为空但全是重复数据这种很难用条件判断出来只能靠 URL 去重兜底。所以我的建议是退出条件要有日志、去重要有状态两个防线同时存在才能把全局控制在壳内。5.5 请求太密集被限流连接复用与间隔控制现象抓取速度快到“炫技”级别每页请求间隔不到 100 毫秒结果抓了几百页后突然连续超时和报 503。 原因目标站点的反向代理检测到单个 IP 在短时间内的并发数和频率远超人类访问特征触发限流。这属于爬虫的黑匣子问题你永远不知道站点的阈值只能通过降速来自保。 解决同一线程里降低请求频率串行抓取每次请求之间用 Thread.sleep 控制节奏。// 每页请求完成之后随机休眠1.5到3秒 Thread.sleep(1500 (long) (Math.random() * 1500));固定间隔比随机间隔更容易被识别因为人不会精确每秒点一次。随机范围要根据目标站点的访问量和响应速度调内容站取 1 到 3 秒比较中庸电商站要更保守。线程池并发这个事我一般建议不要心动jsoup 本身不是设计为高并发爬虫框架的多线程加速固然快但把单 IP 的请求间隔拉短之后被识别和封禁的风险成倍上升。如果确实要并发用固定线程数 2 到 3并分摊不同的出口网络那是另一个话题这里不展开。6. 进阶把分页规则配置化让爬虫换站点只改配置到这里一个完整的分页爬虫已经具备雏形URL 构造、循环、选择器解析、实体映射、存储、断点续抓、异常重试、避坑措施。再进一步把这些硬编码的规则抽出来用配置文件描述“抓哪个站、分页参数是什么、列表选择器怎么写”爬虫本身变成通用引擎才是值得投入的方向。用 properties 文件描述分页规则# 分页规则配置 crawl.baseUrlhttps://example.com/list crawl.pageParampage crawl.pageStart1 crawl.pageEnd-1 # 列表与字段选择器 selector.listul.list li selector.titleh2 a selector.linkh2 a selector.linkAttrabs:href selector.timespan.time selector.countspan.count程序启动时读取配置每个选择器字符串动态编译这样换一个站点只需要新增一套配置不需要改业务代码。更进一步可以把配置改成 JSON 或者数据库表支持多站点同时管理。写配置驱动的爬虫引擎好处不是省那几个类而是让非开发人员也能参与维护解析规则把爬虫从“写死的脚本”变成了“可维护的数据采集工具”。最后说一个我的习惯每次上线爬虫前先抓第一页保存原始 HTML再跑一次解析统计数量再对比浏览器里的真实数据确认无误后才放开全量循环。这个流程看着慢但能从源头堵住大部分翻车。希望这些经验能帮到你让你做分页采集时少走几步弯路。本文还有配套的精品资源点击获取