说实话,我刚开始用Selenium做Web自动化的时候,心态是极其膨胀的。以为装个库、写个find_element、点两下按钮,就能把日常重复的网页操作全扔给脚本。结果真正跑起来才发现,这东西像个脾气古怪的实习生——你说东它往西,你让它点它偏不点,你明明看见元素在页面上摆着,它偏偏报NoSuchElementException。这几年踩过的坑,加起来比我写过的业务代码都多。
这篇文章我不想搞什么理论知识科普,就纯粹聊聊我在用Selenium做Web自动化测试、批量操作、数据校验时真实踩过的大坑,以及我最后是怎么绕过去的。无论你是刚入门不久的新手,还是已经在脚本里挣扎了一段时间的半熟手,我相信下面这些内容对你都有参考价值。
1. 先说结论:Selenium这几年最坑的地方,其实不是库本身
很多人一上来就怪Selenium不稳定、毛病多,但以我的经验来看,八成问题出在你根本没搞清楚它底层的工作逻辑。Selenium本质上是帮你启动了真实的浏览器内核(比如Chrome的Chromium),然后通过一套WebDriver协议往浏览器里注入操作指令。它不是什么魔法,它只是“替你的手去操作浏览器”。而浏览器渲染页面是异步的、动态的、有布局计算的,脚本却默认认为“页面加载完了就是好了”“元素存在就是能点”。
这个认知偏差,是几乎所有坑的源头。
1.1 大部分人遇到的坑,其实集中在这么几类
我复盘了一下自己这么多年写Selenium脚本的经历,遇到的所有问题绕来绕去逃不出六类:
- 元素找不到或定位错:动态ID、iframe嵌套、shadow DOM、元素在视图之外。
- 等待策略失效:页面加载完≠元素渲染完,元素渲染完≠元素可交互。
- 元素看得见但点不了:被遮挡、需要滚动、处于不可交互状态。
- 执行环境问题:浏览器自动升级、驱动版本不匹配、headless模式表现异常。
- 自动化特征被服务端识别:脚本跑得好好的,忽然被跳验证码或拦截。
- 多个页面上下文切换混乱:多窗口、多iframe、alert弹窗互相干扰。
如果你遇到了奇怪的问题,先别急着怀疑Selenium,先对照一下是不是上面六类之一。大概率是。
1.2 我的环境基线:先固定版本再谈稳定
在我长期的经验里,想要Selenium脚本稳定,环境必须固定。这是最容易被忽视的坑。我目前测试机上的环境组合是:
| 组件 | 推荐版本/方式 | 备注 |
|---|---|---|
| 编程语言 | Python 3.9+ | 生态最全,踩坑资料最多 |
| Selenium | 4.x(优先4.11+) | 自带Selenium Manager,驱动管理省心 |
| 浏览器 | Chrome稳定版 | 和Chromedriver大版本必须一致 |
| 运行模式 | 有头模式(调试)+无头模式(执行) | 无头模式有额外的坑,见第7节 |
| IDE | PyCharm/VSCode | 调试脚本必定要用断点看element属性 |
版本这块我吃过大亏:Chrome凌晨自动更新,第二天Chromedriver版本对不上,整个批量任务直接崩溃。后来学乖了,要么锁浏览器自动更新,要么统一走Selenium Manager自动拉匹配的驱动。这一点后面专门展开。
2. 元素定位翻车现场:最容易被甩锅的几类NoSuchElementException
我敢打赌,你使用Selenium遇到最多的报错一定是NoSuchElementException。但这个异常其实是个“大筐”,什么情况都能往里装。很多你以为是时序问题,实际上是定位策略问题。
2.1 不是没加载完,是元素根本不在你找的frame里
这是我早期犯过的、印象最深的一个错误。当时要抓一个嵌在后台管理页面里的弹窗数据,找了好久都报找不到元素。我以为是加载慢,把显式等待从5秒调到30秒,照样找不到。后来用driver.page_source把整个页面打印出来,才意识到那个弹窗其实在一个<iframe>里面。
核心逻辑是:iframe是独立文档,默认情况下Selenium只会在主文档的DOM里找元素。你在主文档里怎么找都找不到iframe内部的元素,这不是因为元素不存在,而是因为你根本没进去它的世界。
正确做法是先定位iframe元素,然后用switch_to.frame()切入,操作完再switch_to.default_content()切回主文档。麻烦的是嵌套iframe——你得一层层切。我建议写个工具函数,按iframe的索引或name递归遍历,不然维护起来想死。
提示:判断元素是否在iframe里,最简单粗暴的方式是打开浏览器DevTools,Ctrl+F搜一下元素名,如果搜索栏提示“此搜索当前位于 XX iframe 中”,那基本实锤了。
2.2 元素属性是动态生成的,别硬写静态定位
现在前后端分离的框架越来越多,很多前端框架(比如Vue、React)渲染出来的元素id和class都是动态拼接的,比如input_1f3az、btn_9x8za_2这种。你要是拿动态ID去定位,第一次侥幸通过,第二次就可能直接翻车,因为ID变了。
我的处理原则是:
- 优先用稳定的业务属性定位:
name、placeholder、>element.scrollIntoView({block: "center", inline: "center"});block控制垂直方向:start、center、end、nearest。inline控制水平方向:start、center、end、nearest。
在Python里这样写:
driver.execute_script("arguments[0].scrollIntoView({block:'center', inline:'center'});", element)如果你要操作的是一个独立的横向滚动容器(不是页面整滚动,而是一个div内部横向滚动),或者要控制滑块左右精确移动,就要直接操作容器的
scrollLeft属性:# 把某个容器的横向滚动条移到最左边 driver.execute_script("document.querySelector('.scroll-panel').scrollLeft = 0;") # 移到某个位置 driver.execute_script("document.querySelector('.scroll-panel').scrollLeft = 800;")在Selenium自带的ActionChains里,横向滚动可以通过
scroll_by_amount(delta_x, delta_y)实现(Selenium 4.x支持),但它在老内核里不一定生效,所以我还是习惯用JS。我的经验是:横向滚动容器,直接操纵scrollLeft是最精准、最省事的方案。还有一个我常遇到的细节:滚动之后元素虽然出现在可视区了,但是被顶部固定导航栏盖住了。这时点击依然会失败。处理方式有两种:
- 在滚动容器用
scrollIntoView({block: 'center'})把元素移到页面中心,而不是顶部。 - 用
driver.execute_script("window.scrollBy(0, -120)")手动多往上滚一段距离,给固定导航让出位置。
4.3 滚动距离的计算逻辑:不要瞎猜,用元素位置算
再分享一个我的习惯:需要精确滚动时,不要凭感觉写
scrollBy(0, 500)这种魔法数字。用getBoundingClientRect()拿到元素相对视口的位置,然后计算要滚动的距离。比如:rect = driver.execute_script("return arguments[0].getBoundingClientRect();", element) # rect.top 是元素左上角相对视口顶部距离 # rect.bottom 是相对视口底部距离如果
rect.top小于0,说明元素在视口上方,需要向下滚动;如果大于页面高度,说明在下方,需要向上滚动。手动算一次之后,再决定滚动值,准确率能提升一个档次。这比靠肉眼估“滚多少合适”要靠谱得多。5. 浏览器驱动与运行环境:掉链子一环卡全程
这类坑不在代码逻辑层面,但在生产环境里最要命。你脚本写得再好,驱动版本对不上,一切白搭。
5.1 Chrome更新完,脚本就报SessionNotCreatedException
有一天早上,同事跑过来跟我说“脚本全挂了”。我一看报错:
selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version 91原因很简单:浏览器后台自动升级到了某个新大版本,而本机指定的ChromeDriver还是老版本,两者协议握手对不上,Session创建失败。
以前我的做法是:下载匹配的ChromeDriver,把新老版本号一根一根对齐。后来版本频繁更新,完全手动盯不过来了。现在的方案有两个:
- 用Selenium Manager自动管理驱动:Selenium 4.11及以上版本默认内置Selenium Manager,它会根据你本地Chrome版本自动下载对应版本的driver,不再需要手动下载。
- 固定浏览器版本,关闭自动更新:在很多测试环境里这是更稳的路线,但会牺牲功能更新和安全性,适合专用的自动化测试机上做。
我个人的建议是:能用Selenium Manager就不要自己下载driver。尤其是团队的CI环境,少一个手动步骤就少一个故障点。
5.2 Selenium Manager帮你少踩一半的坑,但也不是万无一失
Selenium Manager确实减少了很多烦心事,但它自己也有些限制。比如它默认会在用户目录下缓存driver,离线环境下首次运行会失败;比如在某些企业内网环境,它去下载driver的URL可能被网络策略拦截。所以我的兜底方案是:
- 在CI/CD环境里,提前把ChromeDriver的绝对路径放到环境变量,传给WebDriver构造函数。
- 用
webdriver.Chrome(options=options)时,传入service=Service(executable_path='/xxx/chromedriver')显式指定路径,避免每次环境重建时都去解析。 - 如果driver下载源被墙了(别问,问就是内网环境),用镜像源配合工具脚本统一管理版本。
这些都是经验之谈,遇到一次环境崩溃之后,你就会自觉地把驱动管理提前写进部署文档。
5.3 VBA场景调用Selenium的那些奇异问题
热搜词里有个“selenium vba”,看来用Excel VBA做Web自动化的朋友也不少。VBA本身不具备直接调用WebDriver的能力,通常是用早年的SeleniumBasic(一个VBA封装库)来驱动浏览器。
我帮人调过几个VBA的Selenium脚本,发现它有几个和Python完全不同的坑:
- SeleniumBasic对新版Chrome支持滞后:组件的内核版本往往跟不上浏览器更新,很容易像上面那样报Session异常。解决方案是找到对应版本的chromedriver后,再调SeleniumBasic去对接。
- VBA代码变量声明不严格时,元素集合的索引获取方式不同:Python的
find_elements返回列表可以直接下标访问,VBA里是elements集合对象,必须用.Item(0)或.Count先确认数量再操作。 - VBA宿主是Excel,DoEvents/线程模型会导致界面卡死:循环操作大量网页时,Excel可能假死。需要在循环前设置
Application.ScreenUpdating = False和Application.Cursor = xlWait,并且适当加一些DoEvents把控制权还给系统。
说实话,VBA本身就不是干这个的,长期大宗Web自动化的场景,我更建议用Python+Playwright或Selenium,再配一个合适的UI框架。但如果你必须在Excel里自动化,记住上面几条能免掉很多折腾。
6. WebDriver痕迹与自动化检测:测试环境里反爬机制带来的假故障
另一个鲜少在普通教程里被认真讨论的问题,是“以正常手段操作网站时,页面却把你的脚本识别为机器人”。热搜词里也有“反爬虫”相关。我做自动化测试时,遇到自己开发的后台系统里有风控逻辑,本来是正常测试自己的站点,结果风控把跑批的脚本当成异常访问给拦截了,这种“假故障”特别难排查。
6.1 为什么正常脚本会被判定为“机器人”
现在不少Web系统都会做在线行为风控,靠一些前端特征识别自动化操作,比较常见的有:
navigator.webdriver属性:通过WebDriver驱动的浏览器,这个属性值为true,普通手动浏览器为false/undefined,检测成本极低。- 行为轨迹:鼠标不移动直接点击、坐标偏离实际点击区域、执行速度过于均匀等。
- 浏览器指纹关联:User-Agent、屏幕分辨率、语言、字体、WebGL信息组合起来识别。
如果你运行的是自己的测试环境,被这些机制拦了,自然很恼火。
6.2 识别和规避:prefs、参数与CDP自家门道
在自己系统、合法测试范围内,为了提升测试稳定性和效率,减少被误杀,常用的几种做法是:
方法一:加启动参数关掉自动化标志
from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) driver = webdriver.Chrome(options=options)方法二:用CDP命令在页面加载前注入脚本,篡改navigator.webdriver
driver = webdriver.Chrome(options=options) driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", { "source": """ Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); """ })方法三:设置真实的User-Agent
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...")这三个组合起来,在你自己开发的系统/你获得了合法授权的测试站点上,通常能规避掉90%的“轻量检测”。遇到更严格的行为风控时,光靠Selenium就难以稳定扮演真实用户了。
6.3 合理边界:这些手段只用来做测试稳定化
这里必须说清楚一个边界问题。Web自动化是工程手段,不是攻击手段。你拿Selenium去测自己公司开发的网站、去验收自己的项目、去做数据报表自动化收集,这是完全正当的。可如果你拿这些绕过检测的方法去干爬虫、去抓别人网站的数据、去对抗别人的服务条款,那就越线了,而且说不定还会有法律风险。
我自己的原则是:
- 自动化对象以自己开发或明确拥有测试权限的系统为准。
- 优化脚本被误杀的体验,为的是让测试结果更接近真实用户,不是为了让脚本去“偷”什么。
- 一旦发现某一方网站的服务条款明确禁止自动化访问,就停止,换合规的方式对接API。
写自动化的人都应该有这样的自觉。
7. 长期跑自动化会话的其他坑:窗口切换、文件上传下载、headless差异
前六节聊的是最常踩的坑,但把它们解决完,不代表你就永远安全了。跑长期批处理任务、跨页面流程、文件交互时,还有最后一波隐蔽的坑。
7.1 window_handles和iframe混合切换的错乱
多窗口测试是我最烦的场景之一。
driver.window_handles拿到的窗口句柄列表,顺序往往是变化的,而且新窗口打开后如果不做等待,句柄列表还没更新,很容易切到旧的去。我的解法是:
# 点击打开新窗口前,先记录当前句柄 old_handle = driver.current_window_handle # 触发打开新窗口的动作 driver.find_element(...).click() # 等待句柄列表变化 WebDriverWait(driver, 10).until(lambda d: len(d.window_handles) > 1) # 切到新窗口 new_handle = [h for h in driver.window_handles if h != old_handle][0] driver.switch_to.window(new_handle)还有更复杂的坑:新窗口刚打开时,页面还没完全加载,立刻去
find_element又会NoSuchElement。所以切过去后,建议先等待页面的URL或标题是否符合预期,再开始操作。不要一拿到window handle就雀跃。iframe和多窗口经常同时出现,我踩过的经典连环坑是:当前在iframe里,需要切窗口,你直接用switch_to.window切过去了,但切回来的时候如果还指望着自动回到之前的iframe,那是不可能。所以我的习惯是:切换窗口时先记录当时所在的frame路径,回来之后一层层重新切进去。这个脚本在复杂业务系统里救过我好几次。
7.2 文件上传下载:input标签和非input标签的本质区别
上传:很多人不知道,Selenium对
<input type="file">标签的上传,根本不需要模拟点击文件选择框。直接向input元素send_keys完整文件路径就可以:file_input = driver.find_element(By.CSS_SELECTOR, "input[type='file']") file_input.send_keys("/path/to/file.xlsx")如果是非input的拖拽上传组件,那就得用
ActionChains模拟拖拽,或者想办法触发对应的JS事件。这类组件各家实现差异很大,建议优先去读它的接口文档,看有没有直传的隐藏input。下载:Chrome默认会弹出下载栏,并且在无头模式下可能自动下载但不会落盘到预期位置。正确姿势是在浏览器启动时配置下载目录:
prefs = { "download.default_directory": r"D:\downloads", "download.prompt_for_download": False, "plugins.always_open_pdf_externally": True } options.add_experimental_option("prefs", prefs)下载完成后还要等文件真正存在,别想当然地认为点击下载按钮就万事大吉。轮询文件是否存在、大小是否稳定,也是一项基本功。
7.3 headless模式不是“无头就万事大吉”
不少人为了隐藏浏览器界面,跑批处理时喜欢用
options.add_argument("--headless=new")。但无头模式有一些和有头模式完全不同的行为:- 渲染结果未必完全相同:某些依赖真实GPU渲染的动画元素可能加载不出来。
- 窗口尺寸默认偏小:很多脚本在无头模式下找不到页面底部元素,就是因为默认视口太小。要显式设置
window-size参数:options.add_argument("--window-size=1920,1080")。 - 下载和文件选择对话框行为不同:无头模式下“下载”常常直接跳过弹窗,但也可能根本不在预期路径。
我的建议是:新写的脚本,先用有头模式跑通,再切无头模式验证一遍。不要一上来就无头,不然定位问题的时候连浏览器画面都看不到,排查难度成倍上升。
再补一个长期运行常见的坑:浏览器开久了内存会涨,长时间的批处理任务定时重启一下driver,比调一堆参数更有效。比如每处理N条数据,就关闭浏览器重新创建会话,能明显减少卡死、无响应的问题。
写在最后的一点个人体会
踩了这么多坑,回头看看,Selenium做Web自动化真正难的地方不在于你写了多少代码,而在于你能不能理解浏览器和DOM的运作机制、能不能把不稳定因素控制住。我现在写脚本的节奏已经变成了:先定位稳定,再等待明确,再操作干净,最后考虑效率和代码复用。每一步都不玄学,都有清晰的排查路径可以遵循。
如果你刚开始接触Selenium,我希望你把这篇文章收藏起来,不用试图一口气背下所有内容。等你哪天也遇到“明明看到了却点不了”“Chrome一升级就崩”“莫名其妙被识别成机器人”这些问题时,翻回来看一眼就行。坑就摆在那里,你迟早会来踩,但能少踩一个是一个。