1. 先搞清楚driver到底是个什么东西不然后面全是死记硬背刚接触selenium的朋友十个里有八个是这么学的打开教程看到driver.find_element(By.ID, kw).send_keys(xxx)复制粘贴跑通了收工。等到页面稍微变一下元素找不到了瞬间懵掉然后开始到处搜selenium定位不到元素怎么办。我当年也是这样过来的。问题出在哪出在大家把driver当成了一堆API的集合去背而没有把它当成一个遥控器去理解。driver在selenium里承担的角色本质上是你本机浏览器的一个远程控制器准确的类名是WebDriver。它本身不解析HTML不渲染页面也不执行JS——这些活儿全是浏览器干的。driver做的事情只有一件把你写的Python调用翻译成浏览器能听懂的指令在selenium 4里走的是W3C WebDriver协议然后把浏览器返回的结果再翻译回Python对象给你。注意网上搜driver很容易搜到显卡驱动、打印机驱动、虚拟机驱动那一堆东西它们和这里的WebDriver没有半毛钱关系。selenium语境下的driver永远指WebDriver及其各浏览器实现类。把这句话想明白很多东西就顺了。为什么find_element找不到元素因为浏览器那一侧根本没匹配到driver只是如实告诉你没有。为什么click()会报元素不可交互因为浏览器算出来这个元素当前被遮挡或者不可见driver拒绝执行。你的所有排查动作最终都要落到浏览器现在到底看到了什么这个问题上。这篇文章我打算把driver上那些真正会被用到的属性和方法按功能分类过一遍包括初始化、导航、定位、交互、等待、状态读取、截图日志这几大块每一块都会说清楚什么场景用它为什么这么设计坑在哪。适合已经能跑通第一个demo、但遇到复杂页面就抓瞎的同学也适合写了半年脚本想回头把基础夯实的人。全文的代码示例按selenium 4.x的写法来3.x的部分我会单独标注差异。2. driver的初始化与生命周期很多坑从第一行就埋下了2.1 浏览器驱动的创建方式和参数传递最朴素的写法长这样from selenium import webdriver driver webdriver.Chrome()selenium 4.6之后内置了Selenium Manager会自动去下载匹配当前浏览器版本的驱动不用再手动下载chromedriver丢到PATH里了。这是这几年的一个大改进以前版本对不上直接报session not created: This version of ChromeDriver only supports Chrome version XX能把人折腾半小时。需要传启动参数的时候用Options对象from selenium import webdriver from selenium.webdriver.chrome.options import Options opts Options() opts.add_argument(--headlessnew) opts.add_argument(--window-size1920,1080) opts.add_argument(--disable-gpu) opts.add_experimental_option(excludeSwitches, [enable-automation]) driver webdriver.Chrome(optionsopts)这里有个细节值得说add_argument是加启动命令行参数add_experimental_option是加浏览器偏好设置两者作用层面不一样。像--headless这种属于前者excludeSwitches这种属于后者。我第一次用的时候把excludeSwitches写成add_argument脚本不报错但完全没生效排查了半天才发现是方法用错了。如果你需要自定义驱动路径或者指定端口比如远程调试、多浏览器隔离用Service对象from selenium.webdriver.chrome.service import Service service Service(executable_path/path/to/chromedriver, port9515) driver webdriver.Chrome(serviceservice, optionsopts)selenium 3.x里这些参数是直接平铺在webdriver.Chrome(executable_path...)上的4.x把它们收进了Service和Options升级的时候老代码会大面积报错这个迁移成本要有心理准备。2.2 quit和close的区别以及会话泄漏的真实代价这两个方法我必须单独拎出来说因为我见过太多人的脚本跑了一晚上机器上堆了几百个僵尸浏览器进程。driver.close()关闭当前焦点所在的窗口或标签页。如果这是最后一个窗口浏览器进程通常会跟着退出但这取决于驱动实现不要依赖这个行为。driver.quit()结束整个会话关闭所有窗口杀掉浏览器进程释放驱动端资源。结论很简单脚本结束前一律用quit()。养成try/finally的习惯driver webdriver.Chrome(optionsopts) try: driver.get(https://example.com) # 业务逻辑 finally: driver.quit()不写finally的代价是什么脚本中途抛异常quit不执行浏览器进程留着driver端会话也留着。跑批量任务时几十个进程同时开着内存直接吃满后面新起的会话可能因为端口占用或者资源不足起不来。我用CI跑用例的时候踩过一次500条用例跑到第80条开始大面积超时查下来就是前面崩掉的会话没回收干净。另外一个容易忽略的点driver对象在quit之后就不能再用了再调任何方法都会抛InvalidSessionIdException。别在finally之后还去读driver.title。3. 页面导航和窗口控制别只会一个get3.1 get、back、forward、refresh的隐式等待陷阱driver.get(https://example.com/list) driver.back() driver.forward() driver.refresh()这四个是导航四件套。get()的语义是等到页面load事件触发才算完成注意这是load事件不是页面内容渲染完毕。现在的前端页面大量用异步请求填数据load事件触发的时候列表可能还是空的。所以很多人写完driver.get()下一行直接find_element报找不到元素就是这个原因。back()和forward()走的是浏览器历史栈理论上很稳但有个经典坑单页应用SPA里历史栈的变化可能是由history.pushState造出来的back()之后页面元素并不会像传统页面那样重新加载你可能需要手动触发一次刷新或者等待某个组件重新挂载。refresh()在表单提交后调用会弹是否重新提交的确认框这个框不是alertswitch_to.alert抓不到它会直接卡住。解决办法是在refresh之前先driver.get(driver.current_url)绕过去或者用JS自己刷新。提示get()后面永远别急着操作元素。要么显式等待要么用一个稳定的标志性元素做同步点。这是selenium脚本稳定性的第一道防线。3.2 窗口和标签页切换handle的正确用法点击一个链接开了新标签页你的driver默认还停在老标签页上这时候去找新页面的元素必然失败。标准流程是这样main_handle driver.current_window_handle driver.find_element(By.LINK_TEXT, 查看详情).click() WebDriverWait(driver, 10).until(EC.number_of_windows_to_be(2)) for handle in driver.window_handles: if handle ! main_handle: driver.switch_to.window(handle) break # 新页面操作完后切回 driver.close() driver.switch_to.window(main_handle)这里我强烈建议用EC.number_of_windows_to_be等待窗口数量变化而不是time.sleep。sleep是在赌等待是在等事实发生。window_handles返回的是一个列表顺序不保证和视觉上的标签页顺序一致。所以不要用window_handles[0]、window_handles[1]这种下标硬取一定要用set差集的方式找出新增的那个。我以前图省事用下标本地跑得好好的到了无头模式下顺序变了直接翻车。3.3 窗口尺寸、位置与截图driver.maximize_window() driver.set_window_size(1440, 900) driver.set_window_position(0, 0) print(driver.get_window_size()) # {width: 1440, height: 900} driver.save_screenshot(full.png) # 保存到文件 png_bytes driver.get_screenshot_as_png() # 拿到bytes无头模式下maximize_window()经常拿不到你期望的尺寸因为无头浏览器没有真实的屏幕。稳妥做法是初始化的时候直接给--window-size参数或者在启动后显式set_window_size。save_screenshot截的是当前视口不是整页。要截整页得用execute_script滚到底再截或者借助CDP相关能力。做失败用例留证的时候视口截图通常够用但如果页面很长、报错的元素在底部视口截图就会拍到一片空白这时候要么先scrollIntoView把元素滚到视野内再截要么老老实实做整页拼接。4. 元素定位方法家族selenium的核心战场4.1 find_element和find_elements一个字母的差别elem driver.find_element(By.ID, submit) elems driver.find_elements(By.CLASS_NAME, item)区别很关键find_element返回单个元素找不到直接抛NoSuchElementException。find_elements返回列表找不到返回空列表不抛异常。这个差异直接决定了判断元素是否存在该用哪个def is_element_exist(driver, by, value, timeout5): try: WebDriverWait(driver, timeout).until( EC.presence_of_element_located((by, value)) ) return True except TimeoutException: return False当然也可以用find_elements加长度判断写法更短但会有一次完整的超时等待在循环里频繁调用会很慢。我个人倾向用显式等待版本语义更清晰。注意find_element在找不到时抛异常这个异常如果没有被捕获会直接中断脚本。做数据采集的时候对可选字段一律用find_elements加try包裹保证单个字段缺失不会拖垮整个任务。4.2 By的八种定位方式以及优先级怎么排定位方式写法适用场景稳定性IDBy.ID元素有唯一id最高NAMEBy.NAME表单元素高CSS_SELECTORBy.CSS_SELECTOR绝大多数场景高XPATHBy.XPATH需要向上或按文本定位中高CLASS_NAMEBy.CLASS_NAME类名简单唯一中TAG_NAMEBy.TAG_NAME批量取标签低LINK_TEXTBy.LINK_TEXT完整链接文本中PARTIAL_LINK_TEXTBy.PARTIAL_LINK_TEXT部分链接文本中我的选择顺序是ID NAME CSS XPATH。CSS选择器性能比XPATH好语法也更简洁能用CSS就别用XPATH。但有两种情况必须上XPATH一是要按元素文本定位二是要往父级/祖先级找元素CSS在这两点上无能为力。# 按文本定位XPATH独有 driver.find_element(By.XPATH, //button[normalize-space(text())确认提交]) # 从子元素往父元素找 driver.find_element(By.XPATH, //span[text()订单号]/ancestor::div[1])XPATH写文本匹配时text()外面套一层normalize-space()很实用能自动去掉首尾空白和换行。前端模板渲染出来的按钮文字经常带缩进和换行不处理的话text()确认匹配不上你会以为定位写错了其实是空格在捣乱。还有一个细节By.CLASS_NAME一次只能传一个类名多个类名要用CSS写。比如classbtn btn-primary activeBy.CLASS_NAME, btn btn-primary是错的得写By.CSS_SELECTOR, .btn.btn-primary。这个错误非常常见。4.3 非原生下拉框的定位与操作divulli怎么破原生下拉框的HTML是selectoptionselenium提供了专门的Select类from selenium.webdriver.support.ui import Select sel Select(driver.find_element(By.ID, city)) sel.select_by_index(2) sel.select_by_value(310000) sel.select_by_visible_text(上海) print(sel.first_selected_option.text)这套用起来很舒服。但现实是现在的前端框架基本都自己实现了下拉组件实际DOM是div classselectulli这种组合Select类一用就报UnexpectedTagNameException提示你期望的是select标签但拿到的是div。这种非原生下拉框的正确打开方式分三步# 第一步点开下拉面板 trigger driver.find_element(By.CSS_SELECTOR, div.select-trigger) driver.execute_script(arguments[0].scrollIntoView({block:center});, trigger) trigger.click() # 第二步等选项列表可见再按文本选中 target WebDriverWait(driver, 10).until( EC.element_to_be_clickable( (By.XPATH, //ul[classselect-list]//li[normalize-space(.)上海]) ) ) target.click() # 第三步断言选中结果写回了输入框 value driver.find_element(By.CSS_SELECTOR, div.select-trigger).text assert 上海 in value几个实战要点点开面板前先scrollIntoView很多组件在视口外点击无效或者被固定头部遮挡。选项列表往往是动态渲染的点击触发之后才插入DOM所以第二步必须等不能紧接着就找li。有些组件点击li之后面板不立即消失导致下一次操作被浮层挡住。这种情况可以按一下ESC或者等面板的display:none生效再继续。如果组件对真实鼠标事件敏感有些会监听mousedown而不是click普通click不生效就上ActionChainsfrom selenium.webdriver.common.action_chains import ActionChains ActionChains(driver).move_to_element(trigger).click().perform()我遇到过最刁钻的一个组件用JS直接触发click死活不行最后发现它监听的是mousedownmouseup的组合事件用ActionChains的click_and_hold().release().perform()才搞定。所以当点击不生效时别死磕换事件模拟方式试试。4.4 相对定位器元素结构复杂时的救命稻草selenium 4引入了相对定位器可以基于已知元素找它附近的目标from selenium.webdriver.support.relative_locator import locate_with label driver.find_element(By.XPATH, //label[text()手机号]) input_box driver.find_element( locate_with(By.TAG_NAME, input).to_right_of(label) )支持above、below、to_left_of、to_right_of、near五种关系。表格类页面里用表头定位到对应列的数据特别顺手。不过要注意这个是基于元素渲染后的坐标计算的元素必须已经可见否则位置算不出来。5. 元素操作与交互点击不生效的N种原因5.1 click、send_keys、clear的基本盘elem.click() elem.send_keys(hello) elem.clear() elem.send_keys(world) elem.submit() # 只在form内的元素上可用等价于回车提交send_keys可以直接传组合键from selenium.webdriver.common.keys import Keys elem.send_keys(Keys.CONTROL, a) elem.send_keys(Keys.BACKSPACE) elem.send_keys(新内容)clear()在富文本编辑器contenteditable的div上无效因为它不是input或者textarea。对这种情况通用做法是全选后删除elem.send_keys(Keys.CONTROL, a) elem.send_keys(Keys.DELETE)click不生效是最常见的问题按我踩坑的经验原因大致这么几类元素还没渲染出来等、元素被浮层遮挡关掉浮层或滚动、元素在视口外scrollIntoView、元素被禁用检查disabled属性、点击落在了子元素上但事件绑在父元素改点父元素或者用JS触发。排查顺序建议就按这个来从等待开始查八成问题出在第一类。5.2 ActionChains处理复杂交互拖拽、悬停、右键、双击这些用普通click搞不定的都交给ActionChainsactions ActionChains(driver) # 悬停展开二级菜单 actions.move_to_element(menu).perform() driver.find_element(By.LINK_TEXT, 子菜单项).click() # 拖拽 actions.drag_and_drop(source, target).perform() # 右键菜单 actions.context_click(elem).perform() # 带偏移量的点击绕过遮挡 actions.move_to_element_with_offset(elem, 5, 5).click().perform()有个必须记住的点ActionChains是链式累积的所有动作写好之后必须调perform()才会真正执行。忘记perform是新手高频错误脚本不报错但什么也没发生。move_to_element_with_offset的偏移量是相对于元素左上角的不是页面坐标这个容易搞反。另外selenium 4.2之后新增了滚轮方法actions.scroll_by_amount(0, 500).perform() actions.scroll_to_element(elem).perform()比用JS滚屏更贴近真实用户行为某些对滚动事件有监听的前端组件只认这种。5.3 execute_script最后的万能钥匙driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) driver.execute_script(arguments[0].click();, elem) driver.execute_script(arguments[0].setAttribute(value, abc);, elem) result driver.execute_script(return document.title;)execute_script的返回值规则脚本里写了return返回的就是那个值没写return返回None。参数通过arguments数组传入索引从0开始。什么时候必须用它我总结三个场景一是原生click被拦截用JS直接触发DOM的click方法二是需要修改元素属性有些框架的只读输入框send_keys会被拦改value属性反而能写进去三是需要拿页面全局变量或者调用页面上的函数。但别把JS当第一选择。JS触发的click不走真实的鼠标事件链有些前端会校验event.isTrusted发现不是真实用户操作就直接忽略。所以能用原生click就优先用原生JS是兜底手段。6. 等待机制selenium脚本稳定性的命门6.1 三种等待的差异和混用陷阱强制等待time.sleep(2)。无脑但永远不推荐作为主力它要么太短要么太长。隐式等待driver.implicitly_wait(10)。全局生效作用于每一次find_element找不到就轮询到超时。显式等待WebDriverWait配合expected_conditions针对具体条件等待。最关键的一条经验隐式等待和显式等待不要混用。两者叠加时实际等待时间可能变成两者之和甚至出现隐式等待把显式等待的超时判断搞乱的情况。selenium官方文档也明确不建议混用。我个人的配置是全局不开隐式等待所有同步点都用显式等待写清楚。这样每个等待点等的是什么条件一目了然出问题也好定位。6.2 expected_conditions常用条件速查from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10, poll_frequency0.5, ignored_exceptions[ElementNotInteractableException]) wait.until(EC.presence_of_element_located((By.ID, list))) wait.until(EC.visibility_of_element_located((By.ID, list))) wait.until(EC.element_to_be_clickable((By.ID, submit))) wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, loading))) wait.until(EC.text_to_be_present_in_element((By.ID, status), 完成)) wait.until(EC.url_contains(/dashboard)) wait.until(EC.alert_is_present())presence和visibility的区别一定要分清元素在DOM里但display:nonepresence通过visibility不通过。很多找不到元素其实是找到了但不可见用错条件的后果就是明明元素存在却一直超时。ignored_exceptions参数值得多用。默认情况下等待过程中抛出的异常除了TimeoutException会直接冒泡出去中断等待。比如元素出现了但在动画中途不可点击会抛ElementNotInteractableException加上这个参数它就会继续轮询而不是直接炸。自定义条件也不难接受driver返回布尔值即可def page_loaded(driver): return driver.execute_script(return document.readyState) complete WebDriverWait(driver, 15).until(page_loaded)判断加载完成这件事比起等某个具体元素document.readyState在整页导航场景下更通用。但SPA里页面不重新加载这个值一直是complete就失效了还是得回到等具体元素。7. 状态读取与信息获取断言和调试全靠这批方法7.1 元素信息类方法的正确取用elem.text # 渲染后的可见文本 elem.get_attribute(href) # HTML属性原始值 elem.get_property(value) # JS属性当前值 elem.value_of_css_property(color) elem.tag_name elem.size # {width:.., height:..} elem.location # {x:.., y:..}text和get_attribute的差别要讲清楚。text取的是渲染后用户能看到的文本会做空白折叠隐藏元素返回空字符串。get_attribute(textContent)取的是DOM里的原始文本不受CSS可见性影响。我断言列表内容时用前者校验隐藏字段的值时用后者。还有一个高频困惑input输入框的内容用text取不到返回空。因为input的值不在文本节点里而在value属性上。正确写法是value driver.find_element(By.ID, username).get_attribute(value) # 或者用selenium 4的属性读取 value driver.find_element(By.ID, username).get_property(value)用户输入进去的值get_attribute(value)能拿到但要注意它读的是HTML attribute还是DOM property。现代框架里用户输入只改DOM property不改HTML attribute这时候必须用get_property。这个坑我在测React应用时踩过排查思路是先看页面源码源码里的value还是初始值就说明得用get_property。7.2 元素状态判断方法elem.is_displayed() # 是否可见 elem.is_enabled() # 是否可用对应disabled属性 elem.is_selected() # 复选框、单选框是否选中is_displayed的实现逻辑是判断元素及其祖先链上有没有display:none、visibility:hidden、尺寸为0这些情况。但它不能判断元素是否被其他元素遮挡这也就是为什么is_displayed()返回Trueclick还是可能失败。要判断遮挡得靠elementFromPoint那套JS写法或者直接点一次试试。7.3 cookie、日志和页面级信息driver.get_cookies() driver.add_cookie({name: token, value: abc, domain: example.com}) driver.delete_all_cookies() print(driver.current_url) print(driver.title) print(driver.name) # 浏览器名如 chrome print(driver.capabilities) # 会话能力描述排查环境问题很有用 print(driver.page_source) # 当前页面源码add_cookie有个必须注意的点必须先访问目标域名下的任意页面才能往这个域写入cookie。直接开一个新driver就add_cookie会报InvalidCookieDomainException因为此时还没有确定域。标准写法是先driver.get(url)再加cookie再refresh。page_source在排查定位失败时非常有用。把源码dump到本地用浏览器的开发者工具打开你会发现有些元素在selenium眼里和在浏览器里长得很不一样——比如iframe里的内容、shadow DOM里的内容page_source里可能就看不到完整结构。这时候要意识到问题出在iframe或者shadow root上而不是定位表达式写错了。driver.capabilities我经常用来做环境自检脚本启动后打一行日志出来出问题的时候能直接看出浏览器版本、驱动版本、平台信息省去很多来回问。8. 排错实录那几个每年都要遇到几次的报错8.1 无法创建会话类报错unable to obtain driver for chrome这个提示在selenium 4.6之前特别常见根因是找不到和本机浏览器版本匹配的驱动。处理路径有这么几条升级selenium到4.6以上让Selenium Manager自动处理或者手动下载对应版本驱动并放进PATH或者显式指定Service(executable_path...)。如果本机浏览器是企业统一推送的、你没法升级那就必须走手动指定驱动这条路。这时候版本对照表要收藏好大版本号必须对上小版本一般兼容但跨大版本一定不行。还有一种情况是脚本在容器里跑容器里压根没装浏览器。这种要么装个无头浏览器进镜像要么用远程方式连到外部浏览器节点。远程连接的写法driver webdriver.Remote( command_executorhttp://192.168.1.100:4444, optionsopts )8.2 元素找不到、过期、不可交互这三类是selenium日常报错的主力处理思路可以用一张表来对照报错典型原因处理方向NoSuchElementException元素未渲染、在iframe内、定位表达式错加显式等待、切iframe、用page_source核对StaleElementReferenceException元素被重新渲染引用失效重新定位元素或把操作封装成重试ElementNotInteractableException元素不可见或被遮挡滚动到可视区、关闭浮层、改用JS点击ElementClickInterceptedException点击被其他元素拦截等待浮层消失、scrollIntoView、ActionChainsTimeoutException条件始终不满足检查条件本身是否合理别盲目加时间InvalidSelectorException选择器语法错误检查XPATH括号引号、CSS转义StaleElementReferenceException值得多说两句。它的本质是你手里拿到的元素引用指向的是一个已经被销毁的DOM节点。页面上任何一次局部刷新、列表重排、Vue/React的重新渲染都可能让之前的引用失效。解决办法不是多试几次那么简单而是把定位和操作放在同一个短周期内中间不要插入会触发页面变化的其他操作。如果确实需要重试重试的时候必须重新执行find_element拿新引用而不是拿旧对象再点一次。def click_with_retry(driver, by, value, retries3): for _ in range(retries): try: elem WebDriverWait(driver, 10).until( EC.element_to_be_clickable((by, value)) ) elem.click() return except StaleElementReferenceException: continue raise RuntimeError(f点击失败: {by}{value})8.3 alert、iframe、多窗口这三座大山alert是浏览器的原生弹窗不是页面元素find_element永远找不到它必须用alert driver.switch_to.alert print(alert.text) alert.accept() # 确定 alert.dismiss() # 取消要注意alert是阻塞式的弹出的时候页面所有操作都会卡住。有时候alert出现得很慢脚本先跑过去了后面操作全超时还不报alert相关的错非常误导人。遇到莫名其妙的集体超时先去检查有没有alert没处理。iframe的处理原则是进去要切出来也要切driver.switch_to.frame(frame_id) # 或者 frame driver.find_element(By.CSS_SELECTOR, iframe.pay) driver.switch_to.frame(frame) # 操作完后 driver.switch_to.default_content()嵌套iframe要一层层切进去切回的时候用default_content()回主文档或者parent_frame()回上一层。忘了切回主文档是最常见的低级错误表现为在主页面找元素全都找不到其实driver的上下文还停在iframe里。9. 封装层面的几个心得以及我踩过的那些坑9.1 把定位和操作分离是降低维护成本的关键我早期的脚本长这样满屏的driver.find_element(By.XPATH, ...).click()一个定位表达式在多个用例里复制粘贴。后来页面改版一个类名变了我得翻遍所有文件去改改漏一处就跑失败。现在的做法是把定位集中到一个页面对象文件里用字典或者类属性统一管理class LoginPage: USERNAME (By.ID, username) PASSWORD (By.ID, password) SUBMIT (By.CSS_SELECTOR, button.login-btn) def __init__(self, driver): self.driver driver self.wait WebDriverWait(driver, 10) def login(self, user, pwd): self.wait.until(EC.visibility_of_element_located(self.USERNAME)).send_keys(user) self.driver.find_element(*self.PASSWORD).send_keys(pwd) self.wait.until(EC.element_to_be_clickable(self.SUBMIT)).click()这里*self.USERNAME的解包写法要习惯因为find_element要求By和值作为两个参数传而我们把它们放在了一个元组里。写的时候如果忘了星号会报参数数量错误。9.2 定位表达式尽量用稳定特征别依赖样式类前端重构最常动的就是样式类名。classbtn_click_2024_v2这种带版本号或者随机后缀的类名基本半年就变一次。相对稳定的特征是id、name、>