☰
Selenium实战:破解JS动态渲染页面的数据抓取难题
2026/10/22 5:15:51 网站建设 项目流程

最近在给某跨平台系统做数据采集时,遇到个很典型的场景:网页在浏览器里打开,数据清清楚楚摆在列表里,Network面板里也能看到接口返回的JSON,但换成requests直接请求页面地址,拿回来的HTML里却干干净净,连一条数据都找不到。

这种情况就是标准的JavaScript动态渲染页面。数据不是服务器直接输出在HTML里,而是浏览器加载完页面后,脚本再去请求数据、然后把内容动态塞进DOM的。单纯拿源码自然什么都拿不到。处理这类场景,我最常用的方案就是Selenium——它能驱动一个真实的浏览器内核,把JS执行、DOM渲染、Ajax请求全部跑完,最后给你一个和肉眼看到的完全一致的页面状态。

这篇文章我就把手上的实操经验整理一下,从Selenium的工作原理、环境配置,到元素定位、等待策略,再到各种踩坑排查,完整过一遍,给刚开始处理JS渲染页面的朋友一个可以直接上手的参考。

1. 先搞清楚JS渲染的机制,才知道requests为什么失效

1.1 静态HTML与动态渲染的差异

很多人第一次遇到这个问题时会困惑:我在浏览器里右键查看源代码,明明能看到数据啊,怎么用程序请求就没了?

这里有个关键区别:浏览器开发者工具里的Elements面板展示的是实时DOM,是JS执行完之后的页面状态。而“查看源代码”或requests拿到的,是服务器返回的原始HTML响应体。两者在很多页面上完全不是一回事。

一个典型的数据流转过程是这样的:浏览器请求页面 -> 服务器返回包含HTML骨架+JS脚本的响应 -> 浏览器开始解析HTML -> 遇到script标签就开始执行JS -> JS发起Ajax请求获取数据 -> 拿到数据后通过DOM操作把内容渲染到页面上。

所以requests拿到的,只是这个流程的第一步产物。后面的JS执行、Ajax请求、DOM操作,requests一概不参与。用那个原始HTML去解析数据,自然什么都解不出来。

我在模拟项目X里处理过一个招聘信息展示页,HTML源码里有完整的页面结构,但职位列表的区域是空的,所有职位数据都是页面加载后通过三个接口拼接出来的。用requests抓首页只能拿到一个空壳,没有任何职位信息。这就是典型的JS渲染场景。

1.2 Selenium的优势:真实浏览器完整执行

Selenium的思路和requests完全不同。它不再模拟HTTP请求,而是直接驱动一个真实的浏览器进程,把页面加载、JS解析、资源请求、DOM渲染的完整流程从头到尾执行一遍。页面最终变成什么状态,Selenium里看到的就是什么状态,和你手动打开Chrome的体验几乎一致。

这背后的核心是WebDriver协议。浏览器对外暴露一套自动化接口,Selenium通过这些接口向浏览器发送指令,比如“打开这个网址”“点击这个按钮”“获取这组元素的内容”,浏览器接收指令后真实执行,再把结果返回给Selenium。所以Selenium不是用什么魔法解析HTML,而是真的有一个浏览器在里面干活。

这个方案的好处很直接:不管页面的JS逻辑多复杂、框架用的是Vue还是React、数据是通过什么方式和接口串联的,只要页面能在真实浏览器里正常展示,Selenium就能拿到那部分数据。

当然也有代价。Selenium要启动完整浏览器,内存占用大、执行速度比requests慢一个数量级。如果页面结构简单、数据在初始HTML里就有,完全没有必要用它。它的适用场景就是前面说的那种——数据必须等JS执行完才出现的情况。

2. 环境准备与配置:Selenium跑起来的第一步

2.1 安装基础依赖与浏览器驱动

Selenium本身是一个Python库,装起来很简单:

pip install selenium

但光装库还不行。Selenium需要控制一个真实的浏览器,而浏览器需要对应的驱动程序才能接收WebDriver协议指令。Chrome对应ChromeDriver,Firefox对应GeckoDriver,Edge对应EdgeDriver。没有驱动,Selenium连浏览器都启动不了。

以ChromeDriver为例,下载时有个极易踩的坑:驱动版本必须和浏览器版本严格匹配。版本不匹配时会直接报错,常见的有:

  • SessionNotCreatedException: This version of ChromeDriver only supports Chrome version XX
  • Unknown session ID(旧驱动控制新浏览器时经常出现)

我在实际工作中吃过这个亏。某次Chrome自动更新到新版本,我没留意,继续跑之前写的脚本,一启动就报版本不匹配。后来无奈做了个定期检查驱动版本的小工具,才彻底解决。

下载驱动时,优先从各驱动官网的匹配列表里找到自己浏览器对应的版本。如果浏览器版本太新、驱动还没跟上,可以把浏览器降级到和驱动兼容的版本。这个版本匹配关系是Selenium环境搭建中最关键的一环,建议所有人搭环境时第一件事就确认它。

2.2 浏览器参数配置:Options的进阶用法

启动浏览器时,如果直接裸写webdriver.Chrome(),会弹出一个有界面的浏览器窗口。开发调试时问题不大,但如果部署在服务器或想稳定长期跑任务,一般都会用无头模式。

options配置是一个容易被人忽略但又很重要的细节。下面这段是我常用的配置模板:

from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument('--headless') options.add_argument('--no-sandbox') options.add_argument('--disable-gpu') options.add_argument('--disable-dev-shm-usage') options.add_argument('--window-size=1920,1080') options.add_argument('--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') driver = webdriver.Chrome(options=options)

几个参数的作用分别是:

  • --headless:开启无头模式,浏览器不显示界面,后台运行。
  • --no-sandbox:以root权限运行Chrome时需要的参数,普通用户环境加了也没坏处。
  • --disable-dev-shm-usage:解决容器或低内存环境下的共享内存不足问题。
  • --window-size:设置窗口大小。很多页面会根据窗口尺寸做响应式布局,窗口太小会导致部分元素不可见或样式错位,进而影响定位。
  • --user-agent:自定义UA。无头模式的默认UA里经常带HeadlessChrome字样,有些网站会据此识别并拦截,手动指定一个正常的UA能减少很多麻烦。

有几个配置不推荐直接加。比如--disable-images这种降低加载的策略,虽然能提速,但在部分页面上会影响图片懒加载的逻辑,导致后续滚动或解析出问题。我在一次采集商品详情页时因为禁了图片,页面结构整个变了,反而得不偿失。

2.3 环境验证:确保Selenium能正常驱动浏览器

配置完成后,先跑一个最简单的脚本验证环境是否正常:

from selenium import webdriver driver = webdriver.Chrome() driver.get('https://example.com') print(driver.title) driver.quit()

如果能看到浏览器打开并输出页面标题,说明环境没问题。这个验证步骤虽然简单,但能快速区分后面遇到的问题是环境问题还是代码问题,节省大量排查时间。

个人习惯是在每个爬虫项目开始前,先做一个环境自检,把驱动路径、版本号打印出来。这个小习惯在长期维护的项目里非常有用,因为不论浏览器版本还是库版本,隔几个月就可能发生变化,环境变动是爬虫脚本失效的第一大原因。

3. 核心实操:取到JS渲染后的数据

3.1 元素定位方法的选型

Selenium有三种主流定位方式:ID、CSS选择器、XPath。它们的优先级在我这里是这样的:

ID优先。页面上ID是唯一的,定位速度快,几乎不出错。但JS动态渲染的页面,很多元素没有ID或者ID是动态生成的,比如每次刷新都在变化。

CSS选择器其次是较好的选择。通过class、属性组合来定位,灵活度比ID高。比如div.list-item[data-index='1'],能根据元素自身的特征精准锁定。

XPath最灵活,但性能相对最差。它在遍历整个DOM树查找匹配节点,页面越复杂、XPath表达式写得越复杂,性能越差。但遇到层级嵌套很深的元素,XPath是唯一能落地的方案。

定位优先级的具体比较见下表:

定位方式性能稳定性适用场景
ID高高带静态ID的元素
CSS选择器高中class、属性特征明显的元素
XPath低中层级复杂、无特征元素,且其他方式无法定位的情况

一个常见的实操问题是XPath写得太绝对。比如/html/body/div[3]/div[1]/div[2]/span这种,页面结构稍微一变就断裂。我倾向于用相对XPath,基于特征定位,比如//div[contains(@class, 'item')]//span[contains(text(), '关键词')],这种写法对页面结构的鲁棒性更好,遇到改版时还容易调整。

3.2 等待策略:固定sleep是最大坑

JS渲染最大的问题就是元素出现的时机不确定。页面加载慢一点,元素可能晚几百毫秒才出现;网络波动时,可能要几秒。

新手最容易写的是time.sleep(3)——先睡几秒再操作。这种写法的缺陷在于:如果页面在1秒时就渲染完成了,你白等2秒;如果页面要5秒才渲染完成,你等到第3秒就去找元素,一样报错。等于把等待时间写死,既低效又不可靠。

Selenium提供了两种正经的等待方式:

隐式等待:

driver.implicitly_wait(10)

设定一个全局最大等待时间。每次调find_element时,如果元素没出现,会在这个时间内轮询等待,直到元素出现或超时。看起来方便,但它的坑在于:只用这一种等待时,遇到复杂页面,到底等多久全靠猜。

显式等待:

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC element = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, 'list-item')) )

显式等待只针对特定条件,轮询直到元素满足条件或超时。这才是处理动态渲染页面最精准的方式。

我的习惯是:显式等待为主,隐式等待为辅。隐式等待设一个3-5秒的兜底,重要的元素都单独用WebDriverWait显式等待。不推荐在实际项目里用time.sleep,除非你明确知道要等的是某个固定接口的延迟。

3.3 完整案例:抓取一个JS渲染的动态列表页

以模拟项目X的数据页为例,页面进入后列表区是空白的,需要等页面加载完JS后出现一批条目。目标是提取每条内容的标题和链接。

from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = Options() options.add_argument('--headless') options.add_argument('--window-size=1920,1080') options.add_argument('--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') driver = webdriver.Chrome(options=options) try: driver.get('https://example.com/dynamic-list') items = WebDriverWait(driver, 15).until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, 'div.data-item')) ) results = [] for item in items: title_el = item.find_element(By.CSS_SELECTOR, 'h3.title') link_el = item.find_element(By.CSS_SELECTOR, 'a') results.append({ 'title': title_el.text, 'url': link_el.get_attribute('href') }) print(f'共抓取 {len(results)} 条数据') for item in results: print(item['title'], item['url']) finally: driver.quit()

这段代码的核心在于presence_of_all_elements_located——它会等所有符合条件的元素都出现,避免前几个元素出来了但后面还没渲染完的问题。

这里有个重要细节:我用EC.presence_of_all_elements_located而不是EC.presence_of_element_located,前者等待所有匹配元素出现,适合列表页;后者只等待第一个元素,适合详情页的单元素场景。用错的话,列表页可能只抓到前几个元素,后面的还没渲染出来就结束了。

3.4 数据提取:直接操作还是交给解析库

拿到页面后,提取数据有两种思路。

一种是用Selenium的元素方法直接取,比如.text取文本、.get_attribute()取属性。适合数据量小、逻辑不复杂的场景。

另一种是把渲染后的页面源代码交给BeautifulSoup处理:

from bs4 import BeautifulSoup page_source = driver.page_source soup = BeautifulSoup(page_source, 'html.parser') items = soup.select('div.data-item')

两种方式各有利弊。直接操作不引入额外依赖,流程清晰;交给BeautifulSoup解析则可以利用已经被验证过的CSS选择器语法,代码更简洁,尤其在批量提取大量字段的场景下更顺手。

我个人的习惯是:如果只需要拿少量字段,直接用Selenium元素方法;如果页面里要提取的字段多、结构复杂,就交给BeautifulSoup整理。Selenium驱动浏览器的开销本身已经很大,用它再把所有数据都摸一遍,效率不高,不如把数据解析工作交给更轻量的库。

4. 常见问题与排查技巧实录

4.1 NoSuchElementException:元素找不到的几大原因

这是Selenium使用中最高频的报错。报错本身好办,关键是找到原因。

第一个可能原因是页面还没渲染完。元素要等JS执行完才出现,而你找得太早。排查方法:先试显式等待,把超时时间加长,看能不能解决。能解决,就是时序问题。

第二个原因是元素在iframe里。iframe是一个独立的文档,Selenium默认只处理主文档,不会进入iframe内部。

driver.switch_to.frame('frame_id') # 此时才能操作iframe内的元素 driver.switch_to.default_content()

如果不切换frame,直接find_element就会报错。这个问题诡异的地方在于,你在浏览器里肉眼能看到这个元素,但在开发者工具的Elements面板里它嵌套在iframe标签下。一开始不熟悉时确实容易懵。

第三个原因是元素在shadow DOM里。这种情况遇到得相对少,但一旦遇到就非常棘手。shadow DOM里的元素不能用普通方式定位,要用shadow root节点一层层穿透进去,标准定位方式拿它没办法。

第四个原因是页面结构判断错误。可能是class名带空格导致选择器写法不对,可能是同一个class对应了多个元素。排查方式是先在浏览器开发者工具里验证选择器能选中,再回代码里调。

排查这类问题有个通用思路:F12控制台里手动执行document.querySelector验证选择器对不对。如果控制台都选不中,那说明压根是页面结构的问题;如果控制台能选中而代码里报错,那就往时序和上下文(iframe/shadow DOM)方向排查。

4.2 页面加载慢:运行效率优化方案

Selenium慢是常态,但可以通过几个手段让它慢得可控。

第一个思路是调整页面加载策略。默认情况下Selenium会等页面所有资源加载完才返回get()。但对很多页面来说,主数据只需要HTML和JS,图片、广告等静态资源属于可放弃的。

options.page_load_strategy = 'eager' # 只等DOM就绪,不等图片等资源 # 可选值:normal(默认,等全部加载)、eager(DOM就绪即可)、none(立即返回)

eager模式在处理大量图片的页面时效果很明显,可以省掉相当可观的等待时间。但要注意,如果页面数据是懒加载的——比如滚动到某个位置才加载下一批——那eager可能提前返回导致数据缺失。此时配合显式等待依然能保障。

第二个思路是按需才滚动。很多列表页是滚动到底部才触发加载更多。如果只是抓首页可见部分,完全不需要滚动,节省时间和流量。如果必须滚动,用driver.execute_script('window.scrollTo(0, document.body.scrollHeight)')后再配合显式等待,而不是无脑循环滚动条。

第三个思路是减少不必要的等待。implicitly_wait设太长会影响所有find操作,空白等待过多会堆慢整体速度。网络良好时,5秒就够用。

4.3 反爬应对:减少被识别的概率

网站的检测维度通常集中在几个方面:请求频率、User-Agent特征、WebDriver特征。

请求频率上,控制合理的延迟时间,加一点随机抖动,别在几秒内发出几十个请求。具体延迟多少合适应视网站而定,抓得快不如抓得稳。

User-Agent特征上,前面提过要自定义UA,去掉HeadlessChrome字样。有些网站会根据UA里的环境标识判断是否放行。我一般在浏览器里复制自己浏览器的UA字符串,显得比较接近真实用户。

WebDriver特征上,可以通过检查脚本内的navigator.webdriver属性。正常浏览器里这个值是undefined,Selenium控制的页面里是true。网站可以检测此标志。处理思路是用stealth类技巧覆盖这个属性。这也说明了为什么尽量用真实行为模式、少用机械化的固定操作,更能规避风险。

还有个值得注意的点:频繁清理浏览器数据以求“干净”未必高效。部分网站会在Cookie和本地存储里积累用户行为特征,每次都用全新的空状态访问反而显得可疑。维护一个稳定的会话、模拟真实用户的使用节奏,是我更推荐的方向。当然,这里面的分寸要把控好,毕竟我们做的是数据采集,不是攻击行为。

4.4 进程与资源释放:防止内存泄漏

Selenium启动的每个浏览器进程都会占用内存,如果脚本中driver没有正确关闭,内存泄漏会越来越严重,跑两天任务后内存占用可能飙升到几个G。

解决方法是确保每次用完都调用driver.quit(),并且放在finally块里。

driver = webdriver.Chrome(options=options) try: # 业务逻辑 pass finally: driver.quit()

另一个选型是with语句的上下文管理器,配合标准写法更优雅。但要记住,如果脚本异常退出且没走到finally,残留的浏览器进程依然会留在内存里。所以养成用完就关的习惯很重要。在高强度运行任务时,可以定时检测并清理残留的driver进程。

4.5 定位动态类名的处理技巧

JS渲染的页面里,class名经常带动态哈希值,比如class="item_8f3k2",每次刷新后缀都不同。这种情况下绝对不能写死class。我在一次采集任务中遇到过这件事:上午还能跑的脚本,下午报错找元素找不到了,打开页面一看,class全变了。

处理方式有几个:

  • 用稳定的父级class配合子级结构定位。div.list div.item_xxx h3可以简化成先定位div.list,再在它内部找对应层级。
  • 用CSS选择器的属性匹配,比如div[class^='item']表示匹配class以item开头的元素。
  • 用XPath的contains函数匹配包含关系的class。

动态类名是JS渲染页面的一个普遍特征,处理思路不是写死样式名,而是找到稳定的结构关系。定位思路的核心是寻找页面中不变的部分,而不是和变化的部分硬碰硬。

5. 不同方案的取舍:Selenium的最终定位与补充

5.1 为什么不用requests直接解析接口

刚才提过,Selenium处理JS渲染页面是有效的,但它的“重”也让人犹豫。实际上处理这类页面还有另一个思路:直接跳过页面渲染,找到页面上接口请求的URL和参数,用requests调用接口拿JSON数据。这样既不需要启动浏览器,速度也快得多。

那为什么还要用Selenium?

因为找接口这条路,在遇到以下情况时走不通:

  • 接口参数里有复杂的加密签名,比如时间戳、随机数拼接后再哈希。
  • 接口返回的数据乱序或分片,需要JS逻辑拼装后才能还原。
  • 接口本身做了反调试,代码里对数据作了混淆。

这些场景下,分析接口的成本可能比跑Selenium还要高。Selenium的定位不是替代接口方案,而是给那条路走不通时留一个兜底方案。它只要渲染出页面就能提取数据,不依赖对接口逻辑的理解,这是它最大的价值。

5.2 后续扩展方向

Selenium不是终点,理解了它的原理后,还可以向两个方向扩展。

一个是使用Playwright。它和Selenium思路类似,但API更现代化、性能更好。如果对渲染速度有更高要求,可以考虑迁到这类工具。另一个是结合代理池、限速策略来降低反爬命中,但那就是更深的工程问题了。本篇文章先把Selenium这个基础方案吃透,后面再展开聊这些扩展内容。

最后的实操体会

我在实际项目里最常用Selenium的场景是:页面数据完全依赖异步渲染、接口分析成本高、偶尔跑一次且数据量不大。这种场景下,Selenium是我最省心的选择。它的逻辑简单——模拟一个人在看页面,页面里有什么就抓什么——这种直观性在排查复杂问题时特别好用。

跑了一段时间后我养成了一个习惯:不管用什么方案,我都会先想清楚页面为什么拿不到数据。如果是接口请求链路过长,那找接口;如果是页面渲染逻辑复杂,那就直接上Selenium。先判断场景再选工具,比自己一门心思钻研某个工具要省力得多。希望这篇文章能帮你在处理JS渲染页面时少踩几个坑,也欢迎你在评论区聊聊自己遇到过哪些不好处理的页面场景。

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

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

立即咨询