资讯详情

资讯详情

jsoup分页爬取实战:从翻页循环到数据落地,避开乱码与反爬坑

简介这是一个面向Java初/中级开发者的jsoup分页爬虫示例工程聚焦静态HTML网页按页码批量抓取时URL构造、页面解析与数据提取等核心问题。资源共16个文件压缩包约3.08MB包含7个jar依赖库如jsoup-1.9.2、WebCollector、slf4j日志组件等一个Java源码文件、编译后的class文件以及Eclipse工程配置导入IDE即可直接运行免去手动下载依赖的步骤。示例代码完整演示了分页抓取的关键链路通过connect()设置页码参数、parse()解析文档、CSS选择器定位分页链接和正文内容、attr()提取href及所需字段并包含循环遍历多页、异常捕获与重试、日志记录等实用处理。同时工程保留清晰的目录结构便于按需修改和二次开发可作为学习参考或爬虫项目脚手架。若后续遇到JavaScript动态加载页面也可以基于此代码结合Selenium等工具继续扩展但当前包已完整覆盖静态HTML主流的爬取场景。已有1080人学习下载适合有一定Java基础、想快速上手jsoup分页爬取或希望扩展稳定性的开发者参考。1. 分页网页爬不完jsoup 分页爬取网页的第一步先让抓取循环跑起来用 jsoup 做分页爬取网页踩过坑的人都懂第一页抓得顺风顺水翻到第 37 页突然断了或者同一个链接被抓了三次日志里全是乱码和超时。这个方向解决的是服务端渲染的传统页面——按页码遍历、解析列表、提取字段、入库归档这一整条链路Java 后端一个依赖就能跑起来不需要装浏览器。适合做资料归档、商品比价、新闻采集的开发者如果你要抓的是前端动态渲染的 SPAjsoup 会明显吃力那种场景更适合去找数据接口或者直接上无头浏览器。这篇会把请求参数、翻页判停、选择器提取、乱码和反爬这些点逐个说透。2. 为什么选 jsoup 做分页抓取解析模型、连接参数与第一个请求Java 里做 HTTP 抓取从来不是只有 jsoup 一个选择。HttpClient 负责把 HTML 拿回来但解析还得靠正则或者自己封装 DOM 遍历HtmlUnit 能模拟完整浏览器行为但重、慢选择器语法和日常习惯也不够贴近。jsoup 的价值在于它把“连接网页”和“解析网页”两件事合在了一起一次 connect 调用拿到 HTML直接用 CSS 选择器定位元素API 手感接近 jQuery。对分页爬取这种“高重复、小流量、数据散布在列表页”的场景它是成本和效率最平衡的选择。2.1 连接参数怎么设timeout、UA、redirects 的默认值和坑jsoup 的连接入口是 Jsoup.connect(String)返回一个 Connection 对象所有请求参数在调 get() 之前设置。这里是一份我常用的基础配置Connection conn Jsoup.connect(url) // 不设置 UA 会被很多站点直接 403这里伪装成浏览器 .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36) .referrer(https://example.com/) .timeout(10_000) .maxBodySize(10 * 1024 * 1024) .followRedirects(true) .ignoreHttpErrors(false) .ignoreContentType(false); Document doc conn.get();各参数按分页场景解释一下timeout 单位是毫秒10 秒对翻页任务比较合适弱网环境下 5 秒太容易失败长任务里 15 秒又会拖慢整体节奏maxBodySize 默认只有 1MB超过会被截断列表页带大图或大量内联数据时很容易踩设成 10MB 基本够用followRedirects 默认就是 true保持默认就好ignoreHttpErrors 默认 false访问 404 页面会抛异常调试期强烈建议保持默认这样断链能第一时间暴露ignoreContentType 默认 false第 3 章抓 JSON 接口时再改成 true。调试期有个小习惯很省事跑第一页时先只打印条数和页面标题不要急着写完整提取逻辑。确认连接通了再往下写选择器。比如Document doc Jsoup.connect(url).timeout(10_000).get(); System.out.println(doc.title()); System.out.println(doc.select(div.card).size());控制台能看到页面标题和卡片条数说明 URL、UA、编码都通了这时候才进入真正的解析环节。2.2 Document 与 ElementsDOM 模型对分页循环意味着什么分页循环会反复做三件事连接、解析、提取。和 HttpClient 拿到字符串再正则硬切的方式相比jsoup 的 DOM 模型让解析这一环变得可维护。get() 返回的 Document 是一棵解析好的 DOM 树Document.select() 传入 CSS 选择器返回 ElementsElements 继承自 ArrayList可以直接 for 循环。每个 Element 还有自己的 selectFirst()、text()、attr() 方法定位字段非常直接。另一个容易被忽略的点Document 不是线程安全的。如果打算多线程分页抓取每个线程必须有自己的 Connection 和 Document不能共用一个解析结果往外发数据。常见做法是每个线程独立构造请求抓完当前页就把结果交给线程安全的队列去落库解析过程不共享任何可变状态。2.3 最小可行循环先跑通再优化很多分页抓取翻车不是选择器写错了而是上来就写完整循环URL 模板、字段、异常处理全部耦合在一起出了错很难定位。我一般会先用固定页数验证链路跑通再改判停逻辑String url https://example.com/news?page; for (int page 1; page 5; page) { String target url page; Document doc Jsoup.connect(target) .userAgent(Mozilla/5.0 ...) .timeout(10_000) .get(); // 先看每页有没有抓到卡片再谈提取 System.out.println(第 page 页条数: doc.select(div.card).size()); Thread.sleep(500); // 请求间隔避免把站点打挂 }这个循环假设 URL 是 ?page1、?page2 这种简单递增。真实页面可能有三种情况页码从 0 开始、第一页不带 page 参数、页码参数放在路径中间。这些规律先花五分钟翻一下浏览器 Network 面板比在代码里反复试错快得多。3. 分页链接的构造与判停条件让循环在最后一页可靠停车分页抓取最核心的问题不是“怎么解析”而是“下一轮往哪跳、什么时候停”。站点翻页规律五花八门判停条件写错轻则漏几页重则死循环。这一章把最常见的翻页规律和可靠的判停写法拆开讲。3.1 先花 5 分钟看懂目标站点的翻页规律分页参数没有统一标准。我见过的大致能分成四种常规页码、偏移量、路径拼接、第一页特殊。最坑的是第一页特殊有的站第一页是 /news第二页是 /news?page2有的站第一页是 ?page0第二页 ?page1还有的站点 ?page2 才是首页因为真正的第 1 页在 /news/ 下面。抓之前把首页、第二页、末页的 URL 都打开看一眼规律马上清楚。站点样式URL 示例循环写法的注意点查询参数递增/list?page1确认 page 从 1 还是 0 开始偏移量翻页/list?offset0size20每页偏移 页码 × 每页条数处理不当容易重复或漏路径拼接/list/2.html有的站第 1 页不出现在该路径下需单独处理第一页特殊/news 和 /news?page2首页不套模板循环从 2 开始或先单独抓我一般会把“特殊第一页”处理成循环从 page2 开始把首页单独抓一次落库去重阶段用规范化后的 URL 做唯一键。这样既不丢首页又不至于在循环里写一堆 if。3.2 用 while 下一页链接控制翻页不依赖总页数很多列表页不给总页数或者总页数是假的——把总记录数除以每页条数整除时末页会多出一页空列表。与其依赖 page totalPages不如依赖页面上的“下一页”链接String firstUrl https://example.com/news; String nextUrl firstUrl; int page 0; while (nextUrl ! null page 500) { page; Document doc Jsoup.connect(nextUrl) .userAgent(Mozilla/5.0 ...) .timeout(10_000) .get(); // 解析当前页的卡片具体见第 4 章 for (Element card : doc.select(div.card)) { // 提取字段 } // 找“下一页”链接注意有的站点按钮带 disabled class Element next doc.selectFirst(a.next:not(.disabled)); if (next null) { break; } // 必须解析成绝对 URL很多站点的下一页是相对路径 nextUrl next.absUrl(href); if (nextUrl.isEmpty()) { nextUrl next.attr(href); } Thread.sleep(300 new Random().nextInt(400)); } System.out.println(抓到最后页: page);selectFirst(a.next:not(.disabled)) 是找带 next class 的 a 标签:not(.disabled) 能跳过置灰的按钮absUrl(href) 会基于当前页的绝对地址拼接返回空串时再退回 attr(href)。page 500 是安全上限站点改版导致选择器失效时程序不会无限跑下去最多五分钟后自己停下来。需要处理下一页 href 指向同一 URL 的情况这是死循环的典型信号。注意下一页链接的 href 在两次迭代里完全相同是死循环的典型信号循环里记一下上一次的 URL 就能拦住。3.3 动态加载的分页jsoup 拿不到 DOM 时的正路jsoup 只能解析 get() 返回的静态 HTML。如果列表页是 ajax 加载的selectFirst(a.next) 大概率是 null或者“下一页”按钮是a hrefjavascript:void(0)。这时候别急着上无头浏览器先打开浏览器的 Network 面板翻一页看 XHR 里多出来的请求。大多数情况下你会找到一个 JSON 接口/api/list?page2size20。直接抓接口数据// 接口返回 JSON必须允许 content type 不是 text/html String json Jsoup.connect(https://example.com/api/list?page page) .userAgent(Mozilla/5.0 ...) .ignoreContentType(true) // 关键不按 HTML 解析拿到原始字符串 .timeout(10_000) .execute() .body(); // 再用 Jackson 反序列化成对象或 JsonNode JsonNode root objectMapper.readTree(json);ignoreContentType(true) 是关键否则 jsoup 会校验响应的 Content-TypeJSON 请求直接抛 UnsupportedMimeTypeException。接口方式比解析 DOM 稳定得多因为 JSON 里通常直接给 total、pages 字段判停条件就很简单当前页小于 pages 继续翻。响应字段的路径用 Jackson 树模型读取不要一次性绑定整个对象否则页面加字段就得改代码。4. 列表页数据提取用 select 语法把字段一次性榨干净翻页循环跑起来之后真正的体力活是提取字段。列表页的卡片结构通常很规律但懒加载、相对路径、文本清洗这些细节处理不好落库的数据质量会非常差。这一章讲选择器怎么写、字段怎么清洗以及什么时候该进详情页。4.1 选择器怎么写才不脆从卡片的 DOM 结构入手提取字段前先按 F12 看列表页结构。常见结构是外层一个 div里面重复出现相同结构的 card每个 card 里有标题、链接、图片、时间。选择器要选“相对稳定”的锚点优先用 class少用位置索引。div.card 比 div:nth-child(2) 稳得多后者一旦插入广告位就全错。Elements cards doc.select(div.card); for (Element card : cards) { String title card.selectFirst(h3.title).text(); // absUrl 会把相对路径拼成完整地址省掉手动拼接 String detailLink card.selectFirst(a.title-link).absUrl(href); Element img card.selectFirst(img); String cover extractCover(img); String date card.selectFirst(span.date).text().trim(); System.out.println(title | detailLink | date); }selectFirst 返回单个 Element匹配不到时返回 null所以对 img 要判空。图片懒加载是常见陷阱img srcloading.gif>private String extractCover(Element img) { if (img null) return ; String cover img.absUrl(src); // 占位图、data: 开头、空地址时去>String rawDate card.selectFirst(span.date).text(); // 2024-03-11 · 阅读 12万 String datePart rawDate.split(·)[0].trim(); String readPart rawDate.contains(阅读) ? rawDate.split(·)[1].replace(阅读, ).trim() : ;日期字符串格式不统一时用 DateTimeFormatter 逐个匹配先试 yyyy-MM-dd再试 yyyy/MM/dd都失败就记日志并保留原文不要直接抛异常中断整个任务。数字字段同理去掉“万”和逗号再转数值转不动的置空并告警。4.3 分页抓取时的 N1 问题列表页能拿到的绝不进详情页很多列表页只给标题和链接详情内容必须再发请求。如果你在 for 循环里再嵌套请求详情页一个两百页的列表会发出几千个请求耗时拉满还容易被封。常见做法是列表页只收集 URL 和标题落库后单独起一个详情页任务按顺序补抓。结果用一个 record 来接public record Article( String title, String url, String cover, String publishDate ) { } ListArticle articles new ArrayList(); for (Element card : doc.select(div.card)) { String href card.selectFirst(a.title-link).absUrl(href); articles.add(new Article( card.selectFirst(h3.title).text(), href, extractCover(card.selectFirst(img)), extractDate(card) )); } // 每抓完一页就 flush 到数据库或 CSV不要攒满内存再写record 是 Java 16 的语法项目在 Java 8 就把 record 换成普通类或加 Lombok 的 Data。注意articles 集合不要无限增长处理完一页就持久化一次否则抓大列表时会 OOM。5. 分页爬取注意这 4 个坑乱码、403、空页面与重复数据分页抓取跑起来不难难的是长时间稳定运行。这一章四条都是实战里最容易翻车的点每条按现象、原因、解决三个层次说。5.1 中文全变乱码响应头没告诉 jsoup 编码咋办现象页面在浏览器里正常抓回来全是éç¨æ·å这种乱码或者标题变成ç§åç§æ。原因jsoup 默认按响应头 Content-Type 里的 charset 解码不少老站响应头不带 charset页面 meta 又写 GB2312 实际是 GBKjsoup 猜错就乱码。解决绕过 jsoup 的解码自己拿原始字节后按页面声明的编码重新解码Connection.Response resp Jsoup.connect(url) .userAgent(Mozilla/5.0 ...) .timeout(10_000) .execute(); byte[] bodyBytes resp.bodyAsBytes(); // 原始字节jsoup 已处理 gzip String charset resp.charset(); // 响应头里的 charset可能为 null if (charset null || ISO-8859-1.equalsIgnoreCase(charset)) { // 先用单字节无损编码取出 HTML 头正则找 meta 里的 charset String htmlHead new String(bodyBytes, StandardCharsets.ISO_8859_1); java.util.regex.Matcher m java.util.regex.Pattern .compile(meta[^]*charset[\]?([\\w-]), java.util.regex.Pattern.CASE_INSENSITIVE) .matcher(htmlHead); charset m.find() ? m.group(1) : UTF-8; } Document doc Jsoup.parse(new String(bodyBytes, java.nio.charset.Charset.forName(charset)), url);这个手法的关键点resp.bodyAsBytes() 返回的是解压后的原始字节不会因为 gzip 导致解码错乱用 ISO_8859_1 抽出的 HTML 头保证每个字节都无损对应正则再抓 GB2312、GBK、UTF-8 这类声明最后按正确编码整体解码。5.2 请求被 403UA、Referer 和频率三个方向排查现象直接访问页面正常jsoup 跑起来 5 秒后全部 403日志里HTTP error fetching URL ... Status403偶尔穿插 429。原因站点的反爬策略通常看三样User-Agent 像不像浏览器、Referer 是不是站内页面、单位时间请求频率。jsoup 的默认 UA 带着 Java 版本号第一次请求就会被识别。解决三个方向一起调整只加一个请求头治标不治本Connection conn Jsoup.connect(url) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36) .referrer(https://example.com/) .header(Accept-Language, zh-CN,zh;q0.9) .timeout(10_000);Referer 填目标站点自己的首页翻页循环里每次请求后至少 sleep 300 到 800 毫秒。重试要带退避不要在同一秒连续重试三次int retries 3; Document doc null; for (int i 1; i retries; i) { try { doc conn.get(); break; } catch (HttpStatusException e) { if (i retries) throw e; Thread.sleep(1000L * i * i); // 1s, 4s, 9s 递增 } }403 连续出现 3 次就停整个任务不要再翻页烧请求。换个时间段再跑比硬顶着抓更容易成功。5.3 空页面和“最后一页”判错漏抓、多抓都在这现象任务结束统计记录数和网页标注的总数差了几条或者循环永不停止日志里 page 已经到 400 还在跑。原因页码从 0 开始但循环从 1 开始首页被跳过最后一页内容不满一页时列表为空但下一页按钮还亮着部分站翻到最大页数后仍然返回最后一页数据却不给下一个链接导致 while 循环一直重复同一页。解决判停不能只信一个信号用“下一页链接是否为空 当前页是否抓到数据 最大页数上限”三选二int page 0; String nextUrl firstUrl; while (true) { if (nextUrl null || page 500) break; // 信号1没有下一页或触达上限 Document doc fetch(nextUrl); Elements cards doc.select(div.card); if (cards.isEmpty() page 1) break; // 信号2空页视为末页 // 抓取数据 nextUrl nextPageUrl(doc); page; }信号 2 里的 page 1 很关键第一页本身就是空页面内容还没发布时不能直接判停否则整个任务一开始就结束。如果用的是接口方式且有 total 字段优先用 total 计算出来的页数判停那是最稳定的方式。5.4 重复数据爬两遍就翻倍去重键不能只靠标题现象脚本补跑一次数据库里同一条记录出现两条标题一样但链接是两个不同 URL或者同一个 URL 因为带跟踪参数被当成两条。原因只按标题去重但标题可能重复URL 带了 utm、from 之类的跟踪参数同一篇内容在推荐位和列表位重复展示。解决去重键用规范化后的完整 URL去掉跟踪参数和 # 锚点private String normalizeUrl(String raw) { if (raw null) return ; try { URL u new URL(raw); ListString kept new ArrayList(); if (u.getQuery() ! null) { for (String pair : u.getQuery().split()) { // 跳过常见跟踪参数保留 id 这类关键参数 if (pair.startsWith(utm_) || pair.startsWith(from) || pair.startsWith(spm)) { continue; } kept.add(pair); } } return new URL(u.getProtocol(), u.getHost(), u.getPath()) .toString() (kept.isEmpty() ? : ? String.join(, kept)); } catch (MalformedURLException e) { return raw; } }数据库里给规范化 URL 建唯一索引程序里再用 HashSet 挡一道。URL 规范化要在入库前做别等重跑之后才清洗事后清洗的脚本永远赶不上脏数据产生的速度。6. 把分页抓取沉淀成可复用模块配置驱动与结果校验分页抓取写多了你会发现每个站点之间的差异其实就三块URL 怎么翻、卡片选择器是什么、字段怎么映射。把这三块从循环代码里剥出来换站成本从改半天降为加一个类。6.1 用模板方法固定“翻页-选卡-解析-保存”流程public abstract class ListPageCrawler { protected String baseUrl; protected int maxPages 500; public final void run() { String nextUrl baseUrl; int page 0; while (nextUrl ! null page maxPages) { Document doc fetch(nextUrl); for (Element card : doc.select(itemSelector())) { save(parseItem(card)); } nextUrl nextPageUrl(doc); } } // 三个扩展点选择器、卡片映射、下一页地址 protected abstract String itemSelector(); protected abstract Article parseItem(Element card); protected abstract String nextPageUrl(Document doc); }模板方法把翻页循环焊死在父类超时、重试、sleep 全部沉淀在 fetch 和 save 里。子类只需要回答三个问题卡片选哪个、卡片怎么转成 Article、下一页去哪儿找。判停策略想改重写 nextPageUrl 返回 null 即可。6.2 抓完怎么验证页数校验与断点续抓验证我习惯做三层。第一层脚本结束打印总页数和总条数对照站点底部的记录总数总数约等于“页数 × 每页条数”就没有漏页。第二层从库里随机抽三条打开详情页手工核对标题、日期、正文前两句。第三层把当天抓到的 URL 集合和前一天的做差集差集接近零但记录数差异明显说明选择器选空了这时候去本地存的 HTML 快照上看一眼结构而不是对着线上猜。断点续抓的习惯也值得养成。循环里每抓完一页把当前页号和下一页 URL 写到本地文件异常退出后重新启动直接从记录位置继续。我现在接手新的列表站点一定先抓一页存一个 HTML 快照在本地选择器全在那个快照上调试免得一边抓一边改、数据污染。断点续抓、空页告警、URL 去重这三样配齐jsoup 分页抓取就不再是每星期修一次的玄学活。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →