☰
Selenium实战:搞定JS动态渲染页面的数据采集与爬虫稳定方案
2026/10/9 17:50:28 网站建设 项目流程

先讲个真实场景。你写了个爬虫,用requests把页面源码拉回来,配上正则或者 XPath 一顿操作,结果发现关键字段全是None。打开浏览器一看:数据明明好端端地在页面上,表格、价格、评论、翻页全部都渲染得漂漂亮亮。这不是你的解析写错了,而是你拉到的只是页面最初的静态骨架,真正的数据在浏览器执行 JavaScript 之后才出现。这篇文章要解决的就是这一类“页面看得见,源码抓不到”的 JS 渲染问题。我会用 Selenium 把它拆开揉碎讲清楚:怎么判断页面是不是动态渲染的、为什么选 Selenium、环境怎么搭、滚动加载和点击展开怎么写,以及怎么在稳定性、性能和反识别之间找到平衡。适合那些已经能用 requests 写基本爬虫、但一碰到动态页面就束手无策的读者。

1. 先别急着上 Selenium:判断页面到底是不是 JS 渲染

1.1 两个工具的验证流程

在决定使用 Selenium 之前,第一步永远是做“静态抓取验证”,这一步很多人会跳过。因为 Selenium 意味着要额外启动一个真实浏览器,内存占用轻松超过 300MB,打开页面后还要等 JS 跑完,时间和资源成本都远高于一次普通的 HTTP 请求。如果能用静态方式拿到数据,就没必要动用它。

验证方法很简单:写一段最基础的程序,把页面源码抓下来,然后搜索你要的数据关键字。

import requests resp = requests.get("https://example.com/list", timeout=10) html = resp.text # 直接搜你要的数据关键字 print("目标关键词是否存在:", "具体商品名或价格字段" in html) print("源码长度:", len(html))

如果打印出来是False,基本可以断定这个页面是动态渲染的。此时再打开浏览器开发者工具,切换到 Network 面板刷新一次页面,通常会看到两类情况:一是页面加载后发起了额外的 XHR/fetch 请求,数据通过接口返回;二是数据直接藏在 HTML 源码里的某个<script>标签中,等页面运行时再用 JS 解析并拼装到 DOM 里。前者就是后端接口动态返回数据,后者是前后端在浏览器端完成了渲染。只有确认这两种情况之一存在,Selenium 才有上场的必要。

1.2 数据可能出现的位置

在真实项目里,同一个页面上的不同字段,来源可能完全不一样。我处理过一个报价类网站,列表页第一屏的字段藏在 HTML 源码的 JSON 里,滚动加载出来的第二屏数据来自一个接口,而最底部的图表数据又是第三个接口返回的。如果不做区分直接开浏览器,虽然也能抓全,但速度和稳定性都会打折扣。

所以拿到一个页面后,我建议按下面这个顺序排查:

  1. 先看静态 HTML 里能不能直接找到目标字段。搜字符串即可,不需要写复杂正则。
  2. 然后在源码里搜索window.、__INITIAL_STATE__、__NUXT__等常见的关键字,这些通常是页面初始数据的挂载点,数据往往以 JSON 字符串形式存在于<script>标签中。
  3. 最后配合开发者工具,看页面滚动、点击时实际请求了哪些接口,接口的 JSON 结构往往比 DOM 更干净。

如果第 1 步就有结果,直接用requests加正则就能搞定,不必上浏览器。如果数据主要来自第 2 步,也可以考虑用正则或 JSON 解析,在静态层面提取。只有当数据必须等 JS 执行完、并且分散在 DOM 里的时候,才真正需要 Selenium。

2. 方案对比与选型:Selenium、Playwright 还是硬解析

2.1 主流方案对比

处理 JS 渲染的爬虫方案,目前市面上主要有四条路线。我第一次接触动态页面时,因为不了解,走了不少弯路,这里把它们的差异列出来,方便你对号入座。

方案原理上手难度执行速度稳定程度适用场景
requests + 接口模拟直接请求页面背后的 JSON 接口低极快依赖接口签名稳定性能找到纯净接口时优先选
Selenium驱动完整浏览器(Chrome/Firefox)低慢高页面结构复杂、必须模拟真人操作
Playwright类似 Selenium,协议更现代中中高需要拦截请求、多页面并发
PyppeteerPython 封装 Puppeteer偏高中一般老项目维护,新项目不建议入坑

说实话,很多动态页面的数据最终还是来源于接口,如果你能在 Network 面板里找到那个 JSON 接口,并且它的签名没有加密或者只在 Cookie 里做鉴权,用requests直接模拟请求才是效率最高的做法。接口方案的速度比 Selenium 快了一个数量级,资源消耗更是没法比。所以我的建议是:先花十分钟找接口,找不到接口或者接口参数被加密了,再考虑浏览器方案。

2.2 我最终选 Selenium 的三个理由

虽然 Playwright 从技术上讲更新、有自动等待、有拦截请求等高级功能,但我自己大部分项目仍是用 Selenium,原因有三个:第一,Selenium 使用 WebDriver 标准协议,所有主流浏览器都支持,团队里不管谁接手都能快速熟悉;第二,它的文档、社区案例、踩坑记录是最多的,遇到一个罕见缺陷时,搜索出来的解决方案通常都是现成的;第三,Selenium 的学习成本最低,对于刚入门动态爬虫的开发者,几句话就能讲清楚核心用法。

另外,如果你需要把整个页面“截图留存”,或者需要操作页面里那些交互复杂的组件时,Selenium 的生态也相当顺手。选工具这件事不用过度纠结,能解决问题、团队能维护、代码能跑稳定,就是好方案。

3. 环境搭建和第一个可复用的动态爬虫

3.1 环境准备:浏览器驱动匹配

Selenium 并不是一个独立的渲染引擎,它的本质是“指挥真实浏览器干活”。所以除了安装 Selenium 库,你还需要一个对应版本的浏览器驱动。以 Chrome 为例,驱动叫chromedriver,它负责在 Selenium 和 Chrome 之间传递协议指令。需要特别注意的是,驱动的版本必须和浏览器主版本号匹配,否则启动时会直接报SessionNotCreatedException之类的错误。

手动下载驱动这件事最烦人,因为每次浏览器自动升级,驱动就失效了。我现在都用webdriver-manager来管理,它会自动读取你本机浏览器的版本,然后去下载匹配的驱动,省掉了大量环境问题。

pip install selenium webdriver-manager
from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)

这段代码第一次运行时会自动下载驱动,下载完成后会和浏览器版本匹配。团队里有新人加入时,新人只需要安装浏览器和 Python 环境,不必手动折腾驱动文件,体验会好很多。

3.2 代码:带条件等待的基础模板

有了环境,我们写出第一个真正能处理 JS 渲染的基础爬虫。目标页面不管多复杂,核心逻辑都是一样的:打开页面,等待关键元素出现,再提取内容。

from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from webdriver_manager.chrome import ChromeDriverManager options = Options() options.add_argument("--headless=new") # 无头模式,不弹出浏览器窗口 service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service, options=options) try: driver.get("https://example.com/list") # 关键步骤:等待页面渲染完成 # 等到 .item 这个元素出现,说明 JS 已经把数据渲染上来了 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".item")) ) items = driver.find_elements(By.CSS_SELECTOR, ".item") for item in items: title = item.find_element(By.CSS_SELECTOR, ".title").text price = item.find_element(By.CSS_SELECTOR, ".price").text print(title, price) finally: driver.quit()

这段代码里最核心的是WebDriverWait。它做的事情不是固定等待三秒五秒,而是每隔一小段时间去检查一次目标条件是否成立,超时后再抛异常。这个“轮询判断”的过程,在 Selenium 里叫显式等待。

3.3 为什么条件等待比 sleep 靠谱

不少新手习惯在driver.get()之后直接time.sleep(3),只要等的时间够长,页面总能加载完。这种做法在小规模项目里勉强能用,但在生产爬虫里就是定时炸弹。因为机器的网络状况、目标服务器的响应速度、页面 JS 的执行时间是动态变化的,固定 sleep 3 秒可能在某个时刻足够,下个时刻页面却还在转加载动画,于是你拿到一堆空数据;如果 sleep 设得太长,又会拖慢采集速度。

显式等待的思路完全不同:我不关心你花了 1 秒还是 8 秒,我只要“页面里出现某个指定元素”这个结果。条件满足就立刻继续执行,条件不满足就继续等,最长等到 10 秒。这样写出来的代码对网络波动有天然容忍度,比 sleep 高效得多。

另外要稍微注意一个细节:presence_of_element_located只要求元素被挂载到 DOM 上,有时元素已经存在但内容还没填充完。遇到这种情况,可以把条件改成等待元素的文本不再变化,或者等待某个子元素出现。这类问题在后面的调试章节里还会遇到。

4. 高频率场景实战:滚动加载、点击展开、隐藏数据提取

4.1 滚动加载型列表页

现在很多列表页都采用“无限滚动”的设计,页面只渲染第一屏内容,滚动到底部时才继续加载。这类页面在 Selenium 里处理的核心思路是:滚动到底部,等待新的元素出现,重复这个过程,直到页面高度不再变化。

SCROLL_PAUSE_TIME = 2 items = driver.find_elements(By.CSS_SELECTOR, ".item") print("初始数量:", len(items)) last_height = driver.execute_script("return document.body.scrollHeight") while True: # 滚动到底部,触发加载逻辑 driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") # 等待页面高度变化,说明新内容加载完成 WebDriverWait(driver, 5).until( lambda d: d.execute_script("return document.body.scrollHeight") > last_height ) new_height = driver.execute_script("return document.body.scrollHeight") if new_height == last_height: break last_height = new_height # 再次统计页面元素数量 items = driver.find_elements(By.CSS_SELECTOR, ".item") print("当前数量:", len(items))

这里用execute_script直接执行 JavaScript 是 Selenium 非常高效的能力,避免去模拟滚轮操作,既稳定又快速。每次滚动后要等页面高度真的有变化,如果我写的等待条件在 5 秒内没有满足,说明这次滚动没有触发新内容加载,循环就可以结束了。

有一点我踩过坑:某些懒加载页面只监听scroll事件,滚动到底部后还要再“使劲滚一次”才能触发。如果遇到页面高度不变、但你明显感觉还有内容的情况,可以尝试滚动到接近底部的位置,再往回滚一点点,模拟真实用户的操作路径。

4.2 点击加载更多按钮

比无限滚动更老派的是“加载更多”按钮。这类按钮的特点是:每次点击后,页面会追加渲染一批新数据,并且按钮可能一直存在,直到所有数据加载完才会消失。

from selenium.common.exceptions import NoSuchElementException while True: try: more_btn = driver.find_element(By.CSS_SELECTOR, ".load-more") except NoSuchElementException: print("没有加载更多按钮,已到最后一页") break # 用 JS 点击比直接 click 更稳,防止按钮被遮挡 driver.execute_script("arguments[0].scrollIntoView();", more_btn) driver.execute_script("arguments[0].click();", more_btn) # 等新内容出现:记录旧的元素数量,等待数量增加 old_count = len(driver.find_elements(By.CSS_SELECTOR, ".item")) WebDriverWait(driver, 5).until( lambda d: len(d.find_elements(By.CSS_SELECTOR, ".item")) > old_count )

用execute_script的click()是实战里很实用的技巧。因为页面元素经常被悬浮层、广告条、弹窗给挡住,普通.click()会报ElementClickInterceptedException,而用 JS 直接触发点击事件,相当于跳过了“元素是否可见、是否被遮挡”的检查,在绝大多数场景下都能点动。当然这也意味着它偶尔会点到不该点的东西,使用时要明确知道自己为什么要这么做。

4.3 藏在 window.INITIAL_STATE里的数据

有一类页面很有意思:你打开源码一看,明明整段数据都在<script>标签里,但是页面的 DOM 却是一堆空壳子。数据要等 JS 运行起来,才被一个个填回到界面里。

面对这种页面,很多人第一反应是开 Selenium 然后逐个元素.text。这其实可以,但不是最优解。更聪明的办法是直接从page_source里把 JSON 提取出来,因为你已经用浏览器做了“模拟执行”这件事,数据已经在 HTML 源码里了,没必要再等 JS 把数据渲染到 DOM 里。

import re import json html = driver.page_source match = re.search(r"window\.__INITIAL_STATE__\s*=\s*(\{.*?\});", html) if match: data = json.loads(match.group(1)) # 后续直接操作 data print(data.keys())

这种“浏览器加载 + 源码里抓 JSON”的混合方案,是我处理动态页面时最偏爱的一种。它兼顾了稳定性与性能:浏览器只负责让服务端的 JS 跑一遍,后续的数据处理完全交给 Python,速度比逐个 DOM 节点读取快很多。

需要注意正则里的.*?非贪婪匹配,因为页面里的 JSON 可能非常大,如果直接用贪婪匹配,正则会把后面所有内容都吞进去。另外,如果数据不在<script>标签里,而是被 JS 加密后动态拼装,那就要回到 DOM 读取的老路线上。

5. 稳定性与性能:headless、防识别和资源回收

5.1 headless 模式的配置参数

Selenium 跑起来默认会弹出一个真实浏览器窗口,这在本地调试时很直观,但在服务器上部署就完全不合适了。所以生产环境一般用无头模式。Chrome 的新无头模式在兼容性和崩溃率方面要好得多,推荐显式指定--headless=new。

options = Options() options.add_argument("--headless=new") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") # Linux 服务器上经常需要 options.add_argument("--disable-dev-shm-usage")

其中--window-size容易被忽略,但影响很大。无头模式下默认窗口很窄,页面里的元素布局会发生变化,一些只有在宽屏下才显示的内容会被当作不存在。加上一个 1920x1080 的窗口尺寸,能避免很多不必要的定位问题。

5.2 性能优化:关图片、限制并发、善用 quit

Selenium 的慢,很大一部分浪费在加载图片、字体和其他无关资源上。图片对爬虫来说通常没有价值,直接关闭能明显提升加载速度。

prefs = {"profile.managed_default_content_settings.images": 2} options.add_experimental_option("prefs", prefs)

除了资源开销,还有一个常见的性能陷阱是忘记调用driver.quit()。quit()会关闭浏览器进程并释放内存,如果不调用,每次运行都会残留一个后台 Chrome 进程,跑几次之后内存就满了。所以不管代码逻辑走到哪一步,都建议把driver.quit()放在finally块里执行。

并发方面,我的经验是不要开太多浏览器实例。一个无头 Chrome 大约占用 200MB 到 400MB 内存,开五个以上机器就很容易吃紧。更好的做法是控制并发数在 2 到 4 个之间,每个浏览器实例内部用“队列 + 顺序访问”的方式抓取多个页面,既能保证速度,又不至于把机器资源耗尽。

5.3 混合抓取:requests 与 Selenium 搭配

在长期运行的采集任务里,我会尽量把 Selenium 的参与范围压缩到最小。比如一个页面只有登录后的 Token 需要经过一段复杂的 JS 计算,那我只用 Selenium 完成一次完整加载,拿到 Token 之后立刻关闭浏览器,后续所有请求都用 chances 去发。这种方式既绕过了 JS 计算,又避免了每一页都开浏览器的巨大开销。

再比如某个列表页的第一屏数据已经在初始 HTML 里,只有后续翻页要用 Selenium。那就用 Selenium 只做翻页动作,在翻页过程中把每一次的 HTML 源码全部page_source保存到一个列表里,最后统一用正则或 BeautifulSoup 解析。这样 Selenium 只负责“模拟执行翻页”,不做字符串解析,职责单一,速度也会快不少。

6. 高频报错与调试技巧

6.1 三个高频异常解读

Selenium 写多了,你会发现报错就那么几类。我整理了一份高频异常对照表,排查时可以直接对号入座。

异常原因应对方式
NoSuchElementException元素选择器写错,或元素还没渲染出来先用显式等待,再用driver.find_elements检查数量
StaleElementReferenceException页面 DOM 已更新,之前的元素引用失效在循环里重新获取元素,不要缓存引用跨页面使用
TimeoutException等待条件一直没满足检查选择器是否正确、等待目标是否在 iframe 内

StaleElementReferenceException是我早期最常遇到的坑。比如你拿到一个元素列表,然后遍历它,在遍历过程中页面自己刷新了一部分 DOM,之前保存的那些元素对象就全部失效了。解法其实很简单:每次循环都重新find_elements,不要图省事在外面一次性缓存。

6.2 调试三板斧

页面定位不到元素时,与其一遍遍猜选择器,不如直接用 Selenium 的调试三板斧,几乎能解决 80% 的问题。

第一,截图。driver.save_screenshot("debug.png")会把当前页面的渲染结果保存成图片,你一眼就能看出 JS 到底有没有跑成功,页面停留在什么状态。

第二,输出当前页面源码。driver.page_source会返回当前 DOM 的完整 HTML,把它保存到本地文件里,用编辑器搜索你想要的数据关键字,确认数据是否存在、以及它在什么样的 DOM 结构里。

第三,查看当前 URL 和标题。有些页面跳转会改变 URL,而某些数据只有在特定路由下才会显示。driver.current_url和driver.title是最容易被忽略、却最能说明页面状态的指标。

还有一个小技巧,如果怀疑是等待条件出了问题,可以在代码里临时打印driver.execute_script("return document.readyState")。返回complete说明页面加载流程已经走完了,如果一直停在interactive,说明还有资源或者 JS 在持续运行,你就应该调整等待策略。

6.3 一些个人经验

做动态爬虫这两年多,我最大的体会是:Selenium 不是万能的,但它在“模拟真人操作”这件事上依然无可替代。使用它时要时刻记着两条原则:第一,能静态拿到的数据绝不开浏览器;第二,所有资源都要记得释放。抓取速度慢不可怕,可怕的是抓取过程中网页改版了、元素变了之后,你的代码还在盲目重试,拼命请求目标服务器。

另外,任何爬虫项目都请务必遵守目标网站的使用条款和服务协议,只抓取你有权访问、且对方允许公开访问的信息。爬虫本身是中性的工程工具,但使用边界一定要想清楚。设置合理的采集频率、控制并发数,既是对目标服务器的尊重,也是让自己的爬虫能长期稳定运行的前提。

最后分享一个个人习惯:每次给页面写定位选择器时,我都会优先使用语义化的 CSS 类名或 ID,而不是用那种div > div > span:nth-child(3)的绝对路径。因为前者在页面改版时更容易容错,后者稍有 CSS 微调,整条链路就断了。这个习惯让我后续维护代码时省下了大量时间,也少掉了许多半夜被线上任务告警吵醒的烦恼。

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

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

立即咨询