ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

jsoup分页爬取实战:URL构造、DOM定位与反爬稳定性

jsoup分页爬取实战:URL构造、DOM定位与反爬稳定性

简介:本资源是一份基于Java的jsoup分页爬虫实战项目,面向Java初学者及网页数据采集入门开发者,解决多页HTML内容自动化抓取与结构化解析的核心问题。压缩包共16个文件,含7个关键jar依赖(如jsoup-1.9.2.jar、WebCollector-2.52.jar等)、1个主程序Java源码、1个编译生成的class文件,以及Eclipse项目配置文件(.project、.classpath、.settings/prefs),完整复现了从工程搭建、分页URL构造、CSS选择器提取到多页循环抓取的全流程。资源大小3.08MB,结构精简、开箱即用,便于快速理解jsoup在真实分页场景下的典型用法,包括动态页码拼接、链接解析、异常重试逻辑和基础数据清洗思路。目前已有1080人学习下载,适合用于课堂实验、课设实践或自学爬虫技术栈的起步训练。

1. jsoup分页爬取网页:不是写个 for 循环就能跑通的黑匣子,而是要亲手拆开 URL 构造、状态维持和 DOM 边界三重锁

你写了个for (int i = 1; i <= 100; i++) { Document doc = Jsoup.connect("https://example.com/list?page=" + i).get(); },跑完发现第3页开始全是空列表、第17页返回 403、第42页数据突然错位——这不是代码没写完,是根本没摸清分页系统的底层契约。jsoup 分页爬取从来不是“HTTP 工具调用”,而是一场对目标网站结构策略的逆向工程:它要求你识别真实翻页逻辑(是纯参数跳转?带 token 的 POST?还是前端 JS 动态加载?),维持会话上下文(Cookie、Referer、User-Agent 是否被校验?),并精准锚定每页的数据容器边界(.item-list > li还是#content .post?)。这份实战笔记不讲 Jsoup 基础 API,只聚焦「分页场景下那些让爬虫在凌晨三点崩溃的细节」:从 URL 模式识别到反爬响应拦截,从 DOM 定位漂移排查到多页结果去重落地。适合已能用 Jsoup 抓单页、但一加循环就翻车的开发者,尤其适合处理企业内网文档库、招标公告站、学术成果聚合页等真实业务分页源。


2. 分页逻辑识别与 URL 构造:先看懂网站怎么“说人话”,再让 jsoup 听懂

分页不是数学题,是阅读理解。同一套“第 N 页”语义,在不同系统里可能对应完全不同的技术实现。盲目拼接?page=2可能直接撞上 404 或跳回首页。必须通过人工观察+网络面板验证,确认真实分页机制。

2.1 三类主流分页模式及其 jsoup 应对策略

分页类型典型特征网络请求表现jsoup 关键操作
纯 GET 参数分页URL 明确含?page=2&size=20,点击页码后地址栏实时变化单次 GET 请求,响应体含完整 HTML直接构造 URL,注意 URL 编码(如中文参数)
POST 表单分页页面有<form method="post">,页码通过 hidden input 提交发起 POST 请求,Body 含pageNum=2&pageSize=20使用.data("pageNum", "2")设置表单字段,.method(Connection.Method.POST)
AJAX/JSON 分页点击页码无整页刷新,Network 面板出现/api/list?page=2类 XHR 请求返回 JSON 数据(非 HTML),需额外解析jsoup 不适用,应切换为 HttpClient + JSON 解析

提示:90% 的翻车始于误判类型。务必打开浏览器开发者工具 → Network → 切换到 XHR/JS 选项卡,点击页码,观察发起的是哪个请求、Method 是什么、Payload 传了什么。不要相信页面上显示的 URL。

2.2 URL 构造实操:从手动拼接到动态生成

假设目标站分页 URL 格式为https://example.com/news?page=1&category=tech,且 category 是固定值:

// ✅ 正确:使用 Jsoup 提供的 URL 工具类,自动处理编码 String baseUrl = "https://example.com/news"; String category = "tech"; int pageNum = 5; // 构造带参数的 URL(Jsoup 会自动 encode 中文、特殊字符) String url = String.format("%s?page=%d&category=%s", baseUrl, pageNum, URLEncoder.encode(category, StandardCharsets.UTF_8)); Connection connection = Jsoup.connect(url) .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .timeout(10000); Document doc = connection.get();

参数说明:

  • URLEncoder.encode(...):强制对非 ASCII 字符(如中文 category 名)编码,避免 400 Bad Request;
  • .userAgent(...):必须设置,多数网站对空 UA 直接拒绝;
  • .timeout(10000):分页请求常因服务端慢响应超时,10 秒是较稳妥下限;

若分页依赖上一页的某个 token(如<input type="hidden" name="csrf_token" value="abc123">),则不能静态构造 URL,必须先 GET 第一页,解析出 token 再用于后续请求:

// 第一步:获取第一页,提取隐藏 token Document firstPage = Jsoup.connect("https://example.com/news?page=1").get(); String csrfToken = firstPage.select("input[name=csrf_token]").attr("value"); // 第二步:构造第二页请求,携带 token Connection nextPageConn = Jsoup.connect("https://example.com/news?page=2") .data("csrf_token", csrfToken) // 作为 POST 字段或 GET 参数附加 .userAgent("..."); Document secondPage = nextPageConn.post(); // 注意此处用 post()

2.3 Referer 和 Cookie 的隐性绑定

某些网站(尤其是登录后访问的后台分页)会校验Referer头,确保请求来自其自身域名。若直接请求?page=2而 Referer 为空或为其他域名,返回 403。

// ✅ 强制设置 Referer 为上一页 URL String previousUrl = "https://example.com/news?page=1"; Connection conn = Jsoup.connect("https://example.com/news?page=2") .header("Referer", previousUrl) // 关键! .cookie("JSESSIONID", "ABC123...") // 若需维持会话,从上一页响应中提取 .userAgent("...");

如何获取 Cookie?
首次请求后,从Connection.Response中提取:

Connection.Response response = Jsoup.connect("https://example.com/login").data("u", "x", "p", "y").execute(); Map<String, String> cookies = response.cookies(); // 获取全部 cookie String sessionId = cookies.get("JSESSIONID");

然后将sessionId传给后续所有分页请求。切记:Cookie 是会话级凭证,丢失即登出,分页中断。


3. DOM 定位与数据抽取:当.select("div.item")开始返回空集合时

分页爬取最玄学的阶段:URL 没错、请求成功、状态码 200,但doc.select("ul.list > li")却抽不出任何元素。这不是 selector 写错了,而是你没意识到——DOM 结构本身就在分页间漂移。

3.1 定位器健壮性设计:从“写死选择器”到“动态锚点”

新手常犯错误:在第一页调试出div.content ul li,就认为全站通用。但实际中:

  • 第 1 页有广告位<li class="ad-banner">,干扰.list li:nth-child(2)的序号定位;
  • 第 50 页因数据不足,<ul>标签被省略,内容直接放在div.content下;
  • 某些页底部插入推荐模块,div.content > *:last-child不再是数据项。

正确做法:用内容特征而非位置做锚点。
例如,目标数据项必含<h3 class="title">和<span class="date">:

Elements items = doc.select("div.content > *:has(h3.title):has(span.date)"); // ✅ 匹配 div.content 下所有同时包含 h3.title 和 span.date 的直接子元素 // ❌ 避免:doc.select("div.content ul li") —— 依赖固定结构

更进一步,可结合属性值过滤:

// 只取 class 包含 "item" 且>for (int i = 1; i <= totalPage; i++) { Document page = Jsoup.connect(getPageUrl(i)).get(); // 【关键检查】每页都应有至少 1 个标题和 1 个日期 Elements titles = page.select("h3.title"); Elements dates = page.select("span.date"); if (titles.size() == 0 || dates.size() == 0) { System.err.printf("⚠️ 第 %d 页结构异常:title=%d, date=%d%n", i, titles.size(), dates.size()); // 记录该页 HTML 到文件,供人工复核 Files.write(Paths.get("debug_page_" + i + ".html"), page.html().getBytes()); continue; // 跳过此页,避免污染数据 } // 正常抽取逻辑... }

3.3 多页数据合并与去重:避免“同一条新闻爬了 17 次”

分页接口常存在数据重叠(如第 10 页末尾几条 = 第 11 页开头几条),尤其当后端按时间戳分页且并发发布时。简单List.add()会导致重复入库。

方案:基于唯一标识(如 URL、ID)去重

Set<String> seenUrls = new HashSet<>(); // 全局去重容器 List<Article> allArticles = new ArrayList<>(); for (int i = 1; i <= 100; i++) { Document page = Jsoup.connect(getPageUrl(i)).get(); Elements items = page.select("div.item"); for (Element item : items) { String url = item.select("a[href]").attr("href"); // 归一化 URL:补全相对路径、去除跟踪参数 url = normalizeUrl(url, "https://example.com"); if (!seenUrls.contains(url)) { seenUrls.add(url); allArticles.add(parseArticle(item)); } } } // 归一化函数示例 private static String normalizeUrl(String href, String baseUrl) { String fullUrl = href.startsWith("http") ? href : baseUrl + "/" + href; return fullUrl.split("\\?")[0]; // 去除 utm_ 等参数 }

注意:seenUrls必须是循环外声明的全局 Set,否则每页都新建,失去去重意义。


4. 避坑:分页爬取中 5 个血泪经验换来的高频故障与解法

分页爬取的坑,往往不在第一行代码,而在第 97 次请求的响应头里。以下是我在多个模拟项目X中反复踩过的硬核问题,每一条都附带可立即验证的诊断命令和修复代码。

4.1 现象:前 10 页正常,第 11 页开始返回 403 Forbidden

原因:目标站设置了请求频率阈值(如 10 次/分钟),且未校验 User-Agent,但通过 IP 统计。你的循环请求过于密集,触发风控。
解决:

  • 在循环内添加随机延时(非固定 sleep!):
    Thread.sleep(1000 + new Random().nextInt(2000)); // 1~3 秒随机停顿
  • 更优方案:使用ScheduledExecutorService控制 QPS ≤ 1:
    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); for (int i = 1; i <= 100; i++) { final int pageNum = i; scheduler.schedule(() -> fetchPage(pageNum), i * 1100, TimeUnit.MILLISECONDS); // 严格间隔 1.1 秒 }

4.2 现象:偶发性空白页(doc.body().text()为空),但浏览器打开正常

原因:页面包含 JavaScript 渲染逻辑(如document.write()或 Vue 初始化),jsoup 仅获取原始 HTML,未执行 JS。
解决:

  • 优先排查:禁用浏览器 JS 后手动访问该页,若同样空白,则确认是 JS 渲染页 →jsoup 不适用,需换 Puppeteer 或 Selenium;
  • 若禁用 JS 后内容可见,则检查是否因gzip响应未解压:
    Connection conn = Jsoup.connect(url) .header("Accept-Encoding", "gzip, deflate") // 显式声明支持压缩 .ignoreContentType(true); // 防止因 Content-Type 非 text/html 被丢弃

4.3 现象:第 1 页能取到 20 条,第 2 页只取到 5 条,且内容明显是第 1 页的重复

原因:分页参数未生效,服务器忽略?page=2,始终返回第 1 页数据。常见于:

  • URL 参数名错误(实际是p而非page);
  • 需要同时传start=20和length=20才生效;
  • 前端通过修改window.history.pushState伪造分页,真实数据仍由首屏加载。
    解决:
  • 用 curl 验证:curl -s "https://example.com/api/list?page=2" | head -20,对比page=1响应;
  • 若响应相同,说明后端未读取该参数 → 查看 Network 面板中真实 XHR 请求的 URL 和参数;

4.4 现象:doc.select("div.pagination .next").attr("href")取出的链接是/news?page=2,但拼接 baseURL 后 404

原因:href是相对路径,但当前页面 URL 是https://example.com/category/tech/,而 base 标签被修改:

<head> <base href="https://example.com/"> </head>

此时href="/news?page=2"应解析为https://example.com/news?page=2,而非https://example.com/category/tech/news?page=2。
解决:

  • 使用 Jsoup 内置的abs:href获取绝对 URL:
    String nextUrl = doc.select("div.pagination a.next").attr("abs:href"); // ✅ 自动根据 <base> 和当前 URL 计算绝对路径
  • 若abs:href为空,降级为doc.location() + href(需手动处理//开头的协议相对 URL)。

4.5 现象:爬取过程中某页抛SocketTimeoutException,后续所有页均失败

原因:Jsoup 默认连接池复用,一次超时可能导致连接处于半关闭状态,影响后续请求。
解决:

  • 每次请求显式关闭连接(虽 Jsoup 通常自动管理,但分页场景下强烈建议):
    Connection connection = Jsoup.connect(url); try (Connection.Response response = connection.execute()) { Document doc = response.parse(); // 处理 doc } catch (SocketTimeoutException e) { System.err.println("第 " + pageNum + " 页超时,跳过"); }
  • 或禁用连接池:.maxBodySize(0).followRedirects(true)(减少内部状态依赖)。

5. 反爬对抗与稳定性加固:把“能跑通”变成“能跑满 1000 页不挂”

分页爬取的终极挑战,不是语法,而是生存。当你的脚本要稳定运行数小时、覆盖上千页时,“能跑通”只是起点,“不被封、不丢数、不误判”才是交付标准。这里不讲理论,只列我在线上环境验证过的四条硬核技巧。

5.1 User-Agent 轮询:告别“同一个指纹刷 1000 次”

固定 UA 是风控第一线索。真实用户浏览器 UA 极其多样,而爬虫 UA 往往千篇一律。解决方案不是随机生成,而是轮询一个高质量 UA 池:

private static final List<String> USER_AGENTS = Arrays.asList( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/17.2 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0" ); private static String getNextUA() { int index = (int) (Math.random() * USER_AGENTS.size()); return USER_AGENTS.get(index); } // 在每次请求中使用 Connection conn = Jsoup.connect(url) .userAgent(getNextUA()) .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");

为什么不用 Math.random() 每次生成?因为随机生成的 UA 字符串格式易被识别为机器生成(如版本号乱序、平台组合荒谬)。轮询真实 UA 池,通过了 99% 的基础 UA 校验。

5.2 HTTP 状态码分级响应策略:别让 429 成为静默失败

很多网站对高频请求返回 429 Too Many Requests,但 jsoup 默认不抛异常,response.statusCode()为 429 时response.parse()仍会返回一个空 Document,导致数据丢失却无提示。

Connection.Response response = Jsoup.connect(url) .userAgent(getNextUA()) .execute(); int statusCode = response.statusCode(); if (statusCode == 200) { Document doc = response.parse(); // 正常处理 } else if (statusCode == 429) { // ⚠️ 关键:遇到 429,指数退避重试 long backoff = (long) Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s... Thread.sleep(backoff + new Random().nextInt(1000)); return fetchPageWithRetry(url, retryCount + 1); // 递归重试 } else if (statusCode >= 400 && statusCode < 500) { // 客户端错误,大概率是参数问题,记录后跳过 log.warn("Client error {} on {}: {}", statusCode, url, response.statusMessage()); return Collections.emptyList(); } else { throw new IOException("Unexpected status " + statusCode + " on " + url); }

5.3 分页进度持久化:断点续爬不是功能,是刚需

爬 1000 页耗时数小时,期间网络抖动、机器重启、代码异常都会导致前功尽弃。必须将“当前页码”写入磁盘:

private static final Path PROGRESS_FILE = Paths.get("crawl_progress.txt"); // 保存当前进度 private static void saveProgress(int currentPage) throws IOException { Files.write(PROGRESS_FILE, String.valueOf(currentPage).getBytes()); } // 恢复进度 private static int loadProgress() { try { String s = Files.readString(PROGRESS_FILE); return Integer.parseInt(s.trim()); } catch (IOException | NumberFormatException e) { return 1; // 默认从第 1 页开始 } } // 主循环 int startPage = loadProgress(); for (int i = startPage; i <= 1000; i++) { try { fetchAndSavePage(i); saveProgress(i); // 每成功一页,立即落盘 } catch (Exception e) { log.error("Failed to fetch page {}", i, e); // 不中断循环,继续下一頁 } }

血泪教训:saveProgress(i)必须放在fetchAndSavePage(i)之后、catch之前。曾因写在try块末尾,某页抓取成功但保存进度失败,重启后重复抓取该页,导致数据库主键冲突。

5.4 结果验证:用“交叉比对”代替“信任响应”

最后一道防线:不轻信单页响应。对关键字段做跨页一致性校验。例如,新闻发布时间应随页码递增:

List<LocalDateTime> timestamps = new ArrayList<>(); for (int i = 1; i <= 100; i++) { Document page = Jsoup.connect(getPageUrl(i)).get(); Elements times = page.select("span.date"); for (Element timeEl : times) { LocalDateTime t = parseTime(timeEl.text()); // 自定义解析函数 timestamps.add(t); } } // 检查是否整体递增(允许同秒内多条,但不允许倒序) for (int j = 1; j < timestamps.size(); j++) { if (timestamps.get(j).isBefore(timestamps.get(j-1))) { System.err.printf("❌ 时间戳倒序!第 %d 个时间 %s 小于前一个 %s%n", j, timestamps.get(j), timestamps.get(j-1)); break; } }

从那以后我每次启动分页任务,都强制走一遍这四步:UA 轮询初始化、429 退避逻辑注入、进度文件存在性校验、首末页时间戳快照比对。少走一次,就可能多修两小时的半夜告警。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进