☰
jsoup分页爬虫实战:Java数据采集与存储完整方案
2026/10/21 4:10:21 网站建设 项目流程

简介:面向Java开发者的jsoup分页爬取网页示例资源,适合需要解析HTML并实现多页数据抓取的爬虫初学者与进阶者,重点解决翻页URL构造、内容解析与数据提取等常见问题。资源共16个文件,压缩包约3.08MB,核心包含jsoup练习源码、Eclipse工程配置与编译输出文件,并附带WebCollector、Jackson、slf4j、log4j等依赖jar包,可直接导入开发环境运行调试,省去手动搭建依赖的麻烦。已有1082人学习下载。示例围绕分页抓取完整流程展开,涵盖URL翻页参数构造、Document对象解析、CSS选择器定位分页链接、循环遍历多页数据及异常处理等关键环节,读者可对照源码理解jsoup的connect、parse与select方法在实际场景中的配合方式,同时参考日志与配置文件观察请求状态变化,快速迁移至自己的采集任务中,适合边读边改的实操学习。

1. 项目定位:用jsoup把分页数据从网页里"榨"出来

上周我接了个信息整理的需求:要把某技术博客上分页展示的文章标题、链接、发布时间全部抄下来,手工翻页复制了半个小时只搞定两页。后来我把自己常用的这套基于 jsoup 的分页爬取模板改改,五分钟跑完所有页面,数据整整齐齐落进了 SQLite。这个项目解决的正是"分页列表页 → 结构化数据"的采集链路:给定起始 URL,它自动解析当前页的条目,翻页循环到最后一页,再把结果写入文件和数据库。它不依赖无头浏览器,单线程轻量运行,适合小批量数据采集、竞品信息整理,也适合刚入门 Java 爬虫的同学当第一个练手项目。整个工程代码量不大,但连接、解析、翻页、存储这条主链路是完整的。

2. 架构与选型:为什么是jsoup,以及项目骨架的落地细节

2.1 技术选型:轻量解析库里的"甜点区"

做 Java 网页爬取,可用组件其实不少。HttpClient 或 HttpURLConnection 负责发请求,但拿到 HTML 后还得自己用正则或者引入别的解析器;HtmlUnit 能执行 JavaScript、模拟完整浏览器行为,然而依赖体积大、运行速度慢;Selenium 更重,直接拉起一个浏览器实例。这个项目选 jsoup 的原因很实际:它把"请求网页"和"解析 HTML"合并成一套 API,选择器语法直接对标 CSS,写起来快,排错直观。

对分页采集这个场景,目标页面大多是后端渲染好的静态 HTML。jsoup 的解析性能和 API 简洁度刚好落在"够用"和"好用"的交集上。常规的分页列表、详情页都能覆盖;如果真碰上 JS 动态渲染的页面,再升级到 HtmlUnit 也不迟。当年我调一个 JS 渲染的站点时被 Selenium 的启动速度搞到崩溃,后来换成先确认页面是否需要执行 JS,不需要就无脑 jsoup,这成了我选型的第一原则。

方案是否解析HTML是否执行JS依赖重量适用场景
HttpClient + 正则不解析,手工提取否轻简单接口、纯JSON
jsoup自带解析器否轻静态HTML页面、分页列表
HtmlUnit自带解析器是中少量JS渲染的页面
Selenium/Playwright自带解析器是重复杂交互、需要截图

这张表把选择思路落成一句话:静态页面直接 jsoup,不需要犹豫;涉及 JS 渲染再上重武器,不要为了一两个动态字段就把整套无头浏览器背在身上。

2.2 目录结构:一屏能看完的小工程

paged-crawler/ ├── pom.xml ├── src/main/java/com/demo/crawler/ │ ├── Main.java │ ├── model/ │ │ └── Article.java │ ├── parser/ │ │ └── ListPageParser.java │ ├── crawler/ │ │ └── PagedCrawler.java │ └── storage/ │ ├── FileExporter.java │ └── DbExporter.java └── src/main/resources/ └── logback.xml

这个工程只有五个核心类,我故意保持小。爬虫项目最容易犯的错就是"抓一个站点写一堆临时代码",最后自己都看不懂。按职责拆开后每个人物的边界很清楚:

  • Main.java:程序入口,编排流程,调用抓取、解析、存储。
  • Article.java:数据实体,对应列表页里的一条记录。
  • ListPageParser.java:只负责把Document转成List<Article>,不碰连接和存储。
  • PagedCrawler.java:核心流程控制器,负责循环翻页、调用解析器、汇总结果。
  • FileExporter.java与DbExporter.java:输出层,分别写 CSV 和 SQLite。

把连接、解析、存储全塞进一个类,跑通一次可以,换一个站点就得全重写。按这个目录拆的好处是:换站点只改 parser,换存储只改 storage,主流程稳定不变。对后续加需求,比如抓详情页、接其他数据库,也不会动到核心翻页逻辑。

2.3 依赖与运行环境:三行配置把环境拉起来

pom.xml 里只需要三个依赖:

<dependencies> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.16.1</version> </dependency> <dependency> <groupId>org.xerial</groupId> <artifactId>sqlite-jdbc</artifactId> <version>3.42.0.0</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.8</version> </dependency> </dependencies>

三个依赖各司其职。jsoup是核心解析库,1.16.x 是当前稳定主线,API 成熟,后续升级风险低;sqlite-jdbc是 SQLite 的 JDBC 驱动,免安装、单文件存储,适合小规模采集任务;logback-classic是日志框架,跑爬虫看不到进度就像开车不看仪表盘,有日志才能定位卡在哪一页。

JDK 要求 8 以上,我本地用的是 JDK 11,代码里没有用任何 11 特有语法,所以降级到 JDK 8 也能直接编译运行。如果你只是临时跑一次,不用 Maven 也能跑——在 IDEA 里手动引入 jsoup 的 jar 包即可——但我还是建议用 Maven 管依赖,后面加库、换版本都省事。

src/main/resources/logback.xml里配了一个最简单的控制台输出:

<configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="info"> <appender-ref ref="STDOUT"/> </root> </configuration>

这个配置把日志输出到控制台,级别设为 INFO。跑起来每翻一页都有一条进度日志,出错了会有 ERROR 堆栈。日志不是锦上添花,是爬虫排障的第一现场。很多新手不看日志,凭感觉猜代码哪里写错了,其实异常信息写得明明白白。

3. 核心模块拆解:一个请求从进入到返回的完整链路

3.1 连接管理:先戴上"浏览器"的头盔

爬虫第一道关卡不是解析,而是把请求发出去且不被目标网站拉黑。jsoup 的Connection接口把这一步封装得很干净。我专门抽了一个方法统一构建连接:

private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36"; private Connection buildConnection(String url) { return Jsoup.connect(url) .timeout(5000) .userAgent(USER_AGENT) .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") .header("Accept-Language", "zh-CN,zh;q=0.9") .method(Connection.Method.GET); }

逻辑说明:.timeout(5000)表示建立连接和读取数据的超时时间是 5 秒,超过就抛SocketTimeoutException;.userAgent(...)把请求伪装成 Chrome 浏览器——这个头是大多数网站反爬判断的第一参考,默认的Java/1.8很容易被 403;.header("Accept", ...)和.header("Accept-Language", ...)进一步把请求头补全,让请求更像真实浏览器。

参数说明:timeout不建议设太长,5 秒够多数站点正常响应了,设太长会让整个翻页流程在某个卡死的页面上耗掉大量时间。USER_AGENT的值建议跟着主流浏览器版本走,每隔半年换一次。我见过不少案例,页面从正常突然变成 403,排查到最后就是 UA 太旧被对方识别成了爬虫。

有了统一的连接构建方法,列表页和详情页请求都走同一份配置,请求头始终一致。这也是减少被识别的小细节——页面跳转过程中如果请求头风格突然变了,反而更容易触发风控。

3.2 页面解析:CSS选择器精准取数

拿到Document后进入数据抽取环节。一个典型的分页列表页结构是:外层容器里重复排列着多个列表项,每个列表项里有标题、链接、时间、摘要。jsoup 的select()方法用 CSS 选择器定位这些元素:

public class ListPageParser { public List<Article> parse(Document doc) { List<Article> articles = new ArrayList<>(); Elements items = doc.select("div.post-item"); for (Element item : items) { Article article = new Article(); Element titleLink = item.selectFirst("h2.title a"); if (titleLink == null) { continue; } article.setTitle(titleLink.text()); article.setUrl(titleLink.absUrl("href")); Element timeEl = item.selectFirst("span.date"); article.setPublishTime(timeEl != null ? timeEl.text() : ""); Element summaryEl = item.selectFirst("p.excerpt"); article.setSummary(summaryEl != null ? summaryEl.text() : ""); articles.add(article); } return articles; } }

逻辑说明:doc.select("div.post-item")返回所有匹配div.post-item选择器的元素,这里post-item是列表项的 CSS 类名,代表一条完整记录。对每个item,selectFirst("h2.title a")取内部第一个匹配的a标签;.text()获取可见文本,.absUrl("href")把相对路径自动补全为绝对 URL,这比手动拼接域名可靠得多。时间、摘要都做了空值防御,找不到节点就置空字符串,不会因为单条数据缺字段而 NPE。

选择器调试有个笨但有效的方法:先抓一页 HTML 存到本地,在 IDEA 里直接看结构再写选择器,比对着上千行 HTML 肉眼硬找效率高得多。另一个经验是优先用稳定的 class 或 id 作为锚点,少用索引位置nth-child(),因为网站改版时索引最容易变。

3.3 实体建模:字段类型为什么全用String

public class Article { private String title; private String url; private String publishTime; private String summary; public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public String getPublishTime() { return publishTime; } public void setPublishTime(String publishTime) { this.publishTime = publishTime; } public String getSummary() { return summary; } public void setSummary(String summary) { this.summary = summary; } @Override public String toString() { return String.format("Article{title='%s', url='%s', time='%s'}", title, url, publishTime); } }

四个字段正好对应列表页上一条数据的四个展示位。字段类型全部用String是刻意为之:网页上的时间格式极度不可控,"2024-03-15"、"2024/3/15"、"15分钟前"、"Mar 15, 2024"都可能出现。如果实体直接定义成LocalDate,解析阶段就要处理格式异常,碰到"15分钟前"这种相对时间会直接崩掉。先以字符串保留原文,清洗阶段再统一格式化,这是更稳的做法。

toString()重写是为了在日志里直接看数据长什么样,不用开 debugger。字段命名和页面展示保持一致,后续做清洗、写文档时对着原生页面字段名能省很多解释成本。

4. 分页循环与数据落地:把"翻页"做成自动化流水线

4.1 翻页的两种套路与终止条件

分页 URL 无外乎两种形式:路径型https://example.com/articles/page/2和查询参数型https://example.com/articles?page=2。这里用的是查询参数型,构造 URL 最直观:

public class PagedCrawler { private static final Logger log = LoggerFactory.getLogger(PagedCrawler.class); private static final String BASE_URL = "https://example.com/articles"; private static final int MAX_PAGES = 50; private static final int RETRY_TIMES = 3; private final ListPageParser parser = new ListPageParser(); public List<Article> crawl() { List<Article> all = new ArrayList<>(); for (int page = 1; page <= MAX_PAGES; page++) { String url = BASE_URL + "?page=" + page; try { Document doc = fetchWithRetry(url); List<Article> pageData = parser.parse(doc); if (pageData.isEmpty()) { log.info("Page {} is empty, crawl stopped", page); break; } all.addAll(pageData); log.info("Page {} fetched, got {} items, total {}", page, pageData.size(), all.size()); Thread.sleep(1000 + new Random().nextInt(1500)); } catch (Exception e) { log.error("Failed at page {}, skip and continue: {}", page, e.getMessage()); } } return all; } private Document fetchWithRetry(String url) throws IOException { IOException last = null; for (int i = 0; i < RETRY_TIMES; i++) { try { return Jsoup.connect(url) .timeout(5000) .userAgent("Mozilla/5.0 ...") .get(); } catch (IOException e) { last = e; try { Thread.sleep(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } throw last; } }

代码里三个关键细节值得展开。

第一个是终止条件pageData.isEmpty()。很多网站翻到不存在的页码时并不返回 404,而是返回 200 加一个空列表容器。如果只看状态码,程序会一直跑到MAX_PAGES上限,白白浪费请求。判断"有没有数据"才是翻页到底的最可靠信号,这也是我从一个翻车现场学到的。

第二个是单页失败不中断。catch (Exception e)里只记录日志然后继续下一页。爬虫跑几十分钟,中间某页超时是常态,因为一页失败就让整个任务结束,前面全白跑。跳过失败页,最后集中补跑,这才是爬虫的容错方式。

第三个是限速用了1000 + Random.nextInt(1500)毫秒而不是固定值。固定间隔的请求在服务器眼里是明显的机器行为,加一点随机抖动,看起来更像人手动浏览。这个做法在反爬不激进的小站点上很顺畅。

注意:MAX_PAGES = 50只是熔断上限,防止分页逻辑出错时无限翻页。正常结束依赖的是pageData.isEmpty()的判断。如果目标站页面很多,先跑一次看日志里实际用了多少页,再回过头调整这个值。

4.2 数据输出:CSV与SQLite双通道

抓回来的数据不落盘等于白抓。项目里同时实现了两种输出:CSV 适合快速查看和给 Excel 做二次处理,SQLite 适合结构化查询和去重。

public class FileExporter { public void exportToCsv(List<Article> articles, String path) throws IOException { try (Writer writer = new BufferedWriter(new OutputStreamWriter( new FileOutputStream(path), StandardCharsets.UTF_8))) { writer.write('\uFEFF'); writer.write("title,url,time,summary\n"); for (Article a : articles) { writer.write(String.format("%s,%s,%s,%s\n", a.getTitle(), a.getUrl(), a.getPublishTime(), a.getSummary())); } } } }

writer.write('\uFEFF')写的是 CSV 的 BOM 头,专门解决 Excel 打开 UTF-8 CSV 时中文乱码的问题。别小看这一个字符,没它的时候同事拿 Excel 打开全是乱码。用OutputStreamWriter并显式指定StandardCharsets.UTF_8,是保证跨平台编码一致的关键。如果你的下游工具不是 Excel 而是脚本程序,BOM 反而可能干扰解析,那时候可以去掉这行——取决于你喂给谁吃。

public class DbExporter { public void exportToSqlite(List<Article> articles) throws SQLException { String sql = "INSERT INTO articles(title,url,publish_time,summary) VALUES(?,?,?,?)"; try (Connection conn = DriverManager.getConnection( "jdbc:sqlite:data.db"); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (Article a : articles) { ps.setString(1, a.getTitle()); ps.setString(2, a.getUrl()); ps.setString(3, a.getPublishTime()); ps.setString(4, a.getSummary()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } } }

SQLite 导出用 JDBC 批量插入。setAutoCommit(false)关闭自动提交,攒一批数据后executeBatch()一次性执行,commit()提交事务。5000 条数据写入耗时几百毫秒,远快于逐条插入。更重要的是事务保证:中途抛异常不会留下残缺数据。参数jdbc:sqlite:data.db里的data.db是数据库文件名,首次运行自动创建,建表语句放在资源目录里启动时执行就行。

4.3 主流程编排与调试技巧

public class Main { private static final Logger log = LoggerFactory.getLogger(Main.class); public static void main(String[] args) throws Exception { List<Article> all = new PagedCrawler().crawl(); new FileExporter().exportToCsv(all, "articles.csv"); new DbExporter().exportToSqlite(all); log.info("Crawl finished, total {} articles saved.", all.size()); } }

主函数就是串联三个环节:crawl()拿数据,exportToCsv写文件,exportToSqlite入库。实际运行时我先注释掉 SQLite 那行,只跑 CSV 验证数据对不对,确认无误再打开入库,"先文件后数据库"的调试习惯帮我省掉无数次清理脏数据的时间。

日志看起来大约是这样:

14:02:11.123 INFO PagedCrawler - Page 1 fetched, got 20 items, total 20 14:02:12.450 INFO PagedCrawler - Page 2 fetched, got 20 items, total 40 14:02:13.891 INFO PagedCrawler - Page 3 fetched, got 20 items, total 60 ... 14:02:30.250 INFO PagedCrawler - Page 15 is empty, crawl stopped 14:02:30.410 INFO Main - Crawl finished, total 280 articles saved.

从日志能清楚看到每页条数、累计条数、在哪一页终止。一旦某页抓出 0 条,日志立刻告诉你原因大概率是"这页真没数据了"还是"选择器失效了"。我把选择器失效也视作空数据,额外的诊断手段是:当pageData.isEmpty()时把当前页 HTML 存成debug.html,下次跑完直接打开本地文件看结构,不用重新请求服务器。改版排查时这个技巧尤其省心。

5. 避坑指南:分页爬取最容易翻车的五个现场

5.1 乱码:页面标题变成"火星文"

现象:解析出来的标题是æ–‡ç«这种乱码。

原因:目标网站使用 GBK/GB2312 编码而不是 UTF-8,jsoup 默认按 UTF-8 解码,两边对不上就乱码。少数情况是响应头没声明 charset,jsoup 猜错了编码。

解决:先强制指定字符集:

Document doc = Jsoup.connect(url) .charset("GBK") .get();

还不行就把 HTML 原始字节取出来,用页面<meta>标签里声明的编码手动转。我的排查顺序是:先打印doc.charset()看 jsoup 当前认为的编码,再去源码里找<meta charset=...>,两者不一致就手动指定。

5.2 跑到中间某一页突然超时

现象:第 1 到第 16 页都正常,第 17 页抛SocketTimeoutException,程序直接退出,前面数据全部丢失。

原因:目标服务器在高频连续请求下开始限流,或线路波动导致连接重置。单请求超时是概率事件,问题在于程序没有容错。

解决:循环里加 try-catch,单页失败最多重试 3 次,每次间隔 2 秒;重试仍失败就记日志跳过当前页继续下一页。爬虫第一原则是不因单页失败而全盘放弃——宁可最后少几十条数据回头补跑,也不能跑一半全白费。

5.3 选择器匹配结果归零

现象:昨天还能正常抓取的页面,今天select()返回集合大小是 0,程序把所有列表项跳过,最后导出空文件。

原因:网站改版了,HTML 结构变化,旧选择器找不到对应节点。这在活跃网站上非常常见。

解决:用浏览器开发者工具审查元素,找到新结构里列表项对应的选择器,替换ListPageParser里的规则。顺手做一件事:解析结果为空时把原始 HTML 落盘为debug.html,这样问题在本地就能排查,不必反复请求线上页面。

5.4 翻页翻不完:到了上限还在跑

现象:MAX_PAGES设了 50,日志显示到第 50 页时仍有数据,程序停下后总觉得数据没抓干净。

原因:网站实际页数超过设定值,只抓了一部分。另一种更隐蔽的情况是某些网站翻到最后一页后自动跳回第一页,不做重复检测,程序会在前几页死循环。

解决:把MAX_PAGES调大,或设为 0 表示不限页数,完全靠空列表判断终止。同时引入 URL 去重集合:每页第一条数据的详情 URL 装进Set,如果新一页第一条 URL 已存在,判定为循环回来了,立即停止。

5.5 请求频繁触发风控

现象:抓了二三十页后,服务器突然对连续请求返回 403,页面内容变成验证码或空壳。

原因:请求频率太高,同一 IP 短时间内的访问次数超过目标站风控阈值。

解决:把限速间隔从 1 秒提高到 2~3 秒,并加入随机抖动,比如2000 + random.nextInt(2000)毫秒。再准备两三个不同版本的 UA 字符串,每次请求轮流切换。爬虫的本质是克制,保持低调才能长期稳定抓取。贪快把目标站打挂,最后自己也被封,得不偿失。

6. 二次开发:如何把这份爬虫改造成你自己的工具

6.1 换目标站点的三个改动点

拿到源码改造成自己需要的爬虫,一般只需动三个地方:BASE_URL换成目标列表页地址;ListPageParser里的 CSS 选择器换成目标站点的结构;Article实体字段换成你要抓的内容。改完这三处,一个新的分页爬虫就能跑起来。

如果你的目标站是路径型分页,把 URL 拼接从BASE_URL + "?page=" + page换成BASE_URL + "/page/" + page就完事。如果需求要进详情页取正文,流程稍作变化:列表循环获得Article后,对每条数据的url字段再发一次请求,用另一个DetailPageParser解析,把正文回填到实体里。复用buildConnection()即可,详情页解析类单独维护,别和列表解析混在一起,否则页面结构一变两个功能一起挂。

6.2 抽象解析接口:让数据源可插拔

等多目标抓取需求出现后,强烈建议把解析器抽象成接口:

public interface PageParser<T> { List<T> parse(Document doc); }

ListPageParser实现接口,PagedCrawler只依赖接口不依赖实现。以后接第二个目标网站时,写一个新的实现类就够,主流程一行不改。我自己的项目里用这个结构半年内接入了三个采集源,每次都是加类而不是改逻辑,维护成本低很多。

另一个值得做的改造:把限速间隔、最大页数等参数从常量变成可配置项,放进config.properties或命令行参数,比如--delay 1500 --max-pages 30。这样调整采集行为不用重新编译,线上临时改节奏就方便了。

6.3 一条增量改造:数据库去重

落地之后最容易遇到的问题就是重复数据——程序因为超时重跑了一遍,同一个 URL 被插了两次。我的解决思路是建表时对url建唯一索引:

CREATE UNIQUE INDEX IF NOT EXISTS idx_article_url ON articles(url); CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, url TEXT UNIQUE, publish_time TEXT, summary TEXT );

插入时用INSERT OR IGNORE,遇到相同 URL 自动跳过,重复执行不会产生脏数据。有了这个机制,我可以放心让爬虫跑完一遍再跑一遍,而不用担心库里堆满重复记录。

从那以后,我写任何采集程序,落地第一步永远是先确认"哪个字段是唯一键、索引建好没有"。爬虫可以容忍少抓几条数据,但绝不能让脏数据在库里堆积,这条习惯帮我避掉了很多数据清洗的麻烦,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询