1. Edge 上的 Selenium 为什么总比预期多绕几圈
同一份自动化脚本,在别的 Chromium 系浏览器里跑得顺风顺水,换到 Edge 上却开始报各种稀奇古怪的错——这个场景我遇到过太多次了。有人是公司环境统一配了 Edge,有人是客户现场只能用系统自带浏览器,也有人纯粹是想做跨浏览器兼容性验证。不管你属于哪一种,只要脚本要跑在 Edge 上,就得先接受一个事实:Edge 虽然内核是 Chromium,但它的发行节奏、驱动分发方式、默认配置目录、企业策略层,和别的 Chromium 浏览器并不完全一样。这些差异平时看不出来,一旦出问题就特别隐蔽。
这篇内容整理的是我在用 Selenium 调试 Edge 浏览器过程中反复踩到的坑,以及每个坑背后真正的成因和具体解法。适合两类人看:一类是刚把脚本从其他浏览器迁移到 Edge、正在被SessionNotCreatedException折磨的新手;另一类是脚本已经在跑,但总在 CI 或者无人值守环境里间歇性失败,想找稳定性突破口的老手。文中代码以 Python 为主,Selenium 版本按 4.x 来讲,因为 4.6 之后内置的驱动管理机制改变了整个排查思路,这个后面会详细说。
先把最容易出问题的三条主链路摆在前面:第一条是驱动与浏览器版本的对齐,这是所有报错里出现频率最高的;第二条是浏览器启动参数与用户数据目录,这条决定了你的浏览器能不能稳定起来;第三条是元素交互时的等待与渲染时序,这条决定了脚本能不能真正跑完。这三条链路几乎覆盖了日常 80% 以上的 Edge 调试问题,剩下的零碎问题基本都能归类进去。下面按这个顺序逐条拆。
2. 驱动版本对不上:从报错原文反推该对齐哪一层
2.1 那句 session not created 到底在说什么
几乎所有新手遇到的第一个报错都长这样:
selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of EdgeDriver only supports Microsoft Edge version 118 Current browser version is 126.0.2592.87 with binary path C:\Program Files (x86)\...\msedge.exe很多人看到这一大段就懵了,其实它已经把答案写得很清楚了。翻译成人话就是:你手里的msedgedriver是给 118 版本准备的,但你机器上的 Edge 已经升到 126 了,两者对不上,驱动拒绝建立会话。
这里有个关键的认知点:WebDriver 协议要求驱动与浏览器主版本号一致(也就是版本号第一段)。118 的驱动只能驱动 118 的浏览器,126 的驱动只能驱动 126 的浏览器。次版本号可以有小幅差异,但主版本必须对齐。这不是 Edge 独有的规矩,所有 Chromium 系浏览器都这样,只是 Edge 的自动更新比较激进,很多时候你没动过任何东西,浏览器自己半夜升级了,第二天脚本就挂了。
顺带说一个容易混淆的报错:
Message: 'msedgedriver' executable needs to be in PATH这个不是版本问题,是根本找不到驱动可执行文件。前者是"找到了但对不上",后者是"压根没找到",排查方向完全不同,别搞混了。
2.2 手动对齐、Selenium Manager 和第三方管理器的取舍
驱动管理这块目前有三条主流路线,我在不同项目里都用过,各有适用场景。先说结论:新项目优先用 Selenium Manager,老项目或者内网离线机器用手动对齐,需要精细控制版本的时候用第三方管理器。
| 方案 | 适用场景 | 优点 | 明显短板 |
|---|---|---|---|
| 手动下载放置 | 内网、离线、版本需锁定 | 完全可控,无网络依赖 | 每次浏览器升级都要人工跟进 |
| Selenium Manager(4.6+ 内置) | 绝大多数联网场景 | 零配置,自动匹配版本 | 首次运行需联网,内网会卡住 |
| 第三方驱动管理库 | 需要指定版本、多浏览器混跑 | 版本可指定,支持多浏览器 | 多一层依赖,偶发缓存问题 |
Selenium Manager 是 Selenium 4.6 之后内置的,它的工作逻辑是:启动时先探测本机 Edge 的实际版本,然后去驱动仓库找匹配版本,找不到就下载,下载完缓存到本地。整个过程对使用者是透明的,你什么都不用配,直接webdriver.Edge()就能跑。这在 4.11 之后稳定性提升明显,我现在的默认做法就是什么都不指定。
但有三个坑要提前知道。第一,首次运行需要网络,如果你的构建机在内网隔离环境,Selenium Manager 会卡在探测阶段然后超时,报错信息还很含糊。第二,它探测的是默认安装路径下的 Edge,如果你用的是绿色版或者装在非标准目录,探测会失败,这时候必须用binary_location手动指定浏览器路径。第三,缓存目录权限问题,某些 CI 环境里用户对缓存目录没写权限,下载会静默失败。
手动对齐这条路虽然笨,但在受控环境里最可靠。做法是:先查本机 Edge 版本,可以用命令行或者看浏览器设置页的"关于"。
# Windows PowerShell 查 Edge 版本 (Get-Item "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe").VersionInfo.ProductVersion # macOS /Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version拿到版本号后去驱动下载页面对应版本下载,解压后放到一个固定目录,然后在代码里显式指定:
from selenium import webdriver from selenium.webdriver.edge.service import Service service = Service(executable_path=r"D:\drivers\msedgedriver.exe") driver = webdriver.Edge(service=service)这里有个实践细节值得说:我习惯把驱动放在项目目录下的drivers/里,而不是塞进系统 PATH。原因是PATH 里放多个版本驱动是灾难,你永远不知道当前生效的是哪个,排查时会浪费大量时间。放项目内、路径写死、跟着代码一起进版本库(驱动文件不大的话),出问题一眼就能看出用的哪个版本。
2.3 内网机器和版本锁定场景下的处理办法
离线环境是驱动问题的高发区。我见过最典型的情况是:内网机器上的 Edge 被策略锁在某个固定版本,但开发同学本地用的是最新驱动,代码提交到流水线就炸。解决办法是把"浏览器版本 + 驱动版本"作为一个整体来管理,写在配置文件里,构建时校验。
思路是这样:启动前先探测实际 Edge 版本,和配置里声明的期望版本比对,不一致就直接抛异常并打印清晰提示,而不是等到 WebDriver 报一句天书。
import subprocess import re import sys EXPECTED_MAJOR = "126" def get_edge_major_version(): try: out = subprocess.check_output( ['powershell', '-Command', r'(Get-Item "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe").VersionInfo.ProductVersion'], stderr=subprocess.DEVNULL, text=True ) return out.strip().split('.')[0] except Exception: return None major = get_edge_major_version() if major and major != EXPECTED_MAJOR: print(f"[环境校验失败] 期望 Edge 主版本 {EXPECTED_MAJOR},实际 {major}") print("请更新配置中的驱动版本,或联系环境管理员同步浏览器版本") sys.exit(1)这段代码看着朴素,但在我参与的几个多人协作项目里,它把"驱动版本问题"的排查时间从平均半小时压缩到十秒以内。因为报错发生在最前面,而且提示是人话,不用再去翻 WebDriver 的长篇堆栈。
提示:驱动版本升级后记得清掉 Selenium Manager 的本地缓存目录,否则某些情况下它会继续复用旧驱动。缓存位置随系统不同,一般在用户目录下的
.cache/selenium里。
3. 浏览器能启动但立刻退出:启动参数与配置目录的隐雷
3.1 用户数据目录被占用导致的启动失败
这个坑非常隐蔽,表现也很多样:有时候是SessionNotCreatedException,有时候是浏览器窗口一闪而过,有时候是驱动起来了但driver.current_url一直返回data:,。共同点是你手动打开 Edge 是正常的,只有脚本启动会出问题。
根本原因在 Edge 的用户数据目录(user data directory)。Edge 默认的用户数据目录是一个独占资源,同一个目录在同一时间只能被一个浏览器实例使用。如果你本机已经开着 Edge 在正常上网,脚本再去用默认目录启动,新的浏览器进程会尝试连接已有实例而不是新建一个独立实例,驱动就拿不到控制权,各种诡异现象就出来了。
解法很直接:给自动化脚本一套独立的用户数据目录。
from selenium import webdriver from selenium.webdriver.edge.options import Options import tempfile, os options = Options() profile_dir = os.path.join(tempfile.gettempdir(), "edge_auto_profile") options.add_argument(f"--user-data-dir={profile_dir}") # 同时指定 profile 目录名,避免和默认 profile 混用 options.add_argument("--profile-directory=Default") driver = webdriver.Edge(options=options)用tempfile.gettempdir()是为了跨平台,Windows 下会落到临时目录,macOS 和 Linux 下一样能用。这么做还有两个额外好处:一是每次跑都是干净环境,不受本机浏览器历史、缓存、扩展的影响,结果可复现;二是脚本崩了留下的脏数据不会污染你日常用的浏览器配置。
但独立目录也有代价:登录态、Cookie、本地存储全都是空的。如果你的脚本依赖已登录状态,就得在脚本里做登录流程,或者从一份预先准备好的 profile 目录复制过来。我一般做法是准备一份profile_template目录,里面预置好必要的登录态和配置,每次运行时复制一份到临时目录再用,跑完就删。这样既保证了独立性,又保留了必要状态。
3.2 无头模式下的尺寸、渲染与 UA 差异
无头模式是自动化跑批的标配,但它在 Edge 上有几个必须处理的差异点,不处理就会遇到"有头能跑无头不能跑"的经典问题。
第一个是窗口尺寸。有头模式下浏览器窗口通常开得比较大,页面元素都在可视区域内;无头模式默认视口只有 800x600,很多响应式布局会把元素折叠进汉堡菜单,或者出现懒加载不触发的情况。表现就是NoSuchElementException,但你手动打开页面明明能看到。
options.add_argument("--headless=new") # 新版无头,渲染更接近有头 options.add_argument("--window-size=1920,1080") options.add_argument("--disable-gpu") # 部分环境需要注意--headless=new和老的--headless不是一回事。新版无头模式走的是完整渲染路径,页面表现更接近真实浏览器,对现代前端框架更友好。如果你的 Edge 版本比较新,建议直接用new,别用裸的--headless。另外--window-size在无头模式下是生效的,--start-maximized在无头模式下不生效,这是个常见误解。
第二个是User-Agent。无头模式的默认 UA 里会带HeadlessEdg字样,很多站点的风控会直接识别并返回不同内容,甚至直接拦掉。如果你的脚本在有头模式正常、无头模式拿到的是空页面或者验证页,八成是这个问题。
ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0") options.add_argument(f"--user-agent={ua}")这里补充一句:改 UA 是为了让自动化访问的页面表现一致,属于测试环境里的常规做法,别拿它去做绕过正常访问控制的用途,那是另一回事,也不在本文讨论范围内。
第三个是字体和渲染差异。无头环境(尤其是容器里)经常缺中文字体,导致截图里中文全是方框,或者某些依赖字体度量的布局判断出错。如果你的脚本要做视觉比对,务必在容器镜像里装好字体包。
3.3 常用启动参数逐条拆解
参数这块我列一个自己常用的清单,都是实际项目里反复用到的,附上为什么要加。
| 参数 | 作用 | 什么时候需要 |
|---|---|---|
--user-data-dir=路径 | 指定独立配置目录 | 只要本机开着浏览器就必须加 |
--headless=new | 新版无头模式 | 跑批、CI 环境 |
--window-size=1920,1080 | 固定视口尺寸 | 无头模式下必加 |
--disable-gpu | 关闭 GPU 加速 | 容器、虚拟机环境 |
--no-sandbox | 关闭沙箱 | 容器里以 root 运行时 |
--disable-dev-shm-usage | 用磁盘代替共享内存 | Linux 容器内存小的时候 |
--inprivate | 隐私模式启动 | 需要干净会话且不想留痕 |
--disable-extensions | 禁用扩展 | 排查扩展干扰时 |
--remote-debugging-port=9222 | 开调试端口 | 需要接管浏览器时 |
--no-sandbox这条要特别说明:它确实能解决容器里启动失败的问题,但会降低浏览器隔离性。只在明确受控的容器环境里用,本地开发机不要加。我见过有人把这个参数无脑塞进公共配置,结果本地跑的时候安全边界被削弱了还不知道。
--disable-dev-shm-usage是 Linux 容器的经典补丁。容器默认的/dev/shm只有 64MB,Edge 渲染复杂页面时很容易把它撑爆,然后浏览器进程静默崩溃,日志里只有一句DevToolsActivePort file doesn't exist,非常难查。加上这个参数后改为写磁盘,稳定性立刻上来。
4. 元素就在那里却点不到:定位、等待与时序的三重错位
4.1 隐式等待和显式等待混用会把问题放大
等待是 Selenium 里最容易被写错的部分。很多人习惯在启动时来一句driver.implicitly_wait(10),然后觉得万事大吉了。这个做法在简单页面上没问题,但一旦和WebDriverWait混用,行为会变得很难预测。
核心机制是这样的:隐式等待是全局的,它作用于每一次find_element调用,找不到就轮询到超时。显式等待是局部的,你自己控制条件和超时。当两者同时存在时,显式等待内部调用find_element时,每次轮询都会先吃一遍隐式等待的超时。结果就是:你以为显式等待 10 秒,实际上可能等了 10 × N 秒,其中 N 是显式等待的轮询次数。脚本变慢,你还找不到原因。
我的做法是统一用显式等待,把隐式等待设为 0。
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By driver.implicitly_wait(0) # 明确关掉 wait = WebDriverWait(driver, 15, poll_frequency=0.5) elem = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "#submit-btn"))) elem.click()element_to_be_clickable比presence_of_element_located靠谱得多。后者只保证元素在 DOM 里,但元素可能被遮挡、可能是display:none、可能还在动画过程中。前者会同时检查可见性和可交互性,能避免掉一大批ElementClickInterceptedException。
另外一个容易被忽略的点是页面加载策略。Edge 默认的normal策略会等load事件完全触发,遇到那种带长轮询或者大图的页面会等到超时。改成eager只等 DOM 就绪,能显著提速。
from selenium.webdriver.edge.options import Options options = Options() options.page_load_strategy = 'eager'用eager的前提是你的等待逻辑写得足够扎实,因为它不再保证资源加载完。如果脚本里有依赖图片尺寸或者样式表计算结果的逻辑,慎用。
4.2 iframe、Shadow DOM 和动态渲染的处理顺序
元素定位失败的另一个大户是上下文没切对。最常见的是 iframe:元素明明在页面上,但find_element就是找不到,因为它在另一个文档上下文里。
# 等 iframe 本身加载完成,再切进去 iframe = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "iframe#pay-frame"))) driver.switch_to.frame(iframe) # 操作完记得切回来,否则后续定位全在 iframe 里找 driver.switch_to.default_content()顺序很关键:先等 iframe 元素本身出现,再切换,再等内部元素。我见过有人直接switch_to.frame(0),结果页面还没渲染完,切进去是个空文档,报NoSuchFrameException。
Shadow DOM 是另一个麻烦。现代前端组件大量使用 Shadow DOM 封装样式,普通选择器穿透不进去。Selenium 4 提供了专门的支持:
host = driver.find_element(By.CSS_SELECTOR, "my-component") shadow_root = host.shadow_root inner = shadow_root.find_element(By.CSS_SELECTOR, ".inner-button") inner.click()注意shadow_root只能往下一层,遇到嵌套 Shadow DOM 要一层层剥。这时候与其硬写,不如直接在浏览器开发者工具里确认层级结构,用document.querySelector在控制台里试通了再翻译成 Selenium 代码,能省很多来回。
4.3 被遮挡、不可交互与点了没反应的补救手段
ElementClickInterceptedException这个报错,本质是 Selenium 模拟的"真实点击"被挡住了。挡住的可能是固定定位的导航栏、可能是加载遮罩、也可能是弹窗。Edge 上的表现和别的 Chromium 浏览器一致,但因为它默认的滚动行为和渲染时序差异,出现概率会稍高一些。
排查顺序建议这样:先确认是不是遮罩没消失 → 再确认元素是否在视口内 → 最后才用 JS 点击兜底。
from selenium.webdriver.common.action_chains import ActionChains try: elem.click() except Exception: # 先滚动到元素位置,确保在视口内 driver.execute_script("arguments[0].scrollIntoView({block:'center'});", elem) # 再试一次常规点击 try: elem.click() except Exception: # 最后兜底:JS 直接触发 driver.execute_script("arguments[0].click();", elem)这里必须强调:JS 点击是兜底,不是首选。因为 JS 点击绕过了真实的事件链路,某些依赖mousedown/mouseup时序或者浏览器原生行为的组件会表现异常,比如下拉框不展开、拖拽失效。用它之前先想想为什么被挡住,多半是等待逻辑有问题。
还有一类是StaleElementReferenceException,元素"过期"了。原因是页面重渲染后,你手里持有的元素引用指向了已经被销毁的节点。解法不是加重试,而是重新定位:
def click_with_retry(driver, locator, retries=3): for i in range(retries): try: driver.find_element(*locator).click() return except StaleElementReferenceException: if i == retries - 1: raise time.sleep(0.3)注意这里的find_element写在循环内部,每次重新查找,这才是关键。如果写成在外面查一次、循环里反复点,重试多少次都没用。
5. 文件上传下载、弹窗与多标签页的实战处理
5.1 下载目录与保存对话框的绕行方案
Edge 的下载行为在自动化里比较麻烦,默认会弹下载栏,某些文件类型还会弹"是否打开/保存"的对话框。这个对话框是浏览器原生 UI,WebDriver 点不到。
处理思路是通过偏好设置预置下载行为,让浏览器不要弹任何东西。
from selenium.webdriver.edge.options import Options options = Options() prefs = { "download.default_directory": r"D:\auto_downloads", "download.prompt_for_download": False, "download.directory_upgrade": True, "safebrowsing.enabled": True, } options.add_experimental_option("prefs", prefs)这里有几个实战经验。第一,下载目录必须是绝对路径且已经存在,目录不存在的话 Edge 会静默回退到默认下载目录,你会发现自己找半天找不到文件。第二,download.prompt_for_download设为 False 只解决"询问保存位置",不解决"文件类型处理"。像 PDF、某些 Office 格式,Edge 可能选择直接预览而不是下载,需要额外配置。
prefs["plugins.always_open_pdf_externally"] = True第三,下载是异步的,click()返回不代表文件已经落盘。直接去读文件会读到半个文件或者根本不存在。必须做轮询等待:
import os, time def wait_download(dir_path, timeout=30): end = time.time() + timeout while time.time() < end: files = [f for f in os.listdir(dir_path) if not f.endswith('.crdownload')] if files: return os.path.join(dir_path, files[0]) time.sleep(0.5) raise TimeoutError("下载超时").crdownload是 Chromium 系浏览器下载中的临时文件后缀,过滤掉它才是真正下载完成的标志。这个细节很多教程没提,但实际项目里不加这层过滤,读到的经常是残缺文件。
5.2 文件选择框的两种打法
文件上传比下载简单,因为<input type="file">是可以用send_keys直接塞路径的,不需要真的去点开系统文件选择框。
file_input = driver.find_element(By.CSS_SELECTOR, "input[type='file']") file_input.send_keys(r"D:\test\data.xlsx")两个注意点。一是元素必须可见,如果这个 input 被 CSS 隐藏了(常见做法是opacity:0或者display:none),send_keys会失败。前一种情况可以用 JS 临时改样式;后一种就得找页面上那个伪装成按钮的触发元素,很多时候它背后挂着一个隐藏的 input。
# opacity:0 这种可以用 JS 临时改成可见 driver.execute_script( "arguments[0].style.display='block'; arguments[0].style.opacity='1';", file_input )二是路径格式,Windows 下反斜杠要注意转义,用原始字符串r"..."最稳妥。
如果真的遇到无法绕开的原生文件选择框(比如某些老系统用的是自定义控件),那就得上系统级自动化工具了,但那属于另一个技术栈,成本和维护难度都上一个台阶,能用send_keys解决就别走这条路。
5.3 原生弹窗与多标签页的切换陷阱
WebDriver 能处理的弹窗只有alert、confirm、prompt这三种原生对话框。Edge 里表现正常,但有两点要注意。
第一,弹窗出现是异步的,且弹窗出现时后续的 WebDriver 命令会阻塞。所以必须有等待逻辑:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC alert = WebDriverWait(driver, 10).until(EC.alert_is_present()) print(alert.text) # 先读文本,方便排查 alert.accept() # 或者 alert.dismiss()先print(alert.text)是个好习惯。我踩过坑:页面弹了一个我没预料到的确认框,我以为是业务弹窗直接 accept 了,结果把整个流程点乱了。加上这行日志,至少能回溯。
第二,登录认证框不是 alert。那种输入用户名密码的浏览器原生认证框,switch_to.alert抓不到。处理方式是在 URL 里带凭据,或者预先在 profile 里存好登录态。前者的写法是https://user:pass@host/path,但现在很多站点已经不支持这种方式了。
多标签页的问题在于新标签页打开后,driver 的上下文还停在旧标签页。必须显式切换:
main_handle = driver.current_window_handle before = set(driver.window_handles) driver.find_element(By.CSS_SELECTOR, ".open-new").click() # 等新窗口出现 WebDriverWait(driver, 10).until(lambda d: len(d.window_handles) > len(before)) new_handle = (set(driver.window_handles) - before).pop() driver.switch_to.window(new_handle) # 处理完记得关掉并切回 driver.close() driver.switch_to.window(main_handle)这里用集合差集来找新窗口,比driver.window_handles[-1]可靠,因为窗口句柄的顺序不保证。
6. 把黑盒变可观测:日志、截图与远程调试端口
6.1 打开驱动日志的正确姿势
Selenium 的报错信息默认非常克制,很多情况下只给你一句element not interactable,什么上下文都没有。想看到驱动和浏览器之间的实际通信,必须主动打开日志。
from selenium.webdriver.edge.service import Service service = Service( executable_path=r"D:\drivers\msedgedriver.exe", log_output=r"D:\logs\edgedriver.log", service_args=["--verbose"] ) driver = webdriver.Edge(service=service)--verbose会让驱动打印完整的请求和响应,包括每次findElement的定位器、返回结果、页面加载状态。日志文件会很大,但排查问题时是金矿。我遇到过一次元素定位诡异失败的问题,翻日志才发现页面在加载过程中触发了两次导航,第一次渲染的 DOM 很快被替换掉了,元素定位到的是即将销毁的那一份。没有驱动日志,这个问题根本没法定位。
浏览器端的日志也能拿到,但 Edge 需要额外开启:
options.set_capability("goog:loggingPrefs", {"browser": "ALL"}) # 读取页面控制台日志 for entry in driver.get_log("browser"): print(entry["level"], entry["message"])这个对前端报错特别有用。页面上某个按钮点了没反应,看看控制台是不是有 JS 异常,往往比瞎猜快得多。
6.2 用远程调试端口接管一个已打开的浏览器
有些场景下,webdriver.Edge()从零启动浏览器行不通:需要复用已登录的会话、需要人工先完成某些操作、或者要调试一个已经运行很久的页面。这时候可以用调试端口接管。
第一步,带调试端口启动 Edge(注意要配独立用户目录,否则会被已有实例吃掉):
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" ^ --remote-debugging-port=9222 ^ --user-data-dir=D:\edge_debug_profile第二步,代码里通过debuggerAddress连接:
from selenium import webdriver from selenium.webdriver.edge.options import Options options = Options() options.add_experimental_option("debuggerAddress", "127.0.0.1:9222") driver = webdriver.Edge(options=options) print(driver.title) # 直接操作已打开的页面这条路子特别好用的一点是:连接上去之后,浏览器窗口是可见的,你可以一边看它自己操作,一边改代码。调试复杂交互流程时效率极高。
要提醒的是,这种方式下driver.quit()会关掉整个浏览器,如果你还想继续用,用driver.close()只关当前标签页。另外端口冲突是常见问题,多开的时候换个端口就行,别死磕 9222。
6.3 失败现场留证的截图与页面快照
自动化脚本在 CI 里失败,你拿到的往往只有一行异常,没有页面状态,没法复现。所以我在所有正式项目里都会装一个"失败即留证"的钩子。
import traceback, datetime, os def safe_run(driver, func): try: func() except Exception as e: ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") os.makedirs("artifacts", exist_ok=True) driver.save_screenshot(f"artifacts/fail_{ts}.png") with open(f"artifacts/fail_{ts}.html", "w", encoding="utf-8") as f: f.write(driver.page_source) with open(f"artifacts/fail_{ts}.log", "w", encoding="utf-8") as f: f.write(traceback.format_exc()) f.write(f"\ncurrent_url: {driver.current_url}\n") raise三样东西缺一不可:截图看视觉状态,page_source看 DOM 结构,异常堆栈看代码路径。有了这三样,大部分问题不用复现就能定位。截图这块有个小坑:无头模式下截图有时候是全白,通常是因为截图时机太早,页面还没渲染完,加个几百毫秒的等待或者等某个标志元素出现再截。
7. 无人值守环境下的并发、残留与封装
7.1 多进程并发时的端口与目录冲突
单个脚本跑通之后,下一步往往是并发跑多个用例。这里最容易翻车的是多个进程共用同一个用户数据目录,表现是其中几个进程莫名启动失败或者行为错乱。
解法是每个进程一个独立目录,用进程 ID 或者 UUID 区分:
import os, uuid def build_options(): options = Options() profile = os.path.join(tempfile.gettempdir(), f"edge_{os.getpid()}_{uuid.uuid4().hex[:6]}") options.add_argument(f"--user-data-dir={profile}") options.add_argument("--headless=new") options.add_argument("--window-size=1920,1080") return options端口同理,如果用了--remote-debugging-port,别固定写死,按进程号做偏移或者干脆让系统分配。
并发数量也要控制。一个 8 核机器上开 20 个浏览器实例,结果通常是集体变慢然后大面积超时。经验值是按 CPU 核数的 1 到 1.5 倍来设并发上限,单个浏览器实例的内存占用按 300MB 到 500MB 估算,先算内存再定并发数。
7.2 进程残留与僵尸驱动的清理
Selenium 脚本异常退出时,最常见的结果是浏览器进程和驱动进程都变成孤儿留在后台。跑上几天,机器上堆了几百个进程,内存吃满,后续任务全部失败。
根子在driver.quit()有没有被执行。quit()会关闭所有窗口并终止驱动进程,close()只关当前窗口,两者不能混。正确的写法是 try/finally 或者上下文管理器:
driver = webdriver.Edge(options=options) try: do_something(driver) finally: driver.quit()即便如此,遇到进程被强制杀掉(CI 超时、容器被回收)的情况,finally也来不及执行。所以我一般在任务开始前先做一次清理,把上一次遗留的清掉。Windows 下可以按进程名清理,但务必限定范围,别把本机正常使用的浏览器也杀了:
# 只清理驱动进程,风险较低 taskkill /F /IM msedgedriver.exe # 清理自动化专用的浏览器进程,通过命令行参数过滤更安全 wmic process where "name='msedge.exe' and commandline like '%edge_auto_profile%'" delete第二条命令通过命令行参数里的 profile 路径来过滤,只会杀掉自动化启动的实例,不影响用户手开的浏览器。这个思路比无差别杀进程安全得多。
7.3 一套可复用的封装骨架
最后给一份我在多个项目里迭代出来的骨架,把前面说的坑都规避掉了。它不是完整框架,但把关键决策点都收进去了,可以直接拿去改。
import os, tempfile, uuid, logging from selenium import webdriver from selenium.webdriver.edge.options import Options from selenium.webdriver.edge.service import Service from selenium.webdriver.support.ui import WebDriverWait log = logging.getLogger(__name__) def create_driver(headless=True, download_dir=None, driver_path=None): options = Options() profile = os.path.join(tempfile.gettempdir(), f"edge_{uuid.uuid4().hex[:8]}") options.add_argument(f"--user-data-dir={profile}") if headless: options.add_argument("--headless=new") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-gpu") options.add_argument("--no-first-run") options.add_argument("--disable-features=msEdgeSidebarV2") prefs = { "download.prompt_for_download": False, "download.directory_upgrade": True, "safebrowsing.enabled": True, } if download_dir: os.makedirs(download_dir, exist_ok=True) prefs["download.default_directory"] = os.path.abspath(download_dir) options.add_experimental_option("prefs", prefs) options.set_capability("goog:loggingPrefs", {"browser": "ALL"}) options.page_load_strategy = about_page_strategy = "eager" service = None if driver_path: service = Service(executable_path=driver_path, log_output=os.path.join(tempfile.gettempdir(), "edgedriver.log"), service_args=["--verbose"]) driver = webdriver.Edge(options=options, service=service) driver.implicitly_wait(0) driver._wait = WebDriverWait(driver, 15, poll_frequency=0.5) log.info("driver started, profile=%s", profile) return driver有几个点在封装时值得强调。implicitly_wait(0)一定要显式写,哪怕是默认值,写在代码里是给后来者一个明确信号:这个项目不用隐式等待。page_load_strategy设成eager能省不少等待时间,但要在项目里统一,别有的脚本用normal有的用eager,排查时会混乱。把WebDriverWait挂在 driver 上是个取巧但很实用的做法,省得每个模块都重新构造一个。
至于--disable-features=msEdgeSidebarV2这类参数,属于针对性优化:Edge 的侧边栏、首次运行引导、各种推荐气泡,会在启动时抢焦点或者遮挡页面元素,把它们关掉能减少很多莫名其妙的干扰。具体要关哪些 feature,建议先在开发者工具里观察启动时弹了什么,再逐个用--disable-features处理,别一股脑全抄。
另外driver_path这个参数留了口子。默认不传,走 Selenium Manager 自动管理;内网或者需要锁版本时传进去,走手动对齐。两种模式在同一个封装里共存,切换成本几乎为零。
我在实际项目里的体会是,Selenium 调试这件事,难点从来不在 API 怎么用,API 一共就那么几个。真正花时间的是搞清楚每次失败发生在哪一层:是环境层的版本对不上,是启动层的目录冲突,还是交互层的时序问题。把这三层分开看,大部分问题都能在几分钟内定位到。前面那些排查路径和封装写法,本质上就是把这三层的边界划清楚,让报错自己去指向答案,而不是靠猜。