Browser Agent 实战:让 AI 操控浏览器的架构与陷阱
从裸 Playwright 到结构化提取,拆解三层浏览器自动化抽象的适用场景、生产模式和常见踩坑。
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-use 和 Midscene.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 更重要。
本文涉及的项目
browser-use
93.4k ⭐browser-use 提供浏览器自动化 Agent 能力,让 LLM 可以理解页面并执行复杂网页操作。
Midscene.js
13.0k ⭐AI 驱动的视觉化 UI 自动化工具,支持自然语言描述操作,告别传统选择器,兼容浏览器和移动端
Playwright MCP
32.4k ⭐Playwright MCP 是微软提供的 MCP 服务器,将 Playwright 浏览器自动化能力暴露给 AI Agent,支持网页交互、截图和结构化数据提取。
AgentQL
1.4k ⭐将 AI 连接到 Web 的工具套件,提供查询语言和 Playwright 集成,支持精准、大规模地与网页元素交互和提取数据,包含 REST API 和 Python/JS SDK。
Steel Browser
7.0k ⭐Steel Browser 是一个专为 AI Agent 和应用设计的开源浏览器沙盒,提供完整的浏览器 API,支持会话管理、代理集成和自动反检测,让开发者无需关注基础设施即可实现 Web 自动化。
Page Agent
17.7k ⭐Page Agent 是阿里巴巴开发的 JavaScript 页面内 GUI 智能体,通过自然语言控制网页界面,实现自动化表单填写、页面导航和元素操作等任务。