开头
说到浏览器自动化,很多人第一反应是写一堆waitForSelector、page.click()、page.fill()的脚本,跑起来之后小心翼翼,生怕某个弹窗打乱节奏。那如果是让一个 AI 模型来操作浏览器呢?问题一下子变得复杂:模型怎么知道页面上有什么?怎么把“点击右上角那个蓝色的按钮”翻译成可执行的指令?又怎么保证操作的是对的那个元素?
这就引出了今天要聊的核心方案——把 Playwright 与 MCP(Model Context Protocol)结合,用可访问性树(Accessibility Tree)作为 AI 与浏览器之间的“语言中介”。简单说,这个架构不是教 AI 去猜 DOM 结构,而是让 AI 直接读取浏览器已经算好的、语义化的界面描述,再通过一组标准工具完成点击、输入、跳转这些操作。它能解决的核心问题是:LLM 在页面环境里“看不清”和“不会操作”这两大痛点。适合正在做 AI Agent、自动化测试、爬虫工具链,或者想给自己的应用接入自然语言操作能力的开发者参考。
我实际搭建并跑通了这套方案,下面把架构设计、核心实现、踩坑记录全部拆开讲清楚。
1. 先说清楚:为什么是可访问性树,而不是直接抓 DOM
1.1 可访问性树到底是什么
可访问性树是浏览器渲染引擎在 DOM 树之外另外维护的一棵语义树。它存在的本来目的,是给屏幕阅读器、盲文设备这类辅助技术提供“朗读素材”——每个节点都描述一个 UI 元素的作用和当前状态。
比如一个<div>,在 DOM 里就是div,但浏览器可能把它计算成一个button角色,或者一个checkbox角色。再比如一个输入框,DOM 里可能有复杂的嵌套结构,但可访问性树里就是一个干净的textbox节点,带name和value属性。
关键点在于:
- DOM 反映的是开发者写的结构,可访问性树反映的是用户实际感知到的语义。
- DOM 可能很脏,但可访问性树已经被浏览器“清洗”过一遍。
- 对 LLM 来说,语义化描述远胜过一堆标签嵌套。
可以这么类比:DOM 是一堆电路图和焊点,可访问性树则是设备外壳上的按钮标签。后者才是正常人(以及 AI)理解设备的方式。
1.2 LLM 直接看页面,会遇到哪些坑
如果用传统方式把页面内容塞给 LLM,通常有三种做法,各有各的问题:
- 直接喂 HTML 字符串:页面越大文本越长,token 消耗爆炸,而且大量标签对语义理解没有帮助。
- 截图喂视觉模型:视觉模型能看懂布局,但对小字号、低对比度的文字容易看错,而且无法反查出这个元素在 DOM 里的定位信息,操作没法落地。
- 用 XPath/CSS 硬编码:这等于把决策权全部交给人,AI 只是执行预定的选择器,谈不上智能化。
可访问性树恰恰避开了这三个坑。它体积小(通常比 DOM 小几个数量级),语义干净,节点还能直接映射回 DOM 元素。更重要的是,Playwright 原生提供了获取快照的接口,改造成本极低。
1.3 为什么这个架构适合 MCP
MCP 的角色是给 LLM 一套“可调用的工具”,而不是让 LLM 直接接触页面。为什么需要这个中间层?
因为 LLM 本身是没有副作用的——它只能输出文字。真正去点击、输入、截图、跳转的必须是代码。MCP 做的事情就是把这部分能力注册成工具,LLM 根据页面描述决定调用哪个工具、传什么参数,执行后的结果再反馈给 LLM,形成闭环。
Playwright 负责“手”,MCP 负责“接口”,可访问性树负责“眼睛”。三者各司其职,这个架构才算闭环。
2. 架构总览:从 LLM 到浏览器的完整链路
2.1 整体数据流设计
我的实现分为四层:
LLM(大模型) ↓ 自然语言指令 MCP Client(会话管理与工具分发) ↓ JSON-RPC 调用 MCP Server(Playwright 工具集) ↓ Playwright API 浏览器实例(Chromium / Firefox / WebKit)一次典型的对话流程是这样的:
- 用户说“帮我查一下今天的天气,并截图给我”。
- LLM 收到指令后,先调用
navigate_to工具打开天气网站。 - 页面加载完成,LLM 调用
get_page_render_content获取可访问性树快照。 - 模型从快照里识别出搜索框和搜索按钮,调用
fill_input和click_element。 - 页面刷新后再次获取快照,确认结果区域出现。
- LLM 调用
take_screenshot,把截图作为内容回传给用户。
整个过程里,模型每一轮“看”到的都是可访问性树的快照文本,而不是原始 HTML 或者渲染后的像素图。这让模型对页面的理解从“看图猜”变成了“读说明书”。
2.2 为什么用快照式交互,而不是流式遥测
我在最初的设计里考虑过实时把页面每个事件推给 LLM,比如监听 DOM 变化、鼠标移动、网络请求等等。结论是不建议这么做。
原因有三条:
- token 消耗不可控。一个稍复杂的页面,每秒钟可能有几十个 DOM 突变事件,全部推给模型,没几轮对话就烧光了上下文窗口。
- 页面事件和用户意图之间存在巨大鸿沟。LLM 需要的不是“发生了什么”,而是“现在页面上有什么、可以做什么”。事件流反而引入噪声,干扰决策。
- 快照式交互天然具备“轮次边界”。每次模型推理之前拉一次快照,相当于给这个决策点拍了一张“现场照片”,拿到的是当时的页面状态,推理过程本身不受异步变化干扰。
Playwright 的page.accessibility.snapshot()本身就返回一个可序列化的 JSON 树,非常适合做这种“拍照片”式的交互。
注意:快照是异步的,获取时需要等待渲染稳定。后面我会讲怎么处理动态加载的内容。
2.3 MCP 工具集的边界划分
工具不是越多越好。我最初一股脑注册了十几个工具,结果模型经常不知道该选哪个,而且工具描述写得不清楚还会被反复调用,白白浪费时间。
最后收敛成六个核心工具,职责单一、参数明确:
| 工具名 | 作用 | 关键参数 |
|---|---|---|
navigate_to | 跳转到指定 URL | url |
refresh_page | 刷新当前页面 | 无 |
get_page_render_content | 获取可访问性树快照 | max_depth、exclude_hidden |
click_element | 点击指定元素 | xpath、button |
fill_input | 填充输入框 | xpath、text |
take_screenshot | 页面截图 | format、full_page |
为什么是 XPath 而不是别的选择器?因为可访问性树快照里,最容易稳定回传的定位信息就是 XPath。Playwright 的locator虽然强大,但那是给人写代码用的;在 MCP 场景里,模型只能通过文本参数传递定位信息,XPath 这种标准化的、同时在快照节点里直接可见的路径是最省事的。
3. 可访问性树的深度解析与数据设计
3.1 快照里到底有什么
page.accessibility.snapshot()返回的结构大致长这样:
{ "role": "WebArea", "name": "示例页面", "children": [ { "role": "heading", "name": "欢迎使用示例系统", "level": 1 }, { "role": "textbox", "name": "请输入用户名", "value": "" }, { "role": "button", "name": "登录" }, { "role": "checkbox", "name": "记住我", "checked": false } ] }这里每个属性都是有讲究的:
role:元素的语义角色,决定模型该怎么理解它——是按钮、输入框、链接还是表格。name:可访问名称,通常是元素的文本内容或者aria-label。这是模型判断“点哪个”最主要的依据。value:输入框的当前值,模型可以用它确认输入是否生效。checked / disabled / expanded / selected:状态属性,控制在表单场景下尤其重要。
站在 LLM 的视角,这一棵树就是页面的“操作说明书”。它不需要知道<input>和<label>的绑定关系,不需要理解 flex 布局,只要看到“一个叫‘登录’的按钮”,就能做决策。
3.2 快照过滤与降噪
原始快照有一个问题:太深。一个真实业务页面,可访问性树可能有三四层嵌套,包含大量折叠面板、隐藏元素、装饰性结构。直接把整棵树塞给模型,有两个后果:
- 超过模型上下文窗口
- 关键元素被淹没在海量无关节点里
我做了三件事来降噪:
第一,深度截断。给快照设置max_depth,默认取6层。为什么是6?实测中,绝大多数可交互元素(按钮、输入框、链接)在6层以内都能出现。再深的通常是自定义组件内部结构,对模型决策帮助不大。
第二,排除隐藏节点。可访问性树理论上只包含“可感知”的节点,但某些框架会强行注册隐藏的 aria 节点。需要在快照里加一层过滤:对每个节点检查aria-hidden="true"以及对应的 DOM 元素尺寸是否为 0。尺寸为0的元素对模型没有意义。
第三,合并无意义文本节点。有些页面把一句话拆成十几个 span,在可访问性树里也会以多个 text 节点出现。我写了一个合并逻辑:如果连续的同级text节点属于同一个父容器,就拼接成一个节点。
3.3 快照到 XPath 的映射策略
这是整个架构里最容易被忽略、但实际上最容易翻车的环节。
可访问性树节点本身就带有一个domId或者可以通过snapshotNode反查 DOM 节点。但问题是,可访问性树节点和 DOM 节点不是一一对应的。同一个 DOM 元素可能贡献多个可访问性节点(比如一个输入框和一个配套 label 合并成 textbox),反过来一个可访问性节点也可能映射到多个 DOM 元素(比如一组 radio)。
我的方案是:不在快照里直接塞 XPath,而是把每个节点编号,定义成一个扁平列表,然后单独提供一个辅助接口,让模型按编号获取该节点的 XPath。
其实还有一种更实用的做法:直接在工具参数里把element_id作为唯一标识,代码内部维护id → XPath的映射表,每次更新快照时重建映射。这样模型根本不需要关心 XPath 到底是什么,只需要说“点击编号5的元素”。
这个设计避免了一个大坑:XPath 很容易因为页面局部刷新而失效,而编号在整个快照周期内是稳定的,快照过期后强制模型重新拉取即可。
4. 工具实现细节:从零搭建 Playwright MCP 服务
4.1 环境准备与依赖选择
我用的是 Node.js 技术栈,因为 Playwright 官方对 Node 的支持最完整,MCP SDK 也提供了 TypeScript 版本。
npm init -y npm install @playwright/test mcp playwright npx playwright install chromium这里我强烈建议安装@playwright/test而不只是playwright。原因很实际:这个包里包含了断言库和测试运行器,我可以在 MCP 服务里复用expect来做页面断言判断,不用额外引入 jest 或 mocha。
MCP SDK 装的是最新的 TypeScript 版本,里面直接提供McpServer类,注册工具非常方便,不需要手动处理 JSON-RPC 消息。
4.2 MCP Server 核心骨架
下面是服务的主入口:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { chromium } from "@playwright/test"; const server = new McpServer({ name: "playwright-a11y-server", version: "1.0.0", }); let browser; let page; server.tool( "navigate_to", { url: { type: "string", description: "目标URL" } }, async ({ url }) => { if (!browser) { browser = await chromium.launch({ headless: true }); page = await browser.newPage(); } await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }); return { content: [{ type: "text", text: `已跳转到 ${url}` }] }; } ); server.tool( "get_page_render_content", { max_depth: { type: "number", description: "最大深度,默认6" } }, async ({ max_depth = 6 }) => { const snapshot = await page.accessibility.snapshot(); const filtered = flattenAndFilter(snapshot, max_depth); return { content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }] }; } ); const transport = new StdioServerTransport(); await server.connect(transport);这段代码本身不复杂,但有两个关键设计点在注释里没体现:
第一个点:浏览器实例复用。不要每次调用都重新启动浏览器,启动 Chromium 实例大概需要 1-3 秒,对交互体验影响很大。一个 MCP 会话周期内复用一个浏览器实例。注意多页面场景需要用context.newPage()而不是browser.newPage(),我踩过的坑就是直接用browser.newPage(),在非默认 context 下会触发权限问题。
第二个点:networkidle并不总是可靠。有些页面用轮询接口,永远不会 idle,会一直等到超时。更稳妥的策略是domcontentloaded加一段固定等待,然后做一次可达性快照检查,确认关键节点出现。后面章节我会详细说这个策略。
4.3 可访问性树扁平化与工具注册
直接返回嵌套 JSON 树问题是深度不可控。我写了一个flattenAndFilter函数,把嵌套树拍平成带编号的节点列表,同时保留父子关系信息:
function flattenAndFilter(node, maxDepth, currentDepth = 0) { const result = []; if (!node || currentDepth > maxDepth) return result; // 过滤隐藏节点 if (node.hidden) return result; const id = result.length + 1; result.push({ id, role: node.role, name: node.name || "", value: node.value || "", checked: node.checked, disabled: node.disabled, children: [], }); if (node.children) { for (const child of node.children) { const childNodes = flattenAndFilter(child, maxDepth, currentDepth + 1); result[result.length - 1].children.push(...childNodes.map(c => c.id)); result.push(...childNodes); } } return result; }这里我把children从嵌套对象改成 id 列表。模型看到的是:
[ { "id": 1, "role": "WebArea", "name": "示例页面", "children": [2, 3, 4] }, { "id": 2, "role": "heading", "name": "欢迎使用示例系统", "children": [] }, { "id": 3, "role": "textbox", "name": "请输入用户名", "value": "", "children": [] }, { "id": 4, "role": "button", "name": "登录", "children": [] } ]这个格式有两个好处:
- 模型一眼就能看出页面有哪些可交互元素,搜索目标元素时扫描顺序是线性的。
- 传给后续工具的只是一个 id 数字,参数校验简单,出错率低。
click_element和fill_input的实现基于 id 映射回 DOM 的 locator:
server.tool( "click_element", { element_id: { type: "number", description: "可访问性树中的节点ID" } }, async ({ element_id }) => { const xpath = getXPathById(element_id); const locator = page.locator(`xpath=${xpath}`); await locator.click(); return { content: [{ type: "text", text: `点击成功: ${xpath}` }] }; } );注意这里我用了xpath=${xpath}的字符串拼接语法,这是 Playwright locator 的标准写法。之前我试着直接把 XPath 作为 selector 传入,但本地调试没问题,在 Linux 环境就爆炸了——某些特殊字符(比如引号)导致了选择器解析失败。用模板字符串拼xpath=前缀之后问题消失。
4.4 工具描述里的“心智模型”
MCP 工具的description字段会被 LLM 当作使用说明书。这块不能像写代码注释那么随意,我总结了一套写法:
- 说清楚触发场景:比如
navigate_to描述里写“当用户要求打开某个网站或跳转页面时使用”,而不是干巴巴写“跳转”。 - 写清楚返回值形态:比如
get_page_render_content描述里写“返回编号节点列表,后续操作请用 node id 引用”,模型看了就知道下一步该传什么。 - 列出失败场景:比如
fill_input描述里写“如果元素不存在或不可见,会返回错误,此时请先调用 get_page_render_content 刷新快照”。这能大幅减少模型在错误状态下的无效重试。
一个反例是我最初写的click_element,只有一个element_id参数,没有任何上下文说明。结果模型在页面变化后还拿旧的 id 来点,报错了也不知道重新拉取页面状态,反复死循环。后来我在描述里明确加了一句“页面发生导航或刷新后,所有 id 都会失效,必须重新获取快照”,这个问题基本消失了。
5. 实际运行效果与问题排查
5.1 一个完整的对话式操作路径
我用这个 MCP 服务跑过一个模拟项目 X 的操作流程——“打开一个数据报表页面,筛选出上季度的订单,然后将结果截图”。
模型的调用轨迹大概是:
navigate_to,URL 指向报表系统。get_page_render_content,拿到页面快照。快照里有侧边栏、筛选表单、表格区域等节点。- 模型定位到“日期范围”输入框,调用
click_element点击它。 - 页面弹出一个日期选择面板,模型再次调用
get_page_render_content,识别出“本季度”“上季度”等选项,点击“上季度”。 get_page_render_content确认表格数据刷新后,调用take_screenshot返回图片。
整条链路跑下来大概 40 秒,其中模型推理占了大头,页面操作本身不到 5 秒。如果换成纯脚本完成同样操作,大概 3 秒,但脚本对页面变化的容忍度几乎为零——弹窗一跳出来就歇菜。
这个对比恰好说明:这套架构的价值不在“快”,而在“稳”和“通用”。三种不同的报表页面,脚本可能要改三遍,但 MCP 服务一行代码不用动。
5.2 高频踩坑记录与修复方案
问题一:快照里找不到某个按钮。
排查后发现,很多 UI 组件的可访问性树 role 不是直觉上的button。比如一个用<span>加onclick实现的按钮,浏览器可能计算成generic或者link。另外,绝对定位覆盖导致两个元素重叠时,被覆盖的元素在可访问性树里可能直接被剔除。
解决办法:排查时不要只搜button角色,把 role 的枚举值全部拉出来看一遍。如果确实缺失,检查 CSS 是否设置了pointer-events或者 z-index 导致元素不可访问。
问题二:Strict Mode 报错。
Playwright 默认 strict mode 开启,locator 解析到多个元素时会直接报错,而不是选择第一个。这在可访问性树场景下经常出现,比如页面上有多个名称相同的按钮。
我的处理方式有两种:一种是在click_element里捕获strict mode violation错误,然后返回给模型,让它重新选择带具体位置信息的节点;另一种是为每个节点维护一个count字段,如果同 role 同 name 的节点有多个,在快照里用序号区分——“登录按钮 (2/3)”。
问题三:动态加载内容导致的快照过期。
SPA 页面里,用户点击加载更多之后,新内容要等接口返回才出现。如果模型在接口返回前就拿快照,会看到一个残缺页面,做出错误决策。
我最终用的是“轮询快照验证”的方式:模型拿到的快照会带一个page_state_token,每次操作后重新拉取快照并对比 token。如果两次操作之间 token 没变,说明页面没有结构性变化,可以安全继续;如果变了,则提示模型重新审视页面。这个机制写进click_element和fill_input的返回结果里。
问题四:iframe 和 shadow DOM。
Playwright 对这两种结构都有原生支持,但可访问性树的快照接口默认不展开 shadow DOM 内部节点,iframe 里的内容也只会显示为一个Iframe角色节点。
我的处理是:如果快照里发现有Iframe节点,额外调用frameLocator指向对应 frame,单独再拉一次该 frame 的快照,合并进最终结果。shadow DOM 则没办法完全穿透,需要改业务代码给 shadow host 加 aria-label,否则模型只能“看到”最外层。
5.3 快照大小与上下文窗口的平衡
这是实际使用中绕不开的资源瓶颈。一个中等到复杂的页面,未经处理的可访问性树 JSON 可能有 20KB-50KB,按 token 换算大约是 5000-12000 tokens。而一个主流的 128K 上下文窗口,一次快照就吃掉十分之一,对话超过五轮基本就紧张了。
我的经验是四层过滤层层叠加:
- 深度限制(默认不超 6 层)
- 角色白名单:只保留
button、link、textbox、checkbox、radio、combobox、slider、tab、menuitem、switch等可交互角色。展示类文本节点只保留 heading 和表格摘要。 - 数量限制:任何一级节点数量超过 30 个时,只保留前 30 个并标注“已截断”。
- 属性精简:不输出
orientation、multiline、atomic这类与决策无关的低价值属性。
这样处理之后,同样页面的快照通常能压到 4KB-8KB,模型一次性读完毫无压力。代价是,如果页面真的有超过 30 个按钮,模型会漏掉后面的——但实际场景里,超过 30 个同级别按钮的页面本身就该重新设计,所以这个限制我保留着。
5.4 权限与安全性设计
MCP 服务不是玩具,接入生产环境时必须考虑安全边界。我这里做了四件事:
- 浏览器实例以独立子进程运行,与 MCP 服务进程隔离,崩溃互不影响。
- 每个会话新建浏览器 context,会话结束强制关闭,避免 cookie 和数据跨会话泄露。
- 限制可访问的 URL 协议:只允许 http/https,拦截
file://、chrome://等特殊协议,防止模型被诱导访问本地文件。 - 截图输出做降级处理:默认 PNG 压缩质量 80,图片直接从 base64 传输,不落盘,防止脏数据在服务器上残留。
另外还有一个反误用的设计:对navigate_to做了域名白名单限制,默认只允许访问预设的测试域名列表。如果模型试图导航到未知域名,会返回“该域名不在白名单,需人工确认”的错误。这个限制在自动化测试场景很有用,但如果做通用 browsing agent,白名单需要放开,改成域名信誉检查。
6. 最后的几点心里话
这套架构最打动我的地方在于:它把“AI 操作浏览器”这个听上去很玄学的需求,拆解成了三个非常工程化的问题——眼睛看什么(可访问性树)、手怎么动(Playwright 工具集)、脑怎么想(MCP 注册加描述)。每个问题都有成熟的单点方案,组合起来却解决了一个原本无解的问题。
我实际跑的体会是,可访问性树作为 AI 的“眼睛”,比截图加视觉模型更适合以操作目标为导向的场景。视觉模型强在感知氛围和视觉设计,但真要逐像素找出一个登录按钮的精确坐标,并不比读一段快照文本更快更便宜。
最后分享一个小技巧:在建完这套 MCP 服务之后,我在 Playwright 的测试脚本里加了一行——每次跑完用例,把可访问性树快照存一份成 fixture。这样即使后续没有人维护用例,测试失败时也能拿失败时刻的页面语义状态做分析,比截图多了一层机器可读的上下文。这个改动非常小,收益却很明显,推荐你也试试。