|
|
@@ -37,15 +37,16 @@ Native Browser-Use Tools Adapter
|
|
|
3. 任务结束时调用 cleanup_browser_session()
|
|
|
|
|
|
文件操作说明:
|
|
|
-- 浏览器专用文件目录:.browser_use_files/ (在当前工作目录下)
|
|
|
+- 浏览器专用文件目录:.cache/.browser_use_files/ (在当前工作目录下)
|
|
|
用于存储浏览器会话产生的临时文件(下载、上传、截图等)
|
|
|
- 一般文件操作:请使用 agent.tools.builtin 中的文件工具 (read_file, write_file, edit_file)
|
|
|
这些工具功能更完善,支持diff预览、智能匹配、分页读取等
|
|
|
"""
|
|
|
-
|
|
|
+import logging
|
|
|
import sys
|
|
|
import os
|
|
|
import json
|
|
|
+import httpx
|
|
|
import asyncio
|
|
|
import aiohttp
|
|
|
import re
|
|
|
@@ -229,153 +230,71 @@ async def init_browser_session(
|
|
|
browser_profile: Optional[BrowserProfile] = None,
|
|
|
**kwargs
|
|
|
) -> tuple[BrowserSession, Tools]:
|
|
|
- """
|
|
|
- 初始化全局浏览器会话 - 支持三种浏览器类型
|
|
|
-
|
|
|
- Args:
|
|
|
- browser_type: 浏览器类型 ("local", "cloud", "container")
|
|
|
- headless: 是否无头模式
|
|
|
- url: 初始访问URL(可选)
|
|
|
- - local/cloud: 初始化后会自动导航到此URL
|
|
|
- - container: 必需,容器启动时访问的URL
|
|
|
- profile_name: 配置文件/账户名称(默认 "default")
|
|
|
- - local: 用于创建用户数据目录路径
|
|
|
- - cloud: 云浏览器配置ID
|
|
|
- - container: 容器账户名称
|
|
|
- user_data_dir: 用户数据目录(仅 local 模式,高级用法)
|
|
|
- 如果提供则覆盖 profile_name 生成的路径
|
|
|
- browser_profile: BrowserProfile 对象(通用,高级用法)
|
|
|
- 用于预设 cookies 等
|
|
|
- **kwargs: 其他 BrowserSession 参数
|
|
|
-
|
|
|
- Returns:
|
|
|
- (BrowserSession, Tools) 元组
|
|
|
-
|
|
|
- Examples:
|
|
|
- # 本地浏览器
|
|
|
- browser, tools = await init_browser_session(
|
|
|
- browser_type="local",
|
|
|
- url="https://www.baidu.com" # 可选
|
|
|
- )
|
|
|
-
|
|
|
- # 云浏览器
|
|
|
- browser, tools = await init_browser_session(
|
|
|
- browser_type="cloud",
|
|
|
- profile_name="my_cloud_profile" # 可选
|
|
|
- )
|
|
|
-
|
|
|
- # 容器浏览器
|
|
|
- browser, tools = await init_browser_session(
|
|
|
- browser_type="container",
|
|
|
- url="https://www.xiaohongshu.com", # 必需
|
|
|
- profile_name="my_account" # 可选
|
|
|
- )
|
|
|
- """
|
|
|
global _browser_session, _browser_tools, _file_system
|
|
|
|
|
|
if _browser_session is not None:
|
|
|
return _browser_session, _browser_tools
|
|
|
|
|
|
- # 验证 browser_type
|
|
|
valid_types = ["local", "cloud", "container"]
|
|
|
if browser_type not in valid_types:
|
|
|
- raise ValueError(f"无效的 browser_type: {browser_type},必须是 {valid_types} 之一")
|
|
|
+ raise ValueError(f"无效的 browser_type: {browser_type}")
|
|
|
+
|
|
|
+ # --- 核心:定义本地统一存储路径 ---
|
|
|
+ save_dir = Path.cwd() / ".cache/.browser_use_files"
|
|
|
+ save_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
- # 创建浏览器会话参数
|
|
|
+ # 基础参数配置
|
|
|
session_params = {
|
|
|
"headless": headless,
|
|
|
+ # 告诉 Playwright 所有的下载临时流先存入此本地目录
|
|
|
+ "downloads_path": str(save_dir),
|
|
|
}
|
|
|
|
|
|
- # === Container 模式 ===
|
|
|
if browser_type == "container":
|
|
|
print("🐳 使用容器浏览器模式")
|
|
|
-
|
|
|
- # container 模式必须提供 URL
|
|
|
- if not url:
|
|
|
- url = "about:blank" # 使用默认空白页
|
|
|
- print("⚠️ 未提供 url 参数,使用默认空白页")
|
|
|
-
|
|
|
- # 创建容器并获取 CDP URL
|
|
|
- print(f"📦 正在创建容器...")
|
|
|
- container_info = await create_container(
|
|
|
- url=url,
|
|
|
- account_name=profile_name
|
|
|
- )
|
|
|
-
|
|
|
+ if not url: url = "about:blank"
|
|
|
+ container_info = await create_container(url=url, account_name=profile_name)
|
|
|
if not container_info["success"]:
|
|
|
raise RuntimeError(f"容器创建失败: {container_info['error']}")
|
|
|
-
|
|
|
- cdp_url = container_info["cdp"]
|
|
|
- print(f"✅ 容器创建成功")
|
|
|
- print(f" CDP URL: {cdp_url}")
|
|
|
- print(f" Container ID: {container_info['container_id']}")
|
|
|
- print(f" Connection ID: {container_info.get('connection_id')}")
|
|
|
-
|
|
|
- # 使用容器的 CDP URL 连接
|
|
|
- session_params["cdp_url"] = cdp_url
|
|
|
-
|
|
|
- # 等待容器完全启动
|
|
|
- print("⏳ 等待容器浏览器启动...")
|
|
|
+ session_params["cdp_url"] = container_info["cdp"]
|
|
|
await asyncio.sleep(3)
|
|
|
|
|
|
- # === Cloud 模式 ===
|
|
|
elif browser_type == "cloud":
|
|
|
print("🌐 使用云浏览器模式")
|
|
|
session_params["use_cloud"] = True
|
|
|
-
|
|
|
- # profile_name 作为云配置ID
|
|
|
if profile_name and profile_name != "default":
|
|
|
session_params["cloud_profile_id"] = profile_name
|
|
|
|
|
|
- # === Local 模式 ===
|
|
|
else: # local
|
|
|
print("💻 使用本地浏览器模式")
|
|
|
session_params["is_local"] = True
|
|
|
-
|
|
|
- # 设置用户数据目录(持久化登录状态)
|
|
|
if user_data_dir is None and profile_name:
|
|
|
user_data_dir = str(Path.home() / ".browser_use" / "profiles" / profile_name)
|
|
|
Path(user_data_dir).mkdir(parents=True, exist_ok=True)
|
|
|
-
|
|
|
- # macOS 上显式指定 Chrome 路径
|
|
|
+ session_params["user_data_dir"] = user_data_dir
|
|
|
+
|
|
|
+ # macOS 路径兼容
|
|
|
import platform
|
|
|
- if platform.system() == "Darwin": # macOS
|
|
|
+ if platform.system() == "Darwin":
|
|
|
chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
|
if Path(chrome_path).exists():
|
|
|
session_params["executable_path"] = chrome_path
|
|
|
|
|
|
- # 只在有值时才添加 user_data_dir
|
|
|
- if user_data_dir:
|
|
|
- session_params["user_data_dir"] = user_data_dir
|
|
|
-
|
|
|
- # 只在有值时才添加 browser_profile (适用于所有模式)
|
|
|
if browser_profile:
|
|
|
session_params["browser_profile"] = browser_profile
|
|
|
|
|
|
- # 合并其他参数
|
|
|
session_params.update(kwargs)
|
|
|
|
|
|
- # 创建浏览器会话
|
|
|
+ # 创建会话
|
|
|
_browser_session = BrowserSession(**session_params)
|
|
|
-
|
|
|
- # 启动浏览器
|
|
|
await _browser_session.start()
|
|
|
|
|
|
- # 创建工具实例
|
|
|
_browser_tools = Tools()
|
|
|
+ _file_system = FileSystem(base_dir=str(save_dir))
|
|
|
|
|
|
- # 创建文件系统实例(用于浏览器会话产生的文件)
|
|
|
- # 注意:这个目录仅用于浏览器操作相关的临时文件(下载、上传、截图等)
|
|
|
- # 对于一般文件读写操作,请使用 agent.tools.builtin 中的文件工具
|
|
|
- base_dir = Path.cwd() / ".browser_use_files"
|
|
|
- base_dir.mkdir(parents=True, exist_ok=True)
|
|
|
- _file_system = FileSystem(base_dir=str(base_dir))
|
|
|
-
|
|
|
- print("✅ 浏览器会话初始化成功")
|
|
|
+ print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
|
|
|
|
|
|
- # 如果是 local 或 cloud 模式且提供了 URL,导航到该 URL
|
|
|
if browser_type in ["local", "cloud"] and url:
|
|
|
- print(f"🔗 导航到: {url}")
|
|
|
await _browser_tools.navigate(url=url, browser_session=_browser_session)
|
|
|
|
|
|
return _browser_session, _browser_tools
|
|
|
@@ -713,33 +632,132 @@ async def browser_wait(seconds: int = 3) -> ToolResult:
|
|
|
# 元素交互工具 (Element Interaction Tools)
|
|
|
# ============================================================
|
|
|
|
|
|
-@tool()
|
|
|
-async def browser_click_element(index: int) -> ToolResult:
|
|
|
- """
|
|
|
- 通过索引点击页面元素
|
|
|
- Click an element by index
|
|
|
-
|
|
|
- Args:
|
|
|
- index: 元素索引(从浏览器状态中获取)
|
|
|
+# 定义一个专门捕获下载链接的 Handler
|
|
|
+class DownloadLinkCaptureHandler(logging.Handler):
|
|
|
+ def __init__(self):
|
|
|
+ super().__init__()
|
|
|
+ self.captured_url = None
|
|
|
+
|
|
|
+ def emit(self, record):
|
|
|
+ # 如果已经捕获到了(通常第一条是最完整的),就不再处理后续日志
|
|
|
+ if self.captured_url:
|
|
|
+ return
|
|
|
+
|
|
|
+ message = record.getMessage()
|
|
|
+ # 寻找包含下载信息的日志
|
|
|
+ if "redirection?filename=" in message or "Failed to download" in message:
|
|
|
+ # 使用更严格的正则,确保不抓取带省略号(...)的截断链接
|
|
|
+ # 排除掉末尾带有三个点的干扰
|
|
|
+ match = re.search(r"https?://[^\s]+(?!\.\.\.)", message)
|
|
|
+ if match:
|
|
|
+ url = match.group(0)
|
|
|
+ # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
|
|
|
+ if "..." not in url:
|
|
|
+ self.captured_url = url
|
|
|
+ # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
|
|
|
|
|
|
- Returns:
|
|
|
- ToolResult: 包含点击操作结果的工具返回对象
|
|
|
+@tool()
|
|
|
+async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
|
|
|
+ save_dir = Path.cwd() / ".cache/.browser_use_files"
|
|
|
+ save_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+
|
|
|
+ # 提取域名作为 Referer,这能骗过 90% 的防盗链校验
|
|
|
+ from urllib.parse import urlparse
|
|
|
+ parsed_url = urlparse(url)
|
|
|
+ base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
|
|
+
|
|
|
+ # 如果没传 save_name,自动从 URL 获取
|
|
|
+ if not save_name:
|
|
|
+ import unquote
|
|
|
+ # 尝试从 URL 路径获取文件名并解码(处理中文)
|
|
|
+ save_name = Path(urlparse(url).path).name or f"download_{int(time.time())}"
|
|
|
+ save_name = unquote(save_name)
|
|
|
+
|
|
|
+ target_path = save_dir / save_name
|
|
|
+
|
|
|
+ headers = {
|
|
|
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
|
+ "Accept": "*/*",
|
|
|
+ "Referer": base_url, # 动态设置 Referer
|
|
|
+ "Range": "bytes=0-", # 有时对大文件下载有奇效
|
|
|
+ }
|
|
|
|
|
|
- Example:
|
|
|
- click_element(index=5)
|
|
|
+ try:
|
|
|
+ print(f"🚀 开始下载: {url[:60]}...")
|
|
|
+
|
|
|
+ # 使用 follow_redirects=True 处理链接中的 redirection
|
|
|
+ async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=60.0) as client:
|
|
|
+ async with client.stream("GET", url) as response:
|
|
|
+ if response.status_code != 200:
|
|
|
+ print(f"❌ 下载失败,HTTP 状态码: {response.status_code}")
|
|
|
+ return
|
|
|
+
|
|
|
+ # 获取实际文件名(如果服务器提供了)
|
|
|
+ # 这里会优先使用你指定的 save_name
|
|
|
+
|
|
|
+ with open(target_path, "wb") as f:
|
|
|
+ downloaded_bytes = 0
|
|
|
+ async for chunk in response.aiter_bytes():
|
|
|
+ f.write(chunk)
|
|
|
+ downloaded_bytes += len(chunk)
|
|
|
+ if downloaded_bytes % (1024 * 1024) == 0: # 每下载 1MB 打印一次
|
|
|
+ print(f"📥 已下载: {downloaded_bytes // (1024 * 1024)} MB")
|
|
|
+
|
|
|
+ print(f"✅ 下载完成!文件已存至: {target_path}")
|
|
|
+ success_msg = f"✅ 下载完成!文件已存至: {target_path}"
|
|
|
+ return ToolResult(
|
|
|
+ title="直链下载成功",
|
|
|
+ output=success_msg,
|
|
|
+ long_term_memory=success_msg,
|
|
|
+ metadata={"path": str(target_path)}
|
|
|
+ )
|
|
|
|
|
|
- Note:
|
|
|
- 需要先通过 get_selector_map 获取页面元素索引
|
|
|
+ except Exception as e:
|
|
|
+ # 异常捕获返回
|
|
|
+ return ToolResult(
|
|
|
+ title="下载异常",
|
|
|
+ output="",
|
|
|
+ error=f"💥 发生错误: {str(e)}",
|
|
|
+ long_term_memory=f"下载任务由于异常中断: {str(e)}"
|
|
|
+ )
|
|
|
+
|
|
|
+@tool()
|
|
|
+async def browser_click_element(index: int) -> ToolResult:
|
|
|
"""
|
|
|
+ 点击页面元素,并自动通过拦截内部日志获取下载直链。
|
|
|
+ """
|
|
|
+ # 1. 挂载日志窃听器
|
|
|
+ capture_handler = DownloadLinkCaptureHandler()
|
|
|
+ logger = logging.getLogger("browser_use") # 拦截整个 browser_use 命名空间
|
|
|
+ logger.addHandler(capture_handler)
|
|
|
+
|
|
|
try:
|
|
|
browser, tools = await get_browser_session()
|
|
|
|
|
|
+ # 2. 执行原生的点击动作
|
|
|
result = await tools.click(
|
|
|
index=index,
|
|
|
browser_session=browser
|
|
|
)
|
|
|
|
|
|
- return action_result_to_tool_result(result, f"点击元素 {index}")
|
|
|
+ # 3. 检查是否有“意外收获”
|
|
|
+ download_msg = ""
|
|
|
+ if capture_handler.captured_url:
|
|
|
+ captured_url = capture_handler.captured_url
|
|
|
+ download_msg = f"\n\n⚠️ 系统检测到浏览器下载被拦截,已自动捕获准确直链:\n{captured_url}\n\n建议:你可以直接使用 browser_download_direct_url 工具下载此链接。"
|
|
|
+
|
|
|
+ # 如果你想更激进一点,甚至可以在这里直接自动触发本地下载逻辑
|
|
|
+ # await auto_download_file(captured_url)
|
|
|
+
|
|
|
+ # 4. 转换结果并附加捕获的信息
|
|
|
+ tool_result = action_result_to_tool_result(result, f"点击元素 {index}")
|
|
|
+
|
|
|
+ if download_msg:
|
|
|
+ # 关键:把日志里的信息塞进 output,这样 LLM 就能看到了!
|
|
|
+ tool_result.output = (tool_result.output or "") + download_msg
|
|
|
+ tool_result.long_term_memory = (tool_result.long_term_memory or "") + f" 捕获下载链接: {captured_url}"
|
|
|
+
|
|
|
+ return tool_result
|
|
|
|
|
|
except Exception as e:
|
|
|
return ToolResult(
|
|
|
@@ -748,6 +766,9 @@ async def browser_click_element(index: int) -> ToolResult:
|
|
|
error=f"Failed to click element {index}: {str(e)}",
|
|
|
long_term_memory=f"点击元素 {index} 失败"
|
|
|
)
|
|
|
+ finally:
|
|
|
+ # 5. 务必移除监听器,防止内存泄漏和日志污染
|
|
|
+ logger.removeHandler(capture_handler)
|
|
|
|
|
|
|
|
|
@tool()
|
|
|
@@ -868,20 +889,20 @@ async def browser_upload_file(index: int, path: str) -> ToolResult:
|
|
|
long_term_memory=f"上传文件 {path} 失败"
|
|
|
)
|
|
|
|
|
|
-
|
|
|
# ============================================================
|
|
|
# 滚动和视图工具 (Scroll & View Tools)
|
|
|
# ============================================================
|
|
|
@tool()
|
|
|
async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Optional[int] = None) -> ToolResult:
|
|
|
try:
|
|
|
+ # 限制单次滚动幅度,避免 agent 一次滚 100 页
|
|
|
+ MAX_PAGES = 10
|
|
|
+ if pages > MAX_PAGES:
|
|
|
+ pages = MAX_PAGES
|
|
|
+
|
|
|
browser, tools = await get_browser_session()
|
|
|
-
|
|
|
- # --- 核心修复 1: 必须先 await 拿到 session 实例 ---
|
|
|
cdp_session = await browser.get_or_create_cdp_session()
|
|
|
-
|
|
|
- # 这里的执行方式建议参考你已有的 cdp 调用逻辑
|
|
|
- # 如果 cdp_session 没有直接封装 .eval(),使用 Runtime.evaluate
|
|
|
+
|
|
|
before_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
|
|
|
params={'expression': 'window.scrollY'},
|
|
|
session_id=cdp_session.session_id
|
|
|
@@ -890,25 +911,36 @@ async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Opti
|
|
|
|
|
|
# 执行滚动
|
|
|
result = await tools.scroll(down=down, pages=pages, index=index, browser_session=browser)
|
|
|
-
|
|
|
- # 等待渲染并检查偏移
|
|
|
- await asyncio.sleep(1)
|
|
|
-
|
|
|
+
|
|
|
+ # 等待渲染(懒加载页面需要更长时间)
|
|
|
+ await asyncio.sleep(2)
|
|
|
+
|
|
|
after_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
|
|
|
params={'expression': 'window.scrollY'},
|
|
|
session_id=cdp_session.session_id
|
|
|
)
|
|
|
after_y = after_y_result.get('result', {}).get('value', 0)
|
|
|
|
|
|
- # 3. 验证是否真的动了
|
|
|
+ # 如果第一次检测没动,再等一轮(应对懒加载触发后的延迟滚动)
|
|
|
if before_y == after_y and index is None:
|
|
|
+ await asyncio.sleep(2)
|
|
|
+ retry_result = await cdp_session.cdp_client.send.Runtime.evaluate(
|
|
|
+ params={'expression': 'window.scrollY'},
|
|
|
+ session_id=cdp_session.session_id
|
|
|
+ )
|
|
|
+ after_y = retry_result.get('result', {}).get('value', 0)
|
|
|
+
|
|
|
+ if before_y == after_y and index is None:
|
|
|
+ direction = "下" if down else "上"
|
|
|
return ToolResult(
|
|
|
- title="滚动无效",
|
|
|
- output="页面已到达边界或滚动被拦截",
|
|
|
+ title="滚动无效",
|
|
|
+ output=f"页面已到达{direction}边界,无法继续滚动",
|
|
|
error="No movement detected"
|
|
|
)
|
|
|
|
|
|
- return action_result_to_tool_result(result, f"已滚动")
|
|
|
+ delta = abs(after_y - before_y)
|
|
|
+ direction = "下" if down else "上"
|
|
|
+ return action_result_to_tool_result(result, f"已向{direction}滚动 {delta}px")
|
|
|
|
|
|
except Exception as e:
|
|
|
# --- 核心修复 2: 必须补全 output 参数,否则框架会报错 ---
|
|
|
@@ -919,6 +951,7 @@ async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Opti
|
|
|
)
|
|
|
|
|
|
|
|
|
+
|
|
|
@tool()
|
|
|
async def browser_find_text(text: str) -> ToolResult:
|
|
|
"""
|
|
|
@@ -954,7 +987,103 @@ async def browser_find_text(text: str) -> ToolResult:
|
|
|
long_term_memory=f"查找文本 '{text}' 失败"
|
|
|
)
|
|
|
|
|
|
+@tool()
|
|
|
+async def browser_get_visual_selector_map() -> ToolResult:
|
|
|
+ """
|
|
|
+ 获取当前页面的视觉快照和交互元素索引映射。
|
|
|
+ Get visual snapshot and selector map of interactive elements.
|
|
|
|
|
|
+ 该工具会同时执行两个操作:
|
|
|
+ 1. 捕捉当前页面的截图,并用 browser-use 内置方法在截图上标注元素索引号。
|
|
|
+ 2. 生成页面所有可交互元素的索引字典(含 href、type 等属性信息)。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ ToolResult: 包含高亮截图(在 images 中)和元素列表的工具返回对象。
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ browser, _ = await get_browser_session()
|
|
|
+
|
|
|
+ # 1. 构造同时包含 DOM 和 截图 的请求
|
|
|
+ from browser_use.browser.events import BrowserStateRequestEvent
|
|
|
+ from browser_use.browser.python_highlights import create_highlighted_screenshot_async
|
|
|
+
|
|
|
+ event = browser.event_bus.dispatch(
|
|
|
+ BrowserStateRequestEvent(
|
|
|
+ include_dom=True,
|
|
|
+ include_screenshot=True,
|
|
|
+ include_recent_events=False
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ # 2. 等待浏览器返回完整状态
|
|
|
+ browser_state = await event.event_result(raise_if_none=True, raise_if_any=True)
|
|
|
+
|
|
|
+ # 3. 提取 Selector Map
|
|
|
+ selector_map = browser_state.dom_state.selector_map if browser_state.dom_state else {}
|
|
|
+
|
|
|
+ # 4. 提取截图并生成带索引标注的高亮截图(通过 CDP 获取精确 DPI 和滚动偏移)
|
|
|
+ screenshot_b64 = browser_state.screenshot or ""
|
|
|
+ highlighted_b64 = ""
|
|
|
+ if screenshot_b64 and selector_map:
|
|
|
+ try:
|
|
|
+ cdp_session = await browser.get_or_create_cdp_session()
|
|
|
+ highlighted_b64 = await create_highlighted_screenshot_async(
|
|
|
+ screenshot_b64, selector_map,
|
|
|
+ cdp_session=cdp_session,
|
|
|
+ filter_highlight_ids=False
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ highlighted_b64 = screenshot_b64 # fallback to raw screenshot
|
|
|
+ else:
|
|
|
+ highlighted_b64 = screenshot_b64
|
|
|
+
|
|
|
+ # 5. 构建供 Agent 阅读的完整元素列表,包含丰富的属性信息
|
|
|
+ elements_info = []
|
|
|
+ for index, node in selector_map.items():
|
|
|
+ tag = node.tag_name
|
|
|
+ attrs = node.attributes or {}
|
|
|
+ desc = attrs.get('aria-label') or attrs.get('placeholder') or attrs.get('title') or node.get_all_children_text(max_depth=1) or ""
|
|
|
+ # 收集有用的属性片段
|
|
|
+ extra_parts = []
|
|
|
+ if attrs.get('href'):
|
|
|
+ extra_parts.append(f"href={attrs['href'][:60]}")
|
|
|
+ if attrs.get('type'):
|
|
|
+ extra_parts.append(f"type={attrs['type']}")
|
|
|
+ if attrs.get('role'):
|
|
|
+ extra_parts.append(f"role={attrs['role']}")
|
|
|
+ if attrs.get('name'):
|
|
|
+ extra_parts.append(f"name={attrs['name']}")
|
|
|
+ extra = f" ({', '.join(extra_parts)})" if extra_parts else ""
|
|
|
+ elements_info.append(f"Index {index}: <{tag}> \"{desc[:50]}\"{extra}")
|
|
|
+
|
|
|
+ output = f"页面截图已捕获(含元素索引标注)\n找到 {len(selector_map)} 个交互元素\n\n"
|
|
|
+ output += "元素列表:\n" + "\n".join(elements_info)
|
|
|
+
|
|
|
+ # 6. 将高亮截图存入 images 字段,metadata 保留结构化数据
|
|
|
+ images = []
|
|
|
+ if highlighted_b64:
|
|
|
+ images.append({"type": "base64", "media_type": "image/png", "data": highlighted_b64})
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title="视觉元素观察",
|
|
|
+ output=output,
|
|
|
+ long_term_memory=f"在页面观察到 {len(selector_map)} 个元素并保存了截图",
|
|
|
+ images=images,
|
|
|
+ metadata={
|
|
|
+ "selector_map": {k: str(v) for k, v in list(selector_map.items())[:100]},
|
|
|
+ "url": browser_state.url,
|
|
|
+ "title": browser_state.title
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ return ToolResult(
|
|
|
+ title="视觉观察失败",
|
|
|
+ output="",
|
|
|
+ error=f"Failed to get visual selector map: {str(e)}",
|
|
|
+ long_term_memory="获取视觉元素映射失败"
|
|
|
+ )
|
|
|
+
|
|
|
@tool()
|
|
|
async def browser_screenshot() -> ToolResult:
|
|
|
"""
|
|
|
@@ -1325,7 +1454,7 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
|
|
|
pdf_bytes = base64.b64decode(base64_data)
|
|
|
|
|
|
# 保存到本地
|
|
|
- save_dir = Path.cwd() / ".browser_use_files"
|
|
|
+ save_dir = Path.cwd() / ".cache/.browser_use_files"
|
|
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
filename = Path(parsed.path).name if parsed.path else ""
|
|
|
@@ -1787,6 +1916,112 @@ async def browser_done(text: str, success: bool = True,
|
|
|
)
|
|
|
|
|
|
|
|
|
+# ============================================================
|
|
|
+# Cookie 持久化工具
|
|
|
+# ============================================================
|
|
|
+
|
|
|
+_COOKIES_DIR = Path(__file__).parent.parent.parent.parent.parent / ".cache/.cookies"
|
|
|
+
|
|
|
+@tool()
|
|
|
+async def browser_export_cookies(name: str = "", account: str = "") -> ToolResult:
|
|
|
+ """
|
|
|
+ 导出当前浏览器的所有 Cookie 到本地 .cookies/ 目录。
|
|
|
+ 文件命名格式:{域名}_{账号名}.json,如 bilibili.com_zhangsan.json
|
|
|
+ 登录成功后调用此工具,下次可通过 browser_load_cookies 恢复登录态。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ name: 自定义文件名(可选,提供则忽略自动命名)
|
|
|
+ account: 账号名称(可选,用于区分同一网站的不同账号)
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ browser, _ = await get_browser_session()
|
|
|
+
|
|
|
+ # 获取所有 Cookie(CDP 格式)
|
|
|
+ all_cookies = await browser._cdp_get_cookies()
|
|
|
+ if not all_cookies:
|
|
|
+ return ToolResult(title="Cookie 导出", output="当前浏览器没有 Cookie", long_term_memory="无 Cookie 可导出")
|
|
|
+
|
|
|
+ # 获取当前域名,用于过滤和命名
|
|
|
+ from urllib.parse import urlparse
|
|
|
+ current_url = await browser.get_current_page_url() or ''
|
|
|
+ domain = urlparse(current_url).netloc.replace("www.", "") or "default"
|
|
|
+
|
|
|
+ if not name:
|
|
|
+ name = f"{domain}_{account}" if account else domain
|
|
|
+
|
|
|
+ # 只保留当前域名的 cookie(过滤第三方)
|
|
|
+ cookies = [c for c in all_cookies if domain in c.get("domain", "").lstrip(".")]
|
|
|
+
|
|
|
+ # 保存
|
|
|
+ _COOKIES_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
+ cookie_file = _COOKIES_DIR / f"{name}.json"
|
|
|
+ cookie_file.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title="Cookie 已导出",
|
|
|
+ output=f"已保存 {len(cookies)} 条 Cookie 到 .cookies/{name}.json(从 {len(all_cookies)} 条中过滤当前域名)",
|
|
|
+ long_term_memory=f"导出 {len(cookies)} 条 Cookie 到 .cookies/{name}.json"
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ return ToolResult(title="Cookie 导出失败", output="", error=str(e), long_term_memory="导出 Cookie 失败")
|
|
|
+
|
|
|
+
|
|
|
+@tool()
|
|
|
+async def browser_load_cookies(url: str, name: str = "") -> ToolResult:
|
|
|
+ """
|
|
|
+ 根据目标 URL 自动查找本地 Cookie 文件,注入浏览器并导航到目标页面恢复登录态。
|
|
|
+ 重要:此工具会自动完成导航,调用前不需要先调用 browser_navigate_to_url。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ url: 目标 URL(必须提供,同时用于自动匹配 Cookie 文件)
|
|
|
+ name: Cookie 文件名(可选,不传则根据 URL 域名自动查找)
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ browser, tools = await get_browser_session()
|
|
|
+
|
|
|
+ if not url.startswith("http"):
|
|
|
+ url = f"https://{url}"
|
|
|
+
|
|
|
+ # 根据域名自动查找 Cookie 文件
|
|
|
+ if not name:
|
|
|
+ from urllib.parse import urlparse
|
|
|
+ domain = urlparse(url).netloc.replace("www.", "")
|
|
|
+ if _COOKIES_DIR.exists():
|
|
|
+ matches = list(_COOKIES_DIR.glob(f"{domain}*.json"))
|
|
|
+ if matches:
|
|
|
+ cookie_file = matches[0] # 取第一个匹配的
|
|
|
+ else:
|
|
|
+ available = [f.stem for f in _COOKIES_DIR.glob("*.json")]
|
|
|
+ return ToolResult(title="未找到 Cookie", output=f"没有匹配 {domain} 的文件,可用: {available}", error=f"无 {domain} 的 Cookie 文件")
|
|
|
+ else:
|
|
|
+ return ToolResult(title="未找到 Cookie", output=".cookies 目录不存在", error="Cookie 目录不存在")
|
|
|
+ else:
|
|
|
+ cookie_file = _COOKIES_DIR / f"{name}.json"
|
|
|
+ if not cookie_file.exists():
|
|
|
+ available = [f.stem for f in _COOKIES_DIR.glob("*.json")] if _COOKIES_DIR.exists() else []
|
|
|
+ return ToolResult(title="文件不存在", output=f"可用: {available}", error=f"未找到 .cookies/{name}.json")
|
|
|
+
|
|
|
+ cookies = json.loads(cookie_file.read_text(encoding="utf-8"))
|
|
|
+
|
|
|
+ # 直接注入(export 和 load 使用相同的 CDP 格式,无需标准化)
|
|
|
+ await browser._cdp_set_cookies(cookies)
|
|
|
+
|
|
|
+ # 导航到目标页面(带上刚注入的 Cookie)
|
|
|
+ if url:
|
|
|
+ if not url.startswith("http"):
|
|
|
+ url = f"https://{url}"
|
|
|
+ await tools.navigate(url=url, browser_session=browser)
|
|
|
+ await tools.wait(seconds=3, browser_session=browser)
|
|
|
+
|
|
|
+ return ToolResult(
|
|
|
+ title="Cookie 注入并导航完成",
|
|
|
+ output=f"从 {cookie_file.name} 注入 {len(cookies)} 条 Cookie,已导航到 {url}",
|
|
|
+ long_term_memory=f"已从 {cookie_file.name} 注入 Cookie 并导航到 {url},登录态已恢复"
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ return ToolResult(title="Cookie 加载失败", output="", error=str(e), long_term_memory="加载 Cookie 失败")
|
|
|
+
|
|
|
+
|
|
|
# ============================================================
|
|
|
# 导出所有工具函数(供外部使用)
|
|
|
# ============================================================
|
|
|
@@ -1827,7 +2062,9 @@ __all__ = [
|
|
|
'browser_extract_content',
|
|
|
'browser_get_page_html',
|
|
|
'browser_read_long_content',
|
|
|
+ 'browser_download_direct_url',
|
|
|
'browser_get_selector_map',
|
|
|
+ 'browser_get_visual_selector_map',
|
|
|
|
|
|
# JavaScript 执行工具
|
|
|
'browser_evaluate',
|
|
|
@@ -1838,4 +2075,8 @@ __all__ = [
|
|
|
|
|
|
# 任务完成
|
|
|
'browser_done',
|
|
|
+
|
|
|
+ # Cookie 持久化
|
|
|
+ 'browser_export_cookies',
|
|
|
+ 'browser_load_cookies',
|
|
|
]
|