Selenium 3源码解析:WebDriver协议与远程调用本质
1. 为什么今天还要读 Selenium 3 的源码——一个被低估的“过时”技术栈很多人看到标题里的“Selenium 3”第一反应是都 2024 年了Selenium 4 都发布三年多了WebDriver W3C 标准也全面落地还翻老黄历看 Selenium 3是不是在浪费时间我试过直接跳进 Selenium 4 的源码结果卡在RemoteConnection和W3CHandshake两层抽象之间整整两天——不是不会用而是根本不知道它为什么非得绕这么大一圈。后来我才明白Selenium 3 是整个 WebDriver 协议演进的“分水岭式”版本它第一次把“浏览器驱动通信”从硬编码的 JSON Wire ProtocolJWP协议拆解成可插拔的CommandExecutorRequestResponse三层模型。这个设计直接影响了后续所有主流自动化框架的架构逻辑。你今天在 Playwright 里看到的Channel、在 Cypress 里看到的Protocol层甚至 Appium 的BaseDriver抽象都能在 Selenium 3 的remote/目录下找到原型。这不是怀旧是溯源。本文不讲怎么写driver.find_element(By.ID, login)而是带你站在selenium/webdriver/remote/这个目录下看清整个通信链路的第一块砖是怎么垒起来的。如果你正在调试“为什么明明元素存在却报NoSuchElementException”或者纳闷“为什么driver.get()要发两次 HTTP 请求”又或者想搞懂DesiredCapabilities到底在哪个环节被转换成真正的启动参数——那这篇就是为你写的。它适合两类人一类是写了三年自动化脚本、但对底层机制始终隔着一层纸的中级工程师另一类是刚接触 Web 自动化、不想一上来就被封装好的WebDriverWait和expected_conditions带偏方向的初学者。我们不假设你熟悉 HTTP 协议细节但会带你亲手抓包看POST /session请求体里capabilities字段的真实结构我们不预设你了解 Python 的abc.ABC但会解释为什么WebDriver类本身不继承RemoteWebDriver而是一个工厂函数返回的实例。一切从源码开始从部署环境开始从最基础的from selenium import webdriver开始。2. Selenium 的本质不是“浏览器控制库”而是一套标准化的远程过程调用RPC协议实现2.1 从“点开浏览器”到“发起一次 HTTP POST 请求”的完整映射很多人误以为 Selenium 就是“让 Python 控制 Chrome”这其实是个巨大的认知偏差。Selenium 3 的核心设计哲学是WebDriver 是一个客户端它不直接操作浏览器而是通过标准 HTTP 接口向一个独立运行的“浏览器驱动服务”发起远程调用。这个服务就是 ChromeDriver、GeckoDriver 或 EdgeDriver。它们不是 Python 库的一部分而是用 C 编写的独立可执行文件监听本地某个端口默认http://127.0.0.1:9515接收符合 WebDriver 协议的 JSON 请求并返回结构化的 JSON 响应。Selenium Python 绑定本质上就是一个精心构造的 HTTP 客户端 SDK。我们来看一个最简单的例子driver webdriver.Chrome()。这行代码背后发生了什么它绝不是“启动 Chrome 进程”而是启动一个独立的chromedriver进程如果未指定executable_path则从PATH查找chromedriver自动绑定到一个随机空闲端口如9515并启动一个内嵌的 HTTP 服务器Python 端的Chrome类继承自ChromiumDriver初始化一个RemoteConnection实例其remote_server_addr默认为http://127.0.0.1:9515调用start_session()方法向http://127.0.0.1:9515/session发送一个POST请求请求体是包含capabilities的 JSONchromedriver收到后解析 JSON根据capabilities中的browserName、version、platform等字段真正去fork()一个 Chrome 浏览器进程Chrome 启动成功后chromedriver返回一个包含sessionId的 JSON 响应Python 端将其解析并保存为self.session_id。提示你可以用ps aux | grep chromedriver看到它确实是一个独立进程用curl -X POST http://127.0.0.1:9515/session -H Content-Type: application/json -d {desiredCapabilities: {browserName: chrome}}手动模拟这个过程你会得到和 Python 一样的响应。这证明了 Selenium 的“客户端-服务端”分离本质。2.2 JSON Wire ProtocolJWP与 W3C WebDriver 协议的分水岭意义Selenium 3 是 JWP 协议的最后一个主要支持版本。JWP 是 Selenium 团队早期自己定义的一套 RESTful API 规范它的 URL 路径和请求体结构都带有明显的“Selenium 风格”。例如创建会话的 endpoint 是/session而获取元素的 endpoint 是/session/{session id}/element。它的请求体中能力capabilities字段叫desiredCapabilities这是一个典型的驼峰命名。而 W3C WebDriver 协议Selenium 4 及以后的默认协议则是一个由 W3C 标准化组织制定的、更通用、更严格的规范。它的 endpoint 更加语义化如/session保持不变但获取元素变成了/session/{session id}/element路径相同但内部结构不同。最关键的是它的能力字段统一为capabilities小写 c并且要求必须是一个对象而不是 JWP 中允许的任意 JSON 结构。Selenium 3 的源码之所以值得深挖是因为它内部实现了JWP 协议的完整客户端逻辑并且其CommandExecutor抽象层已经为协议切换埋下了伏笔。你可以在selenium/webdriver/remote/remote_connection.py中找到RemoteConnection类它的_commands字典里清晰地定义了每一条命令对应的 URL 模板和 HTTP 方法_commands { Command.STATUS: (GET, /status), Command.NEW_SESSION: (POST, /session), Command.GET_ALL_SESSIONS: (GET, /sessions), Command.QUIT: (DELETE, /session/$sessionId), Command.GET_CURRENT_WINDOW_HANDLE: (GET, /session/$sessionId/window_handle), # ... 其他上百条命令 }这个_commands字典就是整个 JWP 协议的“字典”。每一个Command.XXX常量都对应着一个固定的字符串如newSession而这个字符串又作为 key映射到一个(method, url)元组。当你调用driver.get(https://example.com)时源码最终会走到RemoteWebDriver.get()方法该方法内部会调用self.execute(Command.GET, {url: url})。execute()方法再根据Command.GET在_commands中查到(POST, /session/$sessionId/url)然后拼接 URL、构造请求体、发送 HTTP 请求。注意Command.GET这个常量名容易引起误解它并不是 HTTP 的 GET 方法而是 WebDriver 协议中“导航到 URL”这个操作的代号。真正的 HTTP 方法是POST因为/session/$sessionId/url这个 endpoint 在 JWP 中定义为 POST。这种“协议命令名”与“HTTP 方法名”的分离正是 Selenium 3 架构的精妙之处——它把协议语义和网络传输解耦了。2.3 “WebDriver” 不是一个类而是一个由工厂函数返回的、遵循特定接口的实例这是 Selenium 3 源码中最反直觉也最体现其设计思想的一点。在selenium/webdriver/__init__.py文件的末尾你找不到class WebDriver:的定义。取而代之的是一长串的from .remote.webdriver import WebDriver as RemoteWebDriver以及最关键的几行def __getattr__(name): if name in [Chrome, Firefox, Safari, Opera, Edge, Ie]: from . import webdriver return getattr(webdriver, name) raise AttributeError(fmodule selenium.webdriver has no attribute {name})这意味着当你写下from selenium import webdriver然后driver webdriver.Chrome()你调用的Chrome类实际上来自selenium/webdriver/chrome/webdriver.py。而这个Chrome类其父类是ChromiumDriver再往上是RemoteWebDriver。RemoteWebDriver才是那个真正实现了所有find_element、get、quit等方法的基类。但RemoteWebDriver本身并不直接处理“如何启动 Chrome”。它的__init__方法只做三件事初始化一个RemoteConnection实例负责 HTTP 通信初始化一个CommandExecutor实例负责命令路由调用self.start_session(desired_capabilities, browser_profile)发起创建会话的请求。真正的“浏览器特异性逻辑”比如如何设置 Chrome 的启动参数--headless,--disable-gpu、如何查找chromedriver的路径、如何处理 Chrome 的options对象全部封装在Chrome类自己的__init__方法里。它会先解析你传入的options生成一个desired_capabilities字典然后把这个字典连同RemoteConnection一起交给RemoteWebDriver的__init__。所以WebDriver是一个“契约”一个接口Interface。RemoteWebDriver是这个接口的一个通用实现而Chrome、Firefox等则是针对不同浏览器的“适配器”。这种“面向接口编程”的思想使得 Selenium 能够轻松支持新的浏览器驱动只要它们遵循相同的 HTTP 协议即可。这也是为什么 Appium 能够复用大量 Selenium 的 Python 代码——因为它只是换了一个RemoteConnection的地址指向http://127.0.0.1:4723/wd/hub其余的命令执行逻辑完全一样。3. 环境部署不是简单pip install而是理解三个独立组件的协同关系3.1 三个必须独立安装、且版本必须严格匹配的组件部署一个能正常工作的 Selenium 3 环境绝不是pip install selenium3.141.0就完事了。它涉及三个物理上完全独立、生命周期各自管理的组件任何一个出错都会导致WebDriverException。它们是组件类型作用安装方式版本匹配关键点Selenium Python ClientPython 库提供webdriver模块封装 HTTP 客户端逻辑pip install selenium3.141.0必须与驱动的 JWP 协议版本兼容。Selenium 3.141.x 对应 JWP 的最终稳定版。Browser Driver (e.g., ChromeDriver)独立可执行文件实现 WebDriver 协议的服务端负责启动/控制真实浏览器下载.zip解压或用webdriver-manager必须与你本地安装的Chrome 浏览器主版本号严格一致。Chrome 114 需要 ChromeDriver 114.x。Browser (e.g., Google Chrome)操作系统应用真正的渲染引擎和 JavaScript 运行时系统包管理器或官网下载主版本号如 114决定了它能接受哪个版本的 ChromeDriver 启动。我踩过最深的坑就是在一个 CI 环境里apt-get install google-chrome-stable安装了 Chrome 115但我手动下载的chromedriver_linux64.zip是 114 版本。结果driver webdriver.Chrome()会抛出SessionNotCreatedException: session not created: This version of ChromeDriver only supports Chrome version 114。错误信息非常明确但它指向的是“驱动不支持浏览器”而不是“浏览器不支持驱动”。这提醒我们驱动是“客户端”浏览器是“服务端”驱动的版本上限由它所链接的浏览器决定。3.2 手动部署全流程从零开始看清每一步的依赖让我们抛弃所有自动化工具手动走一遍部署流程以彻底理解其内在逻辑。第一步确认并安装 Chrome 浏览器# Ubuntu/Debian wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb sudo apt install ./google-chrome-stable_current_amd64.deb # 验证 google-chrome --version # 输出: Google Chrome 114.0.5735.198第二步下载并配置 ChromeDriver# 根据上一步的 Chrome 版本去 https://chromedriver.chromium.org/ 查找对应驱动 # Chrome 114 对应 ChromeDriver 114.0.5735.90 wget https://chromedriver.storage.googleapis.com/114.0.5735.90/chromedriver_linux64.zip unzip chromedriver_linux64.zip # 将其移动到 PATH 下或指定给 Python sudo mv chromedriver /usr/local/bin/ chmod x /usr/local/bin/chromedriver # 验证 chromedriver --version # 输出: ChromeDriver 114.0.5735.90 (...)第三步安装 Selenium Python Client# 创建虚拟环境隔离依赖 python3 -m venv selenium_env source selenium_env/bin/activate # 安装指定版本的 Selenium 3 pip install selenium3.141.0 # 验证 Python 端是否能 import python -c from selenium import webdriver; print(OK)第四步编写并运行一个最小验证脚本# test_basic.py from selenium import webdriver from selenium.webdriver.chrome.options import Options # 1. 创建 Chrome 选项 chrome_options Options() chrome_options.add_argument(--headless) # 无头模式CI 环境必需 chrome_options.add_argument(--no-sandbox) chrome_options.add_argument(--disable-dev-shm-usage) # 2. 创建 WebDriver 实例 # 此时Selenium 会尝试在 PATH 中查找 chromedriver driver webdriver.Chrome(optionschrome_options) # 3. 执行一个最简单的操作 driver.get(https://www.python.org) print(driver.title) # 应该输出 Welcome to Python.org # 4. 清理 driver.quit()运行python test_basic.py。如果一切顺利你会看到终端输出Welcome to Python.org。如果失败请按以下顺序排查chromedriver是否在PATH中which chromedriverchromedriver的版本是否与google-chrome --version的主版本号一致selenium的版本是否为3.141.0pip show seleniumgoogle-chrome是否真的能被chromedriver启动chromedriver --version的输出里会显示它期望的 Chrome 路径通常是/usr/bin/google-chrome请确保该路径存在且可执行。经验技巧在 CI/CD 环境中永远显式指定executable_path不要依赖PATH。因为 CI 环境的PATH可能很短或者有多个版本的chromedriver冲突。正确的写法是driver webdriver.Chrome(executable_path/usr/local/bin/chromedriver, optionschrome_options)。这样可以完全掌控依赖来源。3.3webdriver-manager的工作原理与它为何不是“银弹”webdriver-manager是一个非常流行的第三方库它能自动下载并管理chromedriver。它的核心逻辑非常简单却极其有效探测浏览器版本它会调用google-chrome --version或firefox --version来获取本地浏览器的主版本号。查询映射表它内置了一个庞大的 JSON 映射表webdriver_manager/core/constants.py将 Chrome 主版本号如114映射到对应的chromedriver下载 URL如https://chromedriver.storage.googleapis.com/114.0.5735.90/chromedriver_linux64.zip。下载与缓存它会检查本地缓存目录如~/.wdm/drivers/中是否已有该版本的驱动。如果没有则下载、解压并将路径返回给 Selenium。注入路径它返回的不是一个Driver实例而是一个str类型的路径。你需要把它传给webdriver.Chrome(executable_path...)。# 使用 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(serviceservice)它之所以不是“银弹”是因为它无法解决所有问题权限问题在某些受限的 CI 环境中webdriver-manager可能没有权限写入~/.wdm/目录。网络问题它需要访问chromedriver.storage.googleapis.com在某些网络环境下可能超时。版本滞后它的映射表更新可能比 Chrome 的正式发布慢几个小时导致新版本 Chrome 发布后webdriver-manager还无法识别。因此我的经验是在开发机上用webdriver-manager提高效率在生产/CI 环境中坚持手动下载、校验、固定路径。后者虽然繁琐但带来了 100% 的可预测性和稳定性。4. 源码探析实战从webdriver.Chrome()到第一个 HTTP 请求的逐行追踪4.1 路径总览selenium包的物理结构与核心模块定位在开始追踪之前我们必须清楚 Selenium 3 的源码是如何组织的。安装selenium3.141.0后其源码位于 Python 的site-packages目录下。核心模块的物理路径如下selenium/__init__.py: 包的入口定义了__all__和__getattr__。selenium/webdriver/__init__.py: 导出Chrome,Firefox等类的顶层模块。selenium/webdriver/chrome/__init__.py: 导出Chrome类和Options类。selenium/webdriver/chrome/webdriver.py:Chrome类的定义继承自ChromiumDriver。selenium/webdriver/chromium/__init__.py:ChromiumDriver的基类定义。selenium/webdriver/remote/__init__.py: 导出WebDriver即RemoteWebDriver类。selenium/webdriver/remote/webdriver.py:RemoteWebDriver类的定义这是所有浏览器驱动的共同基类。selenium/webdriver/remote/remote_connection.py:RemoteConnection类的定义负责所有 HTTP 通信。selenium/webdriver/remote/command.py:Command常量的定义即那个_commands字典的源头。这个结构清晰地体现了 Selenium 的分层思想chrome/目录处理 Chrome 特有的逻辑remote/目录处理所有驱动共有的、与协议相关的逻辑webdriver/顶层目录则负责对外提供统一的 API。4.2 第一行webdriver.Chrome()的源码之旅现在我们打开selenium/webdriver/chrome/webdriver.py找到Chrome类的定义class Chrome(ChromiumDriver): def __init__(self, executable_pathchromedriver, port0, optionsNone, service_argsNone, desired_capabilitiesNone, service_log_pathNone, chrome_optionsNone, keep_aliveTrue): ... # 关键这里会创建一个 ChromiumService service ChromiumService( executable_pathexecutable_path, portport, service_argsservice_args, log_pathservice_log_path ) # 关键这里会创建一个 ChromeOptions 实例并合并用户传入的 options options options or ChromeOptions() if chrome_options: warnings.warn(use options instead of chrome_options, DeprecationWarning, stacklevel2) options chrome_options # 关键调用父类 ChromiumDriver 的 __init__ super().__init__( executable_pathexecutable_path, portport, optionsoptions, service_argsservice_args, desired_capabilitiesdesired_capabilities, service_log_pathservice_log_path, keep_alivekeep_alive, serviceservice )Chrome类的__init__方法主要做了三件事准备service驱动服务、准备options启动参数、然后调用父类ChromiumDriver的__init__。ChromiumDriver的__init__又会继续调用RemoteWebDriver的__init__。所以真正的初始化逻辑最终都汇聚到了selenium/webdriver/remote/webdriver.py的RemoteWebDriver.__init__方法中。我们跳转到RemoteWebDriver.__init__def __init__(self, command_executorhttp://127.0.0.1:4444/wd/hub, desired_capabilitiesNone, browser_profileNone, proxyNone, keep_aliveFalse, file_detectorNone, optionsNone): # 1. 创建 RemoteConnection 实例 self.command_executor command_executor if isinstance(command_executor, (str, bytes)): self.command_executor RemoteConnection( remote_server_addrcommand_executor, keep_alivekeep_alive) # 2. 创建 CommandExecutor 实例 self._commands self.command_executor._commands # 3. 初始化 session_id 等属性 self.session_id None self.capabilities {} self._is_remote True # 4. 关键启动会话 self.start_session(desired_capabilities, browser_profile)这就是整个链条的“心脏”。self.start_session(...)是一切的起点。我们继续追踪这个方法def start_session(self, capabilities, browser_profileNone): # 1. 如果传入了 options就用它来生成 capabilities if not isinstance(capabilities, dict): capabilities dict(capabilities) # 2. 合并 browser_profile已废弃忽略 # 3. 最关键的一步调用 execute() 方法执行 NEW_SESSION 命令 response self.execute(Command.NEW_SESSION, { desiredCapabilities: capabilities, requiredCapabilities: {}, }) # 4. 解析响应 self.session_id response[sessionId] self.capabilities response[value]start_session方法的核心就是调用self.execute(Command.NEW_SESSION, {...})。Command.NEW_SESSION是一个字符串常量定义在selenium/webdriver/remote/command.py中其值为newSession。execute方法会根据这个字符串在self._commands字典中查到对应的(method, url)然后构造 HTTP 请求。4.3execute()方法Selenium 的“协议翻译器”execute方法是RemoteWebDriver类中最重要的方法它是整个 WebDriver 协议的“翻译中枢”。我们来看它的简化版逻辑def execute(self, driver_command, paramsNone): # 1. 根据 driver_command如 newSession查找对应的 (method, url) command_info self._commands[driver_command] assert len(command_info) 2 command, path command_info # 2. 将 $sessionId 等占位符替换成实际值 if self.session_id and $sessionId in path: path path.replace($sessionId, self.session_id) # 3. 构造完整的 URL url %s%s % (self.command_executor._url, path) # 4. 准备请求体payload data json.dumps(params) # 5. 发起 HTTP 请求这才是真正的网络调用 response self.command_executor._request( methodcommand, urlurl, datadata ) # 6. 解析响应 return response.get(value)这个方法的精妙之处在于它把“协议命令”Command.NEW_SESSION和“网络传输”_request完全解耦了。_request方法属于RemoteConnection类它才是真正使用urllib3或requests库发送 HTTP 请求的地方。而execute方法只负责“翻译”——把一个高层的、语义化的命令翻译成一个底层的、具体的 HTTP 请求。我们可以用一个真实的抓包来验证这一点。在运行test_basic.py时开启tcpdump或Wireshark过滤port 9515你会看到一个清晰的 HTTP 流POST /session HTTP/1.1 Host: 127.0.0.1:9515 Content-Length: 228 Content-Type: application/json;charsetUTF-8 {desiredCapabilities:{browserName:chrome,version:,platform:ANY,javascriptEnabled:true,cssSelectorsEnabled:true,takesScreenshot:true,nativeEvents:true,rotatable:true},requiredCapabilities:{}}这个请求体就是execute方法中json.dumps(params)的结果。params字典中的desiredCapabilities键正是start_session方法传入的那个字典。而browserName的值chrome则来自于Chrome类在初始化时通过options对象推导出来的。经验技巧如果你想深度调试 Selenium 的通信过程最有效的方法不是在 Python 代码里打print而是直接在RemoteConnection._request方法里加日志。你可以在selenium/webdriver/remote/remote_connection.py的_request方法开头加入print(fSending {method} to {url}: {data})。这样你就能看到每一个 WebDriver 命令背后真实的 HTTP 请求是什么样子。这是理解整个框架最直接、最有效的方式。5. 常见部署陷阱与源码级排错指南5.1 陷阱一“Message: session not created” —— 从错误堆栈反推根因的完整过程这个错误是 Selenium 部署中最常见的“拦路虎”但它的堆栈信息往往非常模糊只告诉你“会话创建失败”却不告诉你具体哪里失败了。让我们用源码级的思路一步步拆解它。典型错误信息selenium.common.exceptions.SessionNotCreatedException: Message: session not created from invalid argument: cant parse capabilities from invalid argument: cannot parse capability: goog:chromeOptions from invalid argument: unrecognized chrome option: headless排错步骤定位错误源头这个异常是在start_session方法中self.execute(...)返回的响应里response.get(value)为空或者response本身是一个错误响应response.get(error) is not None时抛出的。它最终会调用ErrorHandler.check_response(response)来解析错误。查看ErrorHandler在selenium/webdriver/remote/errorhandler.py中check_response方法会根据response.get(status)和response.get(value, {}).get(message)来构造具体的异常。上面的错误信息就来自response.get(value).get(message)。理解错误含义unrecognized chrome option: headless这个提示非常关键。它说明chromedriver服务端不认识headless这个选项。但这怎么可能--headless是 Chrome 59 就支持的特性。原因只有一个你本地的chromedriver版本太老不支持新版本 Chrome 的新选项。例如Chrome 114 引入了--headlessnew模式而一个老旧的 ChromeDriver 100 只认识--headlessold。验证与修复回到命令行运行chromedriver --version和google-chrome --version对比主版本号。如果它们不一致或者chromedriver的版本明显低于google-chrome那么解决方案就是下载匹配的chromedriver。提示chromedriver的版本号格式是MAJOR.MINOR.BUILD.PATCH而google-chrome的版本号是MAJOR.MINOR.BUILD.PATCH。必须保证 MAJOR主版本号完全一致。MINOR 可以不同但最好也保持一致。5.2 陷阱二“Message: unknown error: DevToolsActivePort file doesnt exist” —— Chrome 启动失败的深层原因这个错误通常出现在 Linux 无头环境中它表明chromedriver成功启动了 Chrome 进程但 Chrome 进程在启动后未能成功创建用于 DevTools 通信的DevToolsActivePort文件。源码级分析chromedriver在启动 Chrome 时会通过--remote-debugging-portXXXX参数告诉 Chrome 开启一个调试端口。Chrome 启动后会在其临时数据目录下创建一个名为DevToolsActivePort的文件里面记录了实际的调试端口和 WebSocket 地址。chromedriver会轮询这个文件直到它出现然后才认为 Chrome 启动成功。常见原因与修复缺少沙箱支持在容器或某些 Linux 发行版中Chrome 的 sandbox 机制可能被禁用。解决方案是添加--no-sandbox参数。共享内存不足Chrome 需要/dev/shm有足够的空间。解决方案是添加--disable-dev-shm-usage参数。缺少字体库Chrome 渲染页面需要一些系统字体。解决方案是安装fonts-liberation或ttf-dejavu。这些参数都应该通过ChromeOptions添加而不是作为chromedriver的参数。因为chromedriver只负责启动 Chrome而这些是 Chrome 自己的启动参数。chrome_options Options() chrome_options.add_argument(--headless) chrome_options.add_argument(--no-sandbox) chrome_options.add_argument(--disable-dev-shm-usage) # 这些都是 Chrome 的参数不是 chromedriver 的5.3 陷阱三pip install selenium安装了错误的版本 —— 如何强制锁定与验证在大型项目中requirements.txt里只写selenium而不写版本会导致pip install总是拉取最新版可能是 Selenium 4。而 Selenium 4 默认使用 W3C 协议与旧的 JWP 驱动不兼容。强制锁定版本# requirements.txt selenium3.141.0验证安装版本pip show selenium | grep Version # 输出: Version: 3.141.0源码级验证进入 Python 解释器检查Command常量 from selenium.webdriver.remote.command import Command Command.NEW_SESSION newSession Command.GET get在 Selenium 4 中Command.NEW_SESSION的值是session小写 s而Command.GET的值是get与 3 相同但其他命令有变化。这个细微差别是区分 JWP 和 W3C 协议客户端的最可靠方式。我在实际项目中发现最稳妥的部署策略是在 CI 的Dockerfile中明确写出RUN pip install selenium3.141.0 apt-get install -y google-chrome-stable wget ... unzip ... mv chromedriver /usr/local/bin/。每一行都精确控制杜绝任何不确定性。自动化是好东西但在关键基础设施上确定性永远比便利性更重要。