|
|
@@ -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,160 @@ async def browser_extract_content(query: str, extract_links: bool = False,
|
|
|
long_term_memory=f"提取内容失败: {query}"
|
|
|
)
|
|
|
|
|
|
+async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
|
|
|
+ """
|
|
|
+ 检测当前页面是否为 PDF,如果是则通过 CDP(浏览器内 fetch)下载到本地。
|
|
|
+ 优势:自动携带浏览器的 cookies/session,可访问需要登录的 PDF。
|
|
|
+ 返回本地文件路径,非 PDF 页面返回 None。
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ current_url = await browser.get_current_page_url()
|
|
|
+ if not current_url:
|
|
|
+ return None
|
|
|
+
|
|
|
+ parsed = urlparse(current_url)
|
|
|
+ is_pdf = parsed.path.lower().endswith('.pdf')
|
|
|
|
|
|
+ # URL 不明显是 PDF 时,通过 CDP 检查 content-type
|
|
|
+ if not is_pdf:
|
|
|
+ try:
|
|
|
+ cdp = await browser.get_or_create_cdp_session()
|
|
|
+ ct_result = await cdp.cdp_client.send.Runtime.evaluate(
|
|
|
+ params={'expression': 'document.contentType'},
|
|
|
+ session_id=cdp.session_id
|
|
|
+ )
|
|
|
+ content_type = ct_result.get('result', {}).get('value', '')
|
|
|
+ is_pdf = 'pdf' in content_type.lower()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ if not is_pdf:
|
|
|
+ return None
|
|
|
+
|
|
|
+ # 通过浏览器内 fetch API 下载 PDF(自动携带 cookies)
|
|
|
+ cdp = await browser.get_or_create_cdp_session()
|
|
|
+ js_code = """
|
|
|
+ (async () => {
|
|
|
+ try {
|
|
|
+ const resp = await fetch(window.location.href);
|
|
|
+ if (!resp.ok) return JSON.stringify({error: 'HTTP ' + resp.status});
|
|
|
+ const blob = await resp.blob();
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onloadend = () => resolve(JSON.stringify({data: reader.result}));
|
|
|
+ reader.onerror = () => resolve(JSON.stringify({error: 'FileReader failed'}));
|
|
|
+ reader.readAsDataURL(blob);
|
|
|
+ });
|
|
|
+ } catch(e) {
|
|
|
+ return JSON.stringify({error: e.message});
|
|
|
+ }
|
|
|
+ })()
|
|
|
+ """
|
|
|
+ result = await cdp.cdp_client.send.Runtime.evaluate(
|
|
|
+ params={
|
|
|
+ 'expression': js_code,
|
|
|
+ 'awaitPromise': True,
|
|
|
+ 'returnByValue': True,
|
|
|
+ 'timeout': 60000
|
|
|
+ },
|
|
|
+ session_id=cdp.session_id
|
|
|
+ )
|
|
|
+
|
|
|
+ value = result.get('result', {}).get('value', '')
|
|
|
+ if not value:
|
|
|
+ print("⚠️ CDP fetch PDF: 无返回值")
|
|
|
+ return None
|
|
|
+
|
|
|
+ data = json.loads(value)
|
|
|
+ if 'error' in data:
|
|
|
+ print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
|
|
|
+ return None
|
|
|
+
|
|
|
+ # 从 data URL 中提取 base64 并解码
|
|
|
+ data_url = data['data'] # data:application/pdf;base64,JVBERi0...
|
|
|
+ base64_data = data_url.split(',', 1)[1]
|
|
|
+ pdf_bytes = base64.b64decode(base64_data)
|
|
|
+
|
|
|
+ # 保存到本地
|
|
|
+ save_dir = Path.cwd() / ".browser_use_files"
|
|
|
+ save_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+
|
|
|
+ filename = Path(parsed.path).name if parsed.path else ""
|
|
|
+ if not filename or not filename.lower().endswith('.pdf'):
|
|
|
+ import time
|
|
|
+ filename = f"downloaded_{int(time.time())}.pdf"
|
|
|
+ save_path = str(save_dir / filename)
|
|
|
+
|
|
|
+ with open(save_path, 'wb') as f:
|
|
|
+ f.write(pdf_bytes)
|
|
|
+
|
|
|
+ print(f"📄 PDF 已通过 CDP 下载到: {save_path} ({len(pdf_bytes)} bytes)")
|
|
|
+ return save_path
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ print(f"⚠️ PDF 检测/下载异常: {e}")
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+@tool()
|
|
|
+async def browser_read_long_content(
|
|
|
+ goal: Any,
|
|
|
+ source: str = "page",
|
|
|
+ context: Any = "",
|
|
|
+ **kwargs
|
|
|
+) -> ToolResult:
|
|
|
+ """
|
|
|
+ 智能读取长内容。支持自动检测并读取网页上的 PDF 文件。
|
|
|
+
|
|
|
+ 当 source="page" 且当前页面是 PDF 时,会通过 CDP 下载 PDF 并用 pypdf 解析,
|
|
|
+ 而非使用 DOM 提取(DOM 无法读取浏览器内置 PDF Viewer 的内容)。
|
|
|
+ 通过 CDP 下载可自动携带浏览器的 cookies/session,支持需要登录的 PDF。
|
|
|
+ """
|
|
|
+ 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. PDF 自动检测:当 source="page" 时检查是否为 PDF 页面
|
|
|
+ available_files = []
|
|
|
+ if source.lower() == "page":
|
|
|
+ pdf_path = await _detect_and_download_pdf_via_cdp(browser)
|
|
|
+ if pdf_path:
|
|
|
+ source = pdf_path
|
|
|
+ available_files.append(pdf_path)
|
|
|
+
|
|
|
+ # 4. 验证并实例化
|
|
|
+ action_params = ReadContentAction(
|
|
|
+ goal=final_goal_text,
|
|
|
+ source=source,
|
|
|
+ context=business_context
|
|
|
+ )
|
|
|
+
|
|
|
+ # 5. 解包参数调用底层方法
|
|
|
+ result = await tools.read_long_content(
|
|
|
+ **action_params.model_dump(),
|
|
|
+ browser_session=browser,
|
|
|
+ page_extraction_llm=RunnableLambda(extraction_adapter),
|
|
|
+ available_file_paths=available_files
|
|
|
+ )
|
|
|
+
|
|
|
+ return action_result_to_tool_result(result, f"深度读取: {source}")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ 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 +1818,7 @@ __all__ = [
|
|
|
# 内容提取工具
|
|
|
'browser_extract_content',
|
|
|
'browser_get_page_html',
|
|
|
+ 'browser_read_long_content',
|
|
|
'browser_get_selector_map',
|
|
|
|
|
|
# JavaScript 执行工具
|