|
|
@@ -48,9 +48,15 @@ import os
|
|
|
import json
|
|
|
import asyncio
|
|
|
import aiohttp
|
|
|
+import re
|
|
|
+import base64
|
|
|
+from urllib.parse import urlparse, parse_qs, unquote
|
|
|
from typing import Optional, List, Dict, Any, Tuple
|
|
|
from pathlib import Path
|
|
|
-from urllib.parse import urlparse
|
|
|
+from langchain_core.runnables import RunnableLambda
|
|
|
+from argparse import Namespace # 使用 Namespace 快速构造带属性的对象
|
|
|
+from langchain_core.messages import AIMessage
|
|
|
+from ....llm.openrouter import openrouter_llm_call
|
|
|
|
|
|
# 将项目根目录添加到 Python 路径
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
@@ -62,6 +68,7 @@ from agent.tools.builtin.browser.sync_mysql_help import mysql
|
|
|
# 导入 browser-use 的核心类
|
|
|
from browser_use import BrowserSession, BrowserProfile
|
|
|
from browser_use.tools.service import Tools
|
|
|
+from browser_use.tools.views import ReadContentAction
|
|
|
from browser_use.agent.views import ActionResult
|
|
|
from browser_use.filesystem.file_system import FileSystem
|
|
|
|
|
|
@@ -598,7 +605,7 @@ async def browser_navigate_to_url(url: str, new_tab: bool = False) -> ToolResult
|
|
|
|
|
|
|
|
|
@tool()
|
|
|
-async def browser_search_web(query: str, engine: str = "google") -> ToolResult:
|
|
|
+async def browser_search_web(query: str, engine: str = "bing") -> ToolResult:
|
|
|
"""
|
|
|
使用搜索引擎搜索
|
|
|
Search the web using a search engine
|
|
|
@@ -857,45 +864,50 @@ async def browser_upload_file(index: int, path: str) -> ToolResult:
|
|
|
# ============================================================
|
|
|
# 滚动和视图工具 (Scroll & View Tools)
|
|
|
# ============================================================
|
|
|
-
|
|
|
@tool()
|
|
|
-async def browser_scroll_page(down: bool = True, pages: float = 1.0,
|
|
|
- index: Optional[int] = None) -> ToolResult:
|
|
|
- """
|
|
|
- 滚动页面或元素
|
|
|
- Scroll the page or a specific element
|
|
|
-
|
|
|
- Args:
|
|
|
- down: True 向下滚动,False 向上滚动
|
|
|
- pages: 滚动页数(0.5=半页,1=全页,10=滚动到底部/顶部)
|
|
|
- index: 可选,滚动特定元素(如下拉框内部)
|
|
|
-
|
|
|
- Returns:
|
|
|
- ToolResult: 滚动结果
|
|
|
-
|
|
|
- Example:
|
|
|
- scroll_page(down=True, pages=2.0) # 向下滚动2页
|
|
|
- scroll_page(down=False, pages=1.0) # 向上滚动1页
|
|
|
- """
|
|
|
+async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Optional[int] = None) -> ToolResult:
|
|
|
try:
|
|
|
browser, tools = await get_browser_session()
|
|
|
-
|
|
|
- result = await tools.scroll(
|
|
|
- down=down,
|
|
|
- pages=pages,
|
|
|
- index=index,
|
|
|
- browser_session=browser
|
|
|
+
|
|
|
+ # --- 核心修复 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
|
|
|
)
|
|
|
+ before_y = before_y_result.get('result', {}).get('value', 0)
|
|
|
+
|
|
|
+ # 执行滚动
|
|
|
+ result = await tools.scroll(down=down, pages=pages, index=index, browser_session=browser)
|
|
|
+
|
|
|
+ # 等待渲染并检查偏移
|
|
|
+ await asyncio.sleep(1)
|
|
|
+
|
|
|
+ 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)
|
|
|
|
|
|
- direction = "向下" if down else "向上"
|
|
|
- return action_result_to_tool_result(result, f"{direction}滚动 {pages} 页")
|
|
|
+ # 3. 验证是否真的动了
|
|
|
+ if before_y == after_y and index is None:
|
|
|
+ return ToolResult(
|
|
|
+ title="滚动无效",
|
|
|
+ output="页面已到达边界或滚动被拦截",
|
|
|
+ error="No movement detected"
|
|
|
+ )
|
|
|
+
|
|
|
+ return action_result_to_tool_result(result, f"已滚动")
|
|
|
|
|
|
except Exception as e:
|
|
|
+ # --- 核心修复 2: 必须补全 output 参数,否则框架会报错 ---
|
|
|
return ToolResult(
|
|
|
- title="滚动失败",
|
|
|
- output="",
|
|
|
- error=f"Failed to scroll: {str(e)}",
|
|
|
- long_term_memory="滚动失败"
|
|
|
+ title="滚动失败",
|
|
|
+ output="", # 补全这个缺失的必填参数
|
|
|
+ error=str(e)
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1119,6 +1131,69 @@ async def browser_select_dropdown_option(index: int, text: str) -> ToolResult:
|
|
|
# ============================================================
|
|
|
# 内容提取工具 (Content Extraction Tools)
|
|
|
# ============================================================
|
|
|
+def scrub_search_redirect_url(url: str) -> str:
|
|
|
+ """
|
|
|
+ 自动检测并解析 Bing/Google 等搜索引擎的重定向链接,提取真实目标 URL。
|
|
|
+ """
|
|
|
+ if not url or not isinstance(url, str):
|
|
|
+ return url
|
|
|
+
|
|
|
+ try:
|
|
|
+ parsed = urlparse(url)
|
|
|
+
|
|
|
+ # 1. 处理 Bing 重定向 (特征:u 参数带 Base64)
|
|
|
+ # 示例:...&u=a1aHR0cHM6Ly96aHVhbmxhbi56aGlodS5jb20vcC8zODYxMjgwOQ&...
|
|
|
+ if "bing.com" in parsed.netloc:
|
|
|
+ u_param = parse_qs(parsed.query).get('u', [None])[0]
|
|
|
+ if u_param:
|
|
|
+ # 移除开头的 'a1', 'a0' 等标识符
|
|
|
+ b64_str = u_param[2:]
|
|
|
+ # 补齐 Base64 填充符
|
|
|
+ padding = '=' * (4 - len(b64_str) % 4)
|
|
|
+ decoded = base64.b64decode(b64_str + padding).decode('utf-8', errors='ignore')
|
|
|
+ if decoded.startswith('http'):
|
|
|
+ return decoded
|
|
|
+
|
|
|
+ # 2. 处理 Google 重定向 (特征:url 参数)
|
|
|
+ if "google.com" in parsed.netloc:
|
|
|
+ url_param = parse_qs(parsed.query).get('url', [None])[0]
|
|
|
+ if url_param:
|
|
|
+ return unquote(url_param)
|
|
|
+
|
|
|
+ # 3. 兜底:处理常见的跳转参数
|
|
|
+ for param in ['target', 'dest', 'destination', 'link']:
|
|
|
+ found = parse_qs(parsed.query).get(param, [None])[0]
|
|
|
+ if found and found.startswith('http'):
|
|
|
+ return unquote(found)
|
|
|
+
|
|
|
+ except Exception:
|
|
|
+ pass # 解析失败则返回原链接
|
|
|
+
|
|
|
+ return url
|
|
|
+
|
|
|
+async def extraction_adapter(input_data):
|
|
|
+ # 提取字符串
|
|
|
+ if isinstance(input_data, list):
|
|
|
+ prompt = input_data[-1].content if hasattr(input_data[-1], 'content') else str(input_data[-1])
|
|
|
+ else:
|
|
|
+ prompt = str(input_data)
|
|
|
+
|
|
|
+ response = await openrouter_llm_call(
|
|
|
+ messages=[{"role": "user", "content": prompt}]
|
|
|
+ )
|
|
|
+
|
|
|
+ content = response["content"]
|
|
|
+
|
|
|
+ # --- 核心改进:URL 自动修复 ---
|
|
|
+ # 使用正则表达式匹配内容中的所有 URL,并尝试进行洗涤
|
|
|
+ urls = re.findall(r'https?://[^\s<>"\']+', content)
|
|
|
+ for original_url in urls:
|
|
|
+ clean_url = scrub_search_redirect_url(original_url)
|
|
|
+ if clean_url != original_url:
|
|
|
+ content = content.replace(original_url, clean_url)
|
|
|
+
|
|
|
+ from argparse import Namespace
|
|
|
+ return Namespace(completion=content)
|
|
|
|
|
|
@tool()
|
|
|
async def browser_extract_content(query: str, extract_links: bool = False,
|
|
|
@@ -1153,7 +1228,7 @@ async def browser_extract_content(query: str, extract_links: bool = False,
|
|
|
extract_links=extract_links,
|
|
|
start_from_char=start_from_char,
|
|
|
browser_session=browser,
|
|
|
- page_extraction_llm=None, # 需要用户配置
|
|
|
+ page_extraction_llm=RunnableLambda(extraction_adapter), # 需要用户配置
|
|
|
file_system=_file_system
|
|
|
)
|
|
|
|
|
|
@@ -1167,7 +1242,56 @@ async def browser_extract_content(query: str, extract_links: bool = False,
|
|
|
long_term_memory=f"提取内容失败: {query}"
|
|
|
)
|
|
|
|
|
|
+@tool()
|
|
|
+async def browser_read_long_content(
|
|
|
+ goal: Any,
|
|
|
+ source: str = "page",
|
|
|
+ context: Any = "",
|
|
|
+ **kwargs
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 智能读取长内容。已修复参数嵌套导致的 Field Missing 报错。
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ browser, tools = await get_browser_session()
|
|
|
+
|
|
|
+ # 1. 提取目标文本 (针对 GoalTree 字典结构)
|
|
|
+ final_goal_text = ""
|
|
|
+ if isinstance(goal, dict):
|
|
|
+ final_goal_text = goal.get("mission") or goal.get("goal") or str(goal)
|
|
|
+ else:
|
|
|
+ final_goal_text = str(goal)
|
|
|
+
|
|
|
+ # 2. 清洗业务背景 (过滤框架注入的 dict 类型 context)
|
|
|
+ business_context = context if isinstance(context, str) else ""
|
|
|
+
|
|
|
+ # 3. 验证并实例化
|
|
|
+ action_params = ReadContentAction(
|
|
|
+ goal=final_goal_text,
|
|
|
+ source=source,
|
|
|
+ context=business_context
|
|
|
+ )
|
|
|
+
|
|
|
+ # --- 4. 核心修复:解包参数 (Unpacking) ---
|
|
|
+ # 使用 ** 将字典解包为平铺参数:goal='...', source='...', context='...'
|
|
|
+ # 这样底层函数就能直接识别到 goal 字段了
|
|
|
+ result = await tools.read_long_content(
|
|
|
+ **action_params.model_dump(),
|
|
|
+ browser_session=browser,
|
|
|
+ page_extraction_llm=RunnableLambda(extraction_adapter),
|
|
|
+ available_file_paths=[]
|
|
|
+ )
|
|
|
+
|
|
|
+ return action_result_to_tool_result(result, f"深度读取: {source}")
|
|
|
|
|
|
+ except Exception as e:
|
|
|
+ # 补全 output 参数确保报错链路不崩溃
|
|
|
+ return ToolResult(
|
|
|
+ title="深度读取失败",
|
|
|
+ output="",
|
|
|
+ error=f"Read long content failed: {str(e)}",
|
|
|
+ long_term_memory="参数解析或校验失败,请检查输入"
|
|
|
+ )
|
|
|
@tool()
|
|
|
async def browser_get_page_html() -> ToolResult:
|
|
|
"""
|
|
|
@@ -1590,6 +1714,7 @@ __all__ = [
|
|
|
# 内容提取工具
|
|
|
'browser_extract_content',
|
|
|
'browser_get_page_html',
|
|
|
+ 'browser_read_long_content',
|
|
|
'browser_get_selector_map',
|
|
|
|
|
|
# JavaScript 执行工具
|