☰
Jsoup分页抓取实战:Java网页数据采集与HTML解析三步走
2026/10/21 21:52:00 网站建设 项目流程

简介:一份面向Java开发者的jsoup分页爬取网页示例工程,演示如何借助jsoup解析HTML并循环抓取多页数据,适合入门网络爬虫或需要快速落地分页采集的读者。整个压缩包仅3.08MB,含16个文件,主体是7个jar依赖包——jsoup用于HTML解析,WebCollector提供爬虫框架,json负责数据交换,log4j与slf4j完成日志记录;另含Java源码、编译后的class文件、Eclipse项目配置(.project/.classpath)以及少量数据库临时文件。导入IDE即可查看完整代码与依赖关系,直接运行调试。已有1082人学习下载。工程围绕分页抓取的关键链路展开:构造带页码参数的URL、使用CSS选择器定位分页链接与目标字段、用循环逐页请求并解析,同时给出异常处理、数据存储与清洗思路。结合WebCollector与json等库,还可进一步体会请求调度、解析提取、持久化配合的完整流程,适合作为分页爬虫项目的起步模板。

1. 分页抓取JSoup:三个步骤把“下一页”变成循环

分页抓取这件事,看起来是循环上一页下一页,真正动手时才发现,每个网站的分页逻辑都能玩出花来。用jsoup分页爬取网页,核心就是三步:构造分页URL、循环拉取HTML、用选择器提取数据。这套流程能覆盖大部分静态分页网站,也能处理一部分动态参数分页,适合用Java快速做数据采集、索引构建和网页正文抽取的开发者。下面我按实际拆项目的顺序,把选型、代码和踩坑一起讲透。如果你正被这种“手动翻页复制粘贴”折磨,这份笔记就是你想要的。

2. 为什么选Jsoup做分页抓取:选型理由与核心API边界

2.1 Jsoup的定位:干净的DOM解析器,不是无头浏览器

很多刚接触爬虫的人会用正则表达式硬抠网页,结果被HTML结构变化搞得焦头烂额。Jsoup是一个纯Java的HTML解析库,它把网页加载成DOM树,然后用类似jQuery的选择器去找元素。它不执行JavaScript,也不维护浏览器环境。这意味着,如果目标页面是纯静态的服务端渲染页面,Jsoup是首选的轻量方案;如果页面是Vue、React之类的客户端渲染,直接抓下来可能全是空壳,这时候要么改用无头浏览器,要么去找页面背后的XHR接口。

我在拆某跨平台系统时,遇到过列表页只有一个loading动画的情况,用Jsoup抓回来什么数据都没有。后来发现,列表数据是前端通过AJAX接口拿到的,返回的还是JSON。所以动手之前,先判断目标页面是服务端渲染还是客户端渲染,这决定了Jsoup能不能胜任。判断方法很简单:在浏览器里禁用JavaScript刷新页面,如果列表还在,说明服务端渲染;如果页面空了,说明是动态加载。这一步不能省,否则后面全白做。

另外要理解Jsoup和Selenium这类工具的本质区别。Jsoup发送的是一个普通HTTP请求,拿到的是服务器返回的原始HTML,它不会等待页面上的脚本跑完。Selenium会启动真实浏览器内核,把JavaScript执行完之后的DOM呈现给你。对于大多数新闻列表、商品列表、文章分页,服务端渲染仍然是主流,Jsoup的性能和资源占用优势非常明显。代价是遇到客户端渲染页面时,它完全无能为力。知道边界在哪,选型就不会翻车。

2.2 预分析分页参数:拿真实URL而不是猜

正式写代码之前,我一般会先用浏览器打开列表页的每一页,观察URL的变化规律。常见的分页URL有三种模式:一是查询参数型,例如/list?page=1,页码直接替换数字;二是路径型,例如/list/1.html,页码嵌在路径里;三是表单提交型,点击“下一页”会发送POST请求,URL不变,参数体变化。

我用一个表格记录这种差异,方便快速识别:

分页类型URL示例页码带入方式是否适合Jsoup直接处理
查询参数型/list?page=2替换page参数适合
路径型/list/2.html替换路径段适合
POST表单型/list/list.do请求体携带page字段适合,需构造data
JS渲染型/list动态请求接口不适合

第一和第二种是最常见的,第三种在旧系统里经常出现。JS渲染型的处理思路是抓接口而不是抓页面,这在后面章节单独讲。分析URL时,重点关注三个参数:page、pageSize和offset。有的站点用offset表示起始偏移量,offset=(page-1)*pageSize,这种写法更容易踩坑。还有的站点第一页没有page参数,第二页才开始出现page=2,这时循环起始条件要做特殊处理,否则第一页会变成空抓。

2.3 核心API最小闭环:connect、get、select、text

Jsoup抓一页数据的最小闭环可以浓缩成四步。第一步用Jsoup.connect(String url)创建连接对象;第二步设置请求头信息,比如浏览器标识和超时时间;第三步调用.get()或.post()执行请求并返回Document对象;第四步用Document.select(String cssQuery)定位元素,再用Element.text()获取文本。

下面是一个抓取第一页标题的完整代码,我平时调试基本都是从这个模板开始的:

import org.jsoup.Jsoup; import org.jsoup.nodes.Document; public class FirstPageDemo { public static void main(String[] args) throws Exception { // 目标列表页,先抓第1页 String url = "https://example.com/list?page=1"; // connect创建连接,userAgent模拟真实浏览器,timeout设置超时毫秒数 Document doc = Jsoup.connect(url) .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .timeout(3000) .get(); // 用CSS选择器定位所有h2标签并打印文本 doc.select("h2").forEach(el -> System.out.println(el.text())); } }

这段代码里,connect返回的是Connection对象,它是链式调用的起点。userAgent建议不要省略,有些站点对Java默认的UA字符串直接拒绝访问。timeout单位是毫秒,我一般设置在3000到5000之间,太短容易超时,太长会让整个循环被一个卡住的请求拖死。get()方法发送GET请求,返回的Document对象本质上是整个页面的DOM树。select方法接收CSS选择器,例如"h2"表示所有h2标签,".item"表示所有class为item的元素,理论上是后端标准的CSS选择子集,实际使用已经够用。

这里有一个关键思路:分页爬虫和单页抓取的区别,仅仅在于你把这个最小闭环放进一个循环,并控制循环的终止条件。只要这个闭环能跑通,分页只是重复调用的问题。但要注意,jsoup并不提供原生分页支持,所有翻页控制都需要你自己写,这也是我写这篇文章想重点讲清楚的地方。

3. 分页URL的构造与循环:从静态页码到动态参数

3.1 静态分页URL模板:占位符替换与循环

静态分页是最友好的一类,因为页码和URL之间是确定性的映射关系,没有隐藏状态。以查询参数型的出现频率最高,比如 /list?page=1 到 /list?page=2,直接把page值替换成循环变量即可。由于这个处理方式极其直观,很多新手会写成:

for (int page = 1; page <= 10; page++) { String url = "https://example.com/list?page=" + page; // 抓取并解析 }

但这里有个问题:固定循环10页,如果实际只有5页,就会空抓5次;如果实际有20页,又只能抓一半。所以循环条件不能拍脑袋,一般先把“是否有下一页”作为第一终止条件,把最大页数作为第二保险。很多网站的分页条里会给出总页数,比如“共12页”,这时候可以先把总页数解析出来。但也有不给总页数的网站,那就只能靠下一页按钮判断。

一个比较稳的写法,是先访问第一页,解析出总页数或下一页按钮是否存在,再决定循环次数。示例代码如下:

import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; public class StaticPaginationCrawler { public static void main(String[] args) throws Exception { int page = 1; int totalPages; // 先抓第一页,拿到文档 Document firstDoc = fetchPage(page); // 从第一页里解析总页数,这里假设页面中有<span class="total-pages">12</span> Element totalEl = firstDoc.selectFirst("span.total-pages"); totalPages = totalEl != null ? Integer.parseInt(totalEl.text()) : 1; // 循环抓取每一页 while (page <= totalPages) { Document doc = page == 1 ? firstDoc : fetchPage(page); // 处理当前页数据,包括标题、链接等 processPage(doc); page++; } } private static Document fetchPage(int page) throws Exception { String url = "https://example.com/list?page=" + page; // 请求时带一个与浏览器一致的UA,减少被拦概率 return Jsoup.connect(url) .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .timeout(5000) .get(); } private static void processPage(Document doc) { // 这里后续章节展开解析,先留一个输出点位 System.out.println(doc.select("h2").size()); } }

这段代码的核心逻辑是先复用第一页,避免重复请求。totalPages从页面里解析出来,如果解析失败就默认1,防止死循环。while循环的好处是不需要预先知道具体的数字,完全依赖总页数,比固定10页灵活得多。要注意的是,如果页面里没有总页数元素,selectFirst会返回null,必须判空,否则会在Integer.parseInt(null)处抛空指针。

总页数解析还有一种变体是“下一页”按钮的href里带有页码,比如 /list?page=3。这时可以不断读取当前页的下一页链接,直到链接为空。下面这个判断方式很常用:

Element next = doc.selectFirst("a.next"); if (next == null) { break; }

但我要提醒,有些网站的下一页按钮在首页存在,但不是a标签,而是button或者带disabled属性的a标签。如果selector写错,循环可能在第一页就停下来。判断的时候可以同时检查元素存在性和disabled属性,这个我们在后面的避坑章节再展开。

3.2 动态分页参数:POST表单怎么处理

查询参数型分页简单直接,但有些老旧系统或者后台管理系统,点击“下一页”时不是GET请求,而是把页码放在POST表单里提交。比如常见的列表页 /list/list.do,请求体带有 currentPage=1、pageSize=20 这样的字段。此时Jsoup的用法要做一点切换,把.get()换成.post(),用.data()方法添加表单参数。

一个常见请求结构如下:

String url = "https://example.com/list/list.do"; int page = 1; // 使用data方法传递请求体参数 Document doc = Jsoup.connect(url) .userAgent("Mozilla/5.0") .data("currentPage", String.valueOf(page)) .data("pageSize", "20") .data("sortField", "createTime") .post();

这种场景下,先抓第一页还是先抓最后一页不重要,重要的是确认网站是否额外校验了CSRF令牌或SessionID。如果登录后才可见,需要先用Connection的.cookie("name", "value")方法把会话Cookie带进去:

Connection conn = Jsoup.connect(url) .cookie("JSESSIONID", sessionId) .data("currentPage", String.valueOf(page));

有一点要特别说明:Jsoup虽然可以发送POST,但它本质上还是解析HTML的工具,不处理重定向和脚本。如果目标站点在POST之后返回JSON数据,比如常见的分页接口返回一个JSON包,那就不该用select去抠HTML,而是把返回内容当成字符串处理,再用Jackson或者Gson解析。我在实际项目里遇到过好几种“页面表格数据是通过AJAX的POST接口返回JSON”的情况,这时候Jsoup只用来抓取包含Token的入口页面,真正的数据请求用Java原生HttpClient发出去更合适。

动态分页还有一个变体是无限滚动加载,URL始终不变,只能在页面底部追加请求。这种场景Jsoup处理不了,因为追加的数据是异步加载的,静态HTML里只有前几项。如果坚持用Jsoup,就得模拟那个异步接口的参数,比如cursor、lastId之类。这类接口的本质还是HTTP请求,只要找到参数规律,Jsoup的connect同样能打,只是它返回的是JSON字符串,需要配合其他JSON库使用。所以不要把Jsoup局限在“只能抓网页”这个印象里,它抓接口返回内容一样可以。

3.3 终止条件判断:别让循环跑丢

分页爬虫最容易出现两个极端:要么爬到重复页,要么少爬一页。终止条件必须明确。我总结出三种常用判断方法,按可靠性排序:

第一种是数据量判断。如果当前页解析出的列表项数量为零,说明没有更多数据,直接break。这种方式最可靠,因为不管URL怎么变,数据为空就等于到底了。但缺点是最后一页如果设计成空白占位,也会导致提前退出。

第二种是翻页控件判断。检查当前页面中“下一页”按钮是否存在且可点击。比如很多Bootstrap风格的列表在最后一页时,下一页按钮会带有.disabled类,此时需要判断class或者disabled属性。

Element nextBtn = doc.selectFirst("li.next > a"); if (nextBtn == null || nextBtn.hasAttr("disabled") || nextBtn.hasClass("disabled")) { break; }

第三种是总页数判断。就像前面代码里通过span.total-pages拿到的数字。这里有一个需要特别留意的地方:总页数有时候不是真实页数,而是“最大页码”,但列表因为筛选条件不同,实际页码可能并不连续。例如搜索页按照分类分组显示,第1页和第2页内容不同,但第3页可能因为缓存返回了第2页的内容。所以最稳妥的方式是同时结合前两种,数据为空或没有下一页控件,才决定跳出循环。

我在拆某图像处理Demo的搜索结果页时,遇到过一个分页条每秒都在变的情况,直接让我怀疑人生。后来发现是站点的翻页控件用JavaScript动态计算,我抓到的静态HTML里根本没有翻页链接,只有第一页的数据。这种情况的解法是把请求拆成JavaScript在浏览器里执行后的真实接口,而不是继续纠结静态页。记住一个原则:Jsoup面对的是服务端生成的HTML,如果翻页控件完全由前端生成,就应该换工具。

4. 解析页面数据:select选择器与数据清洗

4.1 列表项提取:用select抓取重复结构

分页爬到的是整个页面,但真正需要的往往是列表项里的标题、链接、日期和摘要。网页的列表结构通常是重复的DOM块,比如一个div.item包含h2标题、span.date日期和p.desc摘要。先用select把所有列表块抽出来,再逐字段提取,这是最标准的做法。

假设页面结构是这样:

<div class="item"> <h2><a href="/news/123">某新闻标题</a></h2> <span class="date">2024-05-01</span> <p class="desc">这里是摘要内容</p> </div>

对应的提取代码:

import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; public class ListParser { public static void parse(Document doc) { // 定位所有class为item的列表块 Elements items = doc.select("div.item"); for (Element item : items) { // 每个字段都要做空判断,防止NPE Element titleLink = item.selectFirst("h2 a"); Element dateEl = item.selectFirst("span.date"); Element descEl = item.selectFirst("p.desc"); String title = titleLink != null ? titleLink.text() : ""; // absUrl将相对路径基于页面地址拼成完整URL String link = titleLink != null ? titleLink.absUrl("href") : ""; String date = dateEl != null ? dateEl.text() : ""; String desc = descEl != null ? descEl.text() : ""; System.out.println("标题: " + title); System.out.println("链接: " + link); System.out.println("日期: " + date); System.out.println("摘要: " + desc); } } }

这里select("div.item")返回一个Elements集合,遍历集合时,每个item是Element对象。selectFirst("h2 a")在当前元素的子树里找第一个匹配的h2下面的a标签,返回Element或null。由于页面偶尔会出现某个字段缺失的情况,直接item.selectFirst(...).text()会在元素不存在时抛NullPointerException,所以我建议每个字段都先判空,再用三元表达式兜底。这是爬虫代码里最基本的防御式写法,不要嫌啰嗦。

text()和html()的区别也要注意。text()取出的是纯文本,会把内部的标签剥掉,比如

摘要加粗

的text()是“摘要加粗”,html()则是带标签的原文。一般列表字段用text()就够了。如果遇到类似“标题文本”和“标题链接”分离的结构,可以进一步用attr("href")或absUrl("href")取值。

有时候列表项并不是扁平结构,而是嵌套在ul/li里。比如:

<ul class="news-list"> <li><a href="/a/1">标题一</a><span>2024-05-01</span></li> <li><a href="/a/2">标题二</a><span>2024-05-02</span></li> </ul>

这时可以定位ul下的所有li,然后分别取a和span。选择器可以写成doc.select("ul.news-list > li"),这样比把整个ul遍历再select更直接。无论结构怎么变,思路都是先定位重复块,再从重复块里提取字段,不要试图用一条选择器直接取所有字段,那样可读性太差,后期维护也痛苦。

4.2 相对路径转绝对路径:absUrl的前置条件

列表里的链接经常写相对路径,比如/news/123,并不带域名。如果直接存起来,后续爬文章详情页时,会因为缺少域名而无法打开。Jsoup提供了一个非常方便的absUrl("href")方法,可以把相对路径拼成绝对URL。但这个API有个容易忽略的坑:它依赖Document的baseUri,而这个baseUri只有在页面是通过Jsoup.connect(url).get()加载时才会被自动设置。

如果你在测试环境里用Jsoup.parse(html)直接把字符串解析成Document,baseUri是空的,absUrl调用后返回空字符串。正确做法是给parse方法传入基础URL:

Document doc = Jsoup.parse(html, "https://example.com");

这样即使链接是相对路径,也能拼出绝对地址。在正式代码里,如果项目为了复用解析逻辑,把抓取和解析分开,那么解析函数接收的Document就必须保证是connect出来的,或者手动设置基础URL。否则你会看到所有链接都变成空字符串,这是很玄幻的一个现象。我一般会在解析前的入口加一个断言,如果baseUri为空就中断,而不是等数据入库后才发现链接全是空的。

另外,absUrl只适用于带href或src属性的元素。对于javascript:void(0)这种伪链接,absUrl会原样返回,不会报错,但后续请求详情页时就会失败。所以在清洗阶段要过滤掉不以http开头的链接。还有一种情况是页面用相对路径带版本号,比如/news/123?v=2,absUrl会保留后面的查询参数,这通常是需要的,不用额外处理。

4.3 数据清洗:日期、空格与字段缺省

从页面抓下来的文本,通常还要做三步清洗。第一步是去除空白字符,text()已经把多余空白压缩成单个空格,但首尾可能还有空格,调用trim()去掉。第二步是统一日期格式,有的站点显示“昨天”,有的显示“2024-05-01”,还有的带“年/月/日”这种中文格式。日期统一要靠正则提取或按站点规则单独处理。

// 用正则从原始文本中提取标准日期,匹配不到就返回空串 String rawDate = item.selectFirst("span.date") != null ? item.selectFirst("span.date").text() : ""; java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); java.util.regex.Matcher matcher = pattern.matcher(rawDate); String cleanedDate = matcher.find() ? matcher.group() : "";

这段代码的核心是正则Pattern.compile,它把匹配模式编译成一个Pattern对象,Matcher负责在rawDate里查找。如果找到,group()返回第一个完整匹配,也就是形如2024-05-01的日期。匹配不到就返回空串,不会让空指针污染后续逻辑。日期解析要放在解析层单独封装,方便以后遇到新的日期格式时只改一处。

第三步是字段缺省,如果某个列表项没有摘要,给一个默认空串,而不是把整个条目丢弃。因为后续可能是要入库的,缺字段比缺整条数据更容易补救。清洗过程里还要留意页面内容里夹带的“查看更多”“点击进入”这类垃圾文本,它们经常混在标题或摘要里。比如某些站点的标题区会带“更多>>”,如果直接存会污染数据。可以在解析后用replace或正则把这些固定后缀去掉。不要过度清洗,否则可能会出现把有效内容也削掉的情况。我的习惯是先保存原始字段,再单独做清洗,这样出了问题还能回到源头。

5. 爬虫避坑:分页抓取最常见的五个翻车现场

5.1 页码从0还是1开始

现象:循环明明写了从1到总页数,结果发现第一页数据重复出现,或者始终抓不到第一页。

原因:有些站点的分页页码从0开始,第一页对应的URL是page=0,第二页才是page=1。还有的站点第一页是list,第二页是list?page=2,第一页根本没有page参数。如果无脑从1开始,通常会漏掉第一页或者重复抓第一页。

解决:先手动打开第0页页面看一下。用浏览器访问list?page=0,如果内容和第一页一样,说明是从0开始。写循环时,起始页码可以从0开始,当page=0时保持原链接,page>0时再拼接参数。简单示例:

for (int page = 0; page < totalPages; page++) { String url = page == 0 ? "https://example.com/list" : "https://example.com/list?page=" + page; // 抓取 }

这个判断放在URL构造处,比在循环里写if更干净。还有一种特例是页码虽然是1开始,但列表排序是倒序,越新的数据越靠前,所以爬完所有页后其实是从旧到新排列。如果你要的数据顺序是从新到旧,可以考虑从最后一个有效页倒着爬,但这样会丢失最后一页的变化数据,权衡后再决定。

5.2 超时重试:连接被重置与SSL握手失败

现象:爬虫跑到第几十页时突然抛IOException,提示Connection reset或SSLHandshakeException,程序直接中断。

原因:目标服务器对单IP的请求有频率限制,短时间大量请求触发防爬,服务器主动断开连接。也有一部分原因的确是网络抖动,或者站点用了不常见的TLS版本。

解决:给连接设置合理timeout之外,还必须加上重试机制。我一般会封装一个fetchWithRetry方法,对IOException和HttpStatusException做指数退避重试:

import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import java.io.IOException; public class RetryFetcher { public static Document fetch(String url, int maxRetries) { int retries = 0; while (retries < maxRetries) { try { // 每次最多等待5秒,超过就进入重试逻辑 return Jsoup.connect(url) .userAgent("Mozilla/5.0") .timeout(5000) .get(); } catch (IOException e) { retries++; if (retries == maxRetries) { throw new RuntimeException("持久失败: " + url, e); } // 第一次重试等待1秒,第二次2秒,避免连续请求 try { Thread.sleep(1000L * retries); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } } throw new IllegalStateException(); } }

这里重试间隔采用线性增长,比固定等待更温和,既能对抗瞬时抖动,又不会因为连续的快速重试加重对方服务器负担。如果希望更安全,可以改成Thread.sleep(1000L * (1 << retries))做指数退避,但要设置一个上限,比如最长等待10秒,否则遇到网络问题时会卡很久。HttpStatusException需要单独捕获,它不是IOException的子类,而是JSoup内部对非2xx状态码的包装。如果页面返回404或500,说明目标URL可能已经失效,不要再重试,直接跳过或记录日志。

5.3 重复数据:内存去重与断点续爬

现象:同一个列表项出现在两页里,入库后主键冲突,或者数据量统计翻倍。

原因:分页接口本身不保证数据唯一,尤其是按时间倒序的列表,新数据不断插入后,第二页的第一条可能和第一页的最后一条重复。另外也可能是重试机制造成的页面重复下载。

解决:在内存里维护一个HashSet存放已抓取的详情页URL,每轮解析时先判断集合里是否已有该链接,存在就跳过。如果爬虫一旦中断就要重启,更稳妥的做法是把已抓取的URL持久化到磁盘,比如用一个Set 的文本文件记录,每次启动时加载到内存:

Set<String> visited = new HashSet<>(); // 启动时从文件加载已访问链接 File logFile = new File("visited.txt"); if (logFile.exists()) { visited.addAll(java.nio.file.Files.readAllLines(logFile.toPath())); } // 每处理完一条就写入一行,防止程序崩溃后丢失 visited.add(url); java.nio.file.Files.write(logFile.toPath(), visited, java.nio.charset.StandardCharsets.UTF_8);

这样做虽然简单,但每处理一条都重写整个文件,数据量大了以后性能很差。更好的折中是每抓完一页,把该页的所有新链接追加到一个日志文件,例如一行一个URL。重启时加载这个日志文件,用Set去重。断点续爬时再记录当前进度页码,用进度记录决定从哪里继续,而不是从头重新抓。当然,断点续爬有一个隐含假设:目标页面的分页顺序稳定。如果网站排序经常变化,续爬可能会漏掉部分数据,这一点要提前想清楚。

5.4 字符集乱码:meta声明和实际编码不一致

现象:页面内容全是乱码,或者标题、摘要部分乱码。

原因:Jsoup的Document默认使用页面meta声明里的字符集,但有些站点的HTTP响应头charset和页面meta不一致,甚至页面meta写的是UTF-8,实际内容是GBK。Jsoup按meta解析,所以中文全部乱掉。

解决:最可靠的方式是不依赖meta,直接在解析前显式指定字符集。如果你用的是Jsoup.connect(url).get(),它内部会自动判断,这通常没问题。问题大多出现在把InputStream直接交给Jsoup.parse的写法上。正确写法之一是:

Document doc = Jsoup.parse(inputStream, "GBK", baseUrl);

第二个参数是字符集名称。如果你不确定实际编码,可以用第一个字节判断UTF-8和GBK的差异,或者直接提供一个常见的编码集合逐个试,谁能正确解析出包含中文的文本就用谁。不过使用试错法时要注意,GBK的字节流被按UTF-8解析很可能不会报错,但会出现“锟斤拷”这样的符号。所以更保险的是先看响应头里的Content-Type,再用页面meta确认,两者有冲突时以实际能正常显示的那个为准。

我自己的做法是在抓取时把原始字节流保存下来,再在本地用不同编码尝试解析,确认后固定下来。尽量不要在线上每次请求都做编码猜测,那样性能差且结果不稳定。如果你用Jsoup.connect直接拿Document,可以调用doc.charset(StandardCharsets.UTF_8)强制指定,但前提是页面内容确实已经是按UTF-8解码后的字符串,否则改变charset并不会修复已经乱掉的字符。理解这一步,能省很多求助“乱码”的时间。

5.5 反爬假页面:抓回来的是验证码

现象:请求次数多了以后,正常抓取的页面内容变成了“请滑动验证”或“访问频繁”的提示页,列表数据一条也没有,但程序并不会报错。

原因:触发了服务器风控,对方返回了一个200状态的验证页。Jsoup只负责解析,不知道这个页面内容是否合法,所以代码继续跑,数据却全是无效的。

解决:在解析前增加一个校验步骤,判断页面是否符合预期。最简单的方法是检查一个必然存在的List元素或标题,比如:

Elements items = doc.select("div.item"); if (items.isEmpty() || doc.title().contains("验证")) { // 记录异常,稍后重试或停止 throw new IllegalStateException("可能被反爬,页面内容异常"); }

这里的check越具体越好。也可以用固定关键词判断,比如验证页通常会有“行为验证”“安全验证”等字样。一旦进入反爬状态,继续重试没有意义,应该停止抓取一段时间或更换代理策略。如果是在同一个局域网里跑,也可能是请求频率确实太高了,把循环里的Thread.sleep调大,让每次请求之间至少间隔1秒,会减少很多这种假页面。这种问题最难排查,因为不是报错,只是数据不对。我的习惯是每页解析后都检查一下列表数量,如果连续两三页都是空,就自动进入退避状态,而不是继续傻跑。

6. 让分页爬虫更稳:单测、限速和断点恢复

6.1 先存离线样本再跑解析

分页爬虫最容易出错的地方不是网络,而是选择器写错导致解析结果为空。等爬到一半发现数据不对,再回头改代码,整个过程已经被污染了。我现在的习惯是,在写任何新站点的解析逻辑之前,先把第一页的HTML保存成一个离线文件,放到test-fixtures目录下,然后写一个只解析不请求的单元测试。比如用JUnit:

@Test public void testParseFirstPage() { // 用离线HTML文件构建Document,第三个参数指定基础URL,保证absUrl可用 Document doc = Jsoup.parse( new File("test-fixtures/list.html"), "UTF-8", "https://example.com/list?page=1"); List<Item> items = ListParser.parse(doc); assertEquals(20, items.size()); assertEquals("某新闻标题", items.get(0).getTitle()); }

这样测试跑一遍只要几毫秒,不用访问真实服务器,也不受网络波动影响。选择器改错了,测试会立刻告诉你,而不是等页面请求几百次之后才发现抓回来的数据全部是空的。这个习惯救过我很多次。

6.2 限速与断点恢复的落地习惯

在正式抓取时,我会把网络请求和解析逻辑拆开。网络请求统一走一个带有重试和限速的封装,解析逻辑只接收Document对象。每次请求结束,不管成功还是失败,都记录当前页码。重启后从记录页码继续,而不是从头刷一遍。这样既减少对目标站点的打扰,也降低自己程序被反爬盯上的风险。

从那以后,我每次写新站点的分页抓取,都先做离线样本测试,再小规模跑几页验证,最后才放开全量循环。遇到连续超时或假页面,先停下来观察,不硬扛。这套流程虽然看起来慢,实际踩坑的时间少了很多。希望帮到你。

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

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

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

立即咨询