Browser Agent 实战:让 AI 操控浏览器的架构与陷阱

从裸 Playwright 到结构化提取,拆解三层浏览器自动化抽象的适用场景、生产模式和常见踩坑。

AgentList Team · 2026年4月28日
Browser AgentWeb 自动化PlaywrightDOM 提取AI Agent

Browser Agent 实战:让 AI 操控浏览器的架构与陷阱

Demo 里 AI 打开浏览器、搜索、下单、填写表单一气呵成,看起来像魔法。上线第一天就发现:页面改了个 CSS class,Agent 找不到按钮了;登录态莫名其妙丢了;iframe 里的内容完全看不到。这些问题不是偶发的——它们是浏览器自动化固有的工程挑战。

核心矛盾在于:LLM 擅长理解意图,但浏览器暴露的是 DOM 字符串和像素。如何在这两者之间架起可靠的桥梁,决定了你的 Browser Agent 能跑多久不挂。

三层抽象:从裸控制到结构化提取

选择错误的抽象层,是 Browser Agent 项目失败的第一大原因。不是越高级越好,也不是越底层越稳——关键是匹配任务复杂度和页面稳定性。

第一层:裸浏览器控制(Playwright MCP)

Playwright MCP 把 Playwright 的完整能力通过 MCP 协议暴露给 AI Agent。你拥有对浏览器的完全控制权——标签页管理、网络拦截、文件上传、权限弹窗,一切都可以精确操控。

适合场景:页面结构高度稳定、操作流程明确、需要精细控制(如测试自动化、已知结构的数据采集)。

代价:你需要对每个操作的时序、选择器、异常情况负责。LLM 在这里扮演的角色更接近"代码生成器"——根据意图写出 Playwright 脚本,而不是实时决策。

第二层:AI 引导的页面交互(browser-use、Midscene.js)

browser-useMidscene.js 把决策权交给 LLM:Agent 看到页面截图或 DOM 摘要,自己决定点什么、填什么。你只需要描述任务目标。

适合场景:页面结构经常变化、操作流程不固定、需要"智能"判断下一步(如竞品监控、跨站数据采集)。

代价:每一步操作都需要一次 LLM 调用来决策,成本和延迟显著增加。而且 LLM 可能做出你没想到的操作——这在涉及支付或删除时尤其危险。

第三层:结构化提取(AgentQL)

AgentQL 提供了一种完全不同的思路:不模拟用户操作,而是用查询语言直接从页面中提取结构化数据。你可以像查数据库一样查 DOM——"找到所有价格小于 $50 的商品名称和链接"。

适合场景:主要是数据采集(非交互)、需要高精度提取、目标页面可能有 A/B 测试或频繁 UI 改版。

代价:无法处理需要复杂交互(拖拽、悬停展开)才能显示的内容。本质上这是"读"而非"操作"。

选型决策

维度 Playwright MCP browser-use / Midscene.js AgentQL
页面稳定性要求
操作复杂度 任意 任意 只读
单步延迟 <100ms 2-5s (LLM) 1-3s
单步成本 ~$0 $0.01-0.05 $0.005-0.02
容错能力

实际项目中,最常见的做法是混合使用:用 AgentQL 做初始数据发现,用 Playwright MCP 处理已知路径的交互,用 browser-use 处理未知页面。

生产模式一:可靠的元素定位与 AI 视觉兜底

纯 CSS 选择器在页面改版时会全军覆没,纯 AI 视觉定位又太慢且不够精确。生产级方案是两者的分层组合。

"""
可靠元素定位:CSS 选择器 + AI 视觉兜底
需要: pip install playwright browser-use lxml
"""

import asyncio
from playwright.async_api import async_playwright
from browser_use import Agent, Browser, BrowserConfig
from langchain_openai import ChatOpenAI


async def click_with_fallback(page, selectors: list[str], description: str):
    """
    按优先级尝试多个 CSS 选择器,全部失败则退回 AI 视觉定位。
    selectors: 从最稳定到最宽松的候选选择器列表。
    description: 给 AI 视觉兜底用的元素描述。
    """
    for selector in selectors:
        try:
            locator = page.locator(selector)
            if await locator.count() > 0:
                await locator.first.click(timeout=5000)
                print(f"[CSS] 点击成功: {selector}")
                return True
        except Exception:
            continue

    # 所有选择器都失败,使用 AI 视觉兜底
    print(f"[AI] 选择器全部失败,使用视觉定位: {description}")
    llm = ChatOpenAI(model="gpt-4o")
    browser = Browser(config=BrowserConfig(headless=True))
    agent = Agent(
        task=f"点击页面上的: {description}",
        llm=llm,
        browser=browser,
    )
    result = await agent.run()
    print(f"[AI] 结果: {result}")
    return True


# 使用示例
async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        await page.goto("https://example.com/dashboard")

        # 按优先级排列的候选选择器
        await click_with_fallback(
            page,
            selectors=[
                "[data-testid='submit-btn']",    # 最稳定:测试 ID
                "button.submit-order",            # 次选:语义 class
                "button:has-text('Submit')",      # 兜底:文本匹配
            ],
            description="提交订单按钮,绿色,在页面右下角",
        )

        await browser.close()


asyncio.run(main())

关键设计点:data-testid 是第一优先级,因为它由开发团队维护、不受 UI 改版影响。如果目标站点是第三方站点不可控,就用语义 class 和文本匹配作为补充。

生产模式二:会话管理与认证持久化

Browser Agent 最被低估的挑战是认证。你不仅要登录,还要在多次任务执行之间保持登录态,同时处理 cookie 过期、2FA、SSO 跳转等问题。

"""
会话管理:跨任务持久化认证状态
需要: pip install playwright steel-py
Steel 提供浏览器会话持久化和反检测,适合生产环境。
也可以直接用 Playwright 的 storage_state 做轻量级持久化。
"""

import json
import os
from pathlib import Path
from playwright.sync_api import sync_playwright


SESSION_DIR = Path("browser_sessions")


def save_session(page, session_name: str):
    """保存当前页面的 cookie 和 localStorage 到文件。"""
    SESSION_DIR.mkdir(exist_ok=True)
    state = page.context.storage_state()
    path = SESSION_DIR / f"{session_name}.json"
    path.write_text(json.dumps(state, indent=2))
    print(f"会话已保存: {path}")


def load_session(browser, session_name: str):
    """从文件恢复会话状态,返回已认证的 page。"""
    path = SESSION_DIR / f"{session_name}.json"
    if not path.exists():
        print("未找到已有会话,创建新上下文")
        context = browser.new_context()
        return context.new_page()

    state = json.loads(path.read_text())
    context = browser.new_context(storage_state=state)
    page = context.new_page()

    # 访问目标站点验证会话是否仍然有效
    page.goto("https://example.com/dashboard", wait_until="networkidle")
    if "login" in page.url.lower():
        print("会话已过期,需要重新登录")
        context.close()
        context = browser.new_context()
        return context.new_page()

    print("会话恢复成功")
    return page


def run_task_with_session(task_fn, session_name: str = "default"):
    """
    通用任务运行器:自动加载/保存会话。
    task_fn 接收一个已认证的 page 对象。
    """
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = load_session(browser, session_name)

        try:
            task_fn(page)
            # 任务成功完成后保存会话
            save_session(page, session_name)
        except Exception as e:
            print(f"任务失败: {e}")
            # 即使失败也尝试保存——可能已经完成了部分操作
            save_session(page, f"{session_name}_recovery")
            raise
        finally:
            browser.close()


# 使用示例:带认证的定期数据采集
def collect_report(page):
    page.goto("https://example.com/reports", wait_until="networkidle")
    page.click("[data-testid='export-btn']")
    with page.expect_download() as download_info:
        page.click("button:has-text('CSV')")
    download = download_info.value
    download.save_as(f"report_{download.suggested_filename}")
    print(f"报告已下载: {download.suggested_filename}")


# 首次运行可能需要手动登录,后续运行自动复用会话
run_task_with_session(collect_report, session_name="reports_site")

对于需要更强反检测和多会话并发的场景,Steel Browser 提供了会话级别的隔离、内置代理轮换和指纹管理,省去手动维护 storage_state 的麻烦。

生产模式三:多页面编排——标签页、iframe 和弹窗

真实业务场景几乎不会只在单个页面上完成。你需要在主页面和弹出的 OAuth 窗口之间切换、在 iframe 中填写嵌入式表单、同时操作多个标签页采集数据。

"""
多页面编排:标签页、iframe 和弹窗处理
需要: pip install playwright
"""

from playwright.sync_api import sync_playwright, Page, BrowserContext


def handle_oauth_popup(context: BrowserContext, main_page: Page):
    """处理 OAuth 弹窗:在弹出窗口中完成登录,自动切回主页面。"""
    with context.expect_page() as popup_info:
        main_page.click("button:has-text('Sign in with Google')")

    popup = popup_info.value
    popup.wait_for_load_state("networkidle")
    print(f"OAuth 弹窗已打开: {popup.url}")

    # 在弹窗中执行登录操作(具体选择器因 OAuth 提供方而异)
    popup.fill('input[type="email"]', "user@example.com")
    popup.click("button:has-text('Next')")

    # 等待弹窗自动关闭,主页面刷新
    main_page.wait_for_url("**/dashboard**", timeout=30000)
    print("OAuth 完成,已回到主页面")


def operate_in_iframe(page: Page, frame_selector: str):
    """在 iframe 中执行操作,避免直接在主页面上下文中查找元素。"""
    frame = page.frame_locator(frame_selector)

    # 在 iframe 内部定位和操作元素
    frame.locator("#input-field").fill("data from agent")
    frame.locator("#submit-btn").click()

    # 等待 iframe 内部响应
    frame.locator(".success-message").wait_for(timeout=10000)
    print("iframe 内操作完成")


def multi_tab_collection(context: BrowserContext, urls: list[str]):
    """并行打开多个标签页采集数据,避免串行执行的延迟。"""
    results = []

    for url in urls:
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded")

        # 从每个页面提取关键数据
        title = page.locator("h1").first.text_content(timeout=5000) or ""
        price = page.locator("[data-price]").first.text_content(timeout=3000) or "N/A"

        results.append({"url": url, "title": title.strip(), "price": price.strip()})
        page.close()  # 及时关闭,避免内存泄漏

    return results


# 完整编排示例
def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(viewport={"width": 1920, "height": 1080})
        page = context.new_page()

        page.goto("https://example.com/app", wait_until="networkidle")

        # 场景 1:OAuth 登录弹窗
        handle_oauth_popup(context, page)

        # 场景 2:iframe 嵌入的支付表单
        operate_in_iframe(page, "iframe[name='payment-form']")

        # 场景 3:多标签页并行采集
        product_urls = [
            "https://example.com/product/1",
            "https://example.com/product/2",
            "https://example.com/product/3",
        ]
        data = multi_tab_collection(context, product_urls)
        print(f"采集到 {len(data)} 条数据")

        browser.close()


main()

核心原则:永远不要假设"当前页面"是什么。每次操作前明确你的执行上下文——是 page、是 frame、还是新弹出的 popup。用 Playwright 的 context.expect_page() 捕获弹窗,用 frame_locator() 进入 iframe,用独立的 new_page() 管理多标签页。

三个常见陷阱

陷阱一:时序假设

"点击之后立刻读取数据"——这是最常犯的错误。单页应用的数据加载是异步的,DOM 更新和数据渲染之间存在不可避免的延迟。

# 错误:点击后立即读取,数据可能还没加载
page.click("#search-btn")
results = page.locator(".result-item").all()  # 大概率拿到空列表

# 正确:等待特定条件满足后再读取
page.click("#search-btn")
page.locator(".result-item").first.wait_for(state="visible", timeout=10000)
results = page.locator(".result-item").all()

经验法则:不要用 time.sleep() 等固定延迟——它要么太短(竞态失败),要么太长(浪费时间)。用 Playwright 的 wait_for 系列方法,等待某个可观测条件(元素出现、网络空闲、URL 变化)。

陷阱二:Headless 与有头环境的差异

很多 Agent 在本地有头模式下跑得很顺利,部署到服务器 headless 模式就出问题。原因包括:

  • 视口差异:headless 默认视口可能不同,导致响应式布局变化,元素位置偏移甚至隐藏
  • 字体缺失:服务器上没有安装客户端字体,文本渲染宽度不同,布局可能改变
  • User-Agent 检测:部分网站对 headless Chrome 做特殊处理(隐藏内容、弹出验证)
  • GPU 加速缺失:WebGL 内容在 headless 模式可能无法正常渲染
# 显式指定视口和 User-Agent,缩小有头/无头差异
context = browser.new_context(
    viewport={"width": 1920, "height": 1080},
    user_agent=(
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/125.0.0.0 Safari/537.36"
    ),
    locale="zh-CN",
)

如果目标站点有反爬检测,考虑使用 Steel Browser 等内置反指纹的方案,而不是自己维护 stealth 插件。

陷阱三:忽略 Shadow DOM 和动态渲染

现代前端框架和 Web Components 大量使用 Shadow DOM。Playwright 的 page.locator() 可以穿透 open shadow root,但如果你在用原始 DOM 查询或者 AI 模型只看截图,就会完全漏掉这些内容。

# Playwright 默认可以穿透 open shadow DOM
# 但如果是 closed shadow DOM,需要特殊处理
shadow_text = page.locator("my-custom-element >> internal-button").first
shadow_text.click()

# 对于动态渲染的内容(如懒加载图片、滚动触发加载),
# 需要先触发滚动行为再提取
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
page.wait_for_timeout(1000)  # 等待懒加载完成

总结

  • 选对抽象层:页面稳定用 Playwright MCP,页面多变用 browser-use/Midscene.js,只需数据用 AgentQL。不要在简单任务上烧 LLM token,也不要在复杂交互上死磕选择器。
  • 会话管理是基础设施:不要每次任务都重新登录。用 Playwright 的 storage_state 或 Steel Browser 的会话 API 持久化认证状态,并在每次使用前验证有效性。
  • 永远不要信任时序:用条件等待替代固定延迟,用显式上下文管理替代隐式"当前页面"假设。
  • headless 不是免费午餐:部署前在有头和 headless 两种模式下都测试,显式设置视口、User-Agent 和字体。
  • 混合架构胜过单一方案:生产级 Browser Agent 几乎都需要组合多层抽象——AgentQL 发现数据,Playwright 执行已知路径,browser-use 处理未知页面。

Browser Agent 的成熟度正在快速提升,但"AI 操作浏览器"和"可靠地自动化 Web 任务"之间仍有显著差距。选择正确的工具组合、设计防御性的架构,比追求最炫的 demo 更重要。