ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Selenium实战:窗口句柄切换与Cookie持久化全解析

Selenium实战:窗口句柄切换与Cookie持久化全解析 做Web自动化最怕什么我自己的血泪经验是脚本跑得好好的突然弹出一个新窗口接下来的操作全打在旧页面上或者登录状态一刷新就丢每次都要重新扫码、重新过验证。这两个问题本质上都是没搞懂Selenium里的“窗口句柄”和“Cookie生命周期”。这一篇就把我在实际项目里踩过的坑、试出来的稳定套路一次性讲清楚重点围绕多窗口切换和Cookie管理两件事展开顺便把反爬虫环境下Selenium常见的识别特征、伪装配置也梳理一遍。无论你是刚接触Selenium的测试新人还是写数据采集脚本的老手这篇文章的代码和思路都可以直接拿去用。1. 理解需求和核心概念1.1 为什么多窗口切换是自动化绕不开的坎先看一个真实场景你用Selenium打开某个后台系统输入账号密码后系统弹出一个新页面让你去审核订单而且审核页面是用window.open()或者target_blank方式打开的。此时脚本如果还停留在原来的句柄上哪怕用driver.find_element()去定位新页面里的按钮也只会报NoSuchElementException——因为Selenium当前的“视角”还在旧窗口。这类需求在自动化测试里非常常见比如电商平台的多店铺切换、报表系统的详情页跳转、第三方登录跳转后的回调页面处理。如果窗口切换逻辑写得不对脚本的稳定性会非常差跑十次挂九次。我现在做UI自动化第一件事就是把窗口切换写成公共方法所有测试用例都走同一个入口省掉大量重复代码。再说一个容易踩的细节很多新手以为“新开一个浏览器页面”等于“新窗口”但实际上driver.window_handles返回的是当前浏览器进程中所有标签页和窗口的句柄集合只有存在多个句柄时才会需要切换。如果你是同一个标签页里跳转句柄数量不变也根本不需要切直接用driver.get()或点击链接即可。1.2 Cookie管理的真正价值让脚本“记住”登录状态如果只是简单打开页面跑流程可能感觉不到Cookie的重要性。但只要你做登录类场景就会明白“会话保持”是自动化里的头等大事。举个典型例子每天定时跑一次的任务脚本如果每次都走登录流程不仅慢而且很容易被验证码、短信二次验证拦截。哪怕你的登录环节做得很稳一个登录操作至少也会多花2到5秒大量用例跑下来时间成本非常可观。Cookie就是绕过这个重复登录的关键。它在浏览器里的角色相当于一张“临时通行证”里面记录了会话标识session id、用户偏好、设备信息等。Selenium操作Cookie时可以把登录后浏览器生成的Cookie提取出来存成文件下次执行脚本时先注入Cookie再刷新页面浏览器直接就是已登录状态。这一步做好了脚本的稳定性和执行效率都能提升一个档次。我对Cookie管理的总结是它解决的不单是“登录一次”的问题而是把整个自动化脚本从“必须依赖账号密码”变成“依赖一时有效的会话状态”再通过持久化让会话状态可复用。理解了这一点后面对Cookie的读取、写入、删除操作才会有方向感。1.3 环境准备浏览器驱动和依赖安装窗口切换和Cookie管理都依赖Selenium环境的正确搭建。这里我先讲一下环境层面的准备因为很多人卡在第一步——驱动版本不匹配后面所有代码都是白搭。我用的是Python 3.10 Selenium 4.x浏览器是Chrome。装Selenium很简单pip install selenium但注意一个坑Selenium 4和Selenium 3的API有一些差异最典型的就是窗口切换的语法。Selenium 3里常用driver.switch_to_window()Selenium 4里统一变成了driver.switch_to.window()。如果你读到老教程代码会报AttributeError: SwitchTo object has no attribute switch_to_window这个错误。浏览器驱动方面Chrome新版一般不需要手动下载ChromeDriver因为Selenium 4.6以上的版本自带Selenium Manager会自动匹配浏览器版本。如果用的还是旧版本Selenium就需要去ChromeDriver官方站点下载和浏览器版本完全对应的驱动文件否则启动时直接报SessionNotCreatedException。Firefox用GeckoDriverEdge用Microsoft WebDriver原理都一样。另外如果你用无头模式headless建议先在非无头模式下验证脚本能跑通再看无头环境里的窗口句柄和cookie逻辑。无头模式有很多隐藏差异后面我单独开一节讲。2. 多窗口切换实操从命令到封装2.1 window_handles到底是什么想彻底搞懂窗口切换先要理解三个APIdriver.current_window_handle当前焦点窗口的句柄一个唯一的字符串标识。driver.window_handles当前会话里所有窗口句柄的列表顺序一般按打开顺序排列。driver.switch_to.window(handle)把Selenium的焦点切换到指定句柄对应的窗口。一句生活化类比窗口句柄就像房间里挂的号码牌每个浏览器标签页或窗口都有一张自己的牌子。你想去哪个房间做事就告诉Selenium“我现在要进几号房间”它才会把注意力放到那里。所有的find_element操作都只作用于当前焦点句柄对应的页面这一点极其重要。我第一次踩坑就是忽略了这个“焦点”概念。点击链接之后没有等待新窗口出现直接去取window_handles结果列表里只有一个旧句柄然后想当然地认为新窗口没打开。“没等到”和“没打开”是两回事解决方案完全不同。正确的做法是操作触发新窗口之后先等待窗口中句柄数量发生变化再取得新句柄。这个等待不能省因为浏览器从发起请求到渲染出新页面需要一个过程哪怕这个过程只有几百毫秒脚本运行到临界状态时也会暴雷。2.2 基础切换从当前窗口跳到新窗口最基础的切换逻辑是这样的from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver webdriver.Chrome() driver.get(https://example.com/login) # 记录当前窗口句柄 current_handle driver.current_window_handle # 点击“在新窗口打开”的链接 driver.find_element(By.LINK_TEXT, 去第三方认证).click() # 等待新窗口出现 WebDriverWait(driver, 10).until( lambda d: len(d.window_handles) 1 ) # 找到新窗口句柄 new_handles [h for h in driver.window_handles if h ! current_handle] if new_handles: new_handle new_handles[0] driver.switch_to.window(new_handle) # 此时定位新页面元素就不会报错 title driver.title print(新窗口标题, title)这段代码的核心是WebDriverWait搭配len(d.window_handles) 1。为什么不用sleep(1)因为等待条件里带了个显性等待轮询期间只要句柄数发生变化就立刻返回速度更快也更稳定。sleep的问题在于不同环境下浏览器响应速度不一样你睡3秒可能页面2.5秒就打开了也可能4秒才打开脚本永远在不确定性边缘徘徊。切换到新窗口后如果还要回到旧窗口操作直接再driver.switch_to.window(current_handle)就行。这里有一个小细节current_handle不能写成driver.window_handles[0]因为Windows和部分浏览器环境里窗口的排列顺序不保证固定只有一开始就把driver.current_window_handle记录下来才是当前焦点句柄的准确来源。2.3 等待新窗口出现避免元素定位扑空很多自动化脚本死在“切换窗口”这一步表面上是元素定位不到实际上是没有等待新窗口完全加载。窗口句柄变了不代表页面里的DOM已经available。切到新句柄后最好再加一层等待WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.ID, auth-form)) )因为新窗口打开后页面可能在异步加载数据比如验证码图片、用户信息、授权按钮。如果句柄刚切换过来就立刻定位元素大概率面临ElementNotInteractableException或者NoSuchElementException。我通常的做法是先切句柄再等一个关键元素可见。关键元素的选择有讲究不要选那种每个页面都可能存在的通用元素比如body那样等于没等。要选该页面独有的标志性组件比如登录按钮、标题栏文案、某个固定ID的容器。等到了这个元素再继续后续操作整个链路的稳定性会明显提升。如果你要处理的窗口数量比较多不止两个那就要用集合运算来动态识别新句柄original_handles set(driver.window_handles) driver.find_element(By.XPATH, //button[contains(text(),打开窗口)]).click() WebDriverWait(driver, 10).until(lambda d: len(set(d.window_handles)) len(original_handles)) new_handle list(set(driver.window_handles) - original_handles)[0] driver.switch_to.window(new_handle)这个写法比[h for h in driver.window_handles if h ! current_handle]更稳因为它基于集合差集哪怕中间过程又冒出第三个窗口也能准确识别出新增的那个。2.4 窗口切换的完整封装示例项目里用得多了我一般会把窗口切换封装成工具函数总共就三个场景打开新窗口、切换窗口、切回旧窗口。import time from selenium.webdriver.support.ui import WebDriverWait def wait_for_new_window(driver, timeout10): 等待新窗口出现并返回新句柄 current_handles set(driver.window_handles) WebDriverWait(driver, timeout).until( lambda d: len(set(d.window_handles)) len(current_handles) ) new_handles list(set(driver.window_handles) - current_handles) return new_handles[0] def switch_to_new_window(driver, timeout10): 切换到新打开的窗口 new_handle wait_for_new_window(driver, timeout) driver.switch_to.window(new_handle) return driver def switch_back_to_original(driver, original_handle): 切回原窗口 driver.close() # 关闭当前新窗口 driver.switch_to.window(original_handle)特别注意switch_back_to_original里的driver.close()和driver.quit()区别。close()关闭当前焦点窗口不是结束整个浏览器进程quit()是把所有窗口和会话全部关掉。如果在多个窗口并存的状态下误用了quit()整个会话就结束了后面的代码全废。这个坑我在项目里踩过不止一次。3. Cookie管理登录态的读写与持久化3.1 读取和操作Cookie的基础APISelenium操作Cookie的API不多但每一个都很实用driver.get_cookies()返回当前domain下所有Cookie列表每个Cookie是个字典。driver.get_cookie(name)按名字获取单个Cookie。driver.add_cookie(cookie_dict)注入一个Cookie参数是字典。driver.delete_cookie(name)删除指定Cookie。driver.delete_all_cookies()清空全部Cookie。看一个基础演示driver.get(https://example.com) # 在页面加载完成之后才能读取当前站点的cookie all_cookies driver.get_cookies() print(all_cookies) # 新增一个cookie driver.add_cookie({name: my_cookie, value: hello_selenium}) print(driver.get_cookie(my_cookie)) # 删除 driver.delete_cookie(my_cookie)这里面有一个隐藏约束add_cookie不能任意注入。你访问的是https://example.com就不能往https://other.com写入Cookie否则会报InvalidCookieDomainException。这是浏览器同源策略在Selenium层面的体现也是很多新手第一次写Cookie注入就失败的原因。正确的做法是先driver.get()访问目标域名等页面至少加载到可以识别domain的程度再执行add_cookie。我习惯先访问一次页面再注入登录态Cookie就是因为这个原因。顺序错了Cookie根本写不进去。3.2 登录态的持久化把Cookie存成文件Cookie持久化的思路很简单手工走一遍登录流程等登录成功后把所有Cookie取出来用pickle序列化保存到本地文件下次运行脚本时先读取文件再把Cookie注入浏览器最后刷新页面。这样脚本在几秒内就进入登录态免掉整套登录流程。import pickle # 首次登录后保存Cookie driver.get(https://example.com/login) driver.find_element(By.ID, username).send_keys(your_username) driver.find_element(By.ID, password).send_keys(your_password) driver.find_element(By.ID, login-btn).click() # 等待登录成功 WebDriverWait(driver, 10).until( EC.url_contains(/dashboard) ) with open(cookies.pkl, wb) as f: pickle.dump(driver.get_cookies(), f)下次运行时import pickle driver.get(https://example.com) # 必须先访问同一域名 with open(cookies.pkl, rb) as f: cookies pickle.load(f) for cookie in cookies: driver.add_cookie(cookie) driver.refresh() # 刷新页面后登录态生效这里最关键的顺序就是先访问域名再注入Cookie最后刷新。有一次我把refresh()写在了add_cookie之前结果会话没有带上新注入的Cookie白白排查了半天。页面刷新这个动作本质上是让浏览器重新构造请求头把新加的Cookie带上去。如果你保存的Cookie在本地文件里存放时间超过几天要注意Cookie的过期时间。很多网站的会话Cookie有效期只有一两天过期之后注入进去也没用页面依然会跳到登录页。所以持久化方案一般配套一个判断逻辑如果刷新后检测到还在登录页就主动走一遍登录流程再重新保存Cookie。3.3 注入Cookie时的常见坑Cookie持久化方案看起来简单但实际运行中容易踩几个坑我逐个说。第一个坑Cookie的domain和path不匹配。你从一个子域名获取的Cookie可能只在那个子域名下有效比如从mail.example.com获取的Cookie注入到example.com主站就无法生效。解决办法是注入之前检查字典里的domain必要时做一次重新构造把domain改成当前目标站点的根域名把path改成/。第二个坑Cookie里有expiry字段且格式不对。Selenium返回的Cookie字典里expiry一般是时间戳但在某些浏览器驱动里可能是不符合要求的格式导致add_cookie时报未知错误。我一般在持久化前会过滤掉expiry字段或者把它转成合规的整型时间戳。最简单的方式是保存时用driver.get_cookies()注入时逐项检查并清理无法识别的字段。第三个坑secure属性。如果Cookie被标记为secure它就只能在HTTPS环境下传输。你在http://页面里部署注入带secure标记的Cookie大概率不生效因为页面自身不满足安全传输条件。反过来在HTTPS页面注入不支持secure的Cookie一般可以但也要看站点的校验逻辑。第四个坑Cookie字典的键名必须规范。Selenium要求add_cookie接收一个符合Cookie格式的字典至少包含name和value可选的path、domain、secure、httpOnly、expiry。httpOnly这个属性一般不能由JavaScript设置但Selenium注入时可以带上。如果你在持久化时手动组装了Cookie务必别把字段拼错。3.4 需要注意的字段细节domain、expiry和同源限制我把Cookie字段单独拎出来讲是因为很多人直接把driver.get_cookies()拿到的字典原封不动再传回add_cookie结果还是失败。这里分享一个我踩坑之后总结的处理函数def normalize_cookie(cookie, domainNone): 清洗cookie字段确保能被add_cookie接受 allowed {name, value, path, domain, secure, httpOnly, expiry, sameSite} cleaned {k: v for k, v in cookie.items() if k in allowed and v is not None} if domain and domain in cleaned: cleaned[domain] domain if expiry in cleaned and not isinstance(cleaned[expiry], (int, float)): cleaned.pop(expiry, None) return cleaned注入时遍历清洗后的列表for cookie in cookies: cleaned normalize_cookie(cookie, domain.example.com) try: driver.add_cookie(cleaned) except Exception as e: print(fcookie inject failed: {cookie.get(name)} - {e})sameSite字段也值得注意有些网站在Chrome下会把它设置成Strict或Lax。如果注入后页面行为表现异常比如跳转后丢失登录态可以把sameSite改成None试试。但要记住sameSiteNone必须配合secureTrue否则浏览器会拒绝这个规则在不同Chrome版本下有差异实际项目里需要测试验证。4. 反爬虫环境下的Selenium检测特征4.1 常见反爬虫检测机制很多网站在处理自动化访问时会对浏览器做特征检测判断访问者是不是一个真实的用户使用普通浏览器。Selenium默认启动的Chrome窗口在部分站点眼里有非常明显的“机器人”特征。这不是什么黑科技而是JS检测手段的组合。最典型的特征就是navigator.webdriver。在Selenium控制的Chrome实例里这个属性默认是true。普通用户的标准Chrome浏览器里这个值是undefined或者false。网站只需要一行JS代码就能识别if (navigator.webdriver) { // 被视为自动化工具 }另外很多站点还会检测navigator.plugins长度、navigator.languages的构成是否单一、浏览器启动时是否带有--headless痕迹、是否有Chrome DevTools Protocol的调试特征等。无头模式尤其容易露馅因为无头浏览器的User-Agent一般会带HeadlessChrome字样一查一个准。我的态度是这些识别特征研究明白主要价值在于帮助做自动化测试的人理解为什么自己的脚本在目标站点上“突然不正常”也能帮助避免因为浏览器特征异常而触发一些安全策略。用户自行判断使用边界我只讲技术上怎么识别和规避常见误伤。4.2 降低自动化特征的配置降低自动化特征的常用配置我整理成了一段真实跑过的示例from selenium import webdriver options webdriver.ChromeOptions() # 关闭自动化控制标志 options.add_argument(--disable-blink-featuresAutomationControlled) # 设置一个常规的User-Agent options.add_argument( user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ) # 禁用自动化扩展 options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) driver webdriver.Chrome(optionsoptions)--disable-blink-featuresAutomationControlled这段参数能把大部分Chromium内核检测点去掉excludeSwitches里去掉enable-automation可以在一定程度上隐藏浏览器顶部的“Chrome正在受到自动测试软件控制”提示。还有一个重要的点启动参数里不要加--headless。如果确实需要无头运行不用默认的无头模式而是使用新版Chrome的--headlessnew方式因为老式无头特征非常明显。不过无头模式下再怎么伪装也还是会被少数高级检测手段识别出来这个要心里有数。我再补充一个CDP层面隐藏navigator.webdriver的方案它比加启动参数更彻底很多自动化方案里会用到driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, {get: () undefined}) })这段代码的作用是在每次新建文档时先把navigator.webdriver的属性重新定义成undefined避免JS代码在页面加载瞬间就嗅探到自动化特征。这个方案我在维护老项目里的自动化脚本时用过配合其他伪装措施整体稳定性提升不少。不过我必须强调一点学习这些手段目的是让你自己的自动化工具尽量避免误伤不是让你去攻克哪些不该访问的系统。合规使用数据、尊重站点规则是底线。4.3 行为层面如何规避误伤配置层面的伪装只是第一步行为层面的伪装同样重要甚至更重要。真实用户不会每秒钟都规律性地点击页面也不会在同一个页面上毫秒不差地重复操作。我在写自动化脚本时会有意识地加入节奏控制。最常见的行为伪装策略是“随机等待”。不要固定time.sleep(2)而是用一个区间随机数import random import time def human_pause(min_seconds0.8, max_seconds2.5): time.sleep(random.uniform(min_seconds, max_seconds))为什么要随机因为固定间隔的请求时间序列在服务器端的日志分析里就像是节拍器规律性越强越容易被识别为机器。引入随机性之后请求时间分布就会更接近人类的真实操作。行为伪装的另一个要点是模拟“用户路径”而不是简单机械地点击。比如真实用户登录后会先滚动页面看一下内容再点击某个按钮自动化脚本往往直奔目标元素中间没有任何浏览动作。我通常在关键操作前插入一段滚动操作模拟阅读行为driver.execute_script(window.scrollTo(0, document.body.scrollHeight * 0.6)) human_pause(0.5, 1.2)还有一个容易忽略的点不要让浏览器窗口尺寸固定不变。很多自动化工具默认打开的是标准尺寸窗口真实用户窗口尺寸五花八门。通过driver.set_window_size()随机设定一个合理窗口大小也能降低特征明显度。不过这个方案对页面布局的影响也很大如果你依赖固定尺寸的响应式布局做元素定位需要先确认改动不会破坏原有脚本。5. 高频踩坑与排查实录5.1 window_handles里没有新增句柄这是我在评论区里看到最多的问题点击链接后window_handles长度始终是1根本没有新窗口句柄。问题多半出在链接的打开方式上。如果链接使用target_self或者直接在当前标签页跳转那么窗口句柄不会增加相关内容会直接覆盖原页面。你需要的是target_blank或者JS里的window.open()。遇到这种情况我建议先检查HTML里a标签的属性。如果目标链接不是_blank要么改变页面打开方式要么在自动化脚本里用JS强制新窗口打开driver.execute_script(window.open( url ))但要注意window.open在某些浏览器配置下可能被弹窗拦截触发拦截后连句柄都不会出现。所以稳妥的方式是driver.execute_script(window.open( url , _blank);)执行完这段JS之后再走“等待窗口句柄数量变化”的流程。如果执行后还是没有新句柄就检查Chrome设置里是否禁止了第三方弹窗必要时把options加上禁用弹窗拦截的参数例如options.add_experimental_option(excludeSwitches, [disable-popup-blocking])。5.2 切换窗口后元素定位不到窗口切过去了但find_element还是报找不到元素这也是高频问题而且比“窗口没开”更隐蔽。核心原因前面提过句柄切换成功不代表页面已经加载到可操作状态。这里我有一个固定的调试顺序建议你按这个顺序排查确认driver.current_window_handle是不是目标窗口的句柄。可以打印一下driver.title看页面标题是否符合预期。确认页面是否处于加载状态。用WebDriverWait等待目标元素的可见性而不是立刻find_element。确认页面里是否存在iframe。如果目标元素在iframe里面即使窗口切换对了也定位不到。遇到内嵌的iframe场景还得再调用driver.switch_to.frame()。iframe这个问题非常坑。有些页面打开后新窗口里加载的是一个嵌套了广告或登录控件的iframe页面。你切对了窗口但在主文档里找不到元素。解决办法是driver.switch_to.default_content() driver.switch_to.frame(driver.find_element(By.TAG_NAME, iframe))操作完成后再通过driver.switch_to.default_content()切回顶层文档。窗口和frame是两个不同维度的切换很容易混淆但实际开发中经常叠加出现。5.3 Cookie写入不生效Cookie注入不生效原因基本集中在三块域名不匹配、写入顺序错误、Cookie字段格式非法。域名不匹配很好理解你在A域名下注入B域名的Cookie会被浏览器直接拒绝报InvalidCookieDomainException。唯一解法是先去driver.get()访问目标域名再注入。写入顺序错误则更常见。我遇过几次都是在调用driver.add_cookie()之前没有先访问目标页面或者访问后直接注入却没有刷新。记住一个口诀访问域名→注入Cookie→刷新页面。顺序绝对不能乱。字段格式问题则要在持久化文件里检查。最简单的方式是打印出每个Cookie字典逐字段核对with open(cookies.pkl, rb) as f: cookies pickle.load(f) for cookie in cookies: print(cookie)如果发现expiry是个奇怪的字符串或者sameSite拼写不对就用上一节提到的normalize_cookie函数清洗。5.4 窗口关闭与资源释放脚本跑完后释放资源也是一门学问。很多人只在脚本末尾写了一个driver.quit()但在多窗口场景下可能跑着跑着就冒出了几个孤儿Chrome进程占据大量内存。我自己的习惯是所有测试流程结束用try/finally包裹资源释放逻辑确保无论脚本是否报错驱动都会被清理try: run_tests(driver) finally: driver.quit()如果中途确实需要关闭某个窗口用driver.close()关当前焦点窗口再把焦点切回主窗口。但这里有个经验不要过度依赖close()因为如果当前焦点已经丢失你调用close()关的可能是错误窗口。我会在关闭前加一层判断if driver.current_window_handle in expected_handles: driver.close()无头模式下资源释放比有头模式更容易出问题因为看不到浏览器进程以为脚本结束就自动回收了。实际测试下来Linux服务器上跑完脚本后经常残存僵尸Chrome进程。我的做法是在外层再包一层Shell命令在脚本结束后统一清除无头浏览器的残留进程或者直接开启--disable-dev-shm-usage参数来规避容器环境下临时目录不足的问题。6. 我的实战心得窗口切换和Cookie管理这两个能力平时看起来不起眼但它们决定了你的自动化脚本能不能在复杂场景里稳定跑下去。我维护过的项目里凡是脚本频繁跑挂的排查到最后八成都是这两个问题要么窗口切错了要么登录态丢了。把这两个基础打牢比堆砌一堆花哨的测试框架技巧都管用。最后分享一个小技巧在脚本关键位置加日志每个窗口切换和Cookie注入动作都打印一行信息包括当前句柄、页面标题、注入的Cookie名称。调试的时候你就知道脚本到底站在哪个窗口上是在哪一步丢的登录态。我用这个方式排掉了不少偶发问题效率翻倍。这一篇的代码和思路来自实际项目你可以直接拿去用但一定要结合实际场景调整毕竟每个站点的Cookie规则和窗口行为都不太一样。
返回列表