☰
Playwright实战:从爬虫到自动化测试的浏览器自动化指南
2026/10/22 5:49:33 网站建设 项目流程

1. 项目概述

1.1 核心需求解析

先说结论:Playwright 这个工具,现在已经是我日常工作中离不开的浏览器自动化框架了。做网页数据爬取和自动化测试,为什么偏偏是它?一句话——它把“模拟真人操作浏览器”这件事,做到了开箱即用的极致。

在做这个“从零到英雄”的项目之前,我先后试过好几种方案。老牌的 Selenium 大家应该都不陌生,生态成熟但安装驱动麻烦,等待元素的条件写法有时候真的能让人怀疑人生。Puppeteer 也很好,但 JavaScript 写起来对一些做数据处理的人不太友好,而且如果不熟悉 Node 生态,入门成本不小。Playwright 是微软开源的项目,主动支持 Python 和 JavaScript 两种主流语言,最惊艳的是一次安装就能自动处理浏览器驱动,写起来顺滑得像在操作自己的浏览器一样。

这个项目本身的目标也很纯粹:用一个自动化测试框架,把“人工在网页上点点点、捞数据、验证逻辑”的重复劳动,变成一套可复用、可维护、能跑在定时任务里的脚本。听起来不难,但真正落地时涉及到的细节非常多——从选择器的策略、动态加载的处理,到断言的姿势、反爬的应对,每个环节做好和做坏,效率差十倍都不止。

这篇文章不讲空话,我把从环境搭建、写第一个脚本,到爬取动态页面、做完整回归测试,再到部署到 CI 的全过程拆开揉碎,把我踩过坑、后来证明有效的做法都摆出来。适合谁看?正在被 Selenium 折磨的测试工程师、需要定期从网站采集数据但不想老是手动操作的分析师、想系统入门浏览器自动化的开发者。看完之后,你会发现用 Playwright 做爬取和测试,真的可以做到“一套代码,两处受益”。

1.2 项目闭环与预期效果

整个项目可以拆成四个阶段:环境与基础能力搭建 → 数据爬取实战 → 自动化测试实战 → 集成与维护。每个阶段都不是孤立的,比如你在爬取阶段写的选择器和等待策略,在测试阶段几乎可以原样复用;你在测试阶段写的断言逻辑,反过来能帮你验证爬到的数据是否完整。

我最初设想的“英雄”终点,是你能够独立完成这样的场景:每天定时打开某个需要登录的后台系统,抓取最新的业务报表数据,然后自动触发几个关键流程的测试用例,测试通过后再把结果发送到团队的消息群里。整个过程无人值守,出错了还能自动截图、记录现场。项目做完后,这套脚本在我的机器上稳定跑了三个月,平均每天帮我省下至少一个小时的重复操作。

2. 技术选型:为什么是 Playwright 而不是其他方案

2.1 浏览器自动化工具横向对比

选型的时候,我把主流方案都过了一遍。这里不回避竞争关系,直接说对比结论。

Selenium是绝大多数人入门的第一个工具,支持的语言最多,社区答案也多。但它的痛点非常突出:浏览器驱动需要单独下载和管理,版本不对就是各种诡异报错;对现代前端框架(尤其是大量使用 shadow DOM 的组件)支持比较吃力;等待机制虽然好用,但默认的隐式等待和显式等待混用起来经常出问题。

Puppeteer是 Chrome 团队推出的,性能确实好,操作 API 也很现代。问题在于它本质上只为 Chromium 系服务,虽然社区有第三方适配 Firefox 的方案,但终归不是官方支持。如果你恰好要用 Node 做爬虫,Puppeteer 是不错的选择,但如果你的数据处理流程放在 Python 里,语言切换就比较难受。

Playwright的优势正好落在了前两者的薄弱点上:

  • 驱动零管理:安装 Playwright 时会自动下载配套的浏览器内核,不需要手动配置 webdriver。
  • 三浏览器同 API:Chromium、Firefox、WebKit 共用一套代码,写一次就能在三种内核上跑,对测试场景特别有用,因为很多偶现的兼容问题只有跨内核才能测出来。
  • 等待机制更智能:wait_for_selector、expect这些内置 API 会自动重试,不再需要你手写time.sleep(5)这种既脆弱又浪费时间的代码。

我用一个实际例子说明差异。某后台管理系统有个表格页面,数据是后端接口返回后前端动态渲染的,传统的做法是time.sleep固定等两秒再抓数据。Selenium 时代我写过太多这种代码,一旦网络慢一点或接口响应超过两秒,前面全白等。Playwright 的定位方式是“等待元素达到某个状态”,比如可见、可点击、或者某个文本出现,符合条件立刻继续,最多等满超时时间。这个改动在爬虫场景下,直接决定了脚本是“偶尔抽风”还是“稳如老狗”。

2.2 语言绑定:Python 还是 JavaScript

Playwright 官方支持 TypeScript/JavaScript 和 Python。我的建议很简单:爬虫程序用 Python,测试框架如果团队前端以 JS 为主也可以用 TS。

我自己最终选了 Python,原因有三:

  1. 数据清洗和处理生态更顺手,爬下来之后可以直接用 pandas 做后续分析,不需要跨语言传递数据。
  2. 异步支持很完善,Playwright 的 Async API 配合asyncio.gather可以同时控制多个浏览器标签页,爬取效率成倍提升。
  3. 团队里搞测试和数据分析的同事大多会 Python,后期维护门槛低。

这里补充一个来自实际项目的经验:如果只是做简单的页面截图或冒烟测试,Python 和 JS 差异不大;但如果涉及大量的定制化 wait_for 逻辑和自定义 fixture(测试装置),Python 版写起来更直观,报错信息也更友好。而如果你需要和已有的前端测试基建(比如 Jest)融合,那还是用 TypeScript 绑定更利于统一技术栈。

3. 环境准备与第一个脚本

3.1 安装与初始化

环境准备这一步,我直接给出我在 Windows 和 macOS 上都验证过的流程。

# 建议使用 Python 3.9 及以上版本 pip install playwright # 安装浏览器内核(这一步会下载 chromium、firefox、webkit) playwright install chromium

这里有一个新手容易忽略的细节:playwright install默认会下载三种浏览器内核,体积大概几百 MB。如果只是做日常爬虫,可以只装 Chromium,命令是playwright install chromium。有些公司内网环境不允许直接访问下载服务器,这种情况下可以考虑设置代理变量,或者要求网络管理员提前帮你在内网镜像好浏览器包。

初始化项目目录时,我习惯建一个这样的结构:

playwright_demo/ ├── pages/ # 页面对象封装:把每个页面的选择器和操作逻辑封装成类 ├── tests/ # 测试用例 ├── scrapers/ # 爬虫脚本 ├── data/ # 爬取结果存放 └── reports/ # 测试报告

为什么要单独分一个pages/目录?这是从测试架构里的 Page Object 模式借鉴来的。把页面上的元素定位、操作步骤封装成一个个类,测试用例只负责“做什么”,不关心“元素在哪、怎么点击”。这样做的好处是,前端改了 DOM 结构,只需要修改类里的选择器,所有依赖它的测试和爬虫都不用动。我见过太多人把所有代码堆在一个文件里,前期开发很爽,两周后改版时直接想重写。

3.2 启动浏览器与上下文管理

Playwright 里有一个核心概念:Browser Context(浏览器上下文)。可以把它想象成一个独立的匿名会话——每个 context 拥有独立的 Cookie、缓存、本地存储,互不干扰。这意味着你在一个 context 里登录了网站 A,开另一个 context 时又是全新状态,非常适合并行跑多个爬虫任务。

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context( viewport={"width": 1920, "height": 1080}, locale="zh-CN", ) page = context.new_page() page.goto("https://example.com") print(page.title()) browser.close()

上面的代码是 Playwright 最经典的同步 API 范式。sync_playwright是一个上下文管理器,会自动处理资源的启动和释放。headless=True代表无头模式,浏览器不会弹出窗口,爬虫和 CI 环境里基本都是这个模式;测试阶段我经常用headless=False,这样可以肉眼盯着脚本操作,遇到问题能立刻看到现场。

这里分享一个我自己的习惯:凡是写爬虫脚本,我总会保留一个debug开关。用环境变量控制是否走有头模式,因为在排查复杂页面问题时,能亲眼看到鼠标怎么移动、元素怎么高亮,比盯着几百行日志高效得多。

4. 核心细节解析:选择器与等待策略

4.1 选择器:不做脆弱的定位

选择器的稳健程度,直接决定脚本能被使用多久。我的经验排序是:优先用 text、role 这类接近人类感知的定位方式;其次用 CSS 选择器;最后才用 XPath。

为什么优先get_by_text和get_by_role?因为它们写的是“在页面上找到包含关键词 X 的那个元素”或“找到页面上那个唯一的按钮”,语义清晰,而且对前端改版容错率高。比如一个“确认下单”按钮,用 CSS 写可能是#main > div.btn-group > button.btn-primary,只要开发人员给按钮换个 class,这个选择器立刻失效;但如果用page.get_by_role("button", name="确认下单"),不管外层 DOM 怎么变,只要按钮的文字还是“确认下单”,就能找得到。

另外,我强烈建议尽量少用 XPath 里的绝对路径,类似/html/body/div[2]/div[3]/form/input。这种定位几乎是定时炸弹——不要相信任何“页面结构不会变”的承诺,实际过一周就可能崩。

有一个场景是例外:表格类数据。很多后台管理系统的表格用固定结构渲染,此时 CSS 选择器反而更可靠。比如我要抓取表格第三列的所有单元格,用page.locator("table tbody tr td:nth-child(3)")就比get_by_text精准得多,也更方便批量处理。

4.2 显式等待:告别 sleep

很多自动化脚本最大的时间浪费,就是各种莫名的time.sleep(3)。短了元素没加载出来,长了浪费时间,运维起来极其痛苦。Playwright 的等待机制从根本上解决了这个问题。

最常用的是page.wait_for_selector(selector, state="visible"),它会轮询页面,直到元素出现且可见。还有page.wait_for_load_state("networkidle"),表示等待所有网络请求完成后再继续。不过这里要特别提醒:不要盲目依赖 networkidle。很多网站有轮询接口、埋点请求,网络可能一直不“空闲”,等待时间会超长,甚至直接超时。

我踩过的最深的一个坑是这样的:有一个报表系统,进入页面后要先等图表库渲染完毕,接口早就返回了,但 Canvas 上还在画图。用networkidle判定是完成了,实际界面上还是一片空白。后来改成page.wait_for_selector("canvas")外加一句page.wait_for_timeout(1000)——先确保画布元素存在,再适当等一秒留给渲染,问题才稳定解决。

在爬虫里我更推荐一个组合策略:先主轴页面等待关键内容出现,再辅以短等待兜底,最后用轮询验证数据条数是否满足预期。比如翻页抓取,每翻一页后,等待“下一页按钮变为可点击”或“表格行数大于某个阈值”,而不是固定等两秒。

4.3 动态数据爬取:拦截接口与渲染兜底

做数据爬取时,我们面对的目标页面通常有两类:一类是内容直接写在 HTML 里,这类最简单的直接用requests就能搞定;另一类是数据通过 JS 接口动态加载后渲染到页面上,这才是 Playwright 发挥价值的核心场景。

对于动态页,有两种思路。第一是直接拦截 XHR/Fetch 接口,这有点“降维打击”的意思。Playwright 可以监听网络请求,拿到接口返回的 JSON,极大简化数据提取。代码示例如下:

with page.expect_response(lambda response: "/api/report/list" in response.url) as response_info: page.click("#load-more") response = response_info.value data = response.json()

这个模式的好处很明显:不需要分析 DOM 结构,拿到的是最原始的结构化数据。但有个前提——你得知道接口的 URL 是哪个,而且接口的参数(比如翻页、筛选条件)需要在点击操作之前准备好。这是我最常用的一种方式,因为现在的前端项目基本都走 API 渲染,接口数据往往比 DOM 数据更精确。

第二是渲染后选数据,适用于接口加密、或者数据不是完整 JSON 的情况。直接等表格渲染完毕后,逐行读取文本:

page.goto("https://example.com/report") page.wait_for_selector("table tbody tr") rows = page.locator("table tbody tr").all_text_contents()

这两条路我都走过,给你一个选择建议:接口能拦截就拦截,省时省力;接口太复杂或做了加密,再考虑 DOM 抓取。另外不要忽视一个细节——抓取完成后主动截图或保存 HTML 快照。这不仅是为了留档,更是在排查“今天数据怎么少了”时最直接的证据。

5. 实操过程:构建一个可复用的爬虫

5.1 场景设定与页面对象封装

这个实操示例我设定一个非常典型的场景:抓取某模拟业务后台的“今日订单列表”,数据需要登录后可见,列表是分页的,每页 20 条,点击“下一页”后 URL 不变,内容动态刷新。

首先,我会把登录逻辑封装成一个页面对象类,这样爬虫脚本和测试用例都可以复用。

class LoginPage: def __init__(self, page): self.page = page def login(self, username, password): self.page.goto("https://demo.example.com/login") self.page.get_by_label("用户名").fill(username) self.page.get_by_label("密码").fill(password) self.page.get_by_role("button", name="登录").click() self.page.wait_for_selector(".dashboard")

为什么要用get_by_label而不是 CSS 选择器?因为登录框的 label 是给用户看的,开发者一般不会轻易改文案,但 input 的 id 和 name 非常容易变。用 label 定位,前端改版的容错率大幅提升。

接下来是订单列表页的封装。这里要处理一个核心逻辑:分页循环。我事先测试过,点击“下一页”按钮后,接口响应大概在 0.5 秒到 2 秒之间。不能固定等 2 秒,因为数据量少的时候响应很快,白白浪费时间;数据量大的时候又可能不够。所以最合理的方案是“以元素状态为锚点”:每点击一次下一页,等待当前页的“加载中”标识消失,或者等待表格中新增的行数出现。

class OrderListPage: def __init__(self, page): self.page = page self.next_button = page.get_by_role("button", name="下一页") def get_all_orders(self, max_pages=100): orders = [] for _ in range(max_pages): self.page.wait_for_selector("table tbody tr") rows = self.page.locator("table tbody tr").all_text_contents() if rows[0].strip() == "暂无数据": break orders.extend([row.split() for row in rows]) if not self.next_button.is_enabled(): break self.next_button.click() self.page.wait_for_selector(".table-loading", state="hidden") return orders

这里有一个细节值得细说:is_enabled()是判断按钮是否可用的关键。很多分页器的“下一页”在最后一页时会自动置灰(disabled),利用这个特性,我们可以放心地跳出循环,而不需要依赖“已知总页数”。wait_for_selector(".table-loading", state="hidden")也很关键——如果表格加载时有 loading 遮罩,我等待它消失再继续,保证拿到的每一页数据都是完整的。

5.2 登录状态的保持与复用

频繁登录不仅慢,而且每次登录都有风险(比如触发风控)。在爬虫场景里,我希望每隔一段时间登录一次,然后把登录状态保存下来,后续脚本直接加载。

Playwright 提供了context.storage_state这个接口,可以方便地持久化 Cookie 和本地存储。

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context() page = context.new_page() # 手动或自动执行登录... page.get_by_role("button", name="登录").click() page.wait_for_selector(".dashboard") # 保存登录状态到文件 context.storage_state(path="state.json") browser.close()

后续所有脚本启动时,直接加载这个状态文件:

context = browser.new_context(storage_state="state.json")

你可能会问,登录状态失效了怎么办?我的做法是加一个简单的检测:打开页面后,如果发现 URL 跳转到了登录页,或者页面上出现了“请先登录”的提示,就自动重跑一次登录流程。这个“自动补救”机制在定时任务里特别重要,因为 Cookie 的过期时间是未知的,脚本必须能自己恢复。

5.3 并发抓取提升效率

单页面的串行抓取速度有上限,但 Playwright 支持多页面并发。关键是利用 Python 的asyncio和 Playwright 的异步 API。

import asyncio from playwright.async_api import async_playwright async def scrape_one(url, semaphore): async with semaphore: async with async_playwright() as p: browser = await p.chromium.launch() context = await browser.new_context(storage_state="state.json") page = await context.new_page() await page.goto(url) await page.wait_for_selector("table tbody tr") data = await page.locator("table tbody tr").all_text_contents() await browser.close() return data async def main(): semaphore = asyncio.Semaphore(3) # 控制并发数,避免网站风控 tasks = [scrape_one(f"https://demo.example.com/detail?id={i}", semaphore) for i in range(1, 21)] results = await asyncio.gather(*tasks) return results asyncio.run(main())

这里我刻意加了信号量(Semaphore)控制在 3 个并发。原因无他——并发虽然快,但爬虫的本质是“不要给目标网站造成压力”。如果一口气开 20 个页面,对方的服务端日志一看就知道是爬虫在扫,轻则封 IP,重则整个数据源拉黑。控制在小并发,速度虽然不如满载,但胜在稳定长久。这算是我做爬虫项目坚持的一个底线原则。

6. 从爬虫到测试:复用同一套技术栈

6.1 自动化测试的核心套路

很多人把爬虫和自动化测试看成两套独立的东西,但在我看来它们本来就是一体的。爬虫脚本里的“等待某个元素出现”,其实就是测试里的“断言某个元素存在”;爬虫脚本里对接口数据的拦截,其实就是在做接口测试。

测试用例的编写核心在于断言。Playwright 提供了expect风格的断言,自带重试机制,这是我最喜欢的一点。

from playwright.sync_api import expect def test_login_success(page): page.goto("https://demo.example.com/login") page.get_by_label("用户名").fill("test_user") page.get_by_label("密码").fill("correct_password") page.get_by_role("button", name="登录").click() expect(page.locator(".dashboard")).to_be_visible() expect(page.locator(".user-name")).to_have_text("测试用户")

注意最后两行断言:第一行判断登录成功后的主面板是否可见,第二行进一步判断页面上显示的用户名是否等于预期。这两条断言一个粗粒度验证页面到达,一个细粒度验证交互结果,比单纯检查 URL 变化可靠得多。实战中我见过不少登录测试只断言 URL 或者只等一个网络请求结束,结果页面实际报错了都没测出来。

6.2 关键操作:截图、录屏与追踪

脚本挂掉不可怕,可怕的是挂掉后没有现场线索。Playwright 天生支持自动截图,但推荐用更进阶的方案——Trace Viewer(追踪查看器)。

在测试中开启追踪后,工具会记录整个会话的每个操作、每个网络请求和响应、每次点击的位置、页面截图,甚至是一个完整的“电影回放”。排查失败用例时,打开 trace 文件,拖动时间轴,就能看到脚本在哪个步骤、点了哪里、页面上发生了什么。这个功能帮我解决了大量“在我们这跑得好好的,怎么到 CI 就挂了”的问题。

context = browser.new_context( record_video_dir="videos/", tracing={ "snapshot": True, "screenshot": True, } )

开启录屏对爬虫项目同样有效——如果采集任务失败,回放录屏能看到浏览器实际操作到了哪个环节,而不只是依靠 print 出来的日志。平时为了节省资源可以不开,但定时任务中如果偶发失败,建议先开全套追踪跑一次,定位到问题再关掉。

6.3 报告与留痕

测试报告有多种方案。最简单的是用 pytest 的--html插件生成 HTML 报告,也会把失败用例的截图自动嵌入报告,非常直观。

pytest tests/ -v --html=reports/report.html --self-contained-html

如果是爬虫项目,我建议把抓取结果同时输出成 CSV 和 JSON 各一份,CSV 方便非技术人员直接打开看,JSON 方便后续程序对接。输出文件名带日期时间戳:

from datetime import datetime file_name = f"orders_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"

7. 常见问题与排查技巧实录

7.1 反爬策略与身份伪装

现在越来越多的站点会做基础的反爬。最常见的是根据浏览器的指纹识别headless模式。Playwright 在这方面比 Selenium 好很多,因为它的默认指纹伪装比较到位,但仍不是万无一失。

我的防范建议按优先级排序:

  1. 获取真实浏览器的user_agent并设置到 context。
  2. 设置合适的locale、timezone_id等参数,让环境更像真人。
  3. 控制请求频率,加入随机人话操作——比如偶尔移动一下鼠标、随机等待一小段间隔,不要让每次访问的间隔完全固定。

关于第三点,我再多说两句。真人浏览页面时会有“黏性”,比如在某个区域停留扫描一下再继续。完全规律的操作本身就是一种信号。我写过一个简单工具函数,每次翻页后随机等 0.8 到 1.5 秒:

import random import asyncio async def human_behavior(page): await page.mouse.move( random.randint(200, 1500), random.randint(200, 900) ) await asyncio.sleep(random.uniform(0.8, 1.5))

这里我需要强调一点:爬虫一定要遵守目标网站的服务条款和 robots.txt,控制在合理频率内,只采集公开或你有权访问的数据。自动化脚本是工具,用得好是效率倍增器,用不好就是给别人服务器制造麻烦。

7.2 元素定位失败的典型场景

定位失败是新人最常遇到的问题。我整理了一张排查速查表,方便你遇到时按步骤走:

现象先查什么再怎么办
选择器报 “Not Found”确认元素是否在 iframe 里先切换 frame 再定位
元素在 DOM 里但不可见确认是否有 CSS 遮挡或 display:none用is_visible()判断后再操作
点击不了/被遮挡看是否有弹窗、浮层盖住先关闭弹窗或用force=True
文本匹配不到确认页面是否在响应式布局下渲染了不同文本检查 media query 或换成 role 定位
偶发超时确认是否是网络慢调高超时或调整等待条件

iframe 场景值得单独提一下。很多第三方统计组件、客服聊天窗口都嵌在 iframe 里。Playwright 定位 iframe 内的元素,不需要像 Selenium 那样手动 switch,直接用 frame locator:

frame = page.frame_locator("#iframe-id") frame.get_by_role("button", name="关闭").click()

这个方法是我接手一个改造项目时发现的“神技”:原来同事用 Selenium 处理 iframe 切换,代码里满是 try/except,改了至少五处才搞定。换成 Playwright 之后,一行定位,逻辑清晰得不能再清晰。

7.3 执行慢与资源占用优化

无头浏览器虽然不显示窗口,但它本质上还是一个完整的浏览器进程,内存占用和 CPU 消耗都不小。我遇到过脚本跑久了内存膨胀、最后 OOM 的情况。后来总结了这几条优化经验:

  • 用了不用的 tab 及时close(),不要只靠browser.close()统一回收。
  • 图片对数据抓取通常没实际意义,通过路由拦截禁止加载图片资源。
async def block_resources(route): if route.request.resource_type in ("image", "media", "font"): await route.abort() else: await route.continue_() await page.route("**/*", block_resources)

这个route拦截其实远比单纯的提速功能强大。比如拦截统计类脚本,或者直接修改接口返回值来模拟异常场景——都是测试里的一把好手。用好了,爬虫和测试的执行速度都会有质的提升。

8. 集成自动化与定时调度

8.1 把脚本跑起来:从手动到无人值守

脚本写好了,接下去的关键一步是怎么让它定时自动跑。我当前最常用的方案是:GitHub Actions 作为定时触发器 + 云服务器运行 + 结果自动推送通知。这里我不展开平台细节,只说抽象的做法。

爬虫任务用 Cron 表达式设定执行时间,比如每天早上 9 点跑一次日报采集。测试任务则在每次代码提交后自动触发。运行结束后的通知也很重要,通常用消息 webhook 或发送邮件。如果某次运行失败,通知里要附带失败阶段的截图和关键日志。

建议在脚本开头把debug开关、目标 URL、登录账号密码全部做到环境变量中,不要把任何敏感信息硬编码到源码里。你无法保证脚本文件不会外泄,但做好这些基本习惯,能避免很多不必要的风险。

8.2 数据与代码的优雅管理

最后一步,但也是我公认最重要的习惯:做好版本控制和数据归档。

代码放在 Git 仓库里,每次修改附带 commit message 说清楚改动原因。三个月后回来看代码,你会发现这些 commit 消息是唯一的“当时为什么这么写”的线索。数据文件定期备份,不建议长期存放在运行脚本的机器上,我一般把结果同步到专用的存储目录,并对每个文件打日期标签。

测试用例的数量不用贪多,但关键路径一定要覆盖。我把测试优先级分成三档:冒烟(最高优先级,每次必跑)、关键回归(核心业务逻辑覆盖)、全量回归(定期或发版前跑)。这样既保证了速度,也保证了质量底线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询