首页 / 资讯中心 / 文章详情

Jsoup分页抓取实战:Java网页数据采集与HTML解析三步走

Jsoup分页抓取实战:Java网页数据采集与HTML解析三步走 ★ FEATURED ARTICLE
简介一份面向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?page1页码直接替换数字二是路径型例如/list/1.html页码嵌在路径里三是表单提交型点击“下一页”会发送POST请求URL不变参数体变化。我用一个表格记录这种差异方便快速识别分页类型URL示例页码带入方式是否适合Jsoup直接处理查询参数型/list?page2替换page参数适合路径型/list/2.html替换路径段适合POST表单型/list/list.do请求体携带page字段适合需构造dataJS渲染型/list动态请求接口不适合第一和第二种是最常见的第三种在旧系统里经常出现。JS渲染型的处理思路是抓接口而不是抓页面这在后面章节单独讲。分析URL时重点关注三个参数page、pageSize和offset。有的站点用offset表示起始偏移量offset(page-1)*pageSize这种写法更容易踩坑。还有的站点第一页没有page参数第二页才开始出现page2这时循环起始条件要做特殊处理否则第一页会变成空抓。2.3 核心API最小闭环connect、get、select、textJsoup抓一页数据的最小闭环可以浓缩成四步。第一步用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?page1; // 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?page1 到 /list?page2直接把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 classtotal-pages12/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?page3。这时可以不断读取当前页的下一页链接直到链接为空。下面这个判断方式很常用Element next doc.selectFirst(a.next); if (next null) { break; }但我要提醒有些网站的下一页按钮在首页存在但不是a标签而是button或者带disabled属性的a标签。如果selector写错循环可能在第一页就停下来。判断的时候可以同时检查元素存在性和disabled属性这个我们在后面的避坑章节再展开。3.2 动态分页参数POST表单怎么处理查询参数型分页简单直接但有些老旧系统或者后台管理系统点击“下一页”时不是GET请求而是把页码放在POST表单里提交。比如常见的列表页 /list/list.do请求体带有 currentPage1、pageSize20 这样的字段。此时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 classitem h2a href/news/123某新闻标题/a/h2 span classdate2024-05-01/span p classdesc这里是摘要内容/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 classnews-list lia href/a/1标题一/aspan2024-05-01/span/li lia href/a/2标题二/aspan2024-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)直接把字符串解析成DocumentbaseUri是空的absUrl调用后返回空字符串。正确做法是给parse方法传入基础URLDocument doc Jsoup.parse(html, https://example.com);这样即使链接是相对路径也能拼出绝对地址。在正式代码里如果项目为了复用解析逻辑把抓取和解析分开那么解析函数接收的Document就必须保证是connect出来的或者手动设置基础URL。否则你会看到所有链接都变成空字符串这是很玄幻的一个现象。我一般会在解析前的入口加一个断言如果baseUri为空就中断而不是等数据入库后才发现链接全是空的。另外absUrl只适用于带href或src属性的元素。对于javascript:void(0)这种伪链接absUrl会原样返回不会报错但后续请求详情页时就会失败。所以在清洗阶段要过滤掉不以http开头的链接。还有一种情况是页面用相对路径带版本号比如/news/123?v2absUrl会保留后面的查询参数这通常是需要的不用额外处理。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是page0第二页才是page1。还有的站点第一页是list第二页是list?page2第一页根本没有page参数。如果无脑从1开始通常会漏掉第一页或者重复抓第一页。解决先手动打开第0页页面看一下。用浏览器访问list?page0如果内容和第一页一样说明是从0开始。写循环时起始页码可以从0开始当page0时保持原链接page0时再拼接参数。简单示例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 的文本文件记录每次启动时加载到内存SetString 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目录下然后写一个只解析不请求的单元测试。比如用JUnitTest public void testParseFirstPage() { // 用离线HTML文件构建Document第三个参数指定基础URL保证absUrl可用 Document doc Jsoup.parse( new File(test-fixtures/list.html), UTF-8, https://example.com/list?page1); ListItem items ListParser.parse(doc); assertEquals(20, items.size()); assertEquals(某新闻标题, items.get(0).getTitle()); }这样测试跑一遍只要几毫秒不用访问真实服务器也不受网络波动影响。选择器改错了测试会立刻告诉你而不是等页面请求几百次之后才发现抓回来的数据全部是空的。这个习惯救过我很多次。6.2 限速与断点恢复的落地习惯在正式抓取时我会把网络请求和解析逻辑拆开。网络请求统一走一个带有重试和限速的封装解析逻辑只接收Document对象。每次请求结束不管成功还是失败都记录当前页码。重启后从记录页码继续而不是从头刷一遍。这样既减少对目标站点的打扰也降低自己程序被反爬盯上的风险。从那以后我每次写新站点的分页抓取都先做离线样本测试再小规模跑几页验证最后才放开全量循环。遇到连续超时或假页面先停下来观察不硬扛。这套流程虽然看起来慢实际踩坑的时间少了很多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站