做爬虫的人,迟早会遇到 Selenium WebDriver 这一关。我从 requests 一把梭起步,直到碰上那些渲染型页面——在浏览器里明明看得到商品价格、评论内容,抓回来的 HTML 却空空如也,才发现光靠模拟 HTTP 请求,根本拿不到动态加载的数据。Selenium WebDriver 是干这个的利器:它驱动真实浏览器去执行页面里的 JavaScript,把 DOM 里最终生成的内容原原本本交给你。这篇文章是“爬虫工程师成长之路”系列的第八篇,围绕 Selenium WebDriver 从安装、定位、等待到实战采集、问题排查、性能优化完整过一遍,适合已经会 requests/Scrapy 但卡在动态页面的爬虫开发者,也适合想系统补齐浏览器自动化技能的测试工程师。
1. 为什么爬虫工程师要掌握 Selenium WebDriver
1.1 静态请求与动态渲染之间的鸿沟
先说一个很多人都经历过的场景:你用 requests 打开某个页面,返回的 HTML 里确实有标题、有框架,但涉及数据的部分全是空壳,要么是一个巨大的 JSON 占位符,要么是一整段要等浏览器执行完 JavaScript 才会填充的 div。原因很简单,现在前端框架都在做客户端渲染,数据不是服务器拼好的,而是页面加载后由脚本请求接口、拿到数据再动态渲染到页面上。你把整个 HTML 保存下来看,只有骨架,没有血肉。
这种页面用传统爬虫怎么处理?最经典的方法是抓包找接口、分析加密参数、模拟签名逻辑,这是很多老爬虫工程师的看家本领。但这条路对每个网站都要单独逆向,工作量大且不说,前端代码一改参数就失效,维护成本高得惊人。Selenium 换了一条路:我不去管你背后的接口长什么样,直接把浏览器打开,等脚本跑完,读取最终的 DOM 结果。把“理解页面渲染逻辑”这件事整体置换成了“模拟真实用户操作”,你不需要知道数据从哪个接口来,只要知道它在页面上以什么形式呈现,这对复杂交互、登录态、分页加载、文件下载这类场景尤其有效。
1.2 方案选型:Selenium 什么时候是最优解
并不是所有爬虫都适合拿 Selenium 做,先把场景分清楚再选型,否则会掉进“拿着锤子看什么都是钉子”的坑。我自己选型时会先列一张表格过一遍:
| 场景 | 推荐方案 | 核心原因 |
|---|---|---|
| 纯静态页面、接口参数简单 | requests + BeautifulSoup / lxml | 开销小、速度快、代码简单 |
| 目标网站有公开 API 且参数不复杂 | Scrapy + 中间件 | 并发高、调度成熟、生态完善 |
| 页面有动态渲染且有复杂交互 | Selenium WebDriver | 直接操作真实浏览器,稳定可靠 |
| 大规模并发、需要截图录屏 | Playwright / Selenium Grid | 异步能力更强、上下文隔离更好 |
| 只想轻量渲染、不想常驻浏览器 | 渲染服务或 headless 浏览器 | 减少本地资源占用,适合定时批跑 |
选择 Selenium 的核心依据是“交互复杂度”。页面如果只是数据延迟渲染、没有任何特殊交互,用更轻的方案反而稳;但如果页面有元素点击、悬浮菜单、拖拽上传、多标签页操作,Selenium 这套 WebDriver API 沉淀多年,坑少、资料多、上手路径成熟,团队协作成本也低。
我曾在某公司实习时踩过一次选型错误:一个模拟项目 X 只是需要抓取一批图表数据,我上来就上 Selenium,结果页面每次刷新都要重新注册图表组件,定位按钮极其不稳定,维护代码折腾了好几天。后来静下心抓包,发现数据其实在页面全局变量里以 JSON 形式存着,直接用取值或正则提取,速度快了一个数量级。这个教训让我后来养成了习惯:动手前先花十分钟想清楚“我要的是 DOM 里的最终结果,还是页面呈现后的动作”,再决定工具,这比盲目堆技术重要得多,也省去大量后期返工。
2. 环境搭建:驱动、浏览器与版本匹配
2.1 安装依赖与驱动管理
Selenium 本身是一个 Python 包,安装非常简单,一行命令就能搞定。但这里要提醒一句:pip 装的是客户端库而不是浏览器本身,真正干活的是电脑里已经装好的 Chrome、Firefox 或 Edge,Selenium 通过一套“驱动程序”去和这些浏览器通信,驱动就相当于是遥控器。
pip install selenium真正让新手抓狂的是驱动版本问题。Selenium 并不自带浏览器,你装好 Chrome 之后,还得下载一个版本匹配的 ChromeDriver。驱动和浏览器版本必须一致,否则启动时直接报 “session not created” 或 “this version of ChromeDriver only supports Chrome version X”。我第一次搭环境时下载的 ChromeDriver 比本机浏览器高了两个主版本,死活启动不了,反复折腾才发现驱动主版本号和浏览器主版本号要对齐。
现在有一个工具能省掉这个麻烦,就是 webdriver-manager。它会根据你本机浏览器的实际版本自动下载匹配的驱动,并缓存起来:
pip install webdriver-managerfrom selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)如果你用 Firefox,就把 ChromeDriverManager 换成 GeckoDriverManager;用 Edge 则换成 EdgeChromiumDriverManager。这里有一个实际工程中的坑:webdriver-manager 默认需要联网去下载驱动,如果运行环境网络受限,脚本会长时间卡在下载步骤,所以正式的容器镜像里最好把驱动提前下载好、固化到镜像层,而不是每次启动都去拉取。
2.2 三种启动浏览器的姿势
环境搭好了,启动浏览器也有不同姿势,我在不同阶段分别用过这三种。第一种是最简单的标准启动,适合本地小范围调试,代码就是 webdriver.Chrome() 一行,它会自动去找 PATH 里的驱动并拉起一个带界面的浏览器窗口。
第二种是带自定义参数的启动,适合自动化采集。常见的参数包括无头模式、禁用 GPU、固定窗口大小、指定语言等:
from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless") # 无头模式,不开浏览器界面 options.add_argument("--disable-gpu") # 禁用 GPU 加速 options.add_argument("--no-sandbox") # 容器环境通常需要 options.add_argument("--window-size=1920,1080") # 固定窗口大小,保证布局稳定 options.add_argument("--lang=zh-CN") # 指定页面语言 options.add_experimental_option("prefs", { "profile.default_content_setting_values.notifications": 2 }) driver = webdriver.Chrome(options=options)这些参数不是随便加的,每个都对应一类实际问题:固定窗口大小是为了避免因为窗口尺寸不同导致元素遮挡或懒加载不触发;禁用 GPU 是为了避免某些服务器环境下渲染异常;no-sandbox 则是容器里跑 Chrome 的必备项,不加很可能直接闪退。
第三种是复用已有浏览器进程,通过远程调试端口连接。典型场景是调试阶段:先用普通浏览器手工登录、保存 cookie、把目标页面调到指定状态,然后让 Selenium 连上同一个浏览器继续操作,免去每次都要重新登录的麻烦。实现方式是在命令行启动 Chrome 时加上远程调试端口,代码里用 Remote 连接。这种方式的坑在于页面标签页是共享的,定位元素容易串到其他标签页,我一般只把它用于调试,正式的采集脚本还是走标准启动,保证环境可重复、结果可预期。
3. 定位与等待:稳住动态页面的基本功
3.1 定位方式的选择与 XPath 实战
WebDriver 最核心的动作就是“找到元素,再对它做点什么”。Selenium 提供了 id、name、class name、tag name、css selector、xpath 等定位方式。按我实际项目的使用频率排序,css selector 和 xpath 占绝大多数,id 有就用但不多见,class name 很少直接用,因为现在动态页面的 class 经常是一长串混淆字符串,直接用很容易踩雷。
给新手一个建议:简单场景优先用 css selector,复杂层级和文本匹配用 xpath。比如定位一个按钮,css 可以这样写:
from selenium.webdriver.common.by import By btn = driver.find_element(By.CSS_SELECTOR, "button.submit-btn")但如果按钮在不同状态下文本会变化,用 xpath 按文本定位更稳:
btn = driver.find_element(By.XPATH, "//button[contains(text(), '加载更多')]")实际练习里最常用的几个 xpath 模式:按属性匹配用 //div[@data-id='123'],按后代文本匹配用 //div[.//span[text()='价格']],按出现序号匹配用 (//div[@class='item'])[2]。把这些记熟,大部分定位需求都能覆盖。
还有一个容易被忽略的细节:find_element 和 find_elements 的区别。前者返回第一个匹配项,后者返回所有匹配项的列表。需要判断页面上是否存在某个元素时,很多人习惯 try/except 配合 find_element,但异常捕获的开销和代码可读性都不理想,我推荐用 find_elements 判空代替:
items = driver.find_elements(By.XPATH, "//div[@class='item']") if not items: print("还没有加载出数据") else: for item in items: # 处理每个元素这种写法的另一个好处是天然支持列表遍历:当你需要处理所有匹配元素时,find_elements 直接返回列表,不用先定位容器再二次查找,代码结构清晰很多。
3.2 隐式等待、显式等待与强制停顿怎么用
动态页面的最大麻烦是时机。元素还没渲染完你就去找,肯定报 NoSuchElementException。Selenium 官方推荐用“等待”机制解决,但我观察很多新手只会用 time.sleep(3),这既慢又不可靠——网络快的时候白白浪费 3 秒,慢的时候 3 秒还不够,日志里堆满超时和重试。睡眠时间设大了拖慢全流程,设小了又经常抽风,确实让人很头疼。
隐式等待是全局生效的:一旦设置,之后的每次 find_element 都会在元素未出现时多等一小会儿。元素早点出现就早返回,不出现才等到超时时间。适合做全局兜底,但副作用是元素确实不存在时,查询会白白等满超时时间才抛异常,如果代码里大量循环查询,累计等待时间会非常可观,所以隐式等待的超时值不宜设得太大。
driver.implicitly_wait(5)显式等待是更精准的手段,针对某个具体条件轮询等待。它和 time.sleep 最大的区别是“事件驱动”:元素等到了就立刻继续,等不到才一直等到超时。实际场景中最常用的是 WebDriverWait 配合 expected_conditions:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, timeout=10, poll_frequency=0.5) button = wait.until( EC.element_to_be_clickable((By.XPATH, "//button[contains(text(), '加载更多')]")) )timeout 是总时长,poll_frequency 是每 0.5 秒轮询一次,比固定 sleep 快很多又稳。常用的 expected_conditions 有 element_to_be_clickable、presence_of_element_located、visibility_of_element_located、text_to_be_present_in_element 等,每个都有明确的适用场景,用错会有隐蔽问题,后面排查章节再展开。
我最初也踩过混用的坑:隐式等待和显式等待同时设置时,等待行为会叠加,轮询间隔莫名翻倍,页面元素明明很快就出现了,脚本却总感觉卡顿。后来的统一策略是:全局只设一个较短的隐式等待做兜底(比如 5 秒),关键操作全部用显式等待精确控制,普通操作之间不用 time.sleep,除非确实有动画过渡需要缓冲。这个组合目前用下来最舒服,既能兜底又不过度浪费。
4. 实战案例:商品列表页的数据采集
4.1 先拆分页面结构,再动手写代码
纸上得来终觉浅,这里用一个模拟项目把完整流程走一遍。假设要抓取的某演示站点是典型的商品列表页:顶部有筛选项,往下是商品卡片,卡片里包含名称、价格、销量和详情链接,页面底部“加载更多”按钮点击后会自动追加新的卡片,一共要翻 10 次才能拿完所有数据。
动手之前先拆三步:打开页面、循环点击加载、提取数据。最容易犯的错误是一上来就写定位代码,连页面实际长什么样都没看。我的习惯是先在浏览器开发者工具里按 F12 检查元素,把商品卡片的公共父节点找出来,确认每次点击加载后新卡片接入的规律,然后再写代码。这个前置分析看起来多花几分钟,实际上能帮你避开大量的无效定位尝试。
页面拆解的结果通常是这样:商品卡片的公共容器是 div.product-item;名称在卡片内部的 h3.product-name;价格是 span.product-price;销量是 span.sales-count;加载按钮是 button.load-more。把这些结构记下来,接下来的定位代码就顺理成章了。如果页面里每个卡片结构一致,就放心用相对定位在卡片内部提取字段,千万不要用全局绝对路径去定位每一个商品名称,那样一旦页面结构有微调,整个脚本就全废了。
4.2 企稳式采集代码完整实现
整体代码我习惯写成“函数 + 主流程”的结构,每一块职责单一,既方便复用,也方便出问题时快速定位是哪一段出了问题。下面这段代码是这个模拟项目的完整实现:
import csv import time 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 from selenium.webdriver.chrome.options import Options def init_driver(): options = Options() options.add_argument("--headless") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") return webdriver.Chrome(options=options) def wait_for_products(driver, timeout=10): wait = WebDriverWait(driver, timeout=timeout) return wait.until( EC.presence_of_all_elements_located((By.XPATH, "//div[@class='product-item']")) ) def collect_page_items(driver, products): items = [] for product in products: try: name = product.find_element(By.XPATH, ".//h3[@class='product-name']").text.strip() price = product.find_element(By.XPATH, ".//span[@class='product-price']").text.strip() sales = product.find_element(By.XPATH, ".//span[@class='sales-count']").text.strip() items.append({"name": name, "price": price, "sales": sales}) except Exception as e: print(f"[warn] 解析商品失败: {e}") return items def save_to_csv(filename, rows): with open(filename, "a", encoding="utf-8", newline="") as f: writer = csv.DictWriter(f, fieldnames=["name", "price", "sales"]) writer.writerows(rows) if __name__ == "__main__": driver = init_driver() try: driver.get("https://example.com/list") for page in range(10): products = wait_for_products(driver) if not products: print("未拿到商品数据,退出") break rows = collect_page_items(driver, products) save_to_csv("output.csv", rows) print(f"第 {page + 1} 批已保存 {len(rows)} 条") load_more = driver.find_elements(By.XPATH, "//button[contains(text(), '加载更多')]") if not load_more: print("没有加载更多按钮,采集结束") break load_more[0].click() time.sleep(1) finally: driver.quit()示例页面使用保留域名 example.com,实际运行时请替换成你自己的测试页面。这段代码里有几个关键点值得展开说。第一个是 XPath 里的.//和//的区别。在 product 这个元素内部继续查找时,.//h3表示从当前元素的后代里查找,而//h3会从整个文档根节点开始全局查找。一旦页面顶部还有其他 h3 元素,用后者就会抓到完全错误的数据。我第一次写时就是少了一个点,结果把页面标题抓成了商品名,排查了半天才发现是定位范围的问题。
第二个是单卡解析失败不影响整体。列表页面里偶尔会有某个商品缺字段,如果解析异常直接抛出去,整个采集脚本就断掉了;用 try/except 包住单条解析,顶多丢一条数据,脚本还能继续往下跑。批量任务里,“宁可缺一不可死”是最基本的哲学,尤其当采集时长动辄几小时的时候,一次小异常导致全盘重来,谁都受不了。
第三个是点击“加载更多”后加了 time.sleep(1)。虽然前面已经用了显式等待,但页面有滚动加载动画时,新卡片是渐进式出现的,立刻去定位新卡片很可能只拿到上一批的旧数据。等一秒在这里是合理的,属于“动画缓冲”场景,比无脑 sleep 三秒高效得多。等多久要自己实测,一般 0.5 到 2 秒就够。
4.3 数据落盘与失败重试
上面代码里用了最简单的“追加 CSV”落盘方式,因为示例数据量不大。实际工程中,我强烈建议每页保存一次而不是最后一次性保存,这样即使跑了一半崩了,已经抓到的数据也不会丢。保存时用追加模式,字段名写进表头,后面做数据分析时直接读 CSV 就行,省去很多二次整理。
还有一个很容易被忽略的点是重试。网络抖动、页面结构临时变化、驱动崩溃都可能导致某次抓取失败,我习惯给整个采集流程包一个重试函数:
def run_with_retry(task, retry_times=3): for attempt in range(retry_times): try: return task() except Exception as e: print(f"[retry] 第 {attempt + 1} 次失败: {e}") time.sleep(2 + attempt * 0.5) return None重试次数和间隔也要讲究:间隔不要固定,加一点递增或者随机抖动,避免整个脚本表现出明显的机器节奏;重试两三次还不行就干脆放弃,返回 None 交给外层人工介入,不要无限循环硬扛。真正的采集任务里,“快速失败”往往比“死磕到底”更节省整体时间,因为大多数失败原因是暂时性的,过一段再重跑反而更靠谱。
5. 高频报错与排查技巧实录
5.1 元素找不到、超时与 iframe 陷阱
Selenium 最常见的报错就是 NoSuchElementException 和 TimeoutException。原因五花八门,但按照我排障的经验,顺序一定是先查三个地方。这就像修水管先关总阀一样,别一上来就怀疑代码逻辑,先排除环境问题。
第一,元素是不是在 iframe 里。页面里嵌了一个其他系统的区域,常见于登录框、支付弹窗、地图模块,iframe 里的元素默认在外面看不到,必须先切换进去:
driver.switch_to.frame("frame_id_or_name") # 操作完再切回主文档 driver.switch_to.default_content()判断方法很简单:在开发者工具里看元素标签是不是包在 iframe 标签内,或者检查元素所在位置是否来自独立子文档。切进去容易,出来时别忘了切回主文档,否则后续定位全会跑偏。
第二,元素是不是开在了新窗口或新标签页里。点击一个链接打开新窗口后,driver 的焦点仍停留在旧页面,要对新窗口执行操作就必须先切窗口句柄:
handles = driver.window_handles driver.switch_to.window(handles[-1])第三,元素本身是动态生成的,刚出现时可见性不足。这就要回到等待那一节的核心:你用的是 presence 还是 visibility。presence_of_element_located 只代表 DOM 里有这个节点,不代表它可见;如果元素定义了却被 CSS 隐藏,用 presence 判断会误以为它已经出现,后续点击就会报 element not interactable。所以点按钮用 element_to_be_clickable,读文本用 visibility_of_element_located,这两个条件才符合“真的可以操作”的语义。
5.2 浏览器进程残留与资源释放
另一个高频问题不是报错,而是“脚本跑完了,机器却越来越卡”。很多人写爬虫只记得开头 webdriver.Chrome(),忘了结束要 quit。结果脚本一崩溃,浏览器进程就留在后台,一次采集能攒下十几个僵尸 Chrome 进程,内存直接报警,排查半天还找不到原因。
正确姿势是无论正常结束还是异常退出,都必须保证 driver.quit() 被调用。最稳妥的就是用 finally:
driver = None try: driver = webdriver.Chrome() # 业务逻辑 finally: if driver is not None: driver.quit()批量任务里还有个更隐蔽的坑:如果 driver 已经崩溃,再次调用 quit() 会抛异常。所以要给 quit 也套一层 try/except,或者把 driver 的生命周期管理封装成上下文管理器,出错时不至于二次报错掩盖原始异常。如果已经产生了残留进程,Linux 下可以按进程名清理,Windows 下在任务管理器里结束 chrome.exe 进程树即可。理解思路就能举一反三:关键是“进程树级别清理”,因为 Chrome 本身会拉起一堆子进程,只杀父进程是不够的。
5.3 关于反爬检测与合规采集的提醒
说到动态渲染采集,绕不开的一个话题是反爬。现在不少站点会检测浏览器指纹、检测 headless 特征、检测 navigator.webdriver 属性,一旦命中就返回验证码或者渲染假数据。很多新人第一反应是搜怎么伪装成真人,我明确说一下:这里讨论的都是技术原理,目的是让你理解现象、排查脚本为何不稳定,而不是教你针对特定网站做违规绕过。
真正规范的思路其实有三点:第一,只采集你有权限访问、且使用条款允许的数据;第二,对目标站点保持合理频率,不冲击正常服务,不做会加重别人服务器负担的操作;第三,优先使用官方提供的 API 或页面公开的结构化数据。合规和效率并不矛盾,长期来看反而更省心,不用成天担心脚本因为反爬升级而失效。
在没有条件取得授权、仅做技术验证时,我的做法是严格控制频率,每次请求间隔设置随机延时,同时尽量让脚本保持“自然节奏”而不是伪装成真人。所谓反爬对抗,本质上是在持续消耗资源,没有任何爬虫能保证永远有效;把脚本本身的稳定性守住,不给自己和站点添麻烦,远比研究各种花哨的伪装手法更实际。
6. 性能优化与工程化落地
6.1 让 Selenium 跑得更快的几个配置
Selenium 慢是出了名的,一个页面从启动浏览器到拿到数据可能要几十秒。围绕“降低单任务耗时”和“提高并行度”两个方向,我总结几个立竿见影的做法,实测下来效果都不错。
第一个是无头模式加禁用无用资源。无头模式不开窗口,省掉渲染 UI 的开销;再配合浏览器 preferences 禁用图片、弹窗、CSS 字体加载,大多数页面能提速 20% 到 40%。注意:禁用图片要谨慎,某些页面数据依赖图片懒加载或者元素尺寸布局,禁了图反而导致数据抓不到:
prefs = { "profile.managed_default_content_settings.images": 2, # 禁止图片 "profile.default_content_setting_values.notifications": 2, } options.add_experimental_option("prefs", prefs)第二个是复用 Driver 实例。一个 driver 对应一个浏览器进程,频繁创建销毁是重量级操作。单任务循环采集时,整个流程只启动一次 driver,然后在循环里不断 get 不同页面,最后统一 quit。我实测启动一次 Chrome 再做 10 次抓取,比每次只抓 1 次就退出再重新启动,总耗时能省一半以上。与之配套的是页面级超时设置,比如 page_load_timeout 和 script_timeout,避免某个页面异常时无限等下去。
第三个是减少无意义的等待。显式等待的 timeout 不要设成一个巨大的值,尤其对已知可能不存在的元素,宁可快速失败进重试逻辑,也不要陪它干等 30 秒。很多页面其实 5 秒内就会出数据,超过 10 秒大概率是出了问题,这时候快速失败、记录日志、换下一个页面,整体产出反而更高。
6.2 接入调度任务与结果存储
采集脚本写好后,下一步是把它变成可调度的任务。两个方向我都实践过,按场景选就好。第一个是轻量定时跑。用系统自带的定时任务或者 Python 的 schedule 库,每天固定时间跑一次增量采集,结果写入 CSV 或 SQLite。对这种场景,我建议在脚本入口加参数控制采集页数和输出目录,这样调度时直接传参就行,不用每次改代码:
import sys pages = int(sys.argv[1]) if len(sys.argv) > 1 else 10 output = sys.argv[2] if len(sys.argv) > 2 else "output.csv"第二个是接入队列和并行 worker。数据量大、目标页面多的时候,让多个 worker 各负责一批 URL。注意 Selenium 的 driver 是重量级资源,进程级并行优于线程级并行,Python 的 GIL 会限制线程效率,所以用 multiprocessing 起多个进程,每个进程一个 driver,各自负责一部分 URL,最后汇总入库。并行数不要盲目开大,浏览器进程占用的内存和 CPU 都不少,4 个并发通常已经能满足绝大多数场景,太多反而会因为系统资源争抢导致浏览器崩溃。
结果存储方面,超过几千条就建议别再用 CSV 了,入 SQLite 或 MySQL 更稳。入库前做一次数据清洗:价格字符串去掉货币符号转成浮点,销量为空时补 0,日期统一格式。这部分听着简单,却是真实数据分析环节最大的坑——脏数据比少数据可怕得多,因为它是隐蔽的,会污染你后面的一切结论。
6.3 从 Selenium 平滑过渡到 Playwright
最后聊一下技术路线的下一步。Selenium WebDriver 是入门浏览器自动化的经典选择,但这个领域这些年变化很快,新项目里我越来越多地推荐 Playwright。它自带浏览器内核管理、自动等待、截图录屏、移动端模拟,接口更现代化,对并发场景的支持也更好。如果在 Selenium 上已经积累了经验,迁移并不难,核心概念是一一对应的:
| 维度 | Selenium WebDriver | Playwright |
|---|---|---|
| 驱动管理 | 手动下载 webdriver 或 webdriver-manager | 自动安装并管理浏览器内核 |
| 核心对象 | driver / element | page / locator |
| 元素定位 | find_element(By.XPATH, ...) | locator("xpath=...") |
| 等待机制 | WebDriverWait + expected_conditions | 默认自动等待,极少手写等待 |
| 会话隔离 | 需要手动管理窗口句柄 | BrowserContext 天然隔离 |
迁移的收益在长期维护时特别明显。Selenium 时代要手工维护的驱动版本、等待逻辑,在 Playwright 里大部分被框架吃掉,代码量明显减少,出问题的点也少。但 Selenium 也有不可替代的场景:老项目迁移成本高、某些浏览器插件生态依赖、团队既有技术栈沉淀,都可能让你继续留在 Selenium。我的建议是两条线并行:Selenium 作为基本功要扎实掌握,它帮你理解浏览器自动化的底层机制;新项目优先评估 Playwright,兼顾迭代效率与维护成本。掌握前者是理解地基,学会后者是提升效率,两者并不冲突。
最后分享一个我坚持了很久的小习惯。现在写任何 Selenium 脚本,我都不会直接拿最终代码开跑,而是先写一个最小验证脚本:打开目标页面、打印 driver.title、截图保存,确认环境通、版本对、页面出得来,再往里面加业务逻辑。这个习惯救过我很多次,它能把“环境问题”和“代码问题”快速分开,避免你在定位逻辑和驱动配置之间来回折腾。
还有,浏览器自动化这条路上的问题,九成以上出在“时机”和“上下文”上。数据没加载完就去找元素,是时机问题;找错了 iframe 或者窗口句柄,是上下文问题。把这两个意识刻进脑子里,Selenium 基本就稳了。爬虫成长之路越往后走,真正比拼的其实不是会用多少工具,而是排查问题的思路是否清晰、代码结构是否扛得住长期维护。希望这篇能把 Selenium 这一关讲透,让你少走一些我走过的弯路。