打开网页,查看源码,里面空空如也;拿 requests 去抓,得到的只有一个 HTML 外壳——你要的数据,是浏览器在后台执行了一堆 JavaScript 之后才填进去的。这个场景,凡是写过一阵爬虫的人应该都不陌生。这次就用标题里的方式,把 JavaScript 动态渲染页面的采集问题拆开来讲:什么时候需要上 Selenium,它的核心机制是什么,以及一套能稳定跑起来的代码框架和那些会导致脚本半夜挂掉的坑。
本文适合两类人:一类是刚学会 requests、BeautifulSoup 入门爬虫却发现带 JS 的网站抓不动的新手;另一类是在做数据采集但不想对每个网站都单独维护一套复杂模拟请求逻辑的开发者。我会结合实际抓取过程中最常遇到的场景来聊,尽量不给出一堆看似正确到现场却跑不通的代码。
1. 只靠 requests 拿不到数据,问题出在"渲染"这两个字上
1.1 爬虫取得的 HTML 和浏览器里看到的数据差了什么
很多刚开始写爬虫的人会有一个朴素认知:网页源码里应该包含所有显示内容。你输入一个网址,后端返回一段包含正文内容的 HTML,然后浏览器把它画出来。这种模式存在于大量老式网站中,也很适合用 requests 直接抓取。
但现在随便打开一个内容型网站,尤其是资讯、电商、数据看板、管理后台这类页面,套路已经变了。服务器只返回一个空壳页面,内部包含一堆 <div id="app"></div> 或者 <div id="root"></div>,真正的数据由 JavaScript 在浏览器里通过 fetch 或 axios 向接口请求后,再动态创建 DOM 节点渲染出来。这时候你看到的数据,并不是初始 HTTP 响应的一部分,而是浏览器执行脚本后的结果。
这里要特别提醒一下:爬虫语境里的"渲染"和图形学渲染不是一回事。做 OpenGL、游戏引擎的人嘴里说的渲染是指将 3D 模型、贴图、光照计算成屏幕像素的过程;而 JavaScript 渲染指的是浏览器解析并执行脚本、将数据填入 DOM、最终呈现完整页面内容的过程。这两者经常被搜索引擎混在一起,导致初学者绕弯路。
核心结论就一句:如果你的请求库拿到的 HTML 里找不到目标数据,不是你的 XPath 写错了,而是获取数据的时机不对——你拿到的是渲染前的"毛坯房"。
1.2 判断一个页面到底需不需要上 Selenium
在使用重型工具之前,先做一道判断题:这个页面是真的需要浏览器执行 JavaScript,还是说只是数据隐藏在某个接口里?这个判断决定了后续所有的技术选型。
有一个简单有效的验证方法:在 Chrome 里打开目标页面,按 Ctrl+U 查看网页源代码,然后搜索你要的数据关键字。搜得到,大概率 requests 直接能处理;搜不到,再用 Ctrl+Shift+C 检查元素面板看看目标数据是不是存在于 DOM 中——如果检查元素里能看到、源码里却搜不到,基本可以确定数据来自 JavaScript 的动态渲染或者接口加载。
但"数据来自动态渲染"并不等于"必须上 Selenium"。很多前端框架(Vue、React 等)虽然通过 JS 动态渲染页面,但数据加载背后往往走的是规范的 HTTP 接口。打开浏览器的开发者工具,切到 Network 面板,刷新页面,筛选 XHR 或者 Fetch 类型的请求,经常能发现返回 JSON 数据的接口。这时候只要把那个接口的 URL、请求头、参数模拟出来,用 requests 直接请求,比开浏览器不知快多少倍。
只有当遇到以下情况,Selenium 这类浏览器自动化方案才真正不可替代:
- 页面数据是通过复杂的 JavaScript 计算、加密、转码后才渲染到 DOM 中,单独还原接口的签名逻辑成本极高;
- 数据由用户的点击、滚动、拖拽等交互触发后才生成,且没有可直接调用的接口;
- 页面本身就是一个纯前端算力的应用,比如 Canvas 绘制出的数据图、WebAssembly 处理后的结果。
而本文标题说"高级爬虫技巧",其实最进阶的一句话反而是:用 Selenium 之前,先确认它是不是唯一选项。不要杀鸡用牛刀,更不要因为懒,就用最笨重的方式去解决所有问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用真实浏览器代替模拟请求:Selenium 的执行链路与驱动配置
2.1 WebDriver:Selenium 能和浏览器真正对话的前提
Selenium 的底层逻辑并不复杂:它通过一套标准的协议——WebDriver——去操控真实的浏览器实例。你在代码里写 driver.get("https://example.com"),这句命令会先被打包成一条符合 WebDriver 规范的请求,发给浏览器自带的驱动组件,比如 Chrome 的 chromedriver 或 Edge 的 msedgedriver,再由驱动告诉浏览器要做什么。
和 requests 直接发 HTTP 包不一样,Selenium 操作的是完整的浏览器进程,所以它能自动加载和执行页面里全部的 JavaScript,把整个页面变成一个你可以手动点来点去的"真人环境"。也正是因为这样,Selenium 脚本能做到几乎所有人类浏览器操作:点击按钮、填写表单、滑动滚轮、等待元素出现、获取 DOM 文本、下载文件,甚至模拟键盘操作。
我常用的一个通俗类比是:requests 像是一个不会进店,只站在店门口跟店员喊"把菜单给我看一眼"的顾客;而 Selenium 是那个走进店里、坐下、翻菜单、还会问服务员要水的人。后者当然慢,但能看到的细节远多于前者。
2.2 driver 安装与版本匹配:新手掉坑率最高的一个环节
Selenium 的 Python 包本身很好装,一条 pip install selenium 就完事。真正让新手崩溃的是浏览器驱动:打开 Chrome 之后提示 session not created: This version of ChromeDriver only supports Chrome version xxx,这就是典型的 driver 和浏览器版本不匹配。
我这几年带人入门给的最直接建议是:先打开你的 Chrome,点击右上角菜单里的"关于 Chrome",确认当前版本号的主版本号是多少,比如 126.0.6478.126,那么你要找的就是 ChromeDriver 126.x 系列的文件,下载后放到系统 PATH 能访问到的目录,或者直接在代码里用 Service 指定路径。这个问题在 macOS 和 Windows 上都是同一条逻辑,只是驱动文件的后缀名不一样而已。
从 Selenium 4.6 开始,事情简单了许多,它内置了 Selenium Manager,会在第一次运行脚本时自动检测浏览器版本、下载对应驱动。前提是你的电脑上确实装有原版 Chrome、Edge 或 Firefox。所以我给新手的建议是:直接用新版 selenium 包,不需要手动下载驱动;只有当你有版本锁定或离线环境需求时,才回到手动管理驱动的老路,这也是大团队里更常见的做法,毕竟生产环境不可能允许每个任务临时去外网拉驱动。
需要说明的是,这三种主流浏览器对应的驱动不同:
| 浏览器 | 驱动文件 | 启动类 |
|---|---|---|
| Chrome | chromedriver | ChromeDriverManager 或 Selenium Manager |
| Edge | msedgedriver | EdgeDriver |
| Firefox | geckodriver | FirefoxDriver |
如果你要批量管理几十台机器的采集环境,强烈建议在代码中固定住浏览器主版本号和驱动版本号,避免某天某个机器自动升级浏览器导致任务大面积失败。关于这一块,我后面在"生产环境性能边界"那节还会再提到。
3. 动态渲染页面抓取的完整框架:等待、定位、数据提取缺一不可
3.1 先解决"拿到 driver"的问题
整个 Selenium 采集脚本能成立的前提很清楚:要有稳定的浏览器实例。从代码层面看,拿到一个可用的 driver 对象通常只需要三行:
python复制from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
options = Options()
# 无头模式:不弹出浏览器窗口,适合服务器采集环境
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
不建议代码里直接写死 WebDriver 路径字符串。因为换了机器、部署到 Docker 或 Linux 服务器时,路径一旦失效,整个脚本就要改。相对稳妥的方式是优先使用 Selenium Manager 自动管理驱动(默认行为),如果要指定路径,就放到环境变量里读取。另外,加不加 --headless=new 主要看场景:本地调试阶段我保持有头模式,这样能看到浏览器真实行为和报错;确认脚本稳定后再切无头,减少服务器资源占用。
第一次写的人容易漏一个基础但重要的步骤:脚本最后要调用 driver.quit()。这不是礼貌问题,而是真真切切的资源管理问题。不关闭浏览器进程,跑几次之后系统里会堆积几个残留的 chrome 进程,把内存吃得干干净净。
3.2 最关键的环节:不要用 time.sleep,要用显式等待
拿到可用的 driver 之后,常踩的第二个大坑是数据还没渲染完就开始定位元素。遇到这类问题,新手的第一反应往往是 time.sleep(3),等三秒再抓。这个方案在网页速度稳定时勉强能用,但一旦网络抖动或接口响应变慢,三秒不够一样会翻车,而页面秒开时又白等了,白白损耗效率。
Selenium 官方给的方案是基于条件的等待。核心是 WebDriverWait 配合 expected_conditions,它会在一个时间内不断轮询页面,直到某条件成立,否则超时抛异常:
python复制from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
driver.get("https://example.com/data-list")
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".data-item")))
这里的逻辑是:给浏览器最多十秒,让它执行完 JavaScript 并完成页面渲染,之后才开始定位元素。相比固定 sleep,这种方式既稳又快,因为条件一旦满足立即往下走,不会浪费时间等多余的空闲。
定位方式的选择也在很大程度上影响稳定性。从我的经验看,By.CSS_SELECTOR 和 By.XPATH 是最常用的两种,二者各有优势:CSS 选择器简洁、性能好;XPath 适合处理需要复杂逻辑的层级关系,比如"某个下标题下的第几个兄弟元素"用 CSS 写不出来时就上 XPath。
重点说一个很多人忽略的细节:presence_of_element_located 和 visibility_of_element_located 有本质区别。前者只代表元素出现在 DOM 树中,就算元素是隐藏的、透明的,也算存在;后者还要求元素对用户可见,不会报 ElementNotInteractableException。所以如果你接下来要点击某个元素,等它的可见性,不要只等存在性。不少脚本在页面上反复失败,根因就是这个等待条件写得太浅。
3.3 从渲染后的页面中提取数据
等到页面中的具体区块已经出现,接下来就是常规的提取动作。和解析纯 HTML 不同,Selenium 获取页面内容通常有两种思路:
第一种是先用 driver.find_elements 把所有目标元素圈出来,再挨个取文本或属性。适合数据量不大、结构清晰的场景,比如新闻标题、列表页摘要:
python复制items = driver.find_elements(By.CSS_SELECTOR, "div.list-item")
for item in items:
title = item.find_element(By.CSS_SELECTOR, "h3.title").text
link = item.find_element(By.CSS_SELECTOR, "a").get_attribute("href")
print(title, link)
第二种是把整个页面的 HTML 一次性取出来,交给 BeautifulSoup 或 lxml 去解析。这个方案的好处是能复用你以前写纯请求爬虫时的全部解析逻辑,而且 BeautifulSoup 的容错能力在定位复杂页面时更强:
python复制from bs4 import BeautifulSoup
html = driver.page_source
soup = BeautifulSoup(html, "html.parser")
for item in soup.select("div.list-item"):
title = item.select_one("h3.title").text
从工程角度,第二种更值得推荐,因为 driver.page_source 把页面渲染后的完整 HTML 给你,之后的所有解析工作可以脱离浏览器进程,降低整个流程对 Selenium 的依赖时长。解析逻辑也好做单元测试,可以把 HTML 保存到本地慢慢调试。
坦白说,如果这个页面主要是普通列表数据且数据来自后端接口,直接找接口才是最该做的。选择 Selenium 时,目的往往就是那个接口模拟成本高的"硬骨头"页面,这种情况就没必要硬用 XPath 在动态结构里翻腾了,直接等外部条件到位后,把整个页面源码拿回来解析,是最省心也最稳的方案。
4. 真实页面中常见的坑:滚动加载、iframe、隐藏元素和最磨人的点击问题
4.1 滚动到底部触发"加载更多"这类场景
做信息流网站和数据看板时,最常遇见的交互是"页面滚动到底部自动加载下一页"或者干脆要反复点击"加载更多"按钮。这类页面用 requests 是模拟不出来的,因为滚动事件本身会触发展开后续数据的 JavaScript。用 Selenium 实现无限滚动的逻辑其实很成熟:
python复制last_height = driver.execute_script("return document.body.scrollHeight")
while True:
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
try:
wait.until(lambda d: d.execute_script("return document.body.scrollHeight") > last_height)
except:
break
new_height = driver.execute_script("return document.body.scrollHeight")
if new_height == last_height:
break
last_height = new_height
这段代码里值得解释的是那个 lambda 等待条件。你换用固定 time.sleep 也常常能跑通,但每次滚动之后到底要等多久页面才会加载完,完全取决于接口响应速度,不可控。用 WebDriverWait 去等待页面高度变化,相当于让脚本自己判断"这次请求到底有没有触发新内容"。
采集这种页面时还有一个比较隐蔽的细节:如果页面上的数据不是无限加载而是分页展示,那么在"下一页"按钮上做循环就没有滚动逻辑那么复杂了,但要注意翻页按钮是否在页面底部,如果按钮没出现在视口内,浏览器会拒绝点击并抛一个 ElementClickInterceptedException,此时直接执行 JavaScript 让元素先滚动到可视区域往往是最省事的:
python复制driver.execute_script("arguments[0].scrollIntoView(true);", next_btn)
next_btn.click()
4.2 iframe 里的内容:switch_to 是绕不过的关卡
前端框架中有一种非常磨人的结构是 iframe。有的页面把整个数据表格包在一个 iframe 里,或者嵌入第三方组件。如果你发现 find_element 怎么都定位不到目标内容,大概率不是元素没渲染,而是它根本不在当前的文档树里。直接搜索页面的 HTML 源码是能找到 iframe 标签的,但目标数据在 iframe 标签的内部文档中,需要先把上下文切进去再操作:
python复制frame = wait.until(EC.presence_of_element_located((By.TAG_NAME, "iframe")))
driver.switch_to.frame(frame)
content = driver.find_elements(By.CSS_SELECTOR, "table tbody tr")
操作完这个 iframe 的内容后,如果要继续去操作外层页面的元素,哪怕只是点击下一页,也别忘了切回默认内容:driver.switch_to.default_content()。这个上下文切换非常容易忘,一旦忘了,后面的 driver.find_element 全都会报找不到元素,而报错原因里根本不会主动提示"你在 iframe 里",排查起来极其头疼。
4.3 定位失败与 "javascript:void(0)" 那类点击失效
处理动态页面时有一个非常常见的翻车对象:链接的 href 写的是 javascript:void(0)。早期一些站长会用这种写法实现"点击后不跳转,执行一段 JS"的效果,你看到的是一个 <a> 标签,但它的 href 并不是一个真实 URL,直接用 driver.get(href) 是跳不过去的。正确的方式是把它当成按钮去触发 click() 事件。
点击之后又会出现另一种问题:页面已经弹出了新内容,但脚本还停留在旧 DOM 状态,导致下一步找不到刚生成的元素。这个坑的解法还是回到等待策略上,不能点击完就立刻去定位;要等待新元素的可见性或者某段文本出现。
还有一类挺隐蔽的点击失效:某个元素明明存在且可见,点击时却报 ElementClickInterceptedException。原因往往是页面上有遮罩层、弹层或者其他元素盖住了目标。我用 Chrome 开发者工具实际去看时,经常发现罪魁祸首是页面上浮动的 loading 遮罩或固定的广告条。这类情况用常规 click() 解决不了,我会在确认元素能交互的前提下,改用 JavaScript 直接触发:
python复制driver.execute_script("arguments[0].click();", target_element)
这样绕开了浏览器模拟点击时"必须落在元素上"的限制。还有一个经常被忽略的思路是:先判断是不是元素被隐藏,比如父元素有 display: none 或者 visibility: hidden,这种情况用 execute_script 调整样式也不现实,正确做法是找它的触发入口,或者重审数据来源是否真的值得用 Selenium。
5. Selenium 不是银弹:性能瓶颈分析与何时换方案
5.1 为什么它慢,慢在哪里
Selenium 会把一个完整浏览器跑起来。以 Chrome 为例,每开一个标签页,背后至少有独立的渲染进程和 GPU 进程,内存占用几百 MB 是起步。和单次几十毫秒就能完成的 requests 请求相比,Selenium 完成一个页面的加载从启动浏览器、访问 URL、执行 JS、渲染到最终关浏览器,花几秒到十几秒非常正常。
这个"慢"主要有三层原因:
- 真实浏览器的资源消耗大,需要加载图片、字体、样式表和各种第三方脚本,即使你要的无非是页面里的一段数字;
- WebDriver 协议本身是 HTTP 通信,每一条命令都要从 Python 进程发送到浏览器驱动再返回结果,控制粒度越细,通信次数越多,耗时越长;
- 等待策略在保障稳定性时牺牲了时间,10 秒的 WebDriverWait 只要数据提前回来就会立刻继续,但如果网络慢,任何时候都可能等到超时。
所以如果只是采集几千个纯静态接口数据,使用 Selenium 纯属自讨苦吃。在团队里做技术选型时,我的第一原则是效率与稳定性优先,哪个环节能用接口模拟解决,绝不上真实浏览器;只有当前环节确实无法拆解成接口请求时,才把 Selenium 引入链路。
5.2 动态数据抓取的技术路线选择
针对 JavaScript 渲染页面的数据采集,可选的方案大致有几类,下面从稳定性、性能和上手难度来对比:
| 方案 | 原理 | 速度 | 上手复杂度 | 主要场景 |
|---|---|---|---|---|
| requests + 直接调接口 | 找到 JSON 接口并模拟请求 | 极快 | 中等 | 接口数据可复现,签名简单 |
| Selenium | 浏览器自动化 + WebDriver 协议 | 慢 | 较低 | 复杂交互、纯 JS 计算场景 |
| Playwright | 浏览器自动化,自带等待与拦截能力 | 中 | 中等 | 新一代自动化采集场景 |
| Pyppeteer | 异步控制无头 Chrome | 中 | 中等 | 需要异步并发时 |
| 直接解析源码 | 页面脚本里嵌入数据 | 快 | 低 | 一些 SSR 和初始数据注入页面 |
如果你已经想清楚真的只有 Selenium 能满足需求,那么还是要抓住它的几个使用要点:优先验证渲染完成,尽量用显式等待不要盲睡;能一次取出的数据不要分多次交互,减少浏览器访问次数;在服务器上开启无头模式时也要给浏览器设置合理的内存参数,并用单例模式管理 driver。除此之外,还非常建议给所有页面访问加一个重试机制——真实浏览器的会话比普通 HTTP 请求更容易因为内存或网络原因挂掉。我的做法是把每次页面操作封装在带重试的函数中,连续失败两次立即发告警退出,避免无人值守时脚本一直死循环。
5.3 从工程角度规范化数据采集流程
一个稳定的采集工程,并不会只靠 Selenium 的 API 写得多漂亮,更关键的是把脚本当生产服务来对待。我个人在项目里会做这么几件事:
- 所有目标站点的 URL 和 XPath/CSS 选择器统一放在配置文件或数据表中,脚本不出现硬编码字符串;
- 每次采集结束把 HTML 快照、页面截图或接口响应存一份,用于排查页面结构变化;
- 设置采集频率上限,在合理范围内控制请求频率,避免对目标服务器造成压力;
- 用日志记录每次任务的开始时间、采集数量、异常类型和耗时,方便事后回溯。
这里多说一句关于合理合规的问题:爬虫本身是中性技术,用于公开信息采集和个人学习研究没有任何问题,但在把数据用于商业用途或高频访问别人站点时,至少应该尊重目标网站的访问控制和服务条款,控制好请求频率,不要对他人服务器造成负面影响。我写的脚本一般默认在本地低频率跑,不做过度的去伪装或绕过操作,这样既是保护目标网站,也是保护自己的采集任务不被中断。
如果团队对动态渲染页面有大量采集需求,我会更倾向于在新项目里直接评估 Playwright,它自带可等待页面网络空闲、可拦截请求、可模拟移动端等多功能,接口设计也更贴近现代前端场景。Selenium 并非不能做这些事情,只是相对陈旧,很多能力要用代码一层层补齐。但无论如何,理解浏览器自动化运行的本质——页面的数据是否完全可被 JavaScript 渲染、交互能否被程序真实执行——才是解决问题的根本所在。
如果你准备长期和动态页面打交道,真正值得投资的是"判断数据来源"的眼光和"把交互流程稳定化"的工程能力。前者让你能迅速判断一个页面到底值得不值得开浏览器,后者让你在面对必需的浏览器操作时,能写出一套不靠运气跑通的稳定代码。
