(六)MCP+A2A之------Playwright与BrowserUse:让AI学会像人类一样使用浏览器
1、Manus中的浏览器与工具基础协议的设计与实现
app/domain/external/browser.py

python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time : 2025/05/03 0:20
@Author : thezehui@gmail.com
@File : browser.py
"""
from typing import Protocol, Optional
from app.domain.models.tool_result import ToolResult
class Browser(Protocol):
"""浏览器服务扩展,涵盖:访问页面、URL跳转、输入、移动鼠标、滚动、截图、执行js代码等"""
async def view_page(self) -> ToolResult:
"""浏览获取当前浏览器的页面内容"""
...
async def navigate(self, url: str) -> ToolResult:
"""传递对应的url使用浏览器导航到该页面"""
...
async def restart(self, url: str) -> ToolResult:
"""重启浏览器并访问对应的URL"""
...
async def click(
self,
index: Optional[int] = None,
coordinate_x: Optional[float] = None,
coordinate_y: Optional[float] = None,
) -> ToolResult:
"""传递对应的元素索引或者xy坐标实现点击功能"""
...
async def input(
self,
text: str,
press_enter: bool,
index: Optional[int] = None,
coordinate_x: Optional[float] = None,
coordinate_y: Optional[float] = None,
) -> ToolResult:
"""传递文本+回车标识+索引+xy坐标实现在网页输入框中输入对应内容"""
...
async def move_mouse(self, coordinate_x: float, coordinate_y: float) -> ToolResult:
"""传递对应的xy坐标移动鼠标"""
...
async def press_key(self, key: str) -> ToolResult:
"""传递按键标识实现浏览器模拟按键"""
...
async def select_option(self, index: int, option: int) -> ToolResult:
"""传递索引+选项标识在下拉菜单中选择指定的选项"""
...
async def scroll_up(self, to_top: Optional[bool] = None) -> ToolResult:
"""向上滚动浏览器,如果没传递to_top=True则向上滚动一屏"""
...
async def scroll_down(self, to_down: Optional[bool] = None) -> ToolResult:
"""向下滚动浏览器,如果没传递to_down=True则向下滚动一屏"""
...
async def screenshot(self, full_page: Optional[bool] = None) -> bytes:
"""对当前浏览器的页面进行截图,传递full_page=True则意味着整页截图"""
...
async def console_exec(self, javascript: str) -> ToolResult:
"""传递对应的js脚本在浏览器的控制台执行"""
...
async def console_view(self, max_lines: Optional[int] = None) -> ToolResult:
"""传递最大输出行数,获取控制台的输出结果,如果不传递则获取所有结果"""
...
2、完成浏览器工具参数描述编写掌握参数的声明写法
app/domain/services/tools/browser.py

python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time : 2025/05/01 21:14
@Author : thezehui@gmail.com
@File : browser.py
"""
from typing import Optional
from app.domain.external.browser import Browser
from app.domain.models.tool_result import ToolResult
from .base import BaseTool, tool
class BrowserTool(BaseTool):
"""浏览器工具"""
name: str = "browser"
def __init__(self, browser: Browser) -> None:
"""构造函数,完成浏览器工具的初始化"""
super().__init__()
self.browser = browser
@tool(
name="browser_view",
description="查看当前浏览器页面内容,用于确认已打开页面的最新状态。",
parameters={},
required=[]
)
async def browser_view(self) -> ToolResult:
"""获取浏览器当前网页内容并返回"""
return await self.browser.view_page()
@tool(
name="browser_navigate",
description="将浏览器导航至指定网址,当需要访问新页面时使用。",
parameters={
"url": {
"type": "string",
"description": "要访问的完整URL,必须包含协议前缀(如https://)"
}
},
required=["url"]
)
async def browser_navigate(self, url: str) -> ToolResult:
"""传递url地址,使用浏览器导航到对应页面"""
return await self.browser.navigate(url)
@tool(
name="browser_restart",
description="重新启动浏览器并导航至指定URL,当需要重置浏览器时使用。",
parameters={
"url": {
"type": "string",
"description": "要访问的完整URL,必须包含协议前缀(如https://)"
}
},
required=["url"]
)
async def browser_restart(self, url: str) -> ToolResult:
"""重启浏览器并导航到指定页面后返回页面内容"""
return await self.browser.restart(url)
@tool(
name="browser_click",
description="点击当前页面中的元素,在需要点击页面元素时使用。",
parameters={
"index": {
"type": "integer",
"description": "(可选)需要点击的元素索引"
},
"coordinate_x": {
"type": "number",
"description": "(可选)点击位置的x坐标"
},
"coordinate_y": {
"type": "number",
"description": "(可选)点击位置的y坐标"
}
},
required=[],
)
async def browser_click(
self,
index: Optional[int] = None,
coordinate_x: Optional[float] = None,
coordinate_y: Optional[float] = None,
) -> ToolResult:
"""传递页面元素索引或者页面xy坐标点击对应元素"""
return await self.browser.click(index, coordinate_x, coordinate_y)
@tool(
name="browser_input",
description="覆盖浏览器当前页面可编辑区域的文本(input/textarea输入框),在需要填充输入框时使用。",
parameters={
"text": {
"type": "string",
"description": "要填充到输入框的完整文本内容",
},
"press_enter": {
"type": "boolean",
"description": "输入后是否按下回车键",
},
"index": {
"type": "integer",
"description": "(可选)需要填充文本的元素索引"
},
"coordinate_x": {
"type": "number",
"description": "(可选)需要填充文本元素的x坐标"
},
"coordinate_y": {
"type": "number",
"description": "(可选)需要填充文本元素的y坐标"
},
},
required=["text", "press_enter"],
)
async def browser_input(
self,
text: str,
press_enter: bool,
index: Optional[int] = None,
coordinate_x: Optional[float] = None,
coordinate_y: Optional[float] = None,
) -> ToolResult:
"""根据传递的元素索引或xy坐标,实现浏览器内容输入"""
return await self.browser.input(text, press_enter, index, coordinate_x, coordinate_y)
@tool(
name="browser_move_mouse",
description="将鼠标光标移动至当前浏览器页面的指定位置,用于模拟用户的鼠标移动",
parameters={
"coordinate_x": {
"type": "number",
"description": "目标光标位置的x坐标"
},
"coordinate_y": {
"type": "number",
"description": "目标光标位置的y坐标"
},
},
required=["coordinate_x", "coordinate_y"],
)
async def browser_move_mouse(self, coordinate_x: float, coordinate_y: float) -> ToolResult:
"""传递xy坐标移动浏览器鼠标"""
return await self.browser.move_mouse(coordinate_x, coordinate_y)
@tool(
name="browser_press_key",
description="在当前浏览器页面模拟按键,当需要执行特定的键盘操作时使用。",
parameters={
"key": {
"type": "string",
"description": "要模拟的按键名称(例如: Enter、Tab、ArrowUp),支持组合键(例如: Control+Enter)",
},
},
required=["key"],
)
async def browser_press_key(self, key: str) -> ToolResult:
"""在浏览器页面模拟按键"""
return await self.browser.press_key(key)
@tool(
name="browser_select_option",
description="从当前浏览器页面的下拉列表元素中选择指定选项,用于选择下拉菜单中的选项",
parameters={
"index": {
"type": "integer",
"description": "需要操作的下拉列表元素的索引号(序号)"
},
"option": {
"type": "integer",
"description": "要选择的选项序号,从0开始(注: 指下拉框里的第几项)。"
},
},
required=["index", "option"]
)
async def browser_select_option(self, index: int, option: int) -> ToolResult:
"""传递索引+下拉元素选项序号执行选择"""
return await self.browser.select_option(index, option)
@tool(
name="browser_scroll_up",
description="向上滚动浏览器页面,用于查看上方内容或返回页面顶部。",
parameters={
"to_top": {
"type": "boolean",
"description": "(可选)是否直接滚动到页面顶部,而非向上滚动一屏。"
}
},
required=[]
)
async def browser_scroll_up(self, to_top: Optional[bool] = None) -> ToolResult:
"""向上滚动当前浏览器页面,支持滚动一页或者滚动到顶部"""
return await self.browser.scroll_up(to_top)
@tool(
name="browser_scroll_down",
description="向下滚动当前浏览器页面,用于查看下方内容或跳转到页面底部。",
parameters={
"to_bottom": {
"type": "boolean",
"description": "(可选)是否直接滚动到页面底部,而非向下滚动一屏"
}
},
required=[],
)
async def browser_scroll_down(self, to_bottom: Optional[bool] = None) -> ToolResult:
"""向下滚动当前浏览器页面,支持滚动一页或者滚动到底部"""
return await self.browser.scroll_down(to_bottom)
@tool(
name="browser_console_exec",
description="在浏览器控制台中执行JavaScript代码,当需要执行自定义脚本时使用。",
parameters={
"javascript": {
"type": "string",
"description": "要执行的JavaScript代码,请注意运行时环境为浏览器控制台。"
},
},
required=["javascript"],
)
async def browser_console_exec(self, javascript: str) -> ToolResult:
"""传递js脚本在当前浏览器控制台执行"""
return await self.browser.console_exec(javascript)
@tool(
name="browser_console_view",
description="查看浏览器控制台输出,用于检查JavaScript日志或调试页面错误。",
parameters={
"max_lines": {
"type": "integer",
"description": "(可选)返回的最大日志行数。"
}
},
required=[],
)
async def browser_console_view(self, max_lines: Optional[int] = None) -> ToolResult:
"""传递浏览的最大行数查看控制台的输出"""
return await self.browser.console_view(max_lines)
3、Playwright简介与简化对接CDP难度的技巧

官方文档地址:
python
https://playwright.dev/python/docs/intro
安装指令:
python
uv self update
uv add playwright
playwright install
同步测试脚本:
python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://www.baidu.com/")
print(page.title())
browser.close()
异步测试脚本:
python
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://www.baidu.com/")
print(await page.title())
await browser.close()
asyncio.run(main())
1、在windows命令行启动CDP服务:
第一步:找到系统 Chrome 的可执行文件
在windows命令行执行如下三条指令
python
dir "C:\Program Files\Google\Chrome\Application\chrome.exe"
dir "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
dir "%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"

哪个命令显示了 chrome.exe,就用对应路径启动。例如上述第三个指令路径存在:
已找到 Chrome 的真实安装位置。请先完全关闭所有 Chrome 窗口,然后在命令行执行如下指令:
python
start "" "%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="D:\chrome_dev_profile"
它会启动一个使用独立数据目录的新 Chrome 窗口,并开启远程调试端口 9222。

启动后,在命令行验证:
python
curl http://127.0.0.1:9222/json/version
如果看到包含 Browser、webSocketDebuggerUrl 的 JSON 内容,就表示成功。

2、在ubuntu启动CDP服务:
第一步:Ubuntu 上先确认 Chrome 或 Chromium 的安装位置:
python
which google-chrome
which google-chrome-stable
which chromium
which chromium-browser
常见结果是:
python
/usr/bin/google-chrome

然后先关闭当前所有 Chrome 窗口,再启动独立的远程调试浏览器:
python
google-chrome \
--remote-debugging-port=9222 \
--remote-debugging-address=127.0.0.1 \
--user-data-dir="$HOME/chrome_dev_profile"
如果命令是 chromium,则改为:
python
chromium \
--remote-debugging-port=9222 \
--remote-debugging-address=127.0.0.1 \
--user-data-dir="$HOME/chrome_dev_profile"

python
curl http://127.0.0.1:9222/json/version

python
curl http://127.0.0.1:9222/json/list

第二步:使用Playwright简化CDP连接
在上述第一步服务开启前提下,执行如下脚本
python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time : 2025/05/04 23:17
@Author : thezehui@gmail.com
@File : 10-6 使用Playwright简化CDP连接.py
"""
import asyncio
from playwright.async_api import async_playwright
async def example() -> None:
# 1.创建一个playwright异步实例
# 可以使用另外一种写法:
# playwright = await async_playwright().start()
# await playwright.stop()
async with async_playwright() as playwright:
# 2.连接到cdp获取浏览器实例
browser = await playwright.chromium.connect_over_cdp("http://localhost:9222")
default_context = browser.contexts[0] # 获取浏览器当前的上下文信息
# 2.获取当前上下文的第一个页面
page = default_context.pages[0]
print("页面标题:", await page.title())
print("页面URL:", page.url)
# 3.新增页面并且跳转到imooc.com
page = await default_context.new_page()
await page.goto("https://imooc.com")
# 4.在页面上执行js并获取结果
href = await page.evaluate('() => document.location.href')
print("js执行结果:", href)
# 5.截图
await page.screenshot(path="resources/screenshot.png")
await page.screenshot(path="resources/screenshot-full.png", full_page=True)
if __name__ == "__main__":
asyncio.run(example())

提高:
使用无头模式启动
python
nohup google-chrome \
--headless=new \
--remote-debugging-port=9222 \
--remote-debugging-address=0.0.0.0 \
--user-data-dir="/home/googosoft218/chrome_dev_profile" \
--disable-gpu \
--no-first-run \
--no-default-browser-check \
about:blank \
>/tmp/chrome-9222.log 2>&1 &
3、基于Playwright的浏览器扩展初始化与清除功能实现
1、在Python中执行js代码获取浏览器可视窗口元素
使用js代码获取当前页面可见元素内容
app/infrastructure/external/browser/playwright_browser_fun.py

使用js代码获取当前页面可见元素内容
python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time : 2025/05/05 14:30
@Author : thezehui@gmail.com
@File : playwright_browser_fun.py
"""
# 获取页面的课时内容js代码
GET_VISIBLE_CONTENT_FUNC = """() => {
// 1.定义变量存储所有可视元素+视口宽高
const visibleElements = [];
const viewportHeight = window.innerHeight;
const viewportWeight = window.innerWidth;
// 2.获取页面上所有元素
const elements = document.querySelectorAll("body *");
// 3.循环遍历所有元素逐个处理
for (let i = 0; i < elements.length; i++) {
// 4.获取元素的尺寸与位置
const element = elements[i];
const rect = element.getBoundingClientRect();
// 5.判断元素的宽高,如果没有大小则跳过
if (rect.height === 0 || rect.width === 0) continue;
// 6.排除完全在当前屏幕可视区域之外的元素(上方、下方、左侧、右侧)的元素
if (
rect.bottom < 0 ||
rect.top > viewportHeight ||
rect.right < 0 ||
rect.left > viewportWeight
) continue;
// 7.通用样式判断当前元素是否隐藏
const style = window.getComputedStyle(element);
if (
style.display === 'none' || // 块隐藏
style.visibility === 'hidden' || // 隐藏不可见
style.opacity === '0' // 透明度为0
) continue;
// 8.如果element是文本节点或有意义的元素,请将其添加到结果中
if (
element.innerText ||
element.tagName === "IMG" ||
element.tagName === "INPUT" ||
element.tagName === "BUTTON"
) visibleElements.push(element.outerHTML)
}
// 9.将所有内容使用空格拼接后包裹在div内返回
return '<div>' + visibleElements.join(' ') + '</div>'
}"""
# 获取页面可交互元素js代码
GET_INTERACTIVE_ELEMENTS_FUNC = """() => {
// 1.定义变量存储激活元素列表+视口宽高
const interactiveElements = [];
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// 2.获取页面上所有可交互的元素,包含按钮、a标签、输入框、文本域、下拉菜单、按钮、tab等
const elements = document.querySelectorAll('button, a, input, textarea, select, [role="button"], [tabindex]:not([tabindex="-1"])');
// 3.定义变量用于生成连续的唯一索引
let validElementIndex = 0;
// 4.循环遍历所有元素
for (let i = 0; i < elements.length; i++) {
// 5.取出对应元素并获取尺寸+位置
const element = elements[i];
const rect = element.getBoundingClientRect();
// 6.宽高任意为0则跳过这条元素
if (rect.width === 0 || rect.height === 0) continue;
// 7.视口不可见该元素则跳过
if (
rect.bottom < 0 ||
rect.top > viewportHeight ||
rect.right < 0 ||
rect.left > viewportWidth
) continue;
// 8.样式不可见则跳过该元素
const style = window.getComputedStyle(element);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
) continue;
// 9.获取元素的标签名并转换成小写,同时提取标签内容
let tagName = element.tagName.toLowerCase();
let text = '';
// 10.根据标签类型不同处理不同的逻辑,首先是输入框/文本域/下拉菜单
if (element.value && ['input', 'textarea', 'select'].includes(tagName)) {
text = element.value;
// 11.标签为输入框则执行以下代码,记录label和placeholder
if (tagName === 'input') {
// 12.查询输入框的label是否存在并赋值
let labelText = '';
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label) {
labelText = label.innerText.trim();
}
}
// 13.查找父级或同级的 label (当没有 for 属性绑定时)
if (!labelText) {
const parentLabel = element.closest('label');
if (parentLabel) {
labelText = parentLabel.innerText.trim().replace(element.value, '').trim();
}
}
// 14.拼接label消息
if (labelText) {
text = `[Label: ${labelText}] ${text}`;
}
// 15.拼接placeholder信息
if (element.placeholder) {
text = `${text} [Placeholder: ${element.placeholder}]`;
}
}
} else if (element.innerText) {
// 16.普通元素则提取内部文本并剔除多余空格 (如 <button>提交</button>)
text = element.innerText.trim().replace(/\\s+/g, ' ');
} else if (element.alt) {
// 17.图片按钮,取 alt 属性
text = element.alt;
} else if (element.title) {
// 18.取 title 属性
text = element.title;
} else if (element.placeholder) {
// 19.提取placeholder
text = `[Placeholder: ${element.placeholder}]`;
} else if (element.type) {
// 20.兜底逻辑将元素的类型作为文本描述
text = `[${element.type}]`;
// 21.针对没有值的 Input,再次尝试获取 Label 和 Placeholder (逻辑同上)
if (tagName === 'input') {
let labelText = '';
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label) {
labelText = label.innerText.trim();
}
}
if (!labelText) {
const parentLabel = element.closest('label');
if (parentLabel) {
labelText = parentLabel.innerText.trim();
}
}
if (labelText) {
text = `[Label: ${labelText}] ${text}`;
}
if (element.placeholder) {
text = `${text} [Placeholder: ${element.placeholder}]`;
}
}
} else {
// 22.都不满足,则设置为No text
text = '[No text]';
}
// 23.检测文本长度是否超过100,如果是则剔除多余的部分
if (text.length > 100) {
text = text.substring(0, 97) + '...';
}
// 24.为当前元素添加data-manus-id的属性,值为manus-element-idx,这样可以通过索引找到对应的元素
element.setAttribute('data-manus-id', `manus-element-${validElementIndex}`);
// 25.构建css选择器
const selector = `[data-manus-id="manus-element-${validElementIndex}"]`;
// 26.将索引、标签名、文本、选择器添加到激活元素列表中
interactiveElements.push({
index: validElementIndex,
tag: tagName,
text: text,
selector: selector
});
// 27.索引自增1
validElementIndex++;
}
// 28.最终返回所有激活元素数据
return interactiveElements;
}"""
# 在执行代码前先执行这段js代码,实现将console.log内存存储到window.console.logs中
INJECT_CONSOLE_LOGS_FUNC = """() => {
// 1.定义变量存储控制台输出日志
window.console.logs = [];
// 2.重写window.console.log函数
const originalLog = console.log;
console.log = (...args) => {
window.console.logs.push(args.join(" "));
originalLog.apply(console, args);
};
}"""
2、在Python中执行js代码提取可交互元素的设计实现
python
/*getInteractiveElements*/
const getInteractiveElements = () => {
// 1.定义变量存储激活元素列表+视口宽高
const interactiveElements = [];
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// 2.获取页面上所有可交互的元素,包含按钮、a标签、输入框、文本域、下拉菜单、按钮、tab等
const elements = document.querySelectorAll('button, a, input, textarea, select, [role="button"], [tabindex]:not([tabindex="-1"])');
// 3.定义变量用于生成连续的唯一索引
let validElementIndex = 0;
// 4.循环遍历所有元素
for (let i = 0; i < elements.length; i++) {
// 5.取出对应元素并获取尺寸+位置
const element = elements[i];
const rect = element.getBoundingClientRect();
// 6.宽高任意为0则跳过这条元素
if (rect.width === 0 || rect.height === 0) continue;
// 7.视口不可见该元素则跳过
if (
rect.bottom < 0 ||
rect.top > viewportHeight ||
rect.right < 0 ||
rect.left > viewportWidth
) continue;
// 8.样式不可见则跳过该元素
const style = window.getComputedStyle(element);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
) continue;
// 9.获取元素的标签名并转换成小写,同时提取标签内容
let tagName = element.tagName.toLowerCase();
let text = '';
// 10.根据标签类型不同处理不同的逻辑,首先是输入框/文本域/下拉菜单
if (element.value && ['input', 'textarea', 'select'].includes(tagName)) {
text = element.value;
// 11.标签为输入框则执行以下代码,记录label和placeholder
if (tagName === 'input') {
// 12.查询输入框的label是否存在并赋值
let labelText = '';
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label) {
labelText = label.innerText.trim();
}
}
// 13.查找父级或同级的 label (当没有 for 属性绑定时)
if (!labelText) {
const parentLabel = element.closest('label');
if (parentLabel) {
labelText = parentLabel.innerText.trim().replace(element.value, '').trim();
}
}
// 14.拼接label消息
if (labelText) {
text = `[Label: ${labelText}] ${text}`;
}
// 15.拼接placeholder信息
if (element.placeholder) {
text = `${text} [Placeholder: ${element.placeholder}]`;
}
}
} else if (element.innerText) {
// 16.普通元素则提取内部文本并剔除多余空格 (如 <button>提交</button>)
text = element.innerText.trim().replace(/\\s+/g, ' ');
} else if (element.alt) {
// 17.图片按钮,取 alt 属性
text = element.alt;
} else if (element.title) {
// 18.取 title 属性
text = element.title;
} else if (element.placeholder) {
// 19.提取placeholder
text = `[Placeholder: ${element.placeholder}]`;
} else if (element.type) {
// 20.兜底逻辑将元素的类型作为文本描述
text = `[${element.type}]`;
// 21.针对没有值的 Input,再次尝试获取 Label 和 Placeholder (逻辑同上)
if (tagName === 'input') {
let labelText = '';
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label) {
labelText = label.innerText.trim();
}
}
if (!labelText) {
const parentLabel = element.closest('label');
if (parentLabel) {
labelText = parentLabel.innerText.trim();
}
}
if (labelText) {
text = `[Label: ${labelText}] ${text}`;
}
if (element.placeholder) {
text = `${text} [Placeholder: ${element.placeholder}]`;
}
}
} else {
// 22.都不满足,则设置为No text
text = '[No text]';
}
// 23.检测文本长度是否超过100,如果是则剔除多余的部分
if (text.length > 100) {
text = text.substring(0, 97) + '...';
}
// 24.为当前元素添加data-manus-id的属性,值为manus-element-idx,这样可以通过索引找到对应的元素
element.setAttribute('data-manus-id', `manus-element-${validElementIndex}`);
// 25.构建css选择器
const selector = `[data-manus-id="manus-element-${validElementIndex}"]`;
// 26.将索引、标签名、文本、选择器添加到激活元素列表中
interactiveElements.push({
index: validElementIndex,
tag: tagName,
text: text,
selector: selector
});
// 27.索引自增1
validElementIndex++;
}
// 28.最终返回所有激活元素数据
return interactiveElements;
}
/*getInteractiveElements*/
/*injectConsoleLogs***/
const injectConsoleLogs = () => {
// 1.定义变量存储控制台输出日志
window.console.logs = [];
// 2.重写window.console.log函数
const originalLog = console.log;
console.log = (...args) => {
window.console.logs.push(args.join(" "));
originalLog.apply(console, args);
};
}

3、Agent跳转、浏览网页、重重启工具的设计与实现
4、Agent执行js、滚动页面、截图功能的设计与实现





5、为无多模态支持的Agent实现浏览器元素点击功能


6、Agent的文本框输入与下拉菜单选择功能的设计实现




7、完整脚本代码
app/infrastructure/external/browser/playwright_browser.py

python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time : 2025/05/05 10:17
@Author : thezehui@gmail.com
@File : playwright_browser.py
"""
import asyncio
import logging
from typing import Optional, List, Any
from markdownify import markdownify
from playwright.async_api import async_playwright, Playwright, Browser, Page
from app.domain.external.browser import Browser as BrowserProtocol
from app.domain.external.llm import LLM
from app.domain.models.tool_result import ToolResult
from .playwright_browser_fun import (
GET_VISIBLE_CONTENT_FUNC,
GET_INTERACTIVE_ELEMENTS_FUNC,
INJECT_CONSOLE_LOGS_FUNC,
)
logger = logging.getLogger(__name__)
class PlaywrightBrowser(BrowserProtocol):
"""基础Playwright管理的浏览器扩展"""
def __init__(
self,
cdp_url: str, # CDP的连接地址
llm: Optional[LLM] = None, # 可选参数,传递LLM,如果传递了则会使用LLM对页面内容进行整理变成markdown格式
) -> None:
"""构造函数,完成Playwright浏览器的初始化"""
# LLM相关
self.llm: Optional[LLM] = llm
# 浏览器相关
self.cdp_url: str = cdp_url
self.playwright: Optional[Playwright] = None
self.browser: Optional[Browser] = None
self.page: Optional[Page] = None
async def _ensure_browser(self) -> None:
"""确保浏览器存在,如果不存在则初始化"""
if not self.browser or not self.page:
if not await self.initialize():
raise Exception("初始化Playwright浏览器失败")
async def _ensure_page(self) -> None:
"""确保浏览器页面存在,如果不存在则新建"""
# 1.先保证浏览器存在
await self._ensure_browser()
# 2.如果页面不存在则创建新上下文+页面
if not self.page:
self.page = await self.browser.new_page() # 等同于self.browser.new_context().new_page()
else:
# 3.如果页面存在则提取所有上下文
contexts = self.browser.contexts
if contexts:
# 4.获取默认上下文及页面
default_context = contexts[0]
pages = default_context.pages
# 5.判断页面是否存在
if pages:
# 6.获取当前最新页面(Chrome浏览器新增页面时,默认往右侧添加,相当于pages中序号较大的页面)
latest_page = pages[-1]
# 7.判断当前页面是否为最新页面,如果不是则更新
if self.page != latest_page:
self.page = latest_page
async def _extract_content(self) -> str:
"""提取当前页面内容"""
# 1.使用js代码获取当前页面可见元素内容
visible_content = await self.page.evaluate(GET_VISIBLE_CONTENT_FUNC)
# 2.使用markdownify这个库将html文档转换为markdown
markdown_content = markdownify(visible_content)
# 3.模型上下文长度有限,提取最大不超过50k个字符
max_content_length = min(len(markdown_content), 50000)
# 4.判断是否传递了llm,如果传递了,还可以使用llm对markdown_content进行整理
if self.llm:
# 5.调用llm对markdown_content内容进行二次整理
response = await self.llm.invoke([
{
"role": "system",
"content": "您是一名专业的网页信息提取助手。请从当前页面内容中提取所有信息并将其转换为markdown格式。",
},
{
"role": "user",
"content": markdown_content[:max_content_length],
}
])
return response.get("content", "")
else:
return markdown_content[:max_content_length]
async def _extract_interactive_elements(self) -> List[str]:
"""提取当前页面上的可交互元素"""
# 1.确保页面存在
await self._ensure_page()
# 2.清除当前页面上的缓存可交互元素列表
self.page.interactive_elements_cache = []
# 3.执行js脚本获取可交互的元素列表
interactive_elements = await self.page.evaluate(GET_INTERACTIVE_ELEMENTS_FUNC)
# 4.更新缓存的可交互元素列表
self.page.interactive_elements_cache = interactive_elements
# 5.格式化可交互元素为字符串
formatted_elements = []
for element in interactive_elements:
formatted_elements.append(f"{element['index']}:<{element['tag']}>{element['text']}</{element['tag']}>")
return formatted_elements
async def _get_element_by_id(self, index: int) -> Optional[Any]:
"""根据传递的索引/id获取对应的元素"""
# 1.判断也当前页面是否存在可交互元素缓存
if (
not hasattr(self.page, "interactive_elements_cache") or
not self.page.interactive_elements_cache or
index >= len(self.page.interactive_elements_cache)
):
return None
# 2.构建选择器
selector = f'[data-manus-id="manus-element-{index}"]'
return await self.page.query_selector(selector)
async def click(
self,
index: Optional[int] = None,
coordinate_x: Optional[float] = None,
coordinate_y: Optional[float] = None,
) -> ToolResult:
"""根据传递的索引位置+xy坐标实现点击"""
# 1.确保页面存在
await self._ensure_page()
# 2.判断传递的是xy坐标还是index
if coordinate_x is not None and coordinate_y is not None:
await self.page.mouse.click(coordinate_x, coordinate_y)
elif index is not None:
try:
# 3.根据index获取元素
element = await self._get_element_by_id(index)
if not element:
return ToolResult(success=False, message=f"使用索引{index}查找该元素无效, 未找到")
# 4.检查元素是否是可见的
is_visible = await self.page.evaluate("""(element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return !(
rect.width === 0 ||
rect.height === 0 ||
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
);
}""", element)
# 5.如果元素不可见则执行以下代码
if not is_visible:
# 6.尝试将页面滚动到该元素的位置
await self.page.evaluate("""(element) => {
if (element) {
element.scrollIntoView({behavior: 'smooth', block: 'center'})
}
}""", element)
await asyncio.sleep(1)
# 7.点击元素
await element.click(timeout=5000)
except Exception as e:
return ToolResult(success=False, message=f"点击元素出错: {str(e)}")
return ToolResult(success=True)
async def initialize(self) -> bool:
"""初始化并确保资源是可用的"""
# 1.定义重试次数+重试延迟确保资源存在
max_retries = 5 # 最多重试次数
retry_interval = 1 # 重试间隔
# 2.循环开始资源构建
for attempt in range(max_retries):
try:
# 3.创建playwright上下文并连接到cdp浏览器
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.connect_over_cdp(self.cdp_url)
# 4.获取浏览器的所有上下文
contexts = self.browser.contexts
# 5.如果上下文存在,并且第一个上下文只有一个页面则执行如下逻辑
if contexts and len(contexts[0].pages) == 1:
# 6.获取当前上下文的第一个页面
page = contexts[0].pages[0]
# 7.判断当前页面是不是空页面,如果是则直接使用page,否则新建一个
if (
page.url == "about:blank" or
page.url == "chrome://newtab/" or
page.url == "chrome://new-tab-page/" or
not page.url
):
self.page = page
else:
# 8.当前页面已经有数据则新建一个页面
self.page = await contexts[0].new_page()
else:
# 9.上下文不存在或者页面不唯一则表示数据被污染,新建一个页面
context = contexts[0] if contexts else await self.browser.new_context()
self.page = await context.new_page()
return True
except Exception as e:
# 10.清除所有资源
await self.cleanup()
# 11.判断重试次数是否等于最大重试次数
if attempt == max_retries - 1:
logger.error(f"初始化Playwright浏览器失败(已重试{max_retries}次): {str(e)}")
return False
# 12.使用指数级增长进行休眠,最大休眠时间为10s
retry_interval = min(retry_interval * 2, 10)
logger.warning(f"初始化Playwright浏览器失败, 即将进行第{attempt + 1}次重试: {str(e)}")
await asyncio.sleep(retry_interval)
async def cleanup(self) -> None:
"""清除Playwright资源,包含浏览器+页面+Playwright"""
try:
# 1.检测浏览器是否存在,如果存在则删除该浏览器下的所有tabs页面
if self.browser:
# 2.获取该浏览器的所有上下文
contexts = self.browser.contexts
if contexts:
# 3.循环遍历所有上下文逐个处理
for context in contexts:
# 4.获取每个上下文的所有页面
pages = context.pages
for page in pages:
# 5.循环遍历清除所有页面
if not page.is_closed():
await page.close()
# 6.判断self.page是否关闭
if self.page and not self.page.is_closed():
await self.page.close()
# 7.关闭浏览器
if self.browser:
await self.browser.close()
# 8.停止playwright
if self.playwright:
await self.playwright.stop()
except Exception as e:
# 9记录错误日志
logger.error(f"清理Playwright浏览器资源出错: {str(e)}")
finally:
# 10.重置所有资源
self.page = None
self.browser = None
self.playwright = None
async def wait_for_page_load(self, timeout: int = 15) -> bool:
"""传递超时时间,等待当前页面是否加载完毕"""
# 1.确保当前页面存在
await self._ensure_page()
# 2.使用异步任务事件循环中的时间来作为开始时间(只和异步任务相关)
start_time = asyncio.get_event_loop().time()
check_interval = 5
# 3.循环检测网页是否加载成功
while asyncio.get_event_loop().time() - start_time < timeout:
# 4.使用js代码判断网页是否加载成功
is_completed = await self.page.evaluate("""() => document.readyState === 'complete'""")
if is_completed:
return True
# 5.未加载成功则休眠对应时间
await asyncio.sleep(check_interval)
return False
async def navigate(self, url: str) -> ToolResult:
"""根据传递的url跳转到指定页面"""
# 1.确保页面存在
await self._ensure_page()
try:
# 2.在跳转之前先将可交互元素的缓存清空
self.page.interactive_elements_cache = []
# 3.使用goto进行跳转
await self.page.goto(url)
return ToolResult(
success=True,
data={"interactive_elements": await self._extract_interactive_elements()}
)
except Exception as e:
# 返回错误的工具结果
return ToolResult(success=False, message=f"浏览器导航到[{url}]失败: {str(e)}")
async def view_page(self) -> ToolResult:
"""获取当前网页的内容(内容+可交互元素列表)"""
# 1.确保页面存在
await self._ensure_page()
# 2.等待页面加载完成
await self.wait_for_page_load()
# 3.更新页面的可交互元素
interactive_elements = await self._extract_interactive_elements()
# 4.返回工具结果
return ToolResult(
success=True,
data={
"content": await self._extract_content(),
"interactive_elements": interactive_elements,
}
)
async def input(
self,
text: str,
press_enter: bool,
index: Optional[int] = None,
coordinate_x: Optional[float] = None,
coordinate_y: Optional[float] = None,
) -> ToolResult:
"""根据传递的文本+换行标识+索引+xy位置实现输入框文本输入"""
# 1.确保页面存在
await self._ensure_page()
# 2.判断下是传递xy还是index
if coordinate_x is not None and coordinate_y is not None:
# 3.点击指定位置后输入文本
await self.page.mouse.click(coordinate_x, coordinate_y)
await self.page.keyboard.type(text)
elif index is not None:
try:
# 4.根据索引查找元素
element = await self._get_element_by_id(index)
if not element:
return ToolResult(success=False, message=f"输入文本失败, 该元素不存在")
try:
# 5.先清空原始输入框的内容然后再填充
await element.fill("")
await element.type(text)
except Exception as e:
# 6.如果填充失败则尝试点击后输入文本,而不是直接清空
await element.click()
await element.type(text)
except Exception as e:
return ToolResult(success=False, message=f"输入文本失败: {str(e)}")
# 7.判断是否按Enter键
if press_enter:
await self.page.keyboard.press("Enter")
return ToolResult(success=True)
async def move_mouse(self, coordinate_x: float, coordinate_y: float) -> ToolResult:
"""传递xy坐标移动鼠标"""
await self._ensure_page()
await self.page.mouse.move(coordinate_x, coordinate_y)
return ToolResult(success=True)
async def press_key(self, key: str) -> ToolResult:
"""传递按键进行模拟"""
await self._ensure_page()
await self.page.keyboard.press(key)
return ToolResult(success=True)
async def select_option(self, index: int, option: int) -> ToolResult:
"""传递索引+下拉菜单选项选择指定的菜单信息"""
# 1.确保页面存在
await self._ensure_page()
try:
# 2.获取元素信息
element = await self._get_element_by_id(index)
if not element:
return ToolResult(success=False, message=f"使用索引[{index}]查找该下拉菜单元素不存在")
# 3.调用函数直接选择对应选项
await element.select_option(index=option)
return ToolResult(success=True)
except Exception as e:
return ToolResult(success=False, message=f"选择下拉菜单选项失败: {str(e)}")
async def restart(self, url: str) -> ToolResult:
"""重启并跳转到指定URL"""
await self.cleanup()
return await self.navigate(url)
async def scroll_up(self, to_top: Optional[bool] = None) -> ToolResult:
"""向上滚动浏览器一个屏幕或者整个页面"""
# 1.确保页面存在
await self._ensure_page()
# 2.判断是否滚动到最顶部
if to_top:
await self.page.evaluate("window.scrollTo(0, 0)")
else:
await self.page.evaluate("window.scrollBy(0, -window.innerHeight)")
return ToolResult(success=True)
async def scroll_down(self, to_down: Optional[bool] = None) -> ToolResult:
"""向下滚动浏览器一个屏幕或者到最底部"""
# 1.确保页面存在
await self._ensure_page()
# 2.判断是否滚动到最底部
if to_down:
await self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
else:
await self.page.evaluate("window.scrollBy(0, window.innerHeight)")
return ToolResult(success=True)
async def screenshot(self, full_page: Optional[bool] = None) -> bytes:
"""传递full_page完成网页截图"""
# 1.确保页面存在
await self._ensure_page()
# 2.创建一个截图配置
screenshot_options = {
"full_page": full_page,
"type": "png"
}
return await self.page.screenshot(**screenshot_options)
async def console_exec(self, javascript: str) -> ToolResult:
"""传递js代码在当前页面控制台执行"""
# 1.确保页面存在
await self._ensure_page()
# 2.在正式开始执行代码之前先注入logs
try:
await self.page.evaluate(INJECT_CONSOLE_LOGS_FUNC)
except Exception as e:
logger.warning(f"注入window.console.logs失败: {str(e)}")
# 3.正式执行js脚本
result = await self.page.evaluate(javascript)
return ToolResult(success=True, data={"result": result})
async def console_view(self, max_lines: Optional[int] = None) -> ToolResult:
"""根据传递的行数查看控制台的日志"""
# 1.确保页面存在
await self._ensure_page()
# 2.可以指定另外一段js代码查看控制台的内容
logs = await self.page.evaluate("""() => {
return window.console.logs || [];
}""")
if max_lines is not None:
logs = logs[-max_lines:]
return ToolResult(success=True, data={"logs": logs})