没有哪个用爬虫的人没遇到过这种诡异场面:requests.get() 把页面 HTML 完整地拿回来了,状态码 200,编码也对,源码里什么标签都有,看起来一切正常,可你翻遍整段 HTML 却找不到想要的数据。打开浏览器一看,数据明明显示得好好的。这段“浏览器里有、源码里没有”的内容,就是 JavaScript 渲染出来的。处理这类页面的核心手段之一,就是 Selenium——一个原本属于自动化测试的工具,却被爬虫开发者发掘成了对付动态渲染页面的大杀器。这篇博文我会把 Selenium 处理 JavaScript 渲染的完整思路讲透:从底层原理、环境配置、等待机制,到无限滚动页面的实战代码,再到实际项目里踩过的坑和关于风控的一些真实体会。
1. 为什么页面源码总比浏览器里“少一段”:JS 渲染的底层拆解
1.1 浏览器不是“打开文档”,而是“把文档跑起来”
很多刚接触爬虫的人会下意识地把浏览器理解成一个“HTML 阅读器”:输入网址,服务器把文档发过来,浏览器把标签渲染成漂亮的页面。这个理解对 90% 的静态页面没问题,但对现代 Web 应用来说,它忽略了一个极其关键的环节——脚本执行。
真实情况是:浏览器拿到 HTML 之后,会先解析 DOM 树,碰到 <script> 标签就开始执行 JavaScript。而现代前端框架(Vue、React、Angular)几乎都是“数据驱动视图”的模式,HTML 里往往只有一个根节点,页面上的表格、列表、卡片统统是由 JavaScript 在运行时动态创建并塞进 DOM 的。也就是说,你通过 requests 拿到的 HTML 更像是“一座刚浇筑完框架、还没通水电的毛坯房”,而浏览器里的最终页面是 JavaScript 工人进场施工之后的精装成品。爬虫看不到数据,不是因为数据不存在,而是因为你没有执行那一段段决定“家具摆在哪”的 JavaScript。
1.2 常见三类 JS 渲染页面的特征信号
要想快速判断一个页面是否需要 Selenium,最好的办法不是猜,而是“看源码找特征”。实战中我总结了三种高频出现的情况,特征非常明显:
| 页面类型 | 特征信号 | 直观表现 |
|---|---|---|
| 接口渲染型 | HTML 是空的,但代码里能找到大量 fetch(、axios.get(、XMLHttpRequest |
Requests 抓回来的 body 里只有 <div id="app"> 这类空壳节点 |
| 前端框架型 | 引入 vue.js、react.js、app.js 文件,业务逻辑高度集中在一个 JS 包里 |
HTML 中包含大量 {{ }}、v-for、data-xxx 之类的绑定痕迹 |
| 滚动加载型 | 页面底部没有完整翻页按钮,伴随 “正在加载” 或 Infinity Scroll 组件 | 直接请求第二页 URL 无效,数据由滚动事件触发分段加载 |
以第一种最常见。你按 F12 打开开发者工具的 Network 面板,刷新一次页面,耐心盯一下 XHR/Fetch 类型的请求,会发现页面数据几乎一定来自一些 JSON 接口。如果这个接口你拿 requests 直接请求也能通、也没做签名校验,那就完全没有必要上 Selenium。真正需要 Selenium 的场景,是当你分析完这一堆请求之后发现:接口带时间戳签名、带加密参数、带登录态校验,或者你光看 JS 混淆代码就头大的时候——与其逆向半天,不如让浏览器自己帮我们把 JavaScript 全跑了。
1.3 什么时候该选 Selenium:成本比较才是决定因素
这部分我想传递一个不那么“技术崇拜”的观点:使用 Selenium 的本质不是因为它比接口逆向高级,而是因为它把“执行 JavaScript”这件事外包给了浏览器。代价是速度和资源——一个 Chrome 实例动辄占用几百 MB 内存,每次启动和关闭都是实打实的时间开销。
我的判断标准很朴素:当你花 15 分钟分析网络请求能搞定的事,不要碰 Selenium;当你面对一个重度混淆的网页版应用,网络请求里全是加密参数,逆向工程量估计要 2~3 天,而 Selenium 只要半天就能把数据跑出来,那就果断用 Selenium。工具是为人服务的,不是拿来证明技术水平的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么最后挑中 Selenium:老牌工具的生态红利与横向对比
2.1 Selenium 到底在“自动化什么”
Selenium 最初是给测试人员做浏览器自动化回归测试用的。它的核心思路是:通过一套 WebDriver 协议,让外部程序能够像真实用户一样操控浏览器——打开网址、点击按钮、填写表单、滚动页面、读取 DOM。
这里的“像真实用户一样”是理解 Selenium 在爬虫场景价值的关键点。因为它调度的本身就是系统中安装的那个 Chrome 或 Firefox,JavaScript 在这种环境下是完完全全执行的。相比用 requests 模拟每一个异步请求,Selenium 是更高层级的抽象:你不需要关心页面到底发起了多少个 AJAX 请求、如何加密参数,只需要一个指令——把页面整个加载完,然后把渲染后的 DOM 给我。这种粗暴但有效的模式,就是它处理 JS 渲染最核心的逻辑。
2.2 Selenium 4 带来的实际变化
需要补充一点,如果你翻到的教程还在写 driver.find_element_by_xpath(),那可能是 Selenium 3 时代的产物。Selenium 4 里这些 find_element_by_* 方法被彻底移除了,统一改成 driver.find_element(By.XPATH, "...") 的写法。刚切换到 Selenium 4 的同事经常在这报错,所以文章后面涉及定位的代码我都会写成新写法。
Selenium 4 还带来两个对爬虫很有用的特性:一是原生支持了 Chrome DevTools Protocol 的部分能力,比如模拟网络条件、监听网络请求;二是引入了相对定位器,可以通过“在某元素左边/右边/附近”来定位节点。不过说实话,爬虫场景用到这些高级功能的机会并不多,真正要熟悉的反而是等待、切换、执行脚本这三板斧。
2.3 和当前主流方案的横向取舍对比
很多初学者问我:“现在还有必要学 Selenium 吗?大家都说 Playwright 更好用。”我的回答是:有必要,而且学了不亏。
| 方案 | 执行 JS | 上手速度 | 定位粒度 | 资源占用 | 典型场景 |
|---|---|---|---|---|---|
| requests/httpx | 不执行 | 最快 | 接口级 | 极低 | 纯静态页、明文接口 |
| Selenium | 完整执行 | 快 | DOM 级 | 高 | 兼容多浏览器、需要点击/滚动等复杂交互 |
| Playwright | 完整执行 | 较快 | DOM 级+网络级 | 高 | 新项目,需要拦截网络、自动等待更强 |
| Puppeteer | 完整执行 | 中 | DOM 级+网络级 | 中 | 专注 Chrome/Chromium 生态 |
| 逆向接口 | 不执行 | 取决于难度 | 接口级 | 极低 | 大规模采集,追求性能和稳定 |
单看能力,Playwright 和 Puppeteer 在很多维度确实比 Selenium 更现代——它们能拦截响应、修改请求、自动等待网络空闲,内存控制也更出色。但 Selenium 最大的优势在于生态的稳定性和广度:几乎所有编程语言都有官方库,任何一个浏览器都支持,网上的存量案例和排错文章最多。对于一个需要长期交付的项目,“遇到一个诡异报错能在十分钟内搜到答案”本身就是巨大的效率优势。
3. 开工前最关键的 30 分钟:WebDriver 版本与浏览器匹配问题的踩坑实录
3.1 那个“Process finished with exit code 0” 的灵异事件
不少人第一次跑 Selenium 是在 PyCharm 里,写完代码一运行,控制台干干净净,什么输出都没有,只冒出一句 Process finished with exit code 0。第一次遇到这个情况,大家都会懵:没报错,但也没结果,程序像被沉默杀手掐死了一样。
这里要讲一个真实陷阱。exit code 0 只代表 Python 进程正常退出,不意味着你的代码逻辑执行成功。最常见的两个原因:一是你的代码写在了 if __name__ == "__main__": 外层,某个 driver = webdriver.Chrome() 抛了异常,却被外层的大 try/except Exception 吞掉了,异常堆栈根本没打印;二是压根还没有安装合适的浏览器驱动,但你在某处捕获了 WebDriverException,又没做任何日志输出。排错思路很简单:拿下面这段最小化代码,去掉一切 try/except 跑一遍:
python复制from selenium import webdriver
driver = webdriver.Chrome()
driver.get("https://quotes.toscrape.com/js/")
print(driver.title)
driver.quit()
如果浏览器能启动并正常打印标题,恭喜,你的环境是通的。如果抛 SessionNotCreatedException,就进入最容易踩的大坑——驱动和浏览器版本不匹配。
3.2 ChromeDriver 与 Chrome 的版本匹配规则
Chrome 浏览器的迭代速度非常快,基本每个月都有新版本。ChromeDriver 和 Chrome 之间遵循一条硬规则:大版本号必须一致。
假设你本机 Chrome 版本是 120.0.6099.130,那么你需要下载的 ChromeDriver 也必须是 120.x.x.x。小版本不一致通常可以容忍,但大版本差了,驱动根本无法创建会话:
code复制selenium.common.exceptions.SessionNotCreatedException:
Message: session not created: This version of ChromeDriver only supports Chrome version 119
这个报错信息其实已经把答案说得很直白了,但很多人还是不知道去哪儿下载对应版本。我的习惯是:打开 Chrome 的“设置 -> 关于 Chrome”查看完整版本号;然后去 ChromeDriver 下载站找到对应大版本的目录;下载后把 chromedriver.exe(Windows)或 chromedriver(Linux/macOS)解压到一个固定目录。
3.3 Selenium Manager:自动管理驱动的现代方案
不过现在这个过程其实已经被简化了很多。Selenium 4.6 开始内置了 Selenium Manager,当你没有手动配置驱动路径时,它会尝试自动下载并匹配 ChromeDriver。很多人更新到 Selenium 4.11+ 之后,发现代码不用写 executable_path 也能跑,就是 Selenium Manager 在背后默默工作。如果你的网络环境能正常访问官方驱动下载地址,那下面的写法就够了:
python复制from selenium import webdriver
from selenium.webdriver.chrome.service import Service
service = Service() # 留空让 Selenium Manager 自动处理
driver = webdriver.Chrome(service=service)
要是公司网络有防火墙,自动下载经常失败,那还是建议手动下载驱动,再用 Service(r"/path/to/chromedriver") 指定路径。还有个小技巧:Linux 服务器上一行命令就能装驱动,例如 chromedriver 放到 /usr/local/bin 并加上执行权限,这样代码里就不用处处写路径了。Windows 下如果把驱动放进了项目根目录,直接用相对路径 Service("./chromedriver.exe") 也可以,但绝对路径更省心。
3.4 headless 模式的配置细节
环境搭好之后,有一个选项值得现在就说:headless(无头模式)。生产环境跑 Selenium 爬虫通常没有显示器,也不希望每跑一条数据都弹个浏览器窗口出来,所以很多人会让 Chrome 以无头模式运行。需要注意的是,headless 模式下部分网站的反爬判定更严格,而且旧版 headless 和真实浏览器的指纹差异比较大。Selenium 4 里的写法是:
python复制from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
我建议调试阶段坚决不用 headless,把浏览器窗口开着,亲眼盯着每一步发生了什么;确认逻辑稳定之后,再切到 headless 模式部署。窗口大小也值得设置成固定的 1920x1080,因为有些前端页面会根据视口宽度决定是否渲染某些元素,窗口太小会漏掉内容。
4. WebDriverWait 用得好,爬虫稳定一半:异步渲染下的等待哲学
4.1 动态渲染接口的一个残酷现实:你不知道数据何时到位
写 Selenium 爬虫,等元素是最基本也最容易被忽略的环节。静态页面秒开,元素就在那里,怎么定位都不会出错;而 JS 渲染的页面完全不同——脚本加载要时间、异步请求要时间、框架把数据渲染成 DOM 还要时间。你写 driver.find_element(By.CSS_SELECTOR, ".quote") 时,页面上的 .quote 可能还没出生。
新手的第一反应是 time.sleep(3),我当年也这么干过。这个方案的坏处是:网络快的时候 3 秒纯属浪费;网络慢或者服务器偶发卡顿时 3 秒又不够,爬虫照样崩。真正的解法是用 Selenium 提供的等待机制,让程序“等到元素出现为止”,而不是“睡够就开工”。
4.2 隐式等待与显式等待的选择逻辑
隐式等待是最简单的设置,一行代码让 WebDriver 在查找元素时轮询一段时间:
python复制driver.implicitly_wait(10)
它的意思是:如果在 find_element 时元素不存在,最多轮询等待 10 秒,超时才抛出 NoSuchElementException。这个设置对页面上所有元素查找命令都生效,优点是省心;缺点也很明显——它只处理“元素存在性”的等待,无法处理“元素可见但不可点击”“元素存在但内容还在加载”这类更复杂的时序问题。
显式等待则是针对某个条件做的精准等待,用法是 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)
quote = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".quote")))
实际项目中我习惯把显式等待作为主力,全局只留一个较短的隐式等待兜底。因为显式等待能表达更细腻的条件:presence_of_element_located 只管元素进入 DOM,visibility_of_element_located 要求元素在页面中可见,element_to_be_clickable 则额外判断元素是否可交互。点击“加载更多”按钮这种场景,如果只用 presence,经常遇到元素在 DOM 里已存在但还不可点、被半透明遮罩挡住的情况,等一个 element_to_be_clickable 就稳得多。
4.3 实践中真正高频使用的等待条件清单
按出现频率给一份列表,方便查阅:
presence_of_element_located:元素出现在 DOM 树中。适合判断列表数据是否开始加载。visibility_of_element_located:元素可见,占位但隐藏的不算。适合抓取页面上要展示的文本。element_to_be_clickable:元素可见且可点击。适合点击“下一页”“展开全部”。presence_of_all_elements_located:所有匹配元素都已出现在 DOM。适合一次拿一页列表。frame_to_be_available_and_switch_to_it:iframe 可用并完成切换。这个后文会专门讲。text_to_be_present_in_element:元素文本包含指定内容。适合判断某个异步状态更新完成。
一个额外的思路是“轮询页面状态变量”。如果你要爬的是纯前端渲染应用,可以通过 driver.execute_script("return document.readyState") 判断页面加载状态,但更实用的是观察某个全局变量或者 DOM 长度不再变化再继续。比如抓无限滚动页时,我经常滚动到底部后对比元素数量,连续两次数量一致才认为“加载结束”。
4.4 等待不是越多越好:如何避免死等和超时
显式等待给的时间也不是越长越保险。设了 30 秒超时,如果元素真的因为页面 JS 报错而永远不会出现,程序就得傻等 30 秒才报错。一个稳妥的策略是:等待时间给中等数值(8~12 秒),同时在外部包一层由业务决定的重试机制。比如定义函数 get_quotes(),内部 try 一次,失败后刷新页面重新等待,最多重试 3 次。
还有一点容易被忽略:等待期间页面如果弹出了权限询问框(位置、通知权限),会自动挡住后续操作。启动参数里加上 --disable-notifications 能减少这类干扰;谷歌浏览器弹窗的关闭逻辑我会在第 6 章详细讲。
5. 一个完整案例:无限滚动列表的数据抓取与存储
5.1 为什么拿 quotes.toscrape.com 当演示站点
讲解无限滚动和 JS 渲染,最适合用一个干净、合法、长期稳定的练习站点。我常用的是 Quoted 测试站提供的 JavaScript 示例页,这类站点专门用来研究爬虫,没有复杂的登录和加密机制,很适合作为 Selenium 入门练习。
要演示无限滚动采集,通常是抓 /scroll 页面。该页面是典型的滚动加载模式:页面刚打开时只有第一段内容,滚到页面底部后,脚本自动加载下一页内容并追加到列表,直到所有内容展示完。如果你用 requests 请求,拿到的只是第一段内容的外壳;用 Selenium 驱动真实浏览器,才能通过滚动事件把所有内容全部激发出来。
5.2 从滚动到数据落盘的完整实现
下面这段代码是我在项目里的典型写法,逻辑做了精简但结构上保留了真实采集的骨架。关键步骤都有注释,你可以直接仿着改:
python复制import time
import csv
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
def scroll_to_bottom(driver):
"""滚动到页面底部,触发懒加载"""
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
def fetch_all_quotes():
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-notifications")
driver = webdriver.Chrome(options=options)
driver.get("https://quotes.toscrape.com/scroll")
wait = WebDriverWait(driver, 10)
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".quote")))
collected = []
no_change_count = 0
try:
while no_change_count < 3:
# 记下当前数量和页面高度
current_count = len(driver.find_elements(By.CSS_SELECTOR, ".quote"))
current_height = driver.execute_script("return document.body.scrollHeight")
scroll_to_bottom(driver)
time.sleep(2) # 给异步加载留出时间
new_count = len(driver.find_elements(By.CSS_SELECTOR, ".quote"))
new_height = driver.execute_script("return document.body.scrollHeight")
# 如果数量和高度都没变,视为加载到底
if new_count == current_count and new_height == current_height:
no_change_count += 1
else:
no_change_count = 0
# 解析所有 quote 节点
for quote in driver.find_elements(By.CSS_SELECTOR, ".quote"):
text = quote.find_element(By.CSS_SELECTOR, ".text").text
author = quote.find_element(By.CSS_SELECTOR, ".author").text
collected.append({"text": text, "author": author})
finally:
driver.quit()
return collected
def save_to_csv(quotes):
with open("quotes.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["text", "author"])
writer.writeheader()
writer.writerows(quotes)
if __name__ == "__main__":
data = fetch_all_quotes()
print(f"共采集到 {len(data)} 条内容")
save_to_csv(data)
这里有几个细节值得展开说明。
第一,判断加载结束的条件是“元素数量和页面高度连续 3 次无变化”。为什么不能只判断高度?因为有些页面的懒加载是在达到一个固定阈值后就不再扩展,但内容仍在某个容器里滚动;为什么不能只判断数量?因为列表首屏加载也可能一下子追加很多条,数量连续变化之间的间隙会造成误判。数量加高度的双因子判断是我在多个项目里试出来最稳的方案。
第二,为什么要 no_change_count 而不是连续判断一次就退出?因为 AJAX 请求可能有延迟,你刚滚到底时数据还没回来,页面高度暂时没变,如果立刻判定结束,就会过早退出。连续判断 3 次相当于给网络延迟留了缓冲。
第三,time.sleep(2) 在这个逻辑里是必要的。纯等元素的话,滚动后新增的元素数量没法用 expected_conditions 来表达,所以这里用固定短等待代替了显式等待。这个 2 秒可以根据目标站点的加载速度调,但给个下限很重要——没有任何页面能瞬间完成网络请求加渲染。
5.3 同页面多次滚动和去重的必要性
无限滚动页面的一个隐秘问题是:滚动过快时,浏览器可能还没加载完上一批内容,就触发了下一批的加载回调,最后 DOM 里出现重复节点。这也是为什么我在解析逻辑前,用 find_elements 的数量来驱动循环,而不是简单按“下一页”按钮翻页。如果遇到内容确实有重复,解析时加一个简单的集合去重:
python复制seen = set()
for quote in driver.find_elements(By.CSS_SELECTOR, ".quote"):
text = quote.find_element(By.CSS_SELECTOR, ".text").text
if text not in seen:
seen.add(text)
collected.append(text)
对于抓取任务,宁可多等一秒也不要在源头引入脏数据。清洗脏数据的时间成本,远比你想象中大得多。
6. 踩过的真实坑位:iframe 切换、弹窗拦截、元素被遮挡
6.1 iframe 里“找不到元素”的真相
有一类动态页面的数据不在主文档里,而是嵌在 <iframe> 子框架中。很多第三方组件(地图、支付、社交登录)都喜欢用 iframe 隔离自己的脚本。你按部就班地 driver.find_element,结果永远是 NoSuchElementException,折腾半天都不知道问题出在哪。
本质上,WebDriver 的查找命令默认作用在主文档。当你把鼠标悬停在 iframe 区域时,浏览器上下文就切到了子框架内部——不在代码里显式切换,主文档视角是看不见 iframe 内部内容的。正确的姿势是先定位到 iframe 元素,再用 switch_to.frame() 切换过去:
python复制from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
# 等待 iframe 可用并切换
wait = WebDriverWait(driver, 10)
iframe = wait.until(EC.frame_to_be_available_and_switch_to_it((By.CSS_SELECTOR, "iframe[src*='xxx']")))
# 此时 driver 的上下文已经在 iframe 内,可以正常查找元素了
content = driver.find_element(By.CSS_SELECTOR, ".iframe-content").text
# 操作完回到主文档
driver.switch_to.default_content()
踩坑提示:切换 iframe 之前,如果你有旧的元素引用,切换后这些引用会失效,需要重新查找。另外,如果一个页面嵌套了多层 iframe,你需要按层级逐层 switch_to,每层都要等待 frame_to_be_available_and_switch_to_it,少一层都定位不到。排查时可以在搜索栏执行 return document.querySelectorAll('iframe').length 确认页面上到底有几个 iframe,再决定切哪一个。
6.2 弹窗和“元素被拦截”伪装成的定位失败
现代网页几乎都有各种弹窗:新人礼包、订阅推送、Cookie 授权、客服入口。这些弹窗往往以 position: fixed 的方式盖在整个页面上。你以为你点击的是列表项的“查看详情”,实际上鼠标落下去点中的是弹窗的半透明遮罩层。这类报错在 Selenium 里长这样:
code复制selenium.common.exceptions.ElementClickInterceptedException:
Message: element click intercepted: Element is not clickable at point (x, y)
解决思路分两种情况。如果弹窗是可关闭的,就先把弹窗关掉,再继续业务操作:
python复制try:
close_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, ".modal .close")))
close_btn.click()
except TimeoutException:
pass # 没有弹窗,继续走
如果弹窗是权限请求之类的系统级弹窗,页面内没有关闭按钮,可以考虑两个手段:一是启动参数里带上 --disable-notifications、--disable-popup-blocking;二是切换到弹窗句柄处理:
python复制# 如果有多个窗口标签
main_window = driver.current_window_handle
for handle in driver.window_handles:
if handle != main_window:
driver.switch_to.window(handle)
driver.close()
break
driver.switch_to.window(main_window)
还有一种常见情况是页面元素被右侧悬浮客服之类的小窗挡住。定位本身没问题,就是点不到。此时可以先用 execute_script 让目标元素滚动到可视区域中央,或者直接把遮罩元素隐藏掉:
python复制element = driver.find_element(By.XPATH, "//button[contains(text(), '查看详情')]")
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
driver.execute_script("arguments[0].click();", element)
execute_script 里的 arguments[0].click() 是绕过可见性拦截的直接调用 DOM 点击方法,不会触发 Selenium 的点击拦截检查。但要注意,这个方法虽然好用,却不完全等同于真实用户点击,某些依赖鼠标事件的页面(比如 hover 才出现的菜单)需要改用 ActionChains 模拟鼠标行为,直接执行 click 反而无效。
6.3 隐蔽的坑:元素存在但文本为空
动态加载还有一个令人头疼的怪现象:元素已经存在于 DOM,但文本内容还是空的。这是因为前端框架先占位创建了 DOM 节点,随后异步数据回来才把内容填进去。如果你用 element.text 去取,拿到的可能是一个空字符串。
处理这类问题最稳的方法,不是反复重试读取文本,而是等待“文本不为空”的条件成立。自己写一个简单等待循环,比只靠 time.sleep 更可靠:
python复制from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
element = driver.find_element(By.CSS_SELECTOR, ".dynamic-value")
# 自旋等待元素文本非空
WebDriverWait(driver, 10).until(
lambda d: element.text.strip() != ""
)
WebDriverWait 的 until 方法不仅支持预置的 expected_conditions,也支持传任意一个返回布尔值的函数,这是一种非常灵活的扩展思路,我强烈建议掌握。
6.4 踩坑后的通用排查路线
如果上面的场景都没覆盖你的问题,分享一套我在实际项目中高频使用的排查命令组合,尤其在“动态页面怎么也抓不到”的时候:
python复制# 1. 确认当前页面标题
print(driver.title)
# 2. 打印完整页面源码长度,判断页面是否真的渲染完成
print(len(driver.page_source))
# 3. 打印目标元素是否存在
print(len(driver.find_elements(By.CSS_SELECTOR, ".target-class")))
# 4. 若存在却取不到文本,打印 outerHTML
target = driver.find_element(By.CSS_SELECTOR, ".target-class")
print(target.get_attribute("outerHTML"))
先确认“页面到底渲染没有”,再确认“元素到底在不在”,最后确认“元素在但拿到的内容是什么”。我见过太多人一上来就怀疑定位表达式写得不对,折腾半天才发现是等待时间不够、页面压根没渲染出来。按上面四步走,基本五分钟内能定位到问题的真正层级。
7. 被反爬识别之后:关于风控、性能与合规采集的真实取舍
7.1 Selenium 最容易被识别,但这不代表不能用
说到动态页面的高级爬法,没法回避反爬这个话题。头部互联网公司对自动化的风控投入,比绝大多数技术博客描述的猛烈得多。Selenium 有一个先天的指纹特征,浏览器中会暴露一些自动化相关的标记;再加上无头模式、固定窗口大小、过快的点击速度、过于规律的滚动节奏,都可能成为被判定为“非人类操作”的依据。当你看到一个页面在你正常访问时一切正常、换成 Selenium 驱动后却频繁跳出滑块验证或者直接空白,基本就是风控识别出了自动化特征。
但谈论“如何伪装成真人”并不是这篇文章的目的,我更想分享的是这些年在合规采集上的实际体会:
一是优先尊重网站的 robots.txt 和用户协议。只采集授权范围内的公开数据,不碰需要登录才能查看的非公开页面。这条原则听起来是政治正确的大话,实际上是保护自己最有效的方式。
二是控制采集频率。我的经验值是单线程采集时,两次请求之间至少间隔 3 到 8 秒,随机延时比固定延时重要得多。很多站点对流量峰值的容忍度很低,按浏览器速度强行并发,几千条数据还没抓完,IP 就被限流或封禁了。限流之后再来处理代理池、滑块验证,工作量远远超过当初设置合理延迟的成本。
三是尽量选择低影响的采集时间窗口。如果是针对某个每天更新的列表页做增量采集,放在凌晨执行、避开业务高峰,既不影响目标网站正常用户访问,又能显著降低被风控盯上的概率。
7.2 性能差是 Selenium 的硬伤,架构上绕开而不是硬扛
Selenium 慢是天然的。一个完整的浏览器实例要加载 HTML、CSS、Javascript、图片、字体,哪怕你设置了 --blink-settings=imagesEnabled=false,也要执行完所有脚本逻辑才能渲染出最终 DOM。我们之前也提到“混合架构”的思路:先用 requests/httpx 快速试探静态部分,只有当发现数据依赖 JS 渲染时才让 Selenium 补位。
这个“静态优先、浏览器兜底”策略落实到具体爬虫里,可以分层设计:
| 层级 | 工具 | 使用频率 | 说明 |
|---|---|---|---|
| 第一层 | requests + 解析库 | 尽量高 | 先看普通请求能否直接拿到关键字段 |
| 第二层 | requests 模拟接口 | 按需 | 分析 XHR 请求,无签名时优先复用 |
| 第三层 | Selenium 渲染 | 兜底 | 接口加密、交互复杂时才启动浏览器 |
| 第四层 | 队列+限流 | 全局 | 控制总请求速率,避免触发风控 |
Selenium 该用在刀刃上决不含糊,但绝不该把整站所有页面都交给无头浏览器去啃。给一个直观对比:requests 抓 1000 条数据可能只要几十秒,Selenium 可能要十几分钟甚至更久。所以真正可维护的爬虫架构,一定是能用协议层解决的绝不动浏览器,必须动浏览器的也要控制好采样量。
7.3 关于滑块和验证码:技术能替你解决一部分,但不该解决的那部分别碰
热搜词里有很多“滑块验证、图片滑块、自动化拼图”的搜索需求,这说明动态页爬取到了一定规模,验证码几乎是绕不开的墙。作为一个有十多年经验的从业者,我的真实看法是:滑块验证本身的技术难度没有传说中那么高,很多公开方案把图片缺口识别、轨迹拟合、模拟拖动这一套讲得很透。但这绝不意味着你应该把这些方案直接用于你不拥有或被明确禁止采集的网站。
验证码存在的意义是过滤异常流量。如果选型层面已经走到了“必须高频破解验证码才能采集”的地步,我通常建议先退一步,重新评估这个数据源是否真的值得拿。合规采集和数据安全是一条红线,一次严重越界可能带来的法律风险远超那几条数据的价值。
比起执着于“绕过验证码”,更健康的思考方式是优化数据获取路径:是不是有官方 API?是不是可以购买第三方数据服务?是不是只需要采集少量样本而不是全量数据?这些问题想清楚,对项目的实际价值往往比折腾一个绕过滑块的高可用方案更大。
7.4 我这几年用得最踏实的一套组合习惯
最后分享几个我在实际项目里反复检验过的操作习惯,不算什么高级技巧,但对提高动态页面采集稳定性很有帮助。
- 给
driver.get()之后的第一屏内容做一次强制等待,因为很多单页应用的首次渲染要加载一个巨大的 JS bundle,光靠隐式等待的 10 秒可能不够。 - 每次会话结束务必调用
driver.quit()而不是driver.close()。close()只关当前标签页,浏览器进程还可能残留,时间久了内存泄漏会让你怀疑人生。 - 使用
with或者try/finally保证 driver 一定会退出,防止程序崩溃后残留几十个 chrome 僵尸进程。 - 对长时间运行的采集任务,定期重启浏览器会话是个好习惯。我一般是每采集 500 条数据就重置一次 driver,避免浏览器内存占用持续走高后导致页面响应越来越迟钝。
把 Selenium 用熟练只是动态页面处理的起点。真正让爬虫项目从“能跑”进化到“稳跑”,靠的是等待策略的精细设计、混合架构的成本控制,以及一套尊重数据源规则的自我约束。把这些基础打扎实,再往回看那些被 JavaScript 渲染折磨的日夜,你会发现问题往往不是“页面太难抓”,而是当初少问了几个“为什么”。
