目前市面上**没有一个单一、独立的轻量级库**专门叫“playwright-mcp-optimizer”来实现这个功能。这通常是像 **Browser-use (Python)** 或 **Skyvern** 这样的大型 Agent 框架内部的核心模块。
但是,这一套逻辑的**最佳实现(Best Practice)已经非常成熟。对于 MCP Server 开发者来说,「自行处理」反而是最佳选择**,因为它实际上只需要一段注入到浏览器执行的 JavaScript 代码(约 50 行),引入一个重型库反而显得臃肿。
以下我为你整理了基于**业界主流(参考 Browser-use 和 Tarsier)的「最佳实现代码」**。你可以直接把这段代码复制到你的 MCP Server 项目中。
---
### 核心方案:DOM 蒸馏 + 唯一 ID 映射 (DOM Distillation & Map)
这个方案的核心逻辑分为两步:
1. **观察 (Observe)**:在页面上筛选出可交互元素,给它们打上 `mcp-id`,并提取出精简的文本描述。
2. **操作 (Act)**:让 LLM 只需要返回 `mcp-id`,你在后端将其映射回真实的 DOM 元素进行点击。
#### 1. 注入脚本 (放在你的 MCP `get_content` 工具中)
在 Playwright 获取页面内容时,不要返回 `page.content()`,而是执行下面的 `evaluate`:
```javascript
// 在你的 MCP Server 代码中 (Node.js/Python 均可)
const simplifiedDOM = await page.evaluate(() => {
let idCounter = 1;
const interactableSelectors = [
'a[href]', 'button', 'input', 'textarea', 'select', '[role="button"]', '[onclick]'
];
// 1. 找到所有可交互元素
const elements = document.querySelectorAll(interactableSelectors.join(','));
const map = [];
elements.forEach((el) => {
// 过滤掉不可见元素 (节省 Token 的关键)
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0 || el.style.visibility === 'hidden' || el.style.display === 'none') {
return;
}
// 2. 注入唯一 ID (临时属性,不会影响页面功能)
const mcpId = idCounter++;
el.setAttribute('data-mcp-id', mcpId);
// 3. 构建精简描述 (发送给 LLM 的内容)
let label = el.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '';
label = label.replace(/\s+/g, ' ').trim().slice(0, 50); // 截断过长文本
// 这一行是关键:构建一个 LLM 容易理解的伪 HTML
const tagName = el.tagName.toLowerCase();
const inputType = el.getAttribute('type');
let description = `<${tagName} id="${mcpId}"`;
if (inputType) description += ` type="${inputType}"`;
if (label) description += `>${label}</${tagName}>`;
else description += ` />`;
map.push(description);
});
// 4. 返回拼接好的字符串
return map.join('\n');
});
return simplifiedDOM;
```
**LLM 看到的最终结果 (极度节省 Token):**
```html
<a id="1">Home</a>
<button id="2">Products</button>
<input id="3" type="text" placeholder="Search..." />
<button id="4">Search</button>
<a id="5">Login</a>
```
*注:即使原网页有 5MB 大小,经过这一步通常只有 2KB 左右,而且包含了所有操作核心。*
---
#### 2. 修改点击工具 (修改你的 MCP `click` 工具)
由于 LLM 现在只知道 ID,你需要修改 `click` 工具来根据 ID 查找元素。
**工具定义 (JSON Schema):**
告诉 LLM:`target_id` (integer) - The id of the element to click.
**实现逻辑:**
```javascript
// 当 LLM 调用 click(target_id=4) 时
async function clickElement(page, targetId) {
// 使用刚才注入的 data-mcp-id 属性进行定位
const selector = `[data-mcp-id="${targetId}"]`;
// 增加稳健性判断
const count = await page.locator(selector).count();
if (count === 0) {
throw new Error(`Element with ID ${targetId} not found. Maybe the page refreshed?`);
}
await page.locator(selector).click();
}
```
---
### 业界有没有现成的库?
如果你实在不想自己写这几十行代码,可以参考以下两个库,它们是目前这个领域的标杆:
#### 1. Browser-use (Python)
这是目前 GitHub 上最火的库。虽然它是 Python 的,但它的 `dom_service.py` 文件里的逻辑就是业界的“标准答案”。
* **特性**:它不仅提取 DOM,还会自动判断元素是否在视口内(Viewport Check),并处理 Shadow DOM。
* **借鉴方式**:如果你用 Python 写 MCP,直接 `pip install browser-use` 并调用它的 DOM 提取函数。
#### 2. Tarsier (Python / JS 概念)
* **主打方向**:**Vision (视觉) 标记**。
* **差异**:它不是给 DOM 注入 ID,而是**在截图上画数字标签**(Set-of-Marks 策略)。
* **适用场景**:如果你的 MCP 连接的是 **Claude 3.5 Sonnet** 或 **GPT-4o**,Tarsier 的方式比纯 DOM 注入更好,因为视觉模型能理解布局(比如“右上角的那个叉号”)。
---
### 总结:我该怎么选?
1. **如果你追求极致的 Token 节省和速度 (文本模型)**:
不要找库了,直接使用我上面提供的 **「注入 JS 代码」** 方案。这是最轻量、最可控的方式。它是目前 Playwright MCP 的最佳实践。
2. **如果你使用 Claude 3.5 Sonnet (视觉模型)**:
建议结合 **Screenshot + 坐标映射**。你可以让 JS 在每个元素中心画一个带编号的红框,然后把截图发给 LLM。这种方式抗干扰能力最强(不怕 CSS 遮挡、不怕 DOM 结构混乱)。
**你想在这个“注入脚本”的基础上增加处理 `iframe` 或者 `Shadow DOM` 的逻辑吗?这是进阶场景常见的坑。**