做Java后端这几年,我抓过不少网站数据,但每次遇到视频平台还是会头疼一下。标题里提到的西瓜小视频,就是典型的动态渲染页面——浏览器打开一看,视频标题、封面、播放链接全都有,但用HttpClient把源码拉下来一解析,发现整页除了空壳div什么都没有,数据全是JavaScript异步加载出来的。这次我用Jsoup加Selenium把问题解决了,核心思路很简单:Selenium负责驱动真实浏览器把页面跑起来、让数据渲染出来,Jsoup负责把渲染完的DOM流解析成结构化数据。这篇文章把我完整踩过的坑、实际的代码、排查思路都整理出来,适合有Java基础、正准备做网页数据采集的开发者参考。
1. 项目整体设计与技术选型思路
1.1 静态页面与动态页面的本质区别
很多刚接触爬虫的人会有一个困惑:明明用浏览器能看到数据,为什么用Java代码去请求拿不到?
原因在于页面渲染方式。传统的服务端渲染页面,HTML源码里就带着完整数据,服务器返回什么,浏览器就展示什么,这种页面用HttpClient或OKHttp直接请求然后正则匹配,或者用Jsoup解析,都能轻松拿到数据。但现在的视频平台、资讯流、电商列表,基本都改成了前后端分离架构。
前后端分离的典型流程是:服务器先返回一个只有骨架的HTML,里面通常只有一个root节点和一堆script标签。浏览器加载页面后,JavaScript才向接口发请求,拿到JSON数据后再动态创建DOM节点,把内容渲染到页面中。也就是说,数据的价值都在"浏览器执行脚本之后"才体现出来。
这里有个关键问题:Jsoup是一个纯Java的HTML解析库,它不会执行JavaScript。如果我直接拿Jsoup去解析初始HTML,看到的自然只有一个壳。所以项目需要两条腿走路——Selenium补足"执行脚本"的能力,Jsoup补足"便捷解析"的能力。
1.2 为什么选择Jsoup加Selenium的双引擎方案
选型的时候我其实考虑过几个方向。
第一种方案是直接分析页面的Ajax接口。打开浏览器开发者工具,找到加载视频列表的网络请求,看它的请求参数和返回JSON结构,然后用HttpClient模拟这个接口请求。这个方案性能最好、速度最快,但缺点是接口通常带有签名参数、加密参数,而且不知道哪一天平台就改了接口逻辑,维护成本很高。
第二种方案是使用Selenium一个人硬扛。Selenium本身也提供了元素定位和数据提取的方法,比如findElement、getText、getAttribute这些,理论上能完成整个流程。但它写起来啰嗦,定位元素、遍历列表的代码量比Jsoup多不少,而且如果要做数据清洗、字段抽取、嵌套结构解析,Selenium不如Jsoup顺手。
最终确定的是组合方案:Selenium只负责"让页面变成浏览器里的样子",等它滚动页面、加载完数据之后,直接调用getPageSource()方法把整个渲染后的HTML拿过来,然后丢给Jsoup去select、去遍历、去提取。Jsoup的CSS选择器语法很成熟,一套流程下来代码量少,逻辑也清晰。
这个方案本质上是"用浏览器渲染成本换接口逆向成本",适合接口加密复杂、页面结构相对稳定的场景。实测下来,独立写一个采集脚本的周期可以压缩到半天以内。
1.3 这类组合方案适合哪些抓取场景
以我个人的经验,这套组合并不仅限于视频平台,凡是遇到下面几类情况,都可以优先考虑:
- 页面数据由Ajax异步加载,但接口参数带签名、带混淆,逆向半天没结果。
- 列表页是无限滚动模式,比如feed流、瀑布流,需要不断下拉才能加载新内容。
- 页面里大量使用懒加载,图片、视频链接等到元素进入视口后才写入DOM。
- 需要模拟用户交互,比如点击"加载更多"按钮、翻页、登录后再采集。
换句话说,当目标页面的"数据可见性"依赖于浏览器执行环境时,Jsoup加Selenium的组合就是一个非常稳妥的选择。后续想扩展成并发采集,也可以用多台无头浏览器实例做分布式,不过那是另一个话题了。
2. 环境准备与依赖配置
2.1 JDK与Maven依赖清单
项目基于JDK 8以上版本即可,我这边用的是JDK 11。Maven工程需要引入两个核心依赖,一个是Selenium Java客户端,一个是Jsoup。
<dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-java</artifactId> <version>4.15.0</version> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>Selenium从4.x开始,对WebDriver的API做了调整,比如WebDriverWait的构造函数需要传入Duration对象而不是秒数,后面写代码时要注意这一点。Jsoup则一直保持着稳定的API风格,Jsoup.parse(String html)这个核心方法从1.x到1.17没怎么变过。
需要说明的是,WebDriverManager这个库在4.x版本中很流行,它能自动下载匹配的浏览器驱动,省去手动配环境变量的麻烦。如果想少踩版本匹配的坑,可以在pom里再加一个webdriver-manager的依赖,后面初始化WebDriver时会方便很多。
2.2 浏览器驱动版本匹配问题
这是整个项目里最容易翻车的一环,尤其对没用过Selenium的人来说。
Selenium本身不是浏览器,它只是提供一套协议驱动浏览器执行操作。真正去启动浏览器进程的是各浏览器对应的Driver,比如Chrome需要ChromeDriver,Firefox需要GeckoDriver。问题在于,ChromeDriver与Chrome浏览器之间存在严格的版本对应关系。比如我本地Chrome升级到了120版本,但ChromeDriver还是下载的114版本,启动时就会直接抛异常。
我实际遇到的情况是,代码里用new ChromeDriver(options)启动浏览器时报了一个session not created的错,排查半天发现就是版本不对应。后来我用了一个土办法:打开Chrome的"关于"页面查看版本号,再去第三方镜像站下载对应的驱动,放进项目根目录的drivers文件夹,然后通过System.setProperty指定路径。
更省心的做法是用WebDriverManager自动匹配:
WebDriverManager.chromedriver().setup();它会读取当前Chrome版本,自动下载匹配的驱动。如果网络环境允许,我比较推荐这种方式。另外还要注意操作系统差异,Windows下驱动文件名是chromedriver.exe,Linux/macOS下是chromedriver,路径写法上略有区别。
2.3 初始化WebDriver需要配置的几个参数
初始化WebDriver时,我一般会配置几个关键参数,这些参数不是可有可无的,直接关系到运行稳定性和采集效率。
ChromeOptions options = new ChromeOptions(); options.addArguments("--headless"); options.addArguments("--disable-gpu"); options.addArguments("--no-sandbox"); options.addArguments("--disable-blink-features=AutomationControlled"); options.addArguments("--user-agent=Mozilla/5.0 ...");--headless表示无头模式,不弹出浏览器窗口,适合在服务器上跑定时任务。--disable-gpu在部分Linux环境必须加,否则会有渲染报错。--no-sandbox是因为很多服务器环境没有图形界面,沙箱机制会影响Chrome启动。--disable-blink-features=AutomationControlled是为了去掉Selenium自动化的标记,降低被反爬识别的概率。最后一个是自定义User-Agent,伪装成正常浏览器请求。
这几个参数配置完,基本上能让无头浏览器在服务器环境里稳定运行。如果采集频率高、任务量大的场景,还可以配置--proxy-server,不过代理这部分的坑比较多,没有实际需要的情况下不配也行。
3. 抓取流程完整实现
3.1 控制浏览器访问目标页面与处理懒加载
初始化完WebDriver之后,接下来就是让浏览器访问目标页面。这里有一个关键操作,很多案例分享里讲得比较模糊,但我实际试下来发现极其重要:页面加载和滚动时长的控制。
视频平台首页加载完成后,视频列表并不是一次性全部渲染出来的。页面往下滚动时,浏览器才会触发新的Ajax请求,把下一批视频信息追加渲染到DOM里。这个机制叫无限滚动,也叫懒加载。如果我在访问完页面后立刻抓取源码,拿到的只有第一屏的数据,可能只有七八条。
所以流程上需要循环滚动页面到底部,每次滚动后等待几秒钟,让数据有时间加载。
for (int i = 0; i < 5; i++) { ((JavascriptExecutor) driver) .executeScript("window.scrollTo(0, document.body.scrollHeight)"); Thread.sleep(2000); }这里用了Thread.sleep而不是WebDriverWait,原因在于懒加载触发后数据到达时间是不确定的,没有稳定的WebElement状态可以用来做等待条件。sleep的方式够直白,代价是整体耗时变长,滚动5次就是10秒。鱼与熊掌的问题,先保证数据完整,再优化速度。
3.2 定位视频列表的关键元素
多次滚动完成之后,页面源码里已经包含了足够多的视频条目。这时候需要确认视频列表的DOM结构,我通常会在浏览器开发者工具里直接查看元素,找视频条目的公共class名称。
以视频列表场景为例,页面上每个视频卡片一般是一个div.video-item,里面有一个视频标题节点、一个封面图节点、一个跳转链接。我用Selenium的ExpectedConditions.presenceOfElementLocated在抓源码前先确认列表元素已经出现,避免过早去取源码拿到的是空数据。
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); wait.until(ExpectedConditions.presenceOfElementLocated( By.cssSelector(".video-item")));这个等待非常值得写,因为从滚动结束到页面数据渲染完,中间还有一小段异步时间。明确等一个关键元素出现,比盲目sleep几秒更可靠。写promise或者异步请求代码时我们都知道等待的必要性,其实爬虫也是一样的道理:等数据到位再进行后续操作。
3.3 页面源码交给Jsoup解析与数据抽取
等待结束后,通过driver.getPageSource()拿到当前页面完整的渲染后HTML。这一步是组合方案中最关键的一环——此时拿到的HTML不再是最初的空壳,而是经过JavaScript执行后、包含所有视频信息的完整DOM。
接下来就交给Jsoup处理了:
Document doc = Jsoup.parse(driver.getPageSource()); Elements items = doc.select(".video-item"); for (Element item : items) { String title = item.select(".video-title").text(); String link = item.select("a").attr("href"); String cover = item.select("img").attr("src"); String duration = item.select(".video-duration").text(); // 对每条数据进行后续处理 }Jsoup的select()方法支持CSS选择器,定位class、属性的能力非常强。每个video-item节点内部再通过.video-title、.video-duration等去提取具体字段。这种嵌套式的DOM解析方式,代码读起来就像在写jQuery,上手成本很低。
需要注意的是,attr("src")拿到的可能是图片懒加载的占位地址,真正的图片地址可能在>Set<String> videoUrlSet = new HashSet<>(); List<VideoInfo> videoList = new ArrayList<>(); for (Element item : items) { String link = item.select("a").attr("href"); if (videoUrlSet.add(link)) { VideoInfo info = new VideoInfo(); info.setTitle(title); info.setLink(link); info.setCover(cover); videoList.add(info); } }
这里我把去重逻辑前置到内存里,而不是依赖数据库,是因为采集过程中内存去重可以减少数据库写入压力。等全部采集完成后再统一批量插入,效率比逐条写高好几个量级。
3.5 完整代码参考与运行说明
前面几个环节串起来,就是一个可运行的采集脚本。我整理了一个精简但完整的版本,实际使用时把目标地址和选择器替换成你自己要采的页面即可:
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebDriver; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; public class VideoCrawler { public static void main(String[] args) { ChromeOptions options = new ChromeOptions(); options.addArguments("--headless"); options.addArguments("--disable-gpu"); options.addArguments("--no-sandbox"); options.addArguments("--disable-blink-features=AutomationControlled"); options.addArguments("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); WebDriver driver = new ChromeDriver(options); Set<String> uniqueKeys = new HashSet<>(); List<String[]> rows = new ArrayList<>(); try { driver.get("https://example.com/video-list"); // 多次滚动触发懒加载 for (int i = 0; i < 5; i++) { ((JavascriptExecutor) driver) .executeScript("window.scrollTo(0, document.body.scrollHeight)"); Thread.sleep(2000); } // 等待关键列表元素出现 WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); wait.until(ExpectedConditions.presenceOfElementLocated( By.cssSelector(".video-item"))); Document doc = Jsoup.parse(driver.getPageSource()); Elements items = doc.select(".video-item"); for (Element item : items) { String title = item.select(".video-title").text(); String href = item.select("a").attr("href"); if (uniqueKeys.add(href)) { rows.add(new String[] { title, href }); } } System.out.println("采集条数: " + rows.size()); } catch (Exception e) { e.printStackTrace(); } finally { driver.quit(); } } }运行这个类之前,确保浏览器驱动已经在环境中配置好。程序结束后driver.quit()会在finally块执行,把浏览器进程彻底关闭,防止内存泄漏。这个习惯非常重要,长时间跑采集任务时,进程一旦积累多了,内存占用会越来越大。
4. 实测中的高频问题与排查实录
4.1 拿到的数据为空或只有首屏数据
这个问题我踩过三次,每次原因都不一样,值得逐个说。
第一次是没加Thread.sleep,滚动后立刻取源码,数据还没来得及渲染,Jsoup解析出来的video-item数量自然很少。解决办法就是滚动循环里加适当延时。
第二次是页面使用了iframe内嵌方式组织内容,目标视频列表其实在iframe内部,driver.getPageSource()拿到的外层HTML里根本没有视频数据。这个问题的排查方式是在浏览器开发者工具里看元素嵌套关系,如果确认是iframe结构,代码里要先driver.switchTo().frame()切进去,再取页面源码。
第三次是选择器写得太死,视频条目的class名称里带了动态数字,比如video-item_12345这种。这类动态class每次刷新都可能变,选择器匹配不到元素。解决思路是选择器只写稳定的部分,比如包含关键字的class前缀,或者借助属性匹配[class*="video-item"]来兼容动态变化。
4.2 页面加载超时与浏览器进程残留
Selenium执行过程中如果网络波动大、页面一直处于加载状态,driver.get()可能会卡住很长时间。我的处理方式是设置pageLoadTimeout,超过指定时间就放弃这个页面加载。
不过超时本身只是抛异常,难搞的是异常发生后浏览器进程不退出。在Linux服务器上,如果直接弃用脚本,Chrome进程可能会变成僵尸进程占用内存。我习惯在finally块中尝试调用driver.quit(),同时用shell脚本监控残留进程,发现就手动清理。日常测试我还会故意触发超时情况,验证程序能否正常释放资源。
还有一个容易忽略的点:WebDriverWait等待元素超时,抛的是TimeoutException。捕获这个异常时,最好是先截图保存现场,用FileUtils.copyFile把当前页面截图写到本地,再结合页面源码分析。这一步排查起来效率特别高,我靠截图定位过不少定位器写错的问题。
4.3 网站反爬识别与请求频率控制
无头浏览器默认带了一堆自动化特征,比较明显的包括navigator.webdriver的值是true、CDP相关的信息暴露等。--disable-blink-features=AutomationControlled能去掉一部分,但也不是万能的。更稳妥的方法是降低请求节奏,控制滚动间隔和访问频率,尽量不触发风控。
我个人的实践是:单次运行控制在几十条数据以内,两次运行之间至少间隔几十秒。时间分散开,被识别和封禁的概率会小很多。一旦发现返回了空页面、验证码或者443状态码,首先要做的事不是加重UA伪装,而是立刻停止采集,检查是否触发风控,等待一段时间再恢复。
另外建议UA字符串尽量模拟真实浏览器的完整版本信息,而不是简写成一个空壳。真实UA里包含了WebKit内核版本、Chrome版本、操作系统信息,伪装得越完整越有可信度。
4.4 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 视频元素抓到0条 | 页面未等待渲染完成,或选择器不匹配 | 增加等待,检查DOM实际结构 |
| 拿到的链接是相对地址 | 页面内href为相对路径,未拼接域名 | 使用Jsoup的absUrl("href") |
| 图片地址为空 | 懒加载属性不是src而是data-src | 查看img标签实际属性 |
| 浏览器启动报session not created | ChromeDriver和Chrome版本不匹配 | 更新驱动或浏览器版本 |
| 程序运行中内存持续膨胀 | 浏览器实例未正确关闭 | 确认finally中调用driver.quit() |
| 页面弹出验证码 | 采集频率过高或行为特征明显 | 降低频率、检查UA与自动化标记 |
这个表格里的每条我都实际遇到过,可以理解为高概率出现的问题。作为一个参考手册,排查时可以按表里面找自己的场景。
5. 操作边界与合规注意事项
5.1 遵守平台协议与法律边界
写到这里,必须把合规问题摆到桌面上说。网页数据采集技术本身是中性的,但使用技术的边界在哪里,每个开发者都有责任想清楚。
我个人的原则是:采集对象仅限于公开可见的数据,采集内容用于学习研究、个人分析等正当目的,且严格尊重网站的robots协议。robots协议是网站对爬虫行为的明确声明,虽然它不是法律文件,但在司法实践中经常被作为判断访问意图的参考依据。采集前先去看一眼目标站点的robots.txt,这是基本的职业素养。
同时要关注数据的类型。如果采集的是个人隐私相关数据,或者受版权保护的作品内容,那不管技术怎么实现,使用行为都存在法律风险。动手写爬虫之前,想想这些数据你能不能拿、拿了用来做什么。
5.2 请求节奏与访问频率的自我约束
很多平台的反爬机制不是针对某一次请求,而是针对请求频率和请求特征的统计模型。单位时间内的请求次数、两次请求的时间间隔、访问路径的规律性,都可能被风控系统记录和分析。
我给自己定的节奏是:并发数尽量小,每两次请求之间至少间隔3秒以上,一次采集任务的总请求量控制在百级以内。这种节奏下,后台日志里看起来更像一个普通用户的浏览行为。相反,如果并发开个十几个线程,每个线程又在不断滚动翻页,那行为和正常用户差距太大,被识别几乎是必然的。
想持续稳定采集,核心不是提高反反爬技术,而是学会控制自己的"贪心"。一次少采一点,跑的时间久一点,对站方的影响小一点,其实对采集方也是更稳妥的选择。
5.3 数据使用范围与存储方式
采集得到的数据在存储和使用上也有讲究。如果只是做技术验证或学习用,建议在本地数据库里保存,并且设置自动清理周期。如果要做公开分享或商业化使用,建议先咨询专业人士,把授权链条确认清楚。
我自己的做法是,采集脚本写完后进行一次数据用途检查:这些数据是否涉及他人权益?我的使用方式是否可能给数据主体带来负面影响?如果答案存疑,宁可放弃这部分数据,也不要让技术变成了麻烦的源头。
技术博主常说,爬虫能力是分等级的,但比技术等级更重要的是边界意识。能用代码解决问题是能力,知道什么问题不该用代码解决是另一种能力。
最后分享两个实操心得
这个项目做下来,我最大的体会是:Jsoup和Selenium的组合其实是一种"笨但稳"的思路。它不像直接逆向接口那样高效,也不像纯HTTP请求那样轻量,但它把问题从"逆向别人代码"转变成了"操作自己浏览器",复杂度和不确定性大幅下降。对于大多数中小规模的数据采集需求,这种组合完全够用,而且代码好维护、逻辑好理解。
另外一个心得是关于版本管理的。这个项目里Selenium、ChromeDriver、Chrome浏览器三者的版本相互制约,随便哪个升级了,另外两个可能就要跟着动。所以我会在项目里固定好版本号,升级前先在测试环境把全流程跑一遍。页面结构会变、浏览器驱动会升级,但核心思路——用真实浏览器渲染解决动态加载问题——是长周期内都有效的方法。
如果后续想扩展,可以往这几个方向走:用WebDriverManager统一管理驱动版本,用数据库增量采集做每日监控,再用数据可视化把采集结果展示出来。每一步都有不少坑,但这些坑和经验,正是爬虫开发这个领域最有趣的部分。