Playwright 完全指南

相关文档:Selenium完全指南(/selenium-wan-quan-zhi-nan/) DrissionPage完全指南(/drissionpage-wan-quan-zhi-nan/) asyncio异步编程完全指南(/asyncio-yi-bu-bian-cheng-wan-quan-zhi-nan/) Playwright 是微软开源的现代端到端测试和自动化框架,支持 Chromium、Firefox、WebKit 三种浏览器引擎。 Playwright 提供两套 API: Locator 有内置重试,比直接调用 page.click 更稳定

分享

官方文档:https://playwright.dev/python/
适用版本:playwright 1.44+(2026-05-07 核实)

相关文档:Selenium完全指南 DrissionPage完全指南 asyncio异步编程完全指南


1. 基础概念

Playwright 是什么

Playwright 是微软开源的现代端到端测试和自动化框架,支持 Chromium、Firefox、WebKit 三种浏览器引擎。

特性 Playwright Selenium
浏览器支持 Chromium/Firefox/WebKit 所有主流浏览器
异步支持 原生异步 API 需要额外库
自动等待 内置智能等待 需手动等待
网络拦截 内置 需插件
截图/视频 内置 需插件
CDP 支持 有限
并行执行 原生支持 需要 Grid

安装

pip install playwright

# 安装浏览器(Chromium + Firefox + WebKit)
playwright install

# 只安装 Chromium(爬虫常用)
playwright install chromium

# 安装系统依赖(Linux)
playwright install-deps

2. 同步 vs 异步 API

Playwright 提供两套 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")
    print(page.title())
    browser.close()

# 异步(适合高并发爬虫)
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")
        print(await page.title())
        await browser.close()

asyncio.run(main())

3. 启动配置

from playwright.async_api import async_playwright

async with async_playwright() as p:
    browser = await p.chromium.launch(
        headless=True,           # 无头模式
        slow_mo=100,             # 每步操作延迟 100ms(调试用)
        args=[
            "--no-sandbox",
            "--disable-blink-features=AutomationControlled",
        ],
        proxy={"server": "http://127.0.0.1:7890"},
    )

    # 创建浏览器上下文(隔离的 cookie/存储)
    context = await browser.new_context(
        user_agent="Mozilla/5.0 ...",
        viewport={"width": 1280, "height": 720},
        locale="zh-CN",
        timezone_id="Asia/Shanghai",
        geolocation={"latitude": 39.9, "longitude": 116.4},
        permissions=["geolocation"],
        ignore_https_errors=True,    # 忽略 SSL 错误
        java_script_enabled=True,
        record_video_dir="videos/",  # 录制视频
        record_har_path="trace.har", # 录制 HAR
    )

    page = await context.new_page()

使用已有浏览器 Profile

context = await browser.new_context(
    storage_state="state.json",   # 从文件加载已保存的登录状态
)

# 保存当前登录状态
await context.storage_state(path="state.json")

4. 元素定位

# CSS 选择器(默认)
await page.click("#submit")
await page.fill("input[name='email']", "[email protected]")

# 文本内容
await page.click("text=登录")
await page.click("text=登录", exact=True)

# 角色(Accessible Role,推荐用于测试)
await page.click("role=button[name='提交']")
await page.click("role=link[name='首页']")

# 占位符
await page.fill("placeholder=请输入邮箱", "[email protected]")

# XPath
await page.click("xpath=//button[@type='submit']")

# 链式定位(在某个容器内查找)
form = page.locator("form#login")
await form.locator("input[name='email']").fill("[email protected]")
await form.locator("button[type='submit']").click()

Locator — 推荐的定位方式

# Locator 是惰性的,创建时不执行查找
email_input = page.locator("input[name='email']")
submit_btn = page.locator("button[type='submit']")

# 操作时才执行
await email_input.fill("[email protected]")
await submit_btn.click()

# 过滤
items = page.locator(".item")
active_items = items.filter(has_text="active")
item_with_btn = items.filter(has=page.locator("button.delete"))

# 获取第 n 个
first = items.first
last = items.last
nth = items.nth(2)  # 第 3 个(从 0 开始)

# 断言(内置重试)
await expect(page.locator(".success-msg")).to_be_visible()
await expect(page.locator("#count")).to_have_text("42")

5. 页面操作

# 导航
await page.goto("https://example.com")
await page.goto("https://example.com", wait_until="networkidle")  # 等到网络空闲
await page.go_back()
await page.go_forward()
await page.reload()

# 等待
await page.wait_for_selector("#result")           # 等待元素出现
await page.wait_for_selector("#loading", state="hidden")  # 等待元素消失
await page.wait_for_url("**/dashboard")           # 等待 URL 变化
await page.wait_for_load_state("networkidle")     # 等待网络空闲
await page.wait_for_timeout(1000)                 # 等待 1 秒

# 输入
await page.fill("#email", "[email protected]")
await page.type("#search", "python", delay=50)    # 逐字符输入(模拟人工)
await page.press("#search", "Enter")
await page.keyboard.press("Tab")

# 点击
await page.click("#btn")
await page.click("#btn", button="right")          # 右键
await page.dblclick("#item")                      # 双击
await page.hover("#menu")                         # 悬停

# 选择框
await page.select_option("select#city", "Beijing")
await page.select_option("select#city", label="北京")

# 上传文件
await page.set_input_files("input[type=file]", "photo.jpg")
await page.set_input_files("input[type=file]", ["file1.jpg", "file2.jpg"])

# 截图
await page.screenshot(path="page.png", full_page=True)
await page.locator("#chart").screenshot(path="chart.png")

# 执行 JS
result = await page.evaluate("() => document.title")
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")

# 获取内容
text = await page.inner_text("#content")
html = await page.inner_html("#content")
value = await page.input_value("#email")
attr = await page.get_attribute("a.link", "href")

6. 网络拦截

# 拦截并修改请求
async def handle_route(route):
    if route.request.resource_type == "image":
        await route.abort()          # 拦截图片请求(加速)
    else:
        await route.continue_()      # 放行其他请求

await page.route("**/*", handle_route)

# 修改响应
async def mock_api(route):
    await route.fulfill(
        status=200,
        content_type="application/json",
        body='{"code": 200, "data": []}',
    )

await page.route("**/api/list*", mock_api)

# 监听请求
page.on("request", lambda req: print(f">> {req.method} {req.url}"))
page.on("response", lambda res: print(f"<< {res.status} {res.url}"))

# 等待特定请求/响应
async with page.expect_response("**/api/data*") as response_info:
    await page.click("#load-btn")
response = await response_info.value
data = await response.json()

7. 并发爬虫

import asyncio
from playwright.async_api import async_playwright

async def scrape_page(browser, url: str) -> dict:
    context = await browser.new_context()
    page = await context.new_page()
    try:
        await page.goto(url, timeout=30000)
        await page.wait_for_selector(".content")
        return {
            "url": url,
            "title": await page.title(),
            "content": await page.inner_text(".content"),
        }
    except Exception as e:
        return {"url": url, "error": str(e)}
    finally:
        await context.close()

async def main(urls: list[str], concurrency: int = 5):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)

        sem = asyncio.Semaphore(concurrency)

        async def bounded_scrape(url):
            async with sem:
                return await scrape_page(browser, url)

        results = await asyncio.gather(*[bounded_scrape(url) for url in urls])
        await browser.close()
        return results

8. 反检测

from playwright.async_api import async_playwright

async with async_playwright() as p:
    browser = await p.chromium.launch(
        args=["--disable-blink-features=AutomationControlled"],
    )
    context = await browser.new_context(
        user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    )
    page = await context.new_page()

    # 注入 stealth 脚本
    await page.add_init_script("""
        Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
        Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
        Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
        window.chrome = { runtime: {} };
    """)

    # 使用 playwright-stealth 库(更完整)
    # pip install playwright-stealth
    from playwright_stealth import stealth_async
    await stealth_async(page)

9. 常用代码段

自动登录并保存状态

async def login_and_save(email: str, password: str, state_file: str = "state.json"):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context()
        page = await context.new_page()

        await page.goto("https://example.com/login")
        await page.fill("input[name='email']", email)
        await page.fill("input[name='password']", password)
        await page.click("button[type='submit']")
        await page.wait_for_url("**/dashboard")

        await context.storage_state(path=state_file)
        await browser.close()
        print(f"登录状态已保存到 {state_file}")

处理弹窗

# 自动接受所有 confirm/alert
page.on("dialog", lambda dialog: asyncio.ensure_future(dialog.accept()))

# 或手动处理
async with page.expect_event("dialog") as dialog_info:
    await page.click("#delete-btn")
dialog = await dialog_info.value
print(dialog.message)
await dialog.accept()

下载文件

async with page.expect_download() as download_info:
    await page.click("#download-btn")
download = await download_info.value
await download.save_as("output/file.xlsx")

10. 最佳实践

优先使用 Locator 而非 page.click/fill

Locator 有内置重试,比直接调用 page.click 更稳定:

# 推荐
await page.locator("#submit").click()

# 不推荐(找不到时直接报错,没有重试)
element = await page.query_selector("#submit")
await element.click()

设置合理超时

# 全局超时(默认 30 秒)
page.set_default_timeout(60000)
page.set_default_navigation_timeout(60000)

# 单次操作超时
await page.click("#btn", timeout=5000)

爬虫禁用不必要的资源加载

await page.route("**/*.{png,jpg,jpeg,gif,svg,css,font}", lambda r: r.abort())

11. 踩坑与注意事项

异步上下文必须正确关闭

如果不关闭 browser/context,进程退出时会有资源泄漏,始终使用 async with 或显式 await browser.close()

page.goto 的 wait_until 选项

触发时机
"commit" 收到响应头(最快)
"domcontentloaded" DOM 解析完成
"load" 所有资源加载完成(默认)
"networkidle" 500ms 内无网络请求(最慢,SPA 常用)

SPA 页面用 "networkidle" 或等待特定元素,而不是 "load"


最佳实践

用 Page Object Model(POM)组织测试代码:将页面操作封装为类,测试用例只调用方法,不直接操作 locator。页面结构变化时只需修改 POM 类,测试用例无需改动。

class LoginPage:
    def __init__(self, page):
        self.page = page
        self.email_input = page.get_by_placeholder("Email")
        self.password_input = page.get_by_placeholder("Password")
        self.submit_btn = page.get_by_role("button", name="Login")

    async def login(self, email: str, password: str):
        await self.email_input.fill(email)
        await self.password_input.fill(password)
        await self.submit_btn.click()
        await self.page.wait_for_url("**/dashboard")

优先用语义化定位器(role、label、text)而非 CSS 选择器get_by_role("button", name="Submit")locator(".btn-primary") 更健壮,不受样式重构影响,也符合无障碍规范。

expect() 断言替代手动等待:Playwright 的 expect() 内置自动重试(默认 5 秒),比 wait_for_selector() + assert 更简洁,失败时提示信息也更友好。

from playwright.async_api import expect
await expect(page.get_by_text("Welcome")).to_be_visible()
await expect(page.get_by_role("alert")).to_have_text("Login successful")

CI 中使用 --headed=false + --browser=chromium 固定配置:无头模式减少 CI 资源消耗;固定浏览器避免不同环境的浏览器差异导致测试结果不一致。

录制脚本作为起点,再手工优化playwright codegen 可生成初始代码,但生成的 CSS 选择器往往很脆,需要手动替换为语义化定位器。


常见陷阱

陷阱:未关闭 browser/context 造成资源泄漏

现象: 长时间运行后,系统中出现大量僵尸 Chromium 进程,内存持续增长。

原因: playwright.start() 后若不显式 browser.close(),进程会一直保留在内存中。

解决: 始终用 async with 上下文管理器,或在 finally 块中 close。

# 正确:上下文管理器自动清理
async with async_playwright() as pw:
    browser = await pw.chromium.launch()
    page = await browser.new_page()
    await page.goto("https://example.com")
    # browser 在退出 with 块时自动关闭

陷阱:wait_for_selector 等待超时在 SPA 页面失效

现象: 等待某个元素出现,但元素虽已渲染却仍超时。

原因: SPA 页面用 JS 动态更新 DOM,"load" 事件早于数据渲染完成触发,元素在网络响应返回前还未出现在 DOM 中。

解决: 使用 wait_for_load_state("networkidle") 或更具体地等待数据填充后才出现的元素。

# 等待网络空闲后再查找元素
await page.wait_for_load_state("networkidle")
await page.locator('[data-testid="product-list"]').wait_for()

陷阱:固定坐标点击在不同分辨率下失效

现象: 开发机上点击正确,CI 服务器上点击到错误位置。

原因: 使用 page.mouse.click(x, y) 硬编码坐标,不同屏幕分辨率或视口大小导致元素位置不同。

解决: 始终通过 locator 定位元素后调用 .click(),由 Playwright 自动计算元素中心坐标。

# 错误:硬编码坐标
await page.mouse.click(400, 300)

# 正确:通过 locator 点击
await page.get_by_role("button", name="Submit").click()

参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog