资讯动态

Playwright vs Selenium:自动化测试与数据采集实战指南

发布时间:2026/9/20 16:27:11 来源:尧图企业网站定制
1. 为什么我最终把自动化主力从Selenium换成了Playwright最早接触浏览器自动化用的是Selenium。那会儿写一个登录脚本光等页面加载就得加一堆time.sleep遇到动态渲染的表格更是头疼元素定位经常报NoSuchElementException。后来团队里有人推荐Playwright说它自带自动等待、支持多浏览器、还能直接录制操作生成代码。我抱着试试看的心态跑了一个Demo结果当天就把手头三个爬虫脚本全迁过去了。Playwright是微软开源的一套浏览器自动化工具支持Python、JavaScript、Java、.NET多种语言绑定。它能驱动Chromium、Firefox、WebKit三大内核提供同步和异步两套API。和Selenium相比它最大的不同在于内置了智能等待机制——你不需要手动写WebDriverWaitPlaywright会在执行点击、输入等操作前自动等待元素可交互。这个特性直接消灭了大部分因为页面加载时序导致的偶发失败。这篇文章适合谁看如果你写过Selenium但被元素定位和等待折磨过如果你需要用Python做网页数据采集但不想处理复杂的反爬逻辑如果你正在搭建自动化测试框架需要选型那这篇内容可以直接拿去参考。我会从环境搭建讲到完整项目实战每个环节都附上我实际跑通的代码和踩过的坑。注意Playwright的同步API在Jupyter Notebook里运行会报错因为Notebook本身已经有一个事件循环在跑。解决办法是用异步API或者把脚本写成.py文件用命令行执行。2. 环境搭建与浏览器驱动配置2.1 Python环境准备与Playwright安装Playwright对Python版本的要求是3.8及以上。我本地用的是3.11实测3.9到3.12都没问题。安装命令就一行pip install playwright装完之后还需要安装浏览器驱动。这一步和Selenium不同——Selenium需要你手动下载对应版本的chromedriver还得保证驱动版本和浏览器版本匹配版本对不上就报错。Playwright直接一条命令搞定playwright install这条命令会下载Chromium、Firefox、WebKit三个浏览器的二进制文件默认存放在用户目录下的缓存文件夹里。如果你只需要Chromium可以指定playwright install chromium下载速度取决于网络环境国内的话建议加个镜像源。我实测用清华源大概两分钟能下完三个浏览器。提示playwright install下载的浏览器是独立于你系统里已安装的Chrome的。也就是说你电脑上装的谷歌浏览器和Playwright用的Chromium是两套东西互不影响。这样做的好处是版本可控不会因为系统浏览器自动更新导致脚本突然跑不了。2.2 验证安装与第一个可运行脚本装完之后先验证一下环境是否正常。新建一个test_install.pyfrom playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example.com) print(page.title()) browser.close()运行python test_install.py如果看到浏览器窗口弹出来、控制台打印出页面标题说明环境没问题。headlessFalse的意思是显示浏览器界面方便调试。正式跑的时候改成True或者直接去掉这个参数默认就是无头模式。这里有个细节值得说一下sync_playwright()返回的是一个上下文管理器用with语句包裹能确保浏览器进程在脚本结束后被正确回收。我早期偷懒没写with结果跑了几十次之后系统里堆了一堆僵尸浏览器进程内存直接被吃满。2.3 浏览器启动参数调优默认启动的浏览器窗口比较小而且有些网站会检测自动化特征。我一般会加几个启动参数browser p.chromium.launch( headlessTrue, args[ --disable-blink-featuresAutomationControlled, --no-sandbox, --disable-dev-shm-usage ] )--disable-blink-featuresAutomationControlled这个参数能去掉navigator.webdriver标志降低被网站识别为自动化工具的概率。--no-sandbox和--disable-dev-shm-usage在Docker容器里跑的时候特别有用能避免共享内存不足导致的崩溃。另外创建页面上下文的时候可以设置视口大小和User-Agentcontext browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ) page context.new_page()new_context()创建的是一个独立的浏览器上下文相当于一个全新的无痕窗口。不同context之间的cookie和localStorage是隔离的做多账号测试的时候特别方便。3. 元素定位的六种武器与实战选择3.1 Playwright定位器体系全解析Playwright推荐使用page.locator()方法进行元素定位它返回的是一个Locator对象而不是直接返回元素。这个设计很巧妙——Locator是惰性的只有在真正执行操作时才会去查找元素这样就天然支持了自动等待。Playwright支持以下几种定位方式定位方式语法示例适用场景CSS选择器page.locator(#submit-btn)有id或class的常规元素XPathpage.locator(//button[typesubmit])复杂层级关系定位文本定位page.locator(text登录)按钮、链接等文本明确的元素Role定位page.get_by_role(button, name提交)语义化定位最推荐Label定位page.get_by_label(用户名)表单输入框Placeholder定位page.get_by_placeholder(请输入密码)无label的输入框我最常用的是get_by_role和get_by_text。get_by_role的好处是它按照无障碍角色来定位比如按钮就是button、链接就是link、输入框就是textbox。这种定位方式最稳定因为前端框架再怎么改class名元素的语义角色一般不会变。3.2 自动等待机制背后的逻辑Selenium最让人头疼的就是等待。你点一个按钮页面还没渲染完直接报错。你得手动加WebDriverWait还得判断用什么条件——element_to_be_clickable还是visibility_of_element_located选错了照样失败。Playwright的自动等待是这样工作的当你执行locator.click()时它会依次检查——元素是否存在于DOM中、是否可见、是否稳定没有动画在跑、是否接收事件没有被其他元素遮挡、是否可用没有disabled。只有全部通过才会执行点击。这个检查过程默认超时30秒期间会不断重试。这意味着你几乎不需要写wait语句。我迁移过来的脚本删掉了所有time.sleep和WebDriverWait代码量少了三分之一稳定性反而提高了。注意自动等待不是万能的。如果元素在点击后需要等一个网络请求返回才出现你还是得用page.wait_for_response()或者page.wait_for_selector()来显式等待。自动等待只保证操作那一刻元素是可交互的不保证后续数据已经加载。3.3 定位器调试与codegen录制Playwright自带一个代码生成工具叫codegen。运行playwright codegen https://example.com它会打开一个浏览器窗口和一个代码面板。你在浏览器里的每一步操作——点击、输入、滚动——都会实时转换成Playwright代码显示在面板里。这个工具对新手特别友好你不需要记API直接操作就能得到可运行的代码。我一般用codegen来快速获取元素的定位表达式。比如我想定位一个复杂的表格行手动写XPath很麻烦用codegen点一下它直接生成page.get_by_role(row, name张三 25 北京)这样的代码复制过来就能用。不过codegen生成的代码有时候过于具体比如它会用很长的CSS选择器链。我通常会把它生成的定位器简化一下优先用role或text定位。3.4 处理iframe和动态加载元素iframe是自动化测试里的老大难。Selenium需要switch_to.frame()切来切去很容易搞混。Playwright用frame_locator()处理frame page.frame_locator(#iframe-id) frame.get_by_role(button, name确认).click()frame_locator返回的也是一个Locator你可以在它上面继续用各种定位方法。不需要手动切换上下文Playwright会自动处理。对于动态加载的元素比如滚动到底部才加载的列表可以用page.locator(.item).last.scroll_into_view_if_needed()或者直接等元素出现page.wait_for_selector(.dynamic-content, statevisible, timeout10000)state参数支持attached存在于DOM、detached从DOM移除、visible可见、hidden隐藏四种状态。4. 完整项目实战从零搭建一个数据采集脚本4.1 项目需求与整体设计假设我们要采集一个电商网站的商品列表需求是打开搜索页、输入关键词、翻三页、提取每页的商品名称、价格、销量最后存成CSV。整体设计思路是这样的用同步API写主流程因为采集任务不需要高并发用new_context()创建独立上下文避免cookie污染用get_by_role和get_by_text做定位保证稳定性用page.wait_for_response()监听接口返回确保数据加载完成再提取。为什么不直接用requests加解析因为目标页面是前端渲染的直接请求HTML拿不到商品数据。Playwright能执行JavaScript等页面完全渲染后再提取省去了分析接口的麻烦。4.2 核心代码逐段拆解先看完整代码结构import csv from playwright.sync_api import sync_playwright def scrape_products(keyword, max_pages3): results [] with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context( viewport{width: 1920, height: 1080} ) page context.new_page() # 打开搜索页 page.goto(https://example-shop.com/search) page.get_by_placeholder(搜索商品).fill(keyword) page.get_by_role(button, name搜索).click() # 等待结果加载 page.wait_for_selector(.product-card, timeout15000) for page_num in range(max_pages): # 提取当前页数据 cards page.locator(.product-card).all() for card in cards: name card.locator(.product-name).inner_text() price card.locator(.product-price).inner_text() sales card.locator(.product-sales).inner_text() results.append({ name: name.strip(), price: price.strip(), sales: sales.strip() }) # 翻页 next_btn page.get_by_role(button, name下一页) if next_btn.is_disabled(): break next_btn.click() page.wait_for_timeout(2000) browser.close() return results def save_to_csv(data, filenameproducts.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[name, price, sales]) writer.writeheader() writer.writerows(data) if __name__ __main__: data scrape_products(笔记本电脑) save_to_csv(data) print(f共采集 {len(data)} 条数据)逐段解释关键点context browser.new_context()这行创建了一个独立的浏览器上下文。为什么要用context而不是直接用browser因为context可以设置视口、User-Agent、cookie、权限等而且不同context之间完全隔离。如果你需要同时登录多个账号每个账号开一个context就行。page.get_by_placeholder(搜索商品).fill(keyword)——fill()方法会先清空输入框再输入比type()更高效。type()是模拟逐字输入每个字符之间有延迟适合需要触发输入事件的场景。fill()是直接设置值速度快但不触发键盘事件。page.wait_for_selector(.product-card, timeout15000)——这里显式等待商品卡片出现。虽然Playwright有自动等待但locator.all()不会触发自动等待所以需要先确保元素已经存在。card.locator(.product-name).inner_text()——inner_text()返回元素可见文本会自动去除HTML标签。如果元素是隐藏的返回空字符串。需要获取包含隐藏文本的内容时用text_content()。翻页部分用is_disabled()判断下一页按钮是否可用。如果到了最后一页按钮通常会被禁用这时候直接跳出循环。4.3 数据提取的三种方式对比Playwright提取元素内容有三种方法各有适用场景方法返回值特点inner_text()可见文本自动忽略隐藏元素返回渲染后的文本text_content()全部文本包含隐藏元素返回原始文本内容get_attribute(href)属性值获取链接、图片地址等属性我一般优先用inner_text()因为它返回的是用户实际看到的内容。但有个坑如果元素被CSS隐藏了display:noneinner_text()返回空字符串。这时候得用text_content()。还有一个细节inner_text()返回的文本会保留换行和空格但会做一定的规范化。如果你需要精确的原始文本用text_content()然后自己处理。4.4 监听网络请求获取接口数据有时候页面上的数据是通过接口异步加载的直接解析DOM可能拿不全。这时候可以监听网络请求with page.expect_response(**/api/products**) as response_info: page.get_by_role(button, name搜索).click() response response_info.value data response.json()expect_response会等待匹配的请求返回。**/api/products**是URL匹配模式**匹配任意路径。拿到response后可以直接调.json()解析JSON数据。这种方式的好处是数据最原始、最完整不受页面渲染影响。缺点是得先知道接口地址而且接口格式变了脚本就得改。我通常的做法是先用DOM解析如果发现数据不全或者有遗漏再去看Network面板找接口改用请求监听的方式。5. 常见报错与排查手册5.1 超时类错误报错信息TimeoutError: locator.click: Timeout 30000ms exceeded这是最常见的错误。原因通常有三种元素定位写错了、元素被遮挡了、页面根本没加载出来。排查步骤先把headless改成False看浏览器实际打开的是什么页面。如果页面是空白或者报错页说明goto的URL有问题。如果页面正常但元素找不到用page.locator(...).count()看匹配到几个元素。返回0说明定位器写错了返回多个说明定位不够精确。如果元素存在但点不了可能是被弹窗遮挡了。加一句page.locator(.modal-close).click()先关掉弹窗。报错信息TimeoutError: page.goto: Timeout 30000ms exceeded页面加载超时。可能是网络慢也可能是页面有资源一直加载不完。解决办法是改等待策略page.goto(url, wait_untildomcontentloaded, timeout60000)wait_until支持load、domcontentloaded、networkidle、commit四种。默认是load等所有资源加载完。改成domcontentloaded只等DOM树构建完速度快很多。5.2 元素定位类错误报错信息Error: strict mode violation: locator resolved to 5 elementsPlaywright默认是严格模式一个定位器匹配到多个元素时会报错。解决办法是让定位器更精确或者用.first、.last、.nth(index)指定具体元素。page.locator(.btn).first.click() page.locator(.btn).nth(2).click()我建议优先优化定位器而不是用.first。因为.first依赖元素顺序页面结构一变就失效了。用get_by_role(button, name提交)这种语义化定位更稳定。报错信息Error: Element is not visible元素存在但不可见。可能是被CSS隐藏了或者在视口外。解决办法是先滚动到元素位置page.locator(.target).scroll_into_view_if_needed() page.locator(.target).click()如果滚动后还是不可见检查一下元素的CSS样式看是不是display:none或者visibility:hidden。5.3 浏览器启动类错误报错信息BrowserType.launch: Executable doesnt exist浏览器驱动没装。运行playwright install重新下载。报错信息Target page, context or browser has been closed浏览器被意外关闭了。常见原因是脚本里用了with语句但缩进不对导致浏览器提前关闭。或者页面触发了window.close()。还有一种情况是内存不足。Chromium比较吃内存如果同时开太多页面系统可能会杀掉浏览器进程。解决办法是限制并发数或者加--disable-dev-shm-usage参数。5.4 反自动化检测与应对有些网站会检测navigator.webdriver属性来判断是不是自动化工具。Playwright默认会把这个属性设为true。解决办法是在启动参数里加args[--disable-blink-featuresAutomationControlled]然后在页面加载前注入脚本修改属性context.add_init_script( Object.defineProperty(navigator, webdriver, { get: () undefined }) )add_init_script会在每个页面加载前执行适合做这种全局修改。注意绕过检测这件事本身有风险可能违反网站服务条款。我建议只在合法合规的场景下使用比如测试自己公司的网站。6. 同步与异步API的选择策略6.1 两种API的适用场景Playwright提供同步和异步两套API。同步API用起来简单直观适合脚本类任务。异步API基于asyncio适合需要高并发的场景。同步API的写法from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch() page browser.new_page() page.goto(https://example.com)异步API的写法import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: browser await p.chromium.launch() page await browser.new_page() await page.goto(https://example.com) asyncio.run(main())区别就是每个方法前面多了await上下文管理器从with变成async with。6.2 并发采集的性能对比我做过一个测试采集100个页面同步API串行跑用了约8分钟异步API开10个并发只用了1分半。差距非常明显。异步并发的核心是asyncio.gather()async def scrape_page(context, url): page await context.new_page() await page.goto(url) title await page.title() await page.close() return title async def main(): async with async_playwright() as p: browser await p.chromium.launch() context await browser.new_context() urls [fhttps://example.com/page/{i} for i in range(100)] tasks [scrape_page(context, url) for url in urls] results await asyncio.gather(*tasks) await browser.close()asyncio.gather会同时调度所有任务。但注意不要开太多并发一般10到20个就够了。开太多会导致目标网站响应变慢甚至封IP。6.3 异步环境下的常见陷阱异步API最容易踩的坑是在Jupyter Notebook里跑。Notebook本身运行在事件循环里你再调asyncio.run()会报RuntimeError: This event loop is already running。解决办法是用nest_asyncioimport nest_asyncio nest_asyncio.apply()或者直接用await main()而不是asyncio.run(main())。另一个坑是异步环境下的异常处理。同步代码里try/except能捕获所有异常但异步任务里的异常如果不处理会被静默吞掉。建议在gather里加return_exceptionsTrueresults await asyncio.gather(*tasks, return_exceptionsTrue) for r in results: if isinstance(r, Exception): print(f任务失败: {r})7. 我踩过的五个坑和对应的解决方案7.1 坑一忘记关闭浏览器导致内存泄漏早期写脚本跑完就完事了没管浏览器进程。结果跑了几百次之后系统里堆了几十个Chromium进程每个占几百MB内存16G内存直接吃满。解决方案永远用with语句包裹sync_playwright()或者显式调browser.close()。如果脚本可能中途崩溃用try/finally确保关闭browser p.chromium.launch() try: page browser.new_page() # 业务逻辑 finally: browser.close()7.2 坑二在循环里反复创建context有个脚本需要在多个页面之间切换我一开始在循环里每次都browser.new_context()结果跑了几十次之后浏览器卡死。原因是每个context都会占用资源创建太多不释放就会耗尽。解决方案复用context只在需要隔离cookie时才创建新的。如果确实需要多个context用完就context.close()。7.3 坑三用time.sleep代替wait_for从Selenium迁移过来的时候习惯性地保留了time.sleep(3)。结果发现有时候3秒不够有时候3秒又太长浪费时间。解决方案全部替换成Playwright的等待方法。等元素出现用wait_for_selector等请求返回用wait_for_response等页面加载用wait_for_load_state。这些方法都是条件触发比固定等待高效得多。7.4 坑四忽略strict mode导致定位失败Playwright默认严格模式一个定位器匹配多个元素就报错。我一开始不知道这个机制写了个page.locator(button).click()页面上有十几个按钮直接报错。解决方案定位器尽量精确。用get_by_role指定角色和名称用get_by_text指定文本内容。如果确实需要操作多个元素用.all()获取列表然后遍历。7.5 坑五headless模式下截图是空白的有次用headless模式跑脚本截图出来全是白屏。排查了半天发现是页面还没渲染完就截图了。解决方案截图前先等页面稳定page.wait_for_load_state(networkidle) page.screenshot(pathscreenshot.png, full_pageTrue)full_pageTrue会截取整个页面包括需要滚动才能看到的部分。不加这个参数只截取当前视口。8. 从脚本到框架的进阶路线8.1 用pytest组织测试用例单个脚本跑没问题但用例多了就需要管理。pytest是Python生态里最流行的测试框架和Playwright配合得很好。先装插件pip install pytest-playwright然后写测试文件import pytest from playwright.sync_api import Page, expect def test_login_success(page: Page): page.goto(https://example.com/login) page.get_by_label(用户名).fill(admin) page.get_by_label(密码).fill(123456) page.get_by_role(button, name登录).click() expect(page.get_by_text(欢迎回来)).to_be_visible() def test_login_failure(page: Page): page.goto(https://example.com/login) page.get_by_label(用户名).fill(admin) page.get_by_label(密码).fill(wrong) page.get_by_role(button, name登录).click() expect(page.get_by_text(密码错误)).to_be_visible()pytest-playwright插件会自动注入pagefixture每个测试用例拿到的是全新的页面。expect()是Playwright的断言方法自带自动重试比普通的assert更适合UI测试。8.2 页面对象模型封装用例多了之后定位器散落在各个测试文件里改一个元素要改好几个地方。页面对象模型Page Object Model把页面元素和操作封装成类class LoginPage: def __init__(self, page): self.page page self.username_input page.get_by_label(用户名) self.password_input page.get_by_label(密码) self.login_button page.get_by_role(button, name登录) def goto(self): self.page.goto(https://example.com/login) def login(self, username, password): self.username_input.fill(username) self.password_input.fill(password) self.login_button.click()测试用例变成def test_login(page): login_page LoginPage(page) login_page.goto() login_page.login(admin, 123456) expect(page.get_by_text(欢迎回来)).to_be_visible()这样定位器只在页面类里维护一份页面改版时只需要改一个文件。8.3 配置文件与多环境切换不同环境开发、测试、生产的URL和账号不一样硬编码在代码里不方便。可以用配置文件管理# config.py import os ENV os.getenv(TEST_ENV, dev) CONFIGS { dev: {base_url: https://dev.example.com, username: dev_user}, staging: {base_url: https://staging.example.com, username: stg_user}, prod: {base_url: https://example.com, username: prod_user} } def get_config(): return CONFIGS[ENV]跑的时候通过环境变量切换TEST_ENVstaging pytest。8.4 持续集成中的Playwright配置在CI环境里跑Playwright需要注意几点用headless模式、加--no-sandbox参数、设置合理的超时时间。GitHub Actions的配置大概是这样- name: Install dependencies run: | pip install playwright pytest-playwright playwright install chromium - name: Run tests run: pytest --browserchromium --headedfalse如果测试失败需要看截图可以配置--screenshotonly-on-failure失败时自动截图保存。9. 关于Playwright的一些个人体会用了一年多Playwright最大的感受是它把自动化测试的门槛降低了很多。以前写Selenium脚本光处理等待和元素定位就要花掉一半时间现在这些都被框架自动处理了我可以把精力放在业务逻辑上。自动等待机制是我最喜欢的功能。它不像显式等待那样需要你预判页面行为而是像真人操作一样——看到按钮可点了就点没看到就等一会儿。这种设计思路更符合直觉。codegen工具对新手特别友好。我教团队新人用Playwright第一件事就是让他们用codegen录一遍操作看看生成的代码长什么样。有了直观感受之后再学API理解起来快很多。当然Playwright也不是银弹。它比较吃内存开太多并发会卡。它的定位器虽然强大但遇到Shadow DOM嵌套很深的页面还是得用XPath硬啃。另外它的社区规模比Selenium小遇到冷门问题搜到的资料少一些。不过总体来说如果你现在开始一个新项目我建议直接上Playwright。Selenium的生态虽然成熟但Playwright在开发体验和稳定性上的优势太明显了。迁移成本也不高API设计很直观有Selenium基础的话一两天就能上手。最后分享一个小技巧调试的时候把headless设为False然后用page.pause()在关键步骤暂停。这样浏览器会停在那里你可以打开开发者工具检查元素、看网络请求。排查问题的效率比看日志高得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价