> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Playwright 完全指南
- URL: https://blog.vercanti.com/playwright-wan-quan-zhi-nan/
- Published: 2026-08-28T14:34:42.000Z
- Updated: 2026-08-28T14:57:08.000Z
- Description: 相关文档：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 更稳定
- Author: yellowdog
- Tags: Python, 框架与库

> 官方文档：<https://playwright.dev/python/>  
> 适用版本：playwright 1.44+（2026-05-07 核实）

相关文档：[Selenium完全指南](https://blog.vercanti.com/selenium-wan-quan-zhi-nan/) [DrissionPage完全指南](https://blog.vercanti.com/drissionpage-wan-quan-zhi-nan/) [asyncio异步编程完全指南](https://blog.vercanti.com/asyncio-yi-bu-bian-cheng-wan-quan-zhi-nan/)

---

## 1\. 基础概念

### Playwright 是什么

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

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

### 安装

```bash
pip install playwright

# 安装浏览器（Chromium + Firefox + WebKit）
playwright install

# 只安装 Chromium（爬虫常用）
playwright install chromium

# 安装系统依赖（Linux）
playwright install-deps

```

---

## 2\. 同步 vs 异步 API

Playwright 提供两套 API：

```python
# 同步（适合脚本和简单爬虫）
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\. 启动配置

```python
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

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

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

```

---

## 4\. 元素定位

```python
# CSS 选择器（默认）
await page.click("#submit")
await page.fill("input[name='email']", "alice@example.com")

# 文本内容
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=请输入邮箱", "alice@example.com")

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

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

```

### Locator — 推荐的定位方式

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

# 操作时才执行
await email_input.fill("alice@example.com")
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\. 页面操作

```python
# 导航
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", "alice@example.com")
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\. 网络拦截

```python
# 拦截并修改请求
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\. 并发爬虫

```python
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\. 反检测

```python
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\. 常用代码段

### 自动登录并保存状态

```python
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}")

```

### 处理弹窗

```python
# 自动接受所有 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()

```

### 下载文件

```python
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` 更稳定：

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

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

```

### 设置合理超时

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

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

```

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

```python
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 类，测试用例无需改动。

```python
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` 更简洁，失败时提示信息也更友好。

```python
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。

```python
# 正确：上下文管理器自动清理
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")` 或更具体地等待数据填充后才出现的元素。

```python
# 等待网络空闲后再查找元素
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 自动计算元素中心坐标。

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

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

```

---

## 参见

- [Selenium完全指南](https://blog.vercanti.com/selenium-wan-quan-zhi-nan/)
- [DrissionPage完全指南](https://blog.vercanti.com/drissionpage-wan-quan-zhi-nan/)
- [asyncio异步编程完全指南](https://blog.vercanti.com/asyncio-yi-bu-bian-cheng-wan-quan-zhi-nan/)
- [pytest完全指南](https://blog.vercanti.com/pytest-wan-quan-zhi-nan/)