简介:本资源是一份基于Java的jsoup分页爬虫实战项目,面向Java初学者及网页数据采集入门开发者,解决结构化HTML页面多页批量抓取与解析的核心问题。项目完整封装了URL参数构造、CSS选择器精准提取、分页循环控制、异常重试机制及基础数据清洗逻辑,覆盖从单页解析到全站分页遍历的典型开发流程。压缩包共16个文件,含7个核心jar包(如jsoup-1.9.2.jar、WebCollector-2.52.jar等)、1个主程序Java源码、1个编译后class文件、2个Eclipse工程配置文件(.classpath、.project)及日志与偏好设置相关文件,整体3.08MB,结构清晰,开箱即用。目前已有1082人学习下载,读者可直接运行调试、理解分页请求调度原理、掌握jsoup与常见工具库的协同用法,并借鉴其工程组织方式与错误处理设计思路。
1. jsoup分页爬取网页:不是写个for循环就完事,而是要稳住连接、绕过反爬、对齐页码逻辑
你是不是也试过用 jsoup 写了个for (int i = 1; i <= 100; i++) { Document doc = Jsoup.connect("https://xxx/list?page=" + i).get(); },跑着跑着第17页就卡死、第42页返回空列表、第68页突然跳转到登录页?这不是代码写错了,是没把「分页爬取」当成一个状态可控的会话过程来设计。jsoup 本身不带分页管理能力,它只是个 HTML 解析器;真正决定能不能稳定拿到100页数据的,是你怎么构造请求头、怎么识别真实页码跳转逻辑、怎么处理服务器返回的“假分页”(比如页码参数无效但URL变了)、以及如何在连接中断后精准续爬。这份实战笔记拆解的是某高校课程设计中真实落地的 jsoup 分页爬取方案——它跑通了含动态页码校验、Referer 防盗链、Cookie 会话维持、响应体空值熔断的完整链路。适合正在做课程作业、毕设数据采集或轻量级竞品监控的开发者,尤其当你发现目标网站没有 API、又不想上 Selenium 这种重型方案时,这套纯 jsoup 的轻量级分页架构,就是你该抄的第一份作业。
2. 分页爬取的本质:不是遍历页码,而是模拟人眼+人手的翻页行为
2.1 为什么不能直接拼接 page 参数?——三类典型分页机制拆解
很多新手以为分页就是 URL 里有个?page=1,改成?page=2就能翻页。实际线上网站至少存在三类分页逻辑,直接硬改参数大概率失败:
- 服务端静态分页:URL 参数真实生效,如
/list?page=5&size=20,后台 SQL 直接LIMIT 80,20。这是最理想情况,但占比不足30%; - 前端 JavaScript 渲染分页:页面初始只加载第1页,点击“下一页”时通过 AJAX 请求
/api/items?page=2,返回 JSON 数据再渲染 DOM。此时用 jsoup 直接 GET 原 URL 永远只能拿到第1页; - 服务端动态页码校验:URL 看似支持
page=100,但服务端会校验当前用户权限、访问频次、Referer 头、甚至 Cookie 中的 session token。传入非法页码可能返回 302 跳转、403 拒绝或静默返回第1页内容。
提示:判断分页类型最简单的方法是——打开浏览器开发者工具(F12),切到 Network 标签页,手动点几次“下一页”,观察触发的是 HTML 页面跳转(Document 类型),还是 XHR/Fetch 请求(XHR 类型)。前者可用 jsoup,后者需提取接口并模拟请求。
2.2 jsoup 分页爬取的最小可行架构:四层责任分离
我们不写“万能爬虫”,而是构建一个职责清晰、可调试、可续爬的分页流程。核心是四层抽象:
| 层级 | 职责 | 关键实现要点 |
|---|---|---|
| 页码生成器(PageGenerator) | 输出合法页码序列,支持跳过、断点续爬、页码去重 | 不依赖 URL 拼接,而是从上一页 HTML 中解析<a href="/list?page=2">下一页</a>或 JSON 接口中的"next_page": 2 |
| 请求装配器(RequestBuilder) | 封装 User-Agent、Referer、Cookie、超时、重试策略 | 必须复用 Connection 对象,避免每次 new Jsoup.connect() 丢失会话上下文 |
| 响应处理器(ResponseHandler) | 判定响应有效性:HTTP 状态码、HTML 结构完整性、业务字段存在性 | 例如检查doc.select("div.item").size() == 0即视为本页无数据,应终止而非继续 |
| 结果收集器(ResultCollector) | 统一结构化存储,支持内存缓存 + 文件落盘 + 断点快照 | 每爬完一页立即写入临时文件,记录当前页码和时间戳,防止中断丢进度 |
这个架构不是炫技,而是为后续排查留出明确切口:如果爬到第53页失败,你能快速定位是页码生成错了(查 PageGenerator 日志)、请求被拦了(看 RequestBuilder 发出的 Header)、还是页面结构突变(用 ResponseHandler 的 debug 模式打印原始 HTML)。
2.3 实战:从首页提取真实下一页链接(非 URL 拼接)
很多网站的“下一页”按钮 href 是相对路径或 JS 跳转,直接拼page=2会 404。正确做法是从 HTML 中解析真实跳转地址,并用 jsoup 的abs:语法转为绝对 URL:
// 假设已获取首页 Document 对象 Document firstPage = Jsoup.connect("https://example.com/list") .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .timeout(10000) .get(); // 解析“下一页”链接:兼容多种写法 Elements nextLinks = firstPage.select("a:contains(下一页), a:contains(Next), link[rel=next]"); String nextPageUrl = null; for (Element link : nextLinks) { String href = link.attr("href"); if (!href.isEmpty() && !href.startsWith("javascript:")) { nextPageUrl = link.absUrl("href"); // 自动补全为绝对 URL break; } } if (nextPageUrl != null) { System.out.println("真实下一页地址:" + nextPageUrl); // https://example.com/list?page=2 } else { System.out.println("未找到下一页链接,尝试 fallback 逻辑..."); }这段代码的关键在于link.absUrl("href")—— 它会自动将href="/list?page=2"补全为https://example.com/list?page=2,无需手动拼域名。而:contains()选择器能同时匹配中文“下一页”和英文“Next”,比硬编码select("a.page-next")更鲁棒。如果你的目标网站用<button onclick="goPage(2)">,那就得换策略:用正则从onclick属性里提取数字,或直接分析其 JS 逻辑(这时建议退一步,用浏览器自动化工具)。
3. 请求头与会话管理:让 jsoup 像真实浏览器一样“记住自己”
3.1 必须携带的三大请求头及其业务意义
jsoup 默认请求头极简(只有User-Agent: Java/1.8.0_XXX),而现代网站普遍校验以下三项:
- User-Agent:声明客户端身份。若缺失或过于简单(如
Java),部分网站直接返回 403; - Referer:标识来源页面。例如从
/list点击进入/detail/123,服务端会校验 Referer 是否为/list,否则拒绝访问; - Cookie:维持登录态或防刷 Token。即使未登录,某些网站也会下发
sessionid或cf_clearance(Cloudflare 验证)。
下面是一段生产环境验证过的请求头装配模板:
Connection connection = Jsoup.connect(url) .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", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") .header("Accept-Encoding", "gzip, deflate") .header("Connection", "keep-alive") .header("Upgrade-Insecure-Requests", "1") .header("Sec-Fetch-Dest", "document") .header("Sec-Fetch-Mode", "navigate") .header("Sec-Fetch-Site", "same-origin") .timeout(15000); // 关键:复用同一 Connection 实例的 cookieStore if (cookieStore != null) { connection.cookies(cookieStore); }注意两点:
①header("Sec-Fetch-*")系列是 Chromium 新增的安全头,部分网站(尤其是用了 Cloudflare 的)会校验它,缺失可能导致 403;
②.cookies(cookieStore)是复用 Cookie 的核心——cookieStore是一个Map<String, String>,你在登录页或首页成功请求后,用connection.response().cookies()提取并保存它,后续所有请求都带上,才能维持会话。
3.2 如何安全地维持登录态?——从登录页提取隐藏字段 + 模拟表单提交
假设目标网站需要登录才能查看分页列表。别用“账号密码硬编码”,而是走标准表单提交流程:
// 1. 先 GET 登录页,提取隐藏字段(如 csrf_token) Connection loginPageConn = Jsoup.connect("https://example.com/login") .userAgent(USER_AGENT) .timeout(10000) .method(Connection.Method.GET); Document loginPage = loginPageConn.get(); Map<String, String> cookies = loginPageConn.response().cookies(); // 提取隐藏字段 String csrfToken = loginPage.select("input[name=csrf_token]").attr("value"); String returnUrl = loginPage.select("input[name=return_url]").attr("value"); // 2. POST 登录表单 Connection loginPostConn = Jsoup.connect("https://example.com/login") .userAgent(USER_AGENT) .cookies(cookies) // 携带上一步的 cookies .data("username", "your_user") .data("password", "your_pass") .data("csrf_token", csrfToken) .data("return_url", returnUrl) .timeout(15000) .method(Connection.Method.POST); Response loginResponse = loginPostConn.execute(); Map<String, String> newCookies = loginResponse.cookies(); // 3. 验证登录是否成功:检查重定向目标或页面标题 if (loginResponse.statusCode() == 200) { Document postLoginDoc = loginResponse.parse(); if (postLoginDoc.title().contains("欢迎回来")) { System.out.println("登录成功,新 Cookies 已更新"); cookieStore = newCookies; // 更新全局 cookieStore } }这段代码的价值在于:它不依赖任何外部库,纯 jsoup 实现登录态接管。关键点是两次请求间cookies的传递,以及对csrf_token这类防重放字段的动态提取——这比写死 Cookie 字符串可靠十倍。
3.3 超时与重试策略:别让单页失败拖垮整个流程
网络抖动、服务端限流很常见。jsoup 默认无重试,需手动封装:
public Document fetchWithRetry(String url, Map<String, String> cookies, int maxRetries) { for (int i = 0; i <= maxRetries; i++) { try { Connection conn = Jsoup.connect(url) .userAgent(USER_AGENT) .cookies(cookies) .timeout(12000) .followRedirects(true); Response response = conn.execute(); if (response.statusCode() == 200) { return response.parse(); } else if (response.statusCode() == 429 || response.statusCode() == 403) { // 遇到限流,指数退避 Thread.sleep((long) Math.pow(2, i) * 1000); continue; } else { throw new IOException("HTTP " + response.statusCode()); } } catch (Exception e) { if (i == maxRetries) throw e; Thread.sleep(2000); } } return null; }这里用了指数退避(Exponential Backoff):第一次失败等2秒,第二次等4秒,第三次等8秒……避免集中重试触发更严限流。同时捕获429 Too Many Requests和403 Forbidden,这两者往往是反爬信号,比单纯超时更值得警惕。
4. 避坑:分页爬取中五个血泪经验总结
4.1 现象:爬到第N页后,所有后续页都返回第1页内容
原因:服务端做了页码有效性校验,当传入page=100但实际总页数只有50时,它不报错,而是静默重定向回第1页(302),而 jsoup 默认followRedirects(true)会自动跳转,导致你以为爬到了第100页,实际内容全是第1页的。
解决:关闭自动跳转,手动检查响应头:
Response response = conn.execute(); if (response.statusCode() == 302) { String location = response.header("Location"); if (location.contains("page=1")) { System.out.println("检测到页码越界,终止分页"); break; // 退出循环 } }4.2 现象:本地测试正常,部署到服务器后大量 403
原因:服务器出口 IP 被目标网站拉黑,或服务器系统时间与 NTP 不同步,导致 Cookie 中的expires时间校验失败。
解决:
- 在服务器执行
ntpdate -u ntp.aliyun.com同步时间; - 为 jsoup 请求显式设置
Date头(虽非常规,但部分网站校验):.header("Date", new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'").format(new Date()))
4.3 现象:doc.select("div.item")返回空集合,但浏览器能看到数据
原因:数据由 JavaScript 动态注入,jsoup 获取的是初始 HTML,不含 JS 渲染后的内容。
解决:先确认是否真为 JS 渲染(见 2.1 节 Network 分析)。若是,放弃 jsoup,改用 Puppeteer 或 Playwright;若只是懒加载(如滚动触发动态请求),可尝试在 URL 后加?__a=1或?raw=1参数获取原始 JSON 接口。
4.4 现象:爬取速度越来越慢,最后卡死
原因:jsoup 默认使用java.net.HttpURLConnection,其连接池未配置,每次请求新建 TCP 连接,且未复用 Keep-Alive。
解决:强制启用连接复用,并设置最大连接数:
System.setProperty("http.maxConnections", "20"); // 并在每个 Connection 中显式设置: .connTimeout(10000) .maxBodySize(0) // 取消响应体大小限制4.5 现象:Cookie 有时失效,登录态突然丢失
原因:Cookie 中的Expires或Max-Age过期,或服务端下发了Set-Cookie: sessionid=xxx; Path=/; HttpOnly; Secure,而你的请求未走 HTTPS(Secure 属性要求),导致浏览器/Jsoup 不发送该 Cookie。
解决:
- 所有请求必须用
https://开头; - 每次请求后,用
response.cookies()覆盖旧 Cookie 存储,确保始终用最新有效期的 Cookie; - 对关键 Cookie(如
sessionid)做存在性校验:if (!newCookies.containsKey("sessionid")) { throw new RuntimeException("Session lost! Re-login required."); }
5. 断点续爬与增量采集:让爬虫具备“重启不丢进度”的工业级健壮性
5.1 设计可序列化的进度快照:不只是记页码
“断点续爬”常被简化为“记下当前页码”,但真实场景中,页码只是冰山一角。一次完整的分页任务需持久化至少五项状态:
| 字段 | 类型 | 说明 | 示例 |
|---|---|---|---|
current_page | int | 当前待爬页码(下次启动从此开始) | 57 |
last_success_time | long | 上次成功爬取的时间戳(毫秒) | 1715234567890 |
cookies | Map<String,String> | 当前有效 Cookie(Base64 编码存储) | {"sessionid":"abc123","csrftoken":"xyz789"} |
user_agent | String | 当前使用的 UA(应对 UA 轮换策略) | "Mozilla/5.0 (Mac)...Chrome/120" |
crawl_stats | Map<String,Object> | 统计信息:成功页数、失败页数、总耗时 | {"success":56,"fail":1,"total_ms":245678} |
我们用 JSON 文件存储这些状态,文件名固定为crawl_progress.json,每次爬完一页就原子写入:
public void saveProgress(int currentPage, Map<String, String> cookies, String userAgent, Map<String, Object> stats) { Map<String, Object> progress = new HashMap<>(); progress.put("current_page", currentPage); progress.put("last_success_time", System.currentTimeMillis()); progress.put("cookies", cookies); progress.put("user_agent", userAgent); progress.put("crawl_stats", stats); try (FileWriter writer = new FileWriter("crawl_progress.json")) { writer.write(new ObjectMapper().writeValueAsString(progress)); writer.flush(); } catch (IOException e) { System.err.println("保存进度失败:" + e.getMessage()); } }注意:
ObjectMapper来自 Jackson 库,需添加依赖com.fasterxml.jackson.core:jackson-databind。不要用Properties或纯文本,JSON 支持嵌套结构,未来扩展字段零成本。
5.2 启动时自动恢复:三步校验法确保状态可信
不是所有crawl_progress.json都能直接信任。我们实施三步校验:
- 文件存在且可读:基础 IO 检查;
- JSON 结构完整:必须包含
current_page和cookies字段; - Cookie 有效性验证:用当前 Cookie 访问一个轻量接口(如
/api/ping或首页),检查是否返回 200 且含预期内容。
public Progress loadProgress() { File progressFile = new File("crawl_progress.json"); if (!progressFile.exists()) return new Progress(1, new HashMap<>(), USER_AGENT); try { String json = Files.readString(progressFile.toPath()); JsonNode node = new ObjectMapper().readTree(json); // 步骤2:字段校验 if (!node.has("current_page") || !node.has("cookies")) { System.out.println("进度文件字段缺失,重置为第1页"); return new Progress(1, new HashMap<>(), USER_AGENT); } int page = node.get("current_page").asInt(); Map<String, String> cookies = new ObjectMapper() .convertValue(node.get("cookies"), new TypeReference<Map<String, String>>() {}); String ua = node.has("user_agent") ? node.get("user_agent").asText() : USER_AGENT; // 步骤3:Cookie 有效性验证(GET 首页) Document testDoc = Jsoup.connect("https://example.com/list") .userAgent(ua) .cookies(cookies) .timeout(8000) .get(); if (testDoc.select("title").text().contains("登录")) { System.out.println("Cookie 已失效,清空并重登"); return new Progress(1, new HashMap<>(), ua); } System.out.println("进度恢复成功:从第" + page + "页继续"); return new Progress(page, cookies, ua); } catch (Exception e) { System.err.println("加载进度失败,重置:" + e.getMessage()); return new Progress(1, new HashMap<>(), USER_AGENT); } }这个设计让爬虫具备“自我康复”能力:即使进程被 kill、服务器宕机、Cookie 过期,重启后都能自动识别异常并安全降级,而不是带着失效凭证一路狂奔到 403。
5.3 增量采集:只爬新数据,避免重复入库
分页爬取常用于监控类场景(如商品价格、新闻标题),需识别“哪些是新增条目”。通用做法是为每条数据生成唯一指纹(fingerprint),基于内容哈希:
public String generateFingerprint(Element item) { // 提取关键字段:标题、发布时间、摘要前100字 String title = item.select("h2.title").text().trim(); String pubTime = item.select("span.time").text().trim(); String summary = item.select("p.desc").text().substring(0, Math.min(100, item.select("p.desc").text().length())).trim(); // 拼接后 SHA-256 String raw = title + "|" + pubTime + "|" + summary; try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (Exception e) { return UUID.randomUUID().toString(); // 降级 } }然后维护一个本地HashSet<String>存储已爬指纹,或写入 SQLite 表fingerprint_history (fp TEXT PRIMARY KEY, crawled_at TIMESTAMP)。每次解析出新条目,先查指纹是否存在,不存在才入库并记录:
String fp = generateFingerprint(item); if (!seenFingerprints.contains(fp)) { seenFingerprints.add(fp); saveToDatabase(item); // 保存业务数据 saveFingerprint(fp); // 保存指纹快照 }这样,哪怕你每天运行一次全量分页爬取,也只会入库当天新增的条目,彻底告别“重复插入”和“去重清洗”的脏活。
6. 验证爬取质量:用三类黄金指标守住数据底线
6.1 构建可执行的爬取质量校验清单
写完爬虫不等于结束,上线前必须跑通一套验证流程。我给自己定的铁律是:不通过这三项校验,绝不接入下游业务。
| 校验项 | 方法 | 合格标准 | 不合格后果 |
|---|---|---|---|
| 结构稳定性校验 | 对连续5页的 HTML,统计doc.select("div.item").size()的方差 | 方差 < 2(即每页条目数波动 ≤1) | 页面结构突变,XPath/JQuery 选择器需重构 |
| 内容完整性校验 | 抽样检查每页前3条数据的title、url、pub_time字段是否非空 | 100% 非空率 | 字段提取逻辑有缺陷,需加!text().isEmpty()判空 |
| 分页连贯性校验 | 解析每页的“下一页”链接,检查其page参数是否严格递增 | page=1 → page=2 → page=3...无跳跃、无重复 | 页码生成器逻辑错误,可能漏页 |
下面是一个可直接运行的校验脚本框架:
public void runQualityCheck() { List<Integer> itemCounts = new ArrayList<>(); List<Integer> pageNumbers = new ArrayList<>(); Set<String> allUrls = new HashSet<>(); // 抽样爬取前5页 for (int page = 1; page <= 5; page++) { try { Document doc = fetchPage("https://example.com/list?page=" + page); // 1. 统计条目数 int count = doc.select("div.item").size(); itemCounts.add(count); // 2. 解析页码(从URL或DOM中) int parsedPage = extractPageNumber(doc); pageNumbers.add(parsedPage); // 3. 收集URL去重 doc.select("div.item a").forEach(a -> allUrls.add(a.absUrl("href"))); } catch (Exception e) { System.err.println("第" + page + "页校验失败:" + e.getMessage()); } } // 计算方差 double mean = itemCounts.stream().mapToInt(Integer::intValue).average().orElse(0); double variance = itemCounts.stream() .mapToDouble(x -> Math.pow(x - mean, 2)) .average().orElse(0); System.out.println("条目数方差:" + variance); System.out.println("页码序列:" + pageNumbers); System.out.println("去重URL数:" + allUrls.size()); if (variance > 2) { throw new RuntimeException("结构不稳定:条目数波动过大"); } if (!isStrictlyIncreasing(pageNumbers)) { throw new RuntimeException("页码不连贯:存在跳跃或重复"); } }这段代码不是摆设,而是你交付给导师或上线前的“质量签证”。它把模糊的“感觉差不多”变成可量化的数字结论。
6.2 生产环境必加的日志埋点:让每一次失败都可追溯
日志不是写给机器看的,是写给你自己(或接锅同事)看的。我在每个关键节点都加了结构化日志:
// 爬取前 log.info("START_PAGE", "page", currentPage, "url", currentPageUrl, "cookies_size", cookies.size(), "user_agent_snippet", userAgent.substring(0, Math.min(20, userAgent.length()))); // 解析后 int itemCount = doc.select("div.item").size(); log.info("PARSE_RESULT", "page", currentPage, "item_count", itemCount, "title_sample", doc.select("div.item h2").first() != null ? doc.select("div.item h2").first().text() : "N/A"); // 失败时 log.error("FETCH_FAILED", "page", currentPage, "url", currentPageUrl, "status_code", response.statusCode(), "error_msg", e.getMessage(), "response_body_length", response.body().length());用log.info("EVENT_NAME", key1, value1, key2, value2)这种结构,方便后续用 ELK 或 grep 提取特定事件。比如查所有失败页:grep "FETCH_FAILED" app.log | awk '{print $5}';查某页详情:grep "page=42" app.log。没有这种日志,排查问题就是大海捞针。
6.3 从那以后我每次写分页爬虫,都强制走一遍“三分钟验证流程”
现在我的标准动作是:写完核心逻辑后,不急着跑全量,而是打开终端,三分钟内完成这三件事:
- 跑通单页:
java Crawler --page 1,确认能取到 HTML、解析出数据、打印日志; - 跑通两页跳转:
java Crawler --from 1 --to 2,验证page=1的“下一页”链接是否正确指向page=2,且page=2能正常加载; - 跑通断点续爬:手动删掉
crawl_progress.json,跑一次--from 1 --to 3,再杀进程,重启后检查是否从page=4开始。
这三步花不了三分钟,但它能提前暴露 90% 的低级错误:URL 拼错、选择器写反、Cookie 没传递、页码解析逻辑崩坏……曾经有个项目,我跳过这步直接跑100页,结果爬了8小时发现第1页和第100页数据一模一样——因为服务端把page=100当成非法参数,全部重定向回第1页,而我没关followRedirects。从那以后我每次写分页爬虫,都强制走一遍这个三分钟验证流程,就像飞行员起飞前的 checklist。
希望帮到你。
本文还有配套的精品资源,点击获取