简介:本资源是一份基于Java的jsoup分页爬虫实战项目,面向Java初学者及网页数据采集入门开发者,解决结构化HTML页面多页批量抓取与解析的核心问题。项目完整封装了URL参数化构造、CSS选择器精准定位、分页链接自动提取、多页循环请求与基础数据清洗等关键流程,适合作为WebCollector+jsoup混合爬虫学习的过渡实践案例。压缩包共16个文件,含7个核心jar包(如jsoup-1.9.2.jar、WebCollector-2.52.jar等运行依赖)、2个Eclipse工程配置文件(.classpath、.project)、1个Java源码文件(Test.java)及日志、缓存、偏好设置等辅助文件,整体3.08MB,结构体现典型Java桌面爬虫工程组织方式。已有1082人学习下载,读者可直接导入Eclipse运行调试,获取可复用的分页爬取模板、清晰的目录模块划分、常见异常处理逻辑及jar依赖管理范式。
1. jsoup分页爬取网页:不是写个 for 循环就能跑通的“简单任务”
很多同学拿到「jsoup分页爬取网页」这个毕设/课设题目时,第一反应是:不就是用for (int i = 1; i <= 100; i++) { Document doc = Jsoup.connect(url + "?page=" + i).get(); }吗?——结果跑三页就 403,翻五页就反爬拦截,到第十二页直接返回空文档,控制台连异常都不抛,只有一片沉默。这不是代码写错了,是没把「分页爬取」当成一个带状态、有节奏、需反馈闭环的工程行为来设计。这套方案专为需要稳定获取多页结构化数据的学生和初级开发者准备:它覆盖从真实网站分页特征识别(数字页码 / 下一页按钮 / 滚动加载伪装)、请求节流与会话复用、HTML 解析容错、异常降级策略,到最终数据落库或导出的完整链路。你不需要懂 Selenium,也不必上分布式框架——用纯 Java + jsoup 就能交出一份答辩老师点头、企业导师愿意放进测试环境跑一周的真实可用方案。
2. 分页逻辑建模:先读懂网站怎么“分页”,再决定你怎么“爬”
分页不是数学题,是逆向工程。同一句“下一页”,在不同网站背后可能是五种实现机制。硬套?page=2会翻车,因为很多站点早就不暴露页码参数了。我带过三个课程设计组,90% 的失败都卡在这一步:没做前置分析,直接开写。
2.1 识别真实分页类型:四类典型模式与检测方法
必须人工打开目标网站,用浏览器开发者工具(F12)逐页操作并观察网络请求变化。重点看Network → XHR/Fetch标签页,而不是 Elements。常见模式如下:
| 分页类型 | 典型表现 | jsoup 应对关键点 | 是否推荐初学者使用 |
|---|---|---|---|
| URL 参数分页 | list.html?page=3、/api/data?page=5&size=20 | 直接拼接 URL,注意编码(如中文参数用URLEncoder.encode()) | ✅ 强烈推荐,最可控 |
| “下一页”链接提取 | HTML 中存在<a href="/news/list_4.html">下一页</a> | 用doc.select("a:contains(下一页)").attr("abs:href"),务必加abs:前缀转绝对路径 | ✅ 推荐,但需处理“下一页”文字变体(如“»”、“Next”、“下页”) |
| AJAX 分页(无刷新) | 点击页码时 Network 出现/api/v1/articles类请求,Response 是 JSON | ❌ jsoup 无法直接发 AJAX 请求,必须改用HttpClient或OkHttp配合JsonPath;若坚持用 jsoup,只能模拟其 Referer、User-Agent、Cookie 等头信息伪造请求 | ⚠️ 不推荐,初学者易陷入黑匣子调试 |
| 滚动加载(Infinite Scroll) | 滚动到底部自动加载新内容,Network 中出现带offset=30&limit=10的请求 | ❌ jsoup 无 DOM 事件驱动能力,无法触发滚动;必须换方案(如 Puppeteer Java 绑定)或退化为按固定 offset 构造 URL(风险高) | ❌ 明确不推荐 |
提示:某高校课程设计中,A同学坚持用 jsoup 爬某新闻站的滚动分页,折腾两周后发现该站实际是
https://xxx.com/api/news?start=0&count=10,只是前端隐藏了 URL——他本该在 Network 里多看一眼请求地址栏,而不是反复修改doc.select("div.item")的选择器。
2.2 构建可扩展的分页控制器:PageNavigator 接口与三种实现
把分页逻辑从主爬虫解耦,是后期维护和更换目标网站的关键。我一般定义一个PageNavigator接口:
public interface PageNavigator { /** * 获取当前页的 Document 对象 */ Document getCurrentPage() throws IOException; /** * 判断是否还有下一页 */ boolean hasNext(); /** * 跳转到下一页(内部更新状态) */ void next() throws IOException; }对应三种实现(以某博客列表页为例):
2.2.1 UrlParamNavigator:基于 page 参数的最简实现
public class UrlParamNavigator implements PageNavigator { private final String baseUrl; // e.g. "https://example.com/posts" private int currentPage = 1; private final int maxPage; private final Connection connection; public UrlParamNavigator(String baseUrl, int maxPage) { this.baseUrl = baseUrl; this.maxPage = maxPage; this.connection = Jsoup.connect("").userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); } @Override public Document getCurrentPage() throws IOException { String url = baseUrl + "?page=" + currentPage; return connection.url(url).timeout(10000).get(); } @Override public boolean hasNext() { return currentPage < maxPage; } @Override public void next() { currentPage++; } }设计说明:
connection复用避免重复创建连接对象,减少 TIME_WAIT 占用;timeout(10000)是血泪经验——不设超时,某次 DNS 故障会让整个爬虫卡死 3 分钟;maxPage不建议硬编码,应通过首页解析<span class="page-total">共 86 页</span>动态获取(见 3.2 节)。
2.2.2 LinkTextNavigator:基于“下一页”文本链接的稳健实现
public class LinkTextNavigator implements PageNavigator { private String currentUrl; private final String nextPageText; private final Connection connection; public LinkTextNavigator(String firstUrl, String nextPageText) { this.currentUrl = firstUrl; this.nextPageText = nextPageText; // 可传入 "下一页", "Next", "»" this.connection = Jsoup.connect("").userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"); } @Override public Document getCurrentPage() throws IOException { return connection.url(currentUrl).timeout(10000).get(); } @Override public boolean hasNext() { try { Document doc = getCurrentPage(); // 尝试多种选择器:文字匹配、class 匹配、aria-label 匹配 Elements nextLinks = doc.select("a:contains(" + nextPageText + "), a.next-page, a[aria-label=next]") .not("a:disabled, a[disabled]"); return !nextLinks.isEmpty() && nextLinks.get(0).hasAttr("abs:href"); } catch (IOException e) { return false; // 网络失败视为无下一页,避免死循环 } } @Override public void next() throws IOException { Document doc = getCurrentPage(); Elements nextLinks = doc.select("a:contains(" + nextPageText + "), a.next-page") .not("a:disabled"); if (!nextLinks.isEmpty()) { currentUrl = nextLinks.get(0).attr("abs:href"); // abs:href 自动补全协议+域名 } } }设计说明:
not("a:disabled")过滤掉已禁用的“下一页”按钮(常见于最后一页),避免死循环;abs:href是关键,否则相对路径(如list_5.html)会导致后续请求 404;try-catch包裹hasNext(),防止某页网络异常导致整个流程中断——这是生产级爬虫的底线。
3. jsoup 核心解析层:从“能取到”到“取得稳、取得准”
很多人以为 jsoup 就是select("div.title"),其实真正难的是:当页面结构微调、字段缺失、编码错乱、广告插入时,如何让解析逻辑不崩、数据不丢、日志可追溯。这一层决定了你的毕设能不能跑满 100 页不出错。
3.1 容错式元素提取:ElementExtractor 工具类封装
直接调用doc.select("h1").text()在遇到空元素时会返回空字符串,但你无法区分“真为空”还是“选择器写错”。我习惯封装一层带上下文记录的提取器:
public class ElementExtractor { private final Document doc; private final String sourceUrl; public ElementExtractor(Document doc, String sourceUrl) { this.doc = doc; this.sourceUrl = sourceUrl; } /** * 安全提取文本,返回 Optional 避免空指针,记录缺失日志 */ public Optional<String> text(String selector) { Elements elements = doc.select(selector); if (elements.isEmpty()) { System.err.println("[WARN] 未找到选择器 '" + selector + "',来源页:" + sourceUrl); return Optional.empty(); } String text = elements.first().text().trim(); if (text.isEmpty()) { System.err.println("[INFO] 选择器 '" + selector + "' 匹配到空文本,来源页:" + sourceUrl); } return Optional.of(text); } /** * 安全提取属性值(如 href、src),支持默认值 */ public String attr(String selector, String attrName, String defaultValue) { Elements elements = doc.select(selector); if (elements.isEmpty()) { return defaultValue; } String value = elements.first().attr(attrName); return value.isEmpty() ? defaultValue : value; } /** * 提取多个同类型元素(如所有文章标题),自动去重 & 过滤空值 */ public List<String> texts(String selector) { return doc.select(selector).stream() .map(Element::text) .map(String::trim) .filter(s -> !s.isEmpty()) .distinct() .collect(Collectors.toList()); } }使用示例(解析单篇文章页):
Document articleDoc = Jsoup.connect(articleUrl).get(); ElementExtractor ex = new ElementExtractor(articleDoc, articleUrl); String title = ex.text("h1.post-title").orElse("【缺失标题】"); String author = ex.text("span.author").orElse("匿名"); String publishTime = ex.text("time.date").orElseGet(() -> ex.text("div.meta span:nth-child(2)").orElse("未知时间") ); List<String> tags = ex.texts("a.tag-link");设计说明:
- 所有
.text()调用都包裹在Optional中,强制调用方处理空值,避免NullPointerException; System.err.println输出带[WARN]前缀的日志,方便 grep 过滤定位问题页;texts()方法内置distinct()和空值过滤,解决广告模块插入重复标题的常见问题。
3.2 编码与重定向鲁棒性:绕过乱码与跳转陷阱
jsoup 默认使用 UTF-8,但很多老网站声明charset=gb2312却返回 UTF-8 内容,或中间经历 302 跳转后编码丢失。不处理会导致标题变成æææ°é»。
public Document safeConnect(String url) throws IOException { Connection conn = Jsoup.connect(url) .userAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36") .timeout(15000) .followRedirects(true) // 必须开启,否则 302 后 document 为空 .ignoreContentType(true); // 关键!允许解析非 text/html 类型(如 text/plain 乱码页) Connection.Response response = conn.execute(); String contentType = response.contentType(); String charset = extractCharset(contentType); // 从 Content-Type 提取 charset if (charset == null) { charset = guessCharset(response.body()); // 用字节频次猜测(见下方) } // 用正确 charset 重新解析 byte[] bodyBytes = response.bodyAsBytes(); String html = new String(bodyBytes, charset); return Jsoup.parse(html, url); // 第二个参数是 baseUri,确保 abs:href 正确 } private String extractCharset(String contentType) { if (contentType == null) return "UTF-8"; Pattern p = Pattern.compile("charset=([^;\\s]+)", Pattern.CASE_INSENSITIVE); Matcher m = p.matcher(contentType); return m.find() ? m.group(1) : "UTF-8"; } private String guessCharset(byte[] bytes) { // 简化版:检查 BOM 或高频字节组合(生产环境建议用 juniversalchardet) if (bytes.length >= 2 && bytes[0] == (byte) 0xEF && bytes[1] == (byte) 0xBB) return "UTF-8"; if (bytes.length >= 2 && bytes[0] == (byte) 0xFF && bytes[1] == (byte) 0xFE) return "UTF-16LE"; return "GBK"; // 中文站 fallback }设计说明:
ignoreContentType(true)是玄学开关:某些反爬站点故意返回Content-Type: image/png但 body 是 HTML,不设此参数 jsoup 直接拒绝解析;followRedirects(true)必须开启,否则https://xxx.com/login?redirect=/admin类跳转会失败;guessCharset是后悔药:当服务器不声明 charset 时,靠字节特征兜底,比new String(bytes)盲猜强十倍。
4. 稳定性加固:反爬对抗、节流控制与异常降级
没有反爬意识的爬虫,就像没关车门就上高速。jsoup 本身不带反爬,但你可以用三层防御把它变成“低调的老实人”。
4.1 请求节流与随机化:让请求像真人,而不是扫雷车
固定间隔(如Thread.sleep(1000))太机械。我采用“基础间隔 + 随机抖动 + 页面级延迟”三级策略:
public class RequestThrottler { private final long baseDelayMs; private final Random random; private final Map<String, Long> lastRequestTime = new ConcurrentHashMap<>(); public RequestThrottler(long baseDelayMs) { this.baseDelayMs = baseDelayMs; this.random = new Random(); } /** * 针对特定域名的节流(防单域名过载) */ public void throttleForDomain(String url) throws InterruptedException { String domain = extractDomain(url); long now = System.currentTimeMillis(); Long lastTime = lastRequestTime.get(domain); if (lastTime != null) { long elapsed = now - lastTime; if (elapsed < baseDelayMs) { long sleepMs = baseDelayMs - elapsed + random.nextInt(500); // ±500ms 抖动 Thread.sleep(Math.max(100, sleepMs)); // 至少睡 100ms } } lastRequestTime.put(domain, System.currentTimeMillis()); } private String extractDomain(String url) { try { return new URL(url).getHost(); } catch (Exception e) { return "unknown"; } } }集成到爬取循环中:
RequestThrottler throttler = new RequestThrottler(2000); // 基础 2s while (navigator.hasNext()) { try { throttler.throttleForDomain(navigator.getCurrentUrl()); Document doc = navigator.getCurrentPage(); // 解析逻辑... parsePage(doc); } catch (HttpStatusException e) { if (e.getStatusCode() == 403 || e.getStatusCode() == 429) { System.err.println("[BLOCKED] 被封禁,暂停 60 秒后重试:" + e.getUrl()); Thread.sleep(60000); continue; // 重试当前页 } throw e; } navigator.next(); }设计说明:
ConcurrentHashMap支持多线程安全,为未来扩展多域名并发留余地;random.nextInt(500)让间隔在2000~2500ms波动,打破规律性;Thread.sleep(100)最小值防止过度频繁(如解析失败重试时)。
4.2 Cookie 与 Session 复用:绕过登录态校验
很多网站分页需登录态(如后台文章管理页)。jsoup 本身不维护 session,但可以手动传递 cookie:
// 登录后获取 cookies Connection.Response loginRes = Jsoup.connect("https://xxx.com/login") .data("username", "user") .data("password", "pass") .method(Connection.Method.POST) .execute(); Map<String, String> cookies = loginRes.cookies(); // 后续所有请求带上 cookies Document doc = Jsoup.connect("https://xxx.com/admin/posts?page=2") .cookies(cookies) .get();关键细节:
loginRes.cookies()返回的是Map<String, String>,直接复用即可;- 不要自己拼
Cookie: key=value; key2=value2字符串,jsoup 会帮你处理; - 若网站用 JWT 存 cookie,需额外提取
document.cookie中的 token 字段(用正则)。
4.3 常见问题排查:五条让你少熬三夜的血泪经验
注意:以下问题均来自真实课程设计翻车现场,按发生频率排序。
4.3.1 现象:爬到第 7 页突然返回首页 HTML,且doc.title()是 “用户登录”
原因:网站设置了 session 过期(如 10 分钟无操作),而你的爬虫间隔太久,或未复用 cookies。
解决:登录后立即获取 cookies,并在每次Jsoup.connect()时显式传入.cookies(cookies);添加心跳请求(如每 5 分钟访问一次/api/keepalive)。
4.3.2 现象:doc.select("div.list-item")在本地测试正常,部署到服务器后返回空
原因:服务器 IP 被加入黑名单,返回的是反爬中间页(如 Cloudflare 验证页),但状态码仍是 200。
解决:检查doc.body().text()是否包含 “Checking your browser”、“请开启 JavaScript” 等关键词;添加 UA + Referer + Accept-Language 头模拟真实浏览器。
4.3.3 现象:中文标题全部乱码,但网页源码查看是正常的
原因:jsoup 解析时未指定 charset,且服务器未在 HTTP Header 中声明Content-Type: text/html; charset=utf-8。
解决:用Connection.execute()获取原始字节,再用new String(bytes, "UTF-8")手动解析(见 3.2 节)。
4.3.4 现象:select("a[href]")提取的链接全是相对路径(如/article/123),保存后打不开
原因:未使用abs:href属性,而是用了href。
解决:永远用element.attr("abs:href")替代element.attr("href");或在Jsoup.parse(html, baseUrl)中传入正确的 baseUri。
4.3.5 现象:程序运行半小时后报java.net.SocketException: Too many open files
原因:Linux 系统文件描述符限制(默认 1024),每个Jsoup.connect()创建新 socket 未及时释放。
解决:全局复用Connection对象(见 2.2.1 节);或在 JVM 启动参数加-XX:MaxFDLimit=65536;更彻底的是用连接池(如 Apache HttpClient)。
5. 数据落地与验收:从“爬下来”到“能交差、能答辩”
爬虫的价值不在代码行数,而在产出的数据能否被业务方直接使用。学生交毕设,老师看三点:功能完整、过程可追溯、结果可验证。这一章告诉你怎么做到。
5.1 结构化存储:JSON 文件 + CSV 双输出,适配不同验收场景
不要只存控制台打印。我要求所有课程设计必须生成两种格式:
output/data.json:标准 JSON 数组,含完整字段与元数据(来源页、抓取时间);output/data.csv:Excel 可直接打开,字段名首行,兼容答辩现场快速演示。
public class DataExporter { private final List<Article> articles = new ArrayList<>(); public void add(Article article) { articles.add(article); } public void exportToJson(String filePath) throws IOException { ObjectMapper mapper = new ObjectMapper(); mapper.enable(SerializationFeature.INDENT_OUTPUT); mapper.writeValue(new File(filePath), articles); } public void exportToCsv(String filePath) throws IOException { try (PrintWriter pw = new PrintWriter(new FileWriter(filePath, StandardCharsets.UTF_8))) { pw.println("title,author,publish_time,url,source_page,fetch_time"); for (Article a : articles) { String line = String.format( "%s,%s,%s,%s,%s,%s", escapeCsv(a.getTitle()), escapeCsv(a.getAuthor()), escapeCsv(a.getPublishTime()), escapeCsv(a.getUrl()), escapeCsv(a.getSourcePage()), new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(a.getFetchTime()) ); pw.println(line); } } } private String escapeCsv(String s) { if (s == null) return ""; return "\"" + s.replace("\"", "\"\"") + "\""; } }验收技巧:
- 答辩时打开
data.csv,用 Excel 的「数据透视表」当场统计“各栏目文章数量”、“作者发文频次”,老师会觉得你“真干了活”; data.json用 VS Code 安装 JSON Tools 插件,一键格式化 + 验证,证明数据结构规范。
5.2 过程日志与统计报告:让答辩老师一眼看到你的工作量
光有数据不够,要有过程证据。我在每个项目根目录生成report.md,内容自动生成:
# 爬取报告:某技术博客(2024-06-15) ## 执行概览 - 开始时间:2024-06-15 09:23:11 - 结束时间:2024-06-15 10:47:33 - 总耗时:84 分 22 秒 - 成功页数:97 / 100 - 失败页数:3(详见下方) ## 失败详情 | 页码 | URL | 错误类型 | 时间 | |------|-----|----------|------| | 42 | https://xxx.com/list_42.html | java.net.SocketTimeoutException | 09:35:22 | | 77 | https://xxx.com/list_77.html | org.jsoup.HttpStatusException: HTTP error fetching URL. Status=502 | 10:12:05 | | 99 | https://xxx.com/list_99.html | java.io.IOException: Not found | 10:45:11 | ## 数据统计 - 总文章数:1247 篇 - 标题平均长度:28.3 字 - 最热标签:`Java`(217 次)、`Spring`(189 次)、`MySQL`(152 次)生成逻辑(伪代码):
- 用
StopWatch记录总耗时; - 用
AtomicInteger successCount/failCount统计页数; - 失败时捕获
Exception并记录url + e.getClass().getSimpleName(); - 标签统计用
Map<String, Integer>累加ex.texts("a.tag")结果。
5.3 答辩现场演示技巧:三分钟让老师记住你的项目
别一上来就说“我用了 jsoup”。按这个顺序讲:
- 先展示结果:打开
data.csv,筛选出“最近 7 天发布”的文章,按阅读量排序,指出最高一篇标题和阅读数——证明数据真实有效; - 再演示健壮性:故意删掉
data.json,点击“重新爬取”按钮(你的 GUI 或命令行入口),现场跑 3 页,展示日志里SUCCESS: page 1,WARN: no author on page 3—— 说明你处理了异常; - 最后讲设计亮点:指着
PageNavigator接口说:“我把分页逻辑抽象出来,如果换一个网站,只需要新增一个LinkTextNavigator实现类,主流程代码一行不用改。”
从那以后我每次带课程设计,都强制学生在答辩前用手机拍一段 30 秒视频:左屏是
data.csv,右屏是终端里tail -f crawl.log滚动的日志。这段视频比 PPT 里的架构图管用十倍——它证明你真的跑起来了,而且跑得稳。希望帮到你。
本文还有配套的精品资源,点击获取