(六)MCP+A2A之——Playwright与BrowserUse:让AI学会像人类一样使用浏览器

(六)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})
相关推荐
一水鉴天1 小时前
声调韵的序:从古汉语音韵到系统科学语言的场效应推演 20261013(千问)
开发语言·人工智能
Yolanda_20221 小时前
26.网络模型的保存与读取
人工智能·pytorch·python
涉密IT资质笔记1 小时前
涉密资质投入产出分析模型:时间成本、四块成本构成与 ROI 决策框架
java·开发语言·算法
北风toto1 小时前
KMP 算法详解:从暴力匹配到 O(n+m) 的字符串搜索
java·开发语言·算法
william_yangshun2 小时前
WordCraft 观察:纯 Rust 重写文字处理器,把文档变成可编程对象
开发语言·后端·rust·文档自动化
weixin199701080162 小时前
《京东JOS API:落地方案、接口边界与业务踩坑 —— 联盟免费 vs 商家按量的双轨制》(附python源码)
java·网络·python
外收内放2 小时前
数据分析(数据查询与选择)
python·学习·数据分析
dtq04242 小时前
C++ - 变量
开发语言·c++
林澈在路上2 小时前
哪个写歌软件可以用自己声音?MELO音乐详解
人工智能·python·算法