
做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 groupIdorg.seleniumhq.selenium/groupId artifactIdselenium-java/artifactId version4.15.0/version /dependency dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependencySelenium从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需要ChromeDriverFirefox需要GeckoDriver。问题在于ChromeDriver与Chrome浏览器之间存在严格的版本对应关系。比如我本地Chrome升级到了120版本但ChromeDriver还是下载的114版本启动时就会直接抛异常。我实际遇到的情况是代码里用new ChromeDriver(options)启动浏览器时报了一个session not created的错排查半天发现就是版本不对应。后来我用了一个土办法打开Chrome的关于页面查看版本号再去第三方镜像站下载对应的驱动放进项目根目录的drivers文件夹然后通过System.setProperty指定路径。更省心的做法是用WebDriverManager自动匹配WebDriverManager.chromedriver().setup();它会读取当前Chrome版本自动下载匹配的驱动。如果网络环境允许我比较推荐这种方式。另外还要注意操作系统差异Windows下驱动文件名是chromedriver.exeLinux/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-featuresAutomationControlled); options.addArguments(--user-agentMozilla/5.0 ...);--headless表示无头模式不弹出浏览器窗口适合在服务器上跑定时任务。--disable-gpu在部分Linux环境必须加否则会有渲染报错。--no-sandbox是因为很多服务器环境没有图形界面沙箱机制会影响Chrome启动。--disable-blink-featuresAutomationControlled是为了去掉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)拿到的可能是图片懒加载的占位地址真正的图片地址可能在>SetString videoUrlSet new HashSet(); ListVideoInfo 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-featuresAutomationControlled); options.addArguments(--user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36); WebDriver driver new ChromeDriver(options); SetString uniqueKeys new HashSet(); ListString[] 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-featuresAutomationControlled能去掉一部分但也不是万能的。更稳妥的方法是降低请求节奏控制滚动间隔和访问频率尽量不触发风控。我个人的实践是单次运行控制在几十条数据以内两次运行之间至少间隔几十秒。时间分散开被识别和封禁的概率会小很多。一旦发现返回了空页面、验证码或者443状态码首先要做的事不是加重UA伪装而是立刻停止采集检查是否触发风控等待一段时间再恢复。另外建议UA字符串尽量模拟真实浏览器的完整版本信息而不是简写成一个空壳。真实UA里包含了WebKit内核版本、Chrome版本、操作系统信息伪装得越完整越有可信度。4.4 高频问题速查表现象可能原因排查方向视频元素抓到0条页面未等待渲染完成或选择器不匹配增加等待检查DOM实际结构拿到的链接是相对地址页面内href为相对路径未拼接域名使用Jsoup的absUrl(href)图片地址为空懒加载属性不是src而是data-src查看img标签实际属性浏览器启动报session not createdChromeDriver和Chrome版本不匹配更新驱动或浏览器版本程序运行中内存持续膨胀浏览器实例未正确关闭确认finally中调用driver.quit()页面弹出验证码采集频率过高或行为特征明显降低频率、检查UA与自动化标记这个表格里的每条我都实际遇到过可以理解为高概率出现的问题。作为一个参考手册排查时可以按表里面找自己的场景。5. 操作边界与合规注意事项5.1 遵守平台协议与法律边界写到这里必须把合规问题摆到桌面上说。网页数据采集技术本身是中性的但使用技术的边界在哪里每个开发者都有责任想清楚。我个人的原则是采集对象仅限于公开可见的数据采集内容用于学习研究、个人分析等正当目的且严格尊重网站的robots协议。robots协议是网站对爬虫行为的明确声明虽然它不是法律文件但在司法实践中经常被作为判断访问意图的参考依据。采集前先去看一眼目标站点的robots.txt这是基本的职业素养。同时要关注数据的类型。如果采集的是个人隐私相关数据或者受版权保护的作品内容那不管技术怎么实现使用行为都存在法律风险。动手写爬虫之前想想这些数据你能不能拿、拿了用来做什么。5.2 请求节奏与访问频率的自我约束很多平台的反爬机制不是针对某一次请求而是针对请求频率和请求特征的统计模型。单位时间内的请求次数、两次请求的时间间隔、访问路径的规律性都可能被风控系统记录和分析。我给自己定的节奏是并发数尽量小每两次请求之间至少间隔3秒以上一次采集任务的总请求量控制在百级以内。这种节奏下后台日志里看起来更像一个普通用户的浏览行为。相反如果并发开个十几个线程每个线程又在不断滚动翻页那行为和正常用户差距太大被识别几乎是必然的。想持续稳定采集核心不是提高反反爬技术而是学会控制自己的贪心。一次少采一点跑的时间久一点对站方的影响小一点其实对采集方也是更稳妥的选择。5.3 数据使用范围与存储方式采集得到的数据在存储和使用上也有讲究。如果只是做技术验证或学习用建议在本地数据库里保存并且设置自动清理周期。如果要做公开分享或商业化使用建议先咨询专业人士把授权链条确认清楚。我自己的做法是采集脚本写完后进行一次数据用途检查这些数据是否涉及他人权益我的使用方式是否可能给数据主体带来负面影响如果答案存疑宁可放弃这部分数据也不要让技术变成了麻烦的源头。技术博主常说爬虫能力是分等级的但比技术等级更重要的是边界意识。能用代码解决问题是能力知道什么问题不该用代码解决是另一种能力。最后分享两个实操心得这个项目做下来我最大的体会是Jsoup和Selenium的组合其实是一种笨但稳的思路。它不像直接逆向接口那样高效也不像纯HTTP请求那样轻量但它把问题从逆向别人代码转变成了操作自己浏览器复杂度和不确定性大幅下降。对于大多数中小规模的数据采集需求这种组合完全够用而且代码好维护、逻辑好理解。另外一个心得是关于版本管理的。这个项目里Selenium、ChromeDriver、Chrome浏览器三者的版本相互制约随便哪个升级了另外两个可能就要跟着动。所以我会在项目里固定好版本号升级前先在测试环境把全流程跑一遍。页面结构会变、浏览器驱动会升级但核心思路——用真实浏览器渲染解决动态加载问题——是长周期内都有效的方法。如果后续想扩展可以往这几个方向走用WebDriverManager统一管理驱动版本用数据库增量采集做每日监控再用数据可视化把采集结果展示出来。每一步都有不少坑但这些坑和经验正是爬虫开发这个领域最有趣的部分。