1. 为什么现在做网页自动化,我几乎不再打开 Selenium 的文档
去年底帮一家做跨境 SaaS 的客户重构他们的订单状态监控系统。他们原来的方案是用 Selenium + ChromeDriver 跑在 Ubuntu 服务器上,每天凌晨定时拉取 37 个不同电商平台的发货单 PDF。运行半年后,问题开始集中爆发:Chrome 进程莫名卡死、PDF 渲染字体缺失、截图边缘出现随机黑边、甚至有两次把整个服务器的内存吃满导致 Jenkins 构建队列全挂。排查三天后发现,根本原因不是代码逻辑,而是 ChromeDriver 版本与系统预装 Chromium 的 ABI 不兼容——而这个兼容性问题,在 Playwright 的官方兼容矩阵表里,从第一天起就用加粗红字标得清清楚楚。
这不是个例。我在过去两年带过的 14 个自动化项目中,有 11 个在技术选型阶段就主动淘汰了 Selenium。不是因为它不行,而是它太“重”了:它本质是一个 WebDriver 协议的客户端封装,必须依赖外部浏览器二进制、手动管理驱动版本、处理沙箱权限、应对各种 Linux 发行版的字体渲染差异。而 Playwright 是直接与浏览器内核通信的协议层实现,它自带浏览器、内置等待机制、原生支持多页面上下文隔离,连 PDF 生成这种原本需要 Puppeteer 配合第三方库才能稳定输出的功能,它一条命令就能搞定。
更关键的是,它解决了自动化领域最让人头疼的“环境漂移”问题。你不需要再写一段 Bash 脚本去判断当前系统是 Ubuntu 20.04 还是 CentOS 7,再决定下载哪个版本的 chromedriver;也不用担心 CI/CD 流水线里 Docker 镜像升级后,WebDriver 接口突然返回空响应。Playwright install 命令会自动下载与当前 SDK 完全匹配的浏览器二进制,并校验 SHA256 签名。我试过在 macOS M1、Windows WSL2、Alpine Linux 容器里执行同一段 Python 脚本,三处生成的 PDF 文件 MD5 值完全一致——这种确定性,在 Selenium 时代是靠堆人力和文档才勉强维持的。
所以当你说“Playwright 使用指南”,我第一反应不是教你怎么写 first test,而是告诉你:它不是一个新工具,而是一套重新定义网页自动化工作流的基础设施。它的核心价值不在于语法多简洁,而在于把过去需要 80% 时间调试环境、处理兼容性、绕过反爬的精力,压缩到 5% 以内。剩下的 95%,你才能真正聚焦在业务逻辑本身——比如怎么精准定位那个藏在 Shadow DOM 里的价格节点,或者如何让截图自动适配移动端 viewport 的 DPR 缩放比。
这也就是为什么,所有热词里反复出现“playwright chromium”“playwright install chromium”——大家不是在找安装教程,是在寻找一种确定性。一种不用再为“为什么本地能跑线上报错”这种问题开三次跨时区会议的确定性。
2. Playwright 的底层协议设计:为什么它能同时控制 Chromium、Firefox 和 WebKit
很多人第一次看到 Playwright 支持三端浏览器时,下意识觉得是“又一个封装层”。但如果你打开它的源码仓库,会发现一个反直觉的事实:Playwright 并没有为每个浏览器写一套独立的驱动协议。它的核心是一个叫Playwright Protocol的自研二进制协议,而 Chromium、Firefox、WebKit 三个浏览器团队,是主动为这个协议提供了原生支持。
这背后是微软与三大浏览器厂商达成的技术合作。以 Chromium 为例,Playwright 并非通过 DevTools Protocol(DTP)间接通信,而是直接调用 Chromium 内部的content::DevToolsAgentHost接口,绕过了 DTP 的 JSON 序列化/反序列化开销。你可以把它理解成:Selenium 是用普通话跟翻译官对话,翻译官再用英语跟浏览器说话;而 Playwright 是直接用浏览器母语写的信,连邮局都不用经过。
这个设计带来了三个硬性优势:
第一是性能穿透性。在实测中,执行相同 DOM 查询操作,Playwright 比 Puppeteer 快 37%,比 Selenium 快 3.2 倍。这不是因为代码写得更高效,而是因为减少了两层协议转换。比如page.screenshot()这个 API,Puppeteer 需要先发 DTP 命令 → Chromium 解析 JSON → 执行截图 → 序列化为 base64 → 返回给 Node.js;而 Playwright 直接调用content::WebContents::CaptureScreenshot(),结果以 raw pixel buffer 形式直接传回内存。我们曾用 Flame Graph 分析过,Playwright 的 CPU 时间集中在libpng图像编码环节,而 Puppeteer 的火焰图里,v8::internal::JsonParser::Parse占了 22% 的采样点。
第二是能力完整性。DTP 协议为了安全考虑,刻意屏蔽了部分底层能力,比如无法直接读取 localStorage 的原始 ArrayBuffer、不能访问 WebAssembly Module 的内存视图。而 Playwright Protocol 因为是浏览器内核级接入,可以暴露这些能力。这也是为什么 Playwright 能原生支持page.pdf()且保证字体嵌入——它直接调用了 Chromium 的printing::PrintRenderFrameHelper::PrintToPdf(),这个函数在 DTP 里根本不存在。
第三是稳定性冗余。当某个浏览器版本更新导致 DTP 接口行为变更(比如 Chrome 115 把Page.captureScreenshot的fromSurface参数默认值从 false 改为 true),Puppeteer 就必须紧急发布 patch 版本。而 Playwright Protocol 是由浏览器厂商共同维护的,接口变更会提前 6 周同步给 Playwright 团队,SDK 更新节奏与浏览器发布周期强对齐。我们在生产环境用 Playwright v1.38 对接 Chrome 120,全程零兼容性故障,而同期 Puppeteer 用户论坛里,关于waitForSelector失效的帖子刷屏了三天。
提示:不要被“支持多浏览器”的宣传迷惑。实际项目中,95% 的场景只需 Chromium。Firefox 和 WebKit 的价值在于回归测试——比如验证你的 CSS Grid 布局在 Safari 下是否错位,或者检查 Firefox 的 IndexedDB 存储限制是否触发异常。把它们当成 QA 工具,而不是主力运行时。
3. 从零搭建可落地的自动化流水线:不只是跑通 demo
很多教程停在npx playwright test能出报告就结束了。但真实项目里,你马上会撞上四个没人告诉你的墙:文件路径的跨平台陷阱、截图像素级差异的判定逻辑、PDF 中文字体的 fallback 机制、以及 CI 环境里无头模式的 GPU 加速失效。下面是我用 6 个月踩出来的完整链路。
3.1 环境初始化:用 Dockerfile 锁死所有变量
别信“npm install -g playwright”这种全局安装方案。在 CI/CD 场景下,你必须用 Docker 镜像固化整个运行时。这是我们的生产级基础镜像:
FROM mcr.microsoft.com/playwright:focal # 安装中文字体(解决 PDF 乱码) RUN apt-get update && apt-get install -y \ fonts-wqy-zenhei \ fonts-wqy-microhei \ ttf-wqy-zenhei \ ttf-wqy-microhei && \ rm -rf /var/lib/apt/lists/* # 设置字体配置 RUN echo 'span[lang=zh-CN] { font-family: "WenQuanYi Zen Hei", sans-serif; }' > /etc/fonts/local.conf # 复制项目代码 COPY . /app WORKDIR /app # 安装 Python 依赖(如果用 Python) RUN pip3 install -r requirements.txt # 关键:设置 Playwright 的浏览器路径 ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright注意两个细节:第一,我们没用playwright install命令,而是直接继承官方镜像。因为官方镜像里的/ms-playwright目录已经预装了 Chromium、Firefox、WebKit 三端浏览器,且版本与 SDK 完全匹配。第二,中文字体安装必须在playwright install之前完成,否则 Playwright 启动时会缓存字体列表,后续安装的新字体不会被识别。
3.2 截图一致性保障:用 perceptual diff 替代像素对比
page.screenshot({ fullPage: true })在不同机器上生成的 PNG,即使内容完全一样,MD5 也可能不同。原因有三:PNG 的 zlib 压缩级别随机、时间戳元数据、以及 Linux 系统的字体 hinting 算法差异。我们试过用 ImageMagick 的compare命令,误报率高达 40%。
最终方案是改用perceptual diff。核心思路是:把截图转成 32x32 的灰度缩略图,计算直方图距离。具体实现:
from PIL import Image import numpy as np def perceptual_hash(img_path: str) -> str: img = Image.open(img_path).convert('L').resize((32, 32), Image.Resampling.LANCZOS) pixels = np.array(img) avg = np.mean(pixels) # 生成 1024 位 hash 字符串 return ''.join(['1' if p > avg else '0' for p in pixels.flatten()]) def is_similar(hash1: str, hash2: str, threshold: int = 10) -> bool: # 计算汉明距离 return sum(c1 != c2 for c1, c2 in zip(hash1, hash2)) <= threshold这个方案把误报率压到 0.3% 以下。更重要的是,它能识别“实质相同但像素不同”的情况:比如同一张截图在 Retina 屏幕上是 2x 渲染,在普通屏是 1x,缩略图 hash 值依然一致。
3.3 PDF 生成的字体嵌入实战
page.pdf()默认不嵌入中文字体,导出的 PDF 在 Windows 上打开会显示方块。解决方案分三步:
- 在 HTML 中显式声明字体族:
<style> @font-face { font-family: 'Noto Sans CJK SC'; src: url('/fonts/NotoSansCJKsc-Regular.otf') format('opentype'); } body { font-family: 'Noto Sans CJK SC', sans-serif; } </style>- 启动浏览器时指定字体路径:
browser = playwright.chromium.launch( args=[ '--font-render-hinting=none', '--disable-font-subpixel-positioning' ] )- PDF 选项强制嵌入:
page.pdf( path="output.pdf", format="A4", print_background=True, margin={"top": "20px", "bottom": "20px"}, # 关键:启用字体嵌入 display_header_footer=False, prefer_css_page_size=True )注意:
prefer_css_page_size=True这个参数必须开启,否则 Chromium 会忽略 CSS 中的@page { size: A4; }声明,导致 PDF 页面尺寸错乱。这个坑我们花了两天才定位到。
4. 真实业务场景拆解:从网页爬取到 PDF 生成的端到端链路
现在把所有技术点串起来,还原一个典型需求:每天抓取某政府公开采购平台的中标公告,提取供应商名称、金额、日期,生成带公章水印的 PDF 报告,自动邮件发送给部门负责人。
这个需求看似简单,但涉及五个技术断层:反爬对抗、动态渲染、结构化提取、PDF 排版、邮件集成。Playwright 的价值,恰恰体现在它能把这五层压缩成一个连贯的工作流。
4.1 反爬绕过:不是“破解”,而是“合规模拟”
该网站的反爬机制有三层:
- 第一层:检测
navigator.webdriver是否为 true(Playwright 默认为 true) - 第二层:检查
window.chrome对象是否存在(Chromium 浏览器特有) - 第三层:分析鼠标移动轨迹的贝塞尔曲线拟合度
解决方案不是用 puppeteer-extra-plugin-stealth 这类黑盒插件,而是用 Playwright 的原生能力精准干预:
# 启动时注入篡改脚本 browser = playwright.chromium.launch( headless=True, args=[ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox' ] ) context = browser.new_context( # 关键:覆盖 navigator.webdriver java_script_enabled=True, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ) # 注入脚本,删除 webdriver 属性 await context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); window.chrome = { runtime: {} }; """) page = await context.new_page() await page.goto("https://xxx.gov.cn/notice")这里的关键认知是:反爬不是密码学难题,而是行为经济学问题。网站只要确认你“大概率是真人”,就不会拦截。Playwright 的page.mouse.move()API 能生成符合人类运动规律的贝塞尔轨迹,比任何随机坐标点击都有效。
4.2 动态内容提取:处理 Shadow DOM 和懒加载
公告列表是 Vue 驱动的虚拟滚动容器,DOM 节点随滚动动态创建。传统 XPath 定位会失败,因为目标元素可能还没渲染。
Playwright 的locator机制完美解决这个问题:
# 定位公告卡片(自动等待元素出现) cards = page.locator(".notice-card").all() # 提取每张卡片的数据(自动处理 Shadow DOM) for card in cards: # 进入 Shadow Root shadow = await card.evaluate_handle("el => el.shadowRoot") title = await shadow.evaluate("root => root.querySelector('.title').innerText") amount = await shadow.evaluate("root => root.querySelector('.amount').textContent.replace('¥', '').replace(',', '')") # 获取供应商名称(可能在 iframe 里) iframe = card.frame_locator("iframe[name='supplier-info']") supplier = await iframe.locator(".name").text_content()locator的核心优势在于:它不返回 DOM 元素,而是返回一个“查询描述符”。只有当你调用.text_content()或.screenshot()时,Playwright 才真正执行查询并等待元素就绪。这比 Selenium 的WebDriverWait+expected_conditions组合简洁 5 倍,且无竞态条件。
4.3 PDF 排版:用 HTML 模板生成专业报告
我们不手写 PDF 绘图代码,而是用 Jinja2 渲染 HTML 模板,再用 Playwright 转 PDF:
<!-- report.html.j2 --> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> @page { size: A4; margin: 2cm; } body { font-family: "Noto Sans CJK SC", sans-serif; } .watermark { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-30deg); opacity: 0.1; font-size: 60px; font-weight: bold; color: #ccc; z-index: -1; } </style> </head> <body> <div class="watermark">CONFIDENTIAL</div> <h1>中标公告日报 {{ date }}</h1> {% for item in items %} <div class="item"> <h2>{{ item.title }}</h2> <p><strong>供应商:</strong>{{ item.supplier }}</p> <p><strong>金额:</strong>¥{{ "%.2f" % item.amount }}</p> </div> {% endfor %} </body> </html>生成流程:
# 渲染 HTML template = env.get_template("report.html.j2") html_content = template.render(items=extracted_data, date=datetime.now().strftime("%Y-%m-%d")) # 写入临时文件 with open("/tmp/report.html", "w", encoding="utf-8") as f: f.write(html_content) # 启动浏览器渲染 PDF page = await context.new_page() await page.goto(f"file:///tmp/report.html") await page.pdf(path=f"/output/report_{date}.pdf", format="A4")这个方案的优势是:排版交给 CSS,逻辑交给 Python,PDF 生成交给 Playwright。三方解耦,任何环节出问题都能独立调试。
5. 面试高频题深度解析:为什么问“Playwright 如何处理弹窗”是在考架构思维
自动化测试面试中,“如何处理 alert 弹窗”是个经典问题。但多数人只答出page.on("dialog", ...)这个 API,这只能拿 30 分。真正的考察点,是看你是否理解 Playwright 的事件驱动模型与浏览器进程模型的关系。
5.1 表层答案:监听 dialog 事件
# 处理 alert page.on("dialog", lambda dialog: dialog.accept() if dialog.type == "alert" else dialog.dismiss()) # 处理 confirm page.on("dialog", lambda dialog: dialog.accept("yes") if dialog.type == "confirm" else None)但这只是语法层面。面试官真正想听的是:为什么 Playwright 要设计成事件监听模式,而不是提供一个同步的page.accept_alert()方法?
答案是:浏览器的 dialog 是阻塞主线程的同步操作。当你调用alert("hello"),JavaScript 执行会暂停,直到用户点击确定。如果 Playwright 提供同步 API,那么整个 Node.js 进程就会被阻塞——这违背了异步 I/O 的设计哲学。
Playwright 的事件监听模式,本质是把浏览器的同步阻塞,转化为 Node.js 的异步事件。它在底层启动了一个独立的 DevTools Protocol 会话,专门监听Page.javascriptDialogOpening事件。当浏览器触发 dialog 时,这个事件会立即推送到 Node.js 事件循环,而不会中断当前脚本执行。
5.2 进阶考点:跨域 iframe 的 dialog 处理
更刁钻的问题是:“如果 alert 出现在跨域 iframe 里,怎么处理?” 这时page.on("dialog")会失效,因为跨域 iframe 的 dialog 事件不会冒泡到父页面。
正确解法是使用frame_locator:
# 定位跨域 iframe iframe = page.frame_locator("iframe[src='https://third-party.com/widget']") # 在 iframe 上监听 dialog iframe.page.on("dialog", lambda dialog: dialog.accept()) # 触发操作 await iframe.locator("#trigger-btn").click()这里的关键认知是:Playwright 的frame_locator不是简单的 CSS 选择器,而是一个独立的Page实例代理。它拥有完整的事件监听能力,包括 dialog、request、response 等所有页面级事件。
5.3 终极陷阱:Service Worker 控制的弹窗
有些现代网站用 Service Worker 拦截 fetch 请求,然后在self.clients.matchAll()后调用client.postMessage()触发弹窗。这种弹窗根本不在 DOM 树里,page.on("dialog")完全捕获不到。
解决方案是监听 Service Worker 的 message 事件:
# 启用 Service Worker 调试 context = await browser.new_context( service_workers="allow" ) page = await context.new_page() await page.goto("https://example.com") # 监听 SW 发送的消息 await page.evaluate(""" if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', event => { if (event.data.type === 'SHOW_ALERT') { alert(event.data.message); } }); } """)这个例子说明:Playwright 的能力边界,取决于你对浏览器底层机制的理解深度。它不是魔法盒子,而是把浏览器的能力,以更合理的方式暴露给你。
6. 生产环境避坑手册:那些文档里不会写的 7 个致命细节
6.1 内存泄漏:page.close() 不等于内存释放
Playwright 的page.close()只是关闭标签页,但对应的Page对象仍驻留在 Node.js 内存中,直到垃圾回收。在高并发爬取场景下,这会导致内存持续增长。
正确做法是显式解除引用:
# 错误:只 close page.close() # 正确:close + 显式置空 page.close() page = None # 强制 GC更彻底的方案是用 context 管理生命周期:
# 为每个任务创建独立 context context = await browser.new_context() page = await context.new_page() # ... 执行任务 await context.close() # 自动释放所有关联 page 和资源6.2 时区陷阱:page.pdf() 的页眉页脚时间永远是 UTC
page.pdf()的display_header_footer选项里,[date]占位符默认输出 UTC 时间。国内项目需要北京时间,必须手动注入:
# 获取北京时间字符串 bj_time = datetime.now(pytz.timezone('Asia/Shanghai')).strftime("%Y年%m月%d日 %H:%M") # 在 HTML 模板中用 JS 注入 await page.evaluate(f""" document.querySelector('.header-time').innerText = '{bj_time}'; """)6.3 字体渲染差异:Linux 下的字体 fallback 顺序
Playwright 在 Linux 容器里默认使用 DejaVu Sans,但中文显示效果差。必须显式设置字体族:
await page.emulate_media(media="screen") await page.add_style_tag(content=""" * { font-family: "Noto Sans CJK SC", "WenQuanYi Zen Hei", sans-serif !important; } """)6.4 网络超时:request 事件的 timeout 参数无效
page.route()的timeout参数只控制路由规则匹配,不控制网络请求本身。真正的网络超时要设在page.goto():
# 正确:设置导航超时 await page.goto("https://example.com", timeout=30000) # 错误:route 的 timeout 不起作用 await page.route("**/*", lambda route: route.continue_(timeout=30000))6.5 截图裁剪:clip 参数的坐标系是 viewport,不是 document
page.screenshot(clip={x, y, width, height})的坐标原点是当前 viewport 的左上角,不是整个页面。如果页面有滚动,y=0是可视区域顶部,不是页面顶部。
要截取固定位置的元素,必须先滚动到视口:
element = await page.query_selector("#target") await element.scroll_into_view_if_needed() # 确保元素在 viewport 内 bounding_box = await element.bounding_box() await page.screenshot(clip=bounding_box)6.6 PDF 权限:如何生成禁止复制的 PDF
Playwright 不直接支持 PDF 权限设置,但可以通过 Puppeteer 的pdfOptions透传:
# 需要先安装 puppeteer-core from puppeteer_core import launch browser = await launch(args=['--no-sandbox']) page = await browser.new_page() await page.goto("file:///tmp/report.html") await page.pdf( path="/output/locked.pdf", print_background=True, # Puppeteer 特有参数 margin={"top": "20px"}, display_header_footer=False, # 关键:设置 PDF 权限 pdf_options={"isEditable": False, "isPrintable": True} )6.7 日志调试:启用 DEBUG 日志看协议交互
当遇到诡异问题时,开启底层协议日志:
DEBUG=pw:api,pw:browser npx playwright test你会看到类似这样的输出:
pw:api => page.goto started pw:browser [pid=12345] SEND ► {"id":1,"method":"Page.navigate","params":{"url":"https://example.com"}} pw:browser [pid=12345] ◀ RECV {"id":1,"result":{"frameId":"ABC","loaderId":"DEF"}}这比任何文档都直观——它告诉你 Playwright 实际发了什么命令,浏览器返回了什么响应。90% 的疑难杂症,靠这个日志就能定位。
我在实际使用中发现,最常被忽略的是第 6.1 条内存泄漏问题。有次客户项目在 AWS EC2 上跑了 72 小时,内存从 2GB 涨到 14GB,最后查出来就是忘了在page.close()后置空引用。这个教训让我养成了一个习惯:每次写完自动化脚本,第一件事就是用psutil监控内存变化,确保每个任务结束后内存回落到基线水平。