简介:本资源是一份基于Java的jsoup分页爬虫实战项目,面向Java初学者及网页数据采集入门开发者,解决多页HTML内容批量抓取与结构化解析的核心问题。项目完整封装了URL参数化构造、CSS选择器精准定位、分页链接自动识别、循环请求控制及基础异常处理等关键环节,覆盖从单页解析到全站分页遍历的典型开发流程。压缩包共16个文件,含7个核心jar包(如jsoup-1.9.2.jar、WebCollector-2.52.jar等,支撑HTTP请求与HTML解析)、2个Eclipse工程配置文件(.classpath、.project)、1个Java源码文件(src/com/.../Test.java)及日志、缓存、编译配置类文件,整体3.08MB,结构清晰,开箱即用。目前已有1082人学习下载,读者可直接运行调试源码,理解分页逻辑实现细节,复用其URL生成策略、元素提取模板与工程组织方式,快速构建自有爬虫脚本。
1. 分页爬取不是“加个 for 就完事”:jsoup 做对分页,关键在识别翻页逻辑与状态隔离
很多同学第一次用 jsoup 写爬虫,看到列表页有“下一页”按钮,就直接for (int i = 1; i <= 10; i++) { doc = Jsoup.connect("https://xxx/list?page=" + i).get(); }——跑两页就 403,第三页返回空数据,第五页突然跳登录页。这不是 jsoup 不行,是没把「分页」当成一个有状态、有规则、有边界的业务流程来设计。真实项目里,分页可能是 URL 参数递增(page=1→2→3),也可能是 POST 提交 offset+limit,还可能是滚动加载触发 Ajax 接口,甚至存在反爬校验参数(如 sign、timestamp、token)随页动态生成。本方案聚焦「静态 HTML 分页」这一最常见但极易翻车的场景,用 jsoup 实现可中断、可续爬、防重复、带日志、能收敛的分页抓取闭环。它适合课程设计中需要稳定采集新闻列表、商品目录、论坛帖子等结构化网页的场景,也适合作为毕设中数据采集模块的可靠底座——不追求高并发,但要求每一页都拿得稳、存得准、断点能接上。
2. 总体架构设计:三层结构与四个核心模块的职责划分
本方案采用清晰的三层结构:调度层(Controller)→ 解析层(Parser)→ 存储层(Writer),中间通过统一的数据载体PageResult贯穿。所有模块解耦,可独立替换(比如把 FileWriter 换成 MySQLWriter 只需改一行注入)。整个流程不依赖 Spring 或任何框架,纯 JDK + jsoup 实现,便于嵌入课设系统或毕设主程序。
2.1 调度层:分页控制器 PageCrawler 的核心逻辑
PageCrawler是整个流程的“大脑”,它不关心 HTML 怎么解析、数据怎么存,只专注三件事:判断是否继续翻页、构造下一页请求、控制重试与退出。它基于两个关键策略工作:
- 终止判定策略(StopPolicy):支持
MaxPageCountStopPolicy(100)(最多爬100页)、EmptyContentStopPolicy()(某页无目标元素即停)、DuplicateUrlStopPolicy()(URL 重复即停); - URL 构造策略(UrlBuilder):支持
QueryParamUrlBuilder("page")(?page=1)、PathSegmentUrlBuilder("page")(/page/1/)、NextLinkUrlBuilder()(自动提取<a class="next">下一页</a>的 href)。
// 示例:使用 NextLinkUrlBuilder 自动提取下一页链接(最健壮的通用方案) PageCrawler crawler = PageCrawler.builder() .baseUrl("https://example.com/news/") .urlBuilder(new NextLinkUrlBuilder("a.pagination-next", "href")) // CSS 选择器定位下一页链接 .stopPolicy(new EmptyContentStopPolicy("div.news-item")) // 若页面中无 div.news-item 元素则停止 .parser(new NewsListParser()) // 注入解析器 .writer(new JsonFileWriter("./output/")) // 注入写入器 .build(); crawler.start(); // 启动爬取提示:
NextLinkUrlBuilder是我带过三个毕设项目后总结出的首选方案。它不依赖页码数字,天然兼容“下一页”文字变化、页码跳转(如 1→2→5→10)、甚至 AJAX 分页的初始 HTML 快照。比硬编码page=i少踩 80% 的坑。
2.2 解析层:NewsListParser 的结构化解析与容错处理
解析器接收原始Document,输出结构化List<NewsItem>。关键在于分离关注点:selectElements()只负责定位节点,extractItem()只负责从单个节点提取字段,cleanText()统一做文本清洗。这样每一层都可单独单元测试。
public class NewsListParser implements Parser<List<NewsItem>> { private static final String ITEM_SELECTOR = "div.news-item"; private static final String TITLE_SELECTOR = "h2.title a"; private static final String TIME_SELECTOR = "span.time"; @Override public List<NewsItem> parse(Document doc) { Elements items = doc.select(ITEM_SELECTOR); List<NewsItem> results = new ArrayList<>(); for (Element item : items) { try { NewsItem news = extractItem(item); if (news != null && isValid(news)) { // 非空且标题不为"广告"等无效内容 results.add(news); } } catch (Exception e) { // 单条解析失败不中断整体流程,记录警告日志 System.err.println("解析单条新闻失败,跳过: " + item.html().substring(0, Math.min(50, item.html().length()))); } } return results; } private NewsItem extractItem(Element item) { String title = cleanText(item.selectFirst(TITLE_SELECTOR)); String url = item.selectFirst(TITLE_SELECTOR).attr("href"); String timeStr = cleanText(item.selectFirst(TIME_SELECTOR)); return new NewsItem(title, url, parseTime(timeStr)); } private String cleanText(Element el) { return el == null ? "" : el.text().trim().replaceAll("\\s+", " "); } private boolean isValid(NewsItem item) { return item.getTitle() != null && !item.getTitle().contains("广告") && !item.getUrl().isEmpty(); } }这段代码体现了三个关键设计点:
- 异常隔离:单条解析崩溃用
try-catch捕获,避免整页报废; - 空值防御:
selectFirst()返回null时cleanText(null)返回空字符串,不抛 NPE; - 语义校验:
isValid()过滤明显无效数据(如含“广告”的标题),这是学生课设常忽略的“脏数据入口关”。
2.3 存储层:JsonFileWriter 的原子写入与增量追加
存储模块必须解决两个现实问题:写入中途崩溃导致文件损坏、重复运行覆盖历史数据。本方案采用“先写临时文件 + 原子重命名”机制,并默认启用时间戳前缀实现增量隔离。
public class JsonFileWriter implements Writer<List<NewsItem>> { private final Path outputDir; private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); public JsonFileWriter(String outputDirPath) { this.outputDir = Paths.get(outputDirPath); try { Files.createDirectories(this.outputDir); } catch (IOException e) { throw new RuntimeException("创建输出目录失败: " + outputDirPath, e); } } @Override public void write(List<NewsItem> data, String sourceUrl) { if (data.isEmpty()) return; String timestamp = LocalDateTime.now().format(formatter); String filename = String.format("news_%s_%s.json", sourceUrl.hashCode() & 0xFFFFF, // URL 简易哈希,避免长文件名 timestamp); Path tempFile = outputDir.resolve(filename + ".tmp"); Path targetFile = outputDir.resolve(filename); try (BufferedWriter writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { // 使用 Gson 序列化,确保中文不乱码 Gson gson = new GsonBuilder().setPrettyPrinting().create(); gson.toJson(data, writer); writer.flush(); // 原子重命名:Linux/macOS 是原子操作,Windows 下尽量保证 Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING); System.out.printf("✅ 已保存 %d 条新闻到 %s%n", data.size(), targetFile.getFileName()); } catch (IOException e) { throw new RuntimeException("写入文件失败: " + targetFile, e); } } }注意sourceUrl.hashCode() & 0xFFFFF这个技巧:它把任意长度 URL 映射为 6 位十六进制数(0–1048575),既避免文件名过长(Windows 限制 260 字符),又保证同源网站的文件名有区分度。这是某高校课程设计答辩时导师当场提问“如何避免文件名冲突”的标准答案。
3. 关键实现细节:从 URL 构造到反爬绕过的真实落地方案
分页爬取的成败,往往藏在几个具体参数和一行配置里。这里展开三个最常被忽略、但一错就全盘皆输的实现点。
3.1 User-Agent 与 Referer 的组合设置:绕过基础服务端拦截
很多网站对无 UA 的请求直接返回 403 或空页。但只设 UA 不够——若目标页是通过点击列表页跳转而来,服务端可能校验Referer。jsoup 默认不发 Referer,需手动添加:
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("Referer", "https://example.com/news/") // 必须是上级页面 URL .timeout(10000) .followRedirects(true);注意:
Referer必须是上一页的实际 URL,不能写死为首页。在分页循环中,应将上一页的 URL 作为Referer传给下一页请求。这是某跨平台新闻聚合毕设中,前期 70% 请求失败的根源——A同学只设置了 UA,却没补 Referer,直到用浏览器开发者工具对比请求头才发现差异。
3.2 Cookie 管理:应对登录态与会话保持
当目标网站需要登录才能看全部分页(如后台文章管理页),或存在 CSRF Token 校验时,必须复用 Cookie。jsoup 本身不维护 CookieStore,需自行管理:
// 全局 Cookie 存储(线程安全) private static final Map<String, String> COOKIE_STORE = new ConcurrentHashMap<>(); // 在首次登录成功后,提取并保存 Cookie private void saveLoginCookies(Connection.Response loginResponse) { Map<String, String> cookies = loginResponse.cookies(); cookies.forEach((key, value) -> COOKIE_STORE.put(key, value)); } // 每次请求前注入 Cookie private Connection addCookies(Connection connection) { COOKIE_STORE.forEach(connection::cookie); return connection; } // 使用示例 Connection conn = addCookies(Jsoup.connect(nextUrl)); Document doc = conn.get();这个方案轻量且可控。相比CookieManager,它避免了全局 Cookie 泄露风险;相比每次手动cookie("JSESSIONID", "..."),它支持动态更新。某实验室的论文数据采集项目就因未管理 Cookie,导致第 3 页开始返回登录页,调试两天才发现是会话超时。
3.3 动态参数提取:从 HTML 中抠出隐藏的 sign/token
部分网站分页 URL 包含动态签名,如?page=2&sign=abc123xyz&ts=1700000000。sign可能由前端 JS 计算生成,但其原始参数(如page、ts)一定存在于 HTML 中(常藏在<script>或<meta>标签里)。此时需用 jsoup 先解析 HTML,再用正则提取:
// 从页面中提取 ts 和 sign(示例:页面中有 <script>var ts=1700000000, sign="abc123";</script>) private Map<String, String> extractDynamicParams(Document doc) { Elements scripts = doc.select("script"); for (Element script : scripts) { String text = script.html(); Matcher tsMatcher = Pattern.compile("ts\\s*=\\s*(\\d+)").matcher(text); Matcher signMatcher = Pattern.compile("sign\\s*=\\s*\"([^\"]+)\"").matcher(text); if (tsMatcher.find() && signMatcher.find()) { Map<String, String> params = new HashMap<>(); params.put("ts", tsMatcher.group(1)); params.put("sign", signMatcher.group(1)); return params; } } return Collections.emptyMap(); } // 在构造下一页 URL 时调用 Map<String, String> params = extractDynamicParams(currentDoc); String nextUrl = baseUrl + "?page=" + (currentPage + 1) + "&ts=" + params.get("ts") + "&sign=" + params.get("sign");这是“玄学”参数处理的标准解法:不逆向 JS,只信 HTML 源码。只要页面渲染时把参数写进了 HTML,就能用 jsoup 拿到。某电商价格监控课设曾因试图用 Selenium 执行 JS 生成 sign,导致性能暴跌、内存溢出,换成此法后单页耗时从 8s 降至 0.3s。
4. 常见问题排查:5 个让初学者熬夜到凌晨的典型翻车点
分页爬取的调试成本极高,往往卡在某个看似微小的细节。以下是我在指导多个项目时,学生最高频报错的 5 个点,按“现象 → 原因 → 解决”给出可立即执行的排查路径。
4.1 现象:第 1 页正常,第 2 页返回 403 Forbidden
原因:服务端校验Referer头,而第 2 页请求的Referer是空或错误(如写死为首页,而非第 1 页 URL)。
解决:检查Jsoup.connect(url).header("Referer", ...)中传入的 Referer 是否为上一页的实际 URL;用curl -v -H "Referer: https://xxx/page1" https://xxx/page2手动验证。
4.2 现象:爬到第 5 页突然开始重复,后续全是第 5 页内容
原因:URL 构造逻辑错误,如page=i但网站实际用offset=20,导致i=5时offset超出范围,服务端返回第 1 页缓存。
解决:打开浏览器开发者工具 → Network 标签 → 手动点“下一页”,观察真实请求 URL 和响应内容;用NextLinkUrlBuilder替代硬编码页码。
4.3 现象:解析出的标题全是空字符串,但浏览器能看到
原因:目标元素被 JavaScript 渲染(如div.news-item在 DOM 加载后才由 JS 插入),而 jsoup 只获取静态 HTML。
解决:查看网页源代码(Ctrl+U),确认div.news-item是否存在于原始 HTML 中;若不存在,说明是 SPA 页面,jsoup 不适用,需换 Puppeteer 或提供渲染后 HTML 的接口。
4.4 现象:程序运行一会儿就卡住,CPU 占用 100%
原因:Jsoup.connect().get()默认无限等待,遇到网络抖动或服务端不响应时线程挂起。
解决:必须设置超时:.timeout(10000)(单位毫秒);并增加重试机制(如失败后 sleep 1s 再试 2 次);在PageCrawler中加入最大重试计数,避免死循环。
4.5 现象:JSON 文件写入一半程序崩溃,再运行时文件损坏无法读取
原因:直接Files.write(path, content)覆盖写入,崩溃时文件被截断。
解决:严格采用“临时文件 + 原子重命名”模式(见 2.3 节代码);生产环境建议额外增加 JSON 格式校验(用Gson.fromJson(content, Object.class)预检)。
提示:这 5 条是我给 A同学毕设答辩前划的重点。他当时卡在第 4.1 条,反复检查代码却漏看了 Referer,最后用 curl 三分钟定位。从此我养成了一个习惯:任何 HTTP 状态码异常,第一反应是用 curl 复现,而不是改 Java 代码。
5. 方案验收与答辩准备:用三张表建立可信交付证据链
毕设或课设答辩时,老师最常问:“你这个爬虫真的能稳定跑完 100 页吗?数据准确率多少?怎么证明不是假数据?”——这时候光说“我测试过了”毫无说服力。你需要一份可展示、可验证、可回溯的验收材料。我通常要求学生提交三张表,它们共同构成一条完整的证据链。
5.1 分页过程日志表(log_summary.csv)
这是最核心的交付物,记录每一次请求的完整生命周期。用 CSV 格式,方便 Excel 打开和筛选。
| page_no | url | status_code | item_count | duration_ms | error_msg | timestamp |
|---|---|---|---|---|---|---|
| 1 | https://example.com/news/?page=1 | 200 | 20 | 1245 | 2024-03-15 10:02:33 | |
| 2 | https://example.com/news/?page=2 | 200 | 20 | 987 | 2024-03-15 10:02:35 | |
| 3 | https://example.com/news/?page=3 | 403 | 0 | 3210 | Forbidden | 2024-03-15 10:02:40 |
| 4 | https://example.com/news/?page=3 | 200 | 20 | 1102 | 2024-03-15 10:02:42 |
实现要点:在
PageCrawler的fetchPage()方法中,用System.currentTimeMillis()记录起止时间,捕获HttpStatusException获取status_code,用doc.select(...).size()记录item_count。答辩时打开这张表,老师一眼就能看出稳定性、成功率、性能瓶颈。
5.2 数据质量抽样表(quality_sample.xlsx)
随机抽取 5–10 页的原始 HTML 片段与解析结果并排对比,证明解析逻辑正确。
| page_no | html_snippet (first 100 chars) | parsed_title | expected_title | pass |
|---|---|---|---|---|
| 1 | <div class="news-item"><h2 class="title"><a href="/n1.html">人工智能新突破</a></h2> | 人工智能新突破 | 人工智能新突破 | ✅ |
| 5 | <div class="news-item"><h2 class="title"><a href="/n5.html">【公告】系统维护通知</a></h2> | 【公告】系统维护通知 | 【公告】系统维护通知 | ✅ |
| 12 | <div class="news-item"><h2 class="title"><a href="/n12.html"> </a></h2> | (空) | ❌ |
抽样原则:覆盖首页、中间页、末页、含特殊字符页(如标题带【】、emoji)、含空字段页。答辩时只需展示 3 行,老师就会相信你做了真实校验。
5.3 配置与参数对照表(config_reference.md)
明确列出所有可配置项及其生产环境推荐值,体现工程化思维。
| 参数 | 说明 | 课设推荐值 | 毕设推荐值 | 修改位置 |
|---|---|---|---|---|
maxRetry | 单页请求最大重试次数 | 2 | 3 | PageCrawler.Builder |
timeoutMs | 单次请求超时(毫秒) | 10000 | 15000 | Jsoup.connect().timeout() |
delayMs | 页间延迟(毫秒) | 1000 | 2000 | Thread.sleep()in loop |
stopPolicy | 终止策略 | MaxPageCountStopPolicy(50) | EmptyContentStopPolicy("article") | PageCrawler.builder().stopPolicy() |
这张表的价值在于:让老师确信你理解每个参数的意义,而不是盲目复制粘贴。某高校答辩中,导师指着
delayMs问“为什么设 2000 而不是 500”,学生答“避免触发 QPS 限流,符合 robots.txt 建议”,当场加分。
从那以后我每次带课设,都强制学生在README.md里手写这三张表——不是为了凑字数,而是逼他们真正走完“写代码 → 跑数据 → 查日志 → 验结果”的完整闭环。没有日志表的爬虫,就像没有仪表盘的飞机;没有抽样表的解析,就像没有测试用例的算法。希望帮到你。
本文还有配套的精品资源,点击获取