baseClass.py 76 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200
  1. """
  2. Browser-Use 原生工具适配器
  3. Native Browser-Use Tools Adapter
  4. 直接使用 browser-use 的原生类(BrowserSession, Tools)实现所有浏览器操作工具。
  5. 不依赖 Playwright,完全基于 CDP 协议。
  6. 核心特性:
  7. 1. 浏览器会话持久化 - 只启动一次浏览器
  8. 2. 状态自动保持 - 登录状态、Cookie、LocalStorage 等
  9. 3. 完整的底层访问 - 可以直接使用 CDP 协议
  10. 4. 性能优异 - 避免频繁创建/销毁浏览器实例
  11. 5. 多种浏览器类型 - 支持 local、cloud、container 三种模式
  12. 支持的浏览器类型:
  13. 1. Local (本地浏览器):
  14. - 在本地运行 Chrome
  15. - 支持可视化调试
  16. - 速度最快
  17. - 示例: init_browser_session(browser_type="local")
  18. 2. Cloud (云浏览器):
  19. - 在云端运行
  20. - 不占用本地资源
  21. - 适合生产环境
  22. - 示例: init_browser_session(browser_type="cloud")
  23. 3. Container (容器浏览器):
  24. - 在独立容器中运行
  25. - 隔离性好
  26. - 支持预配置账户
  27. - 示例: init_browser_session(browser_type="container", container_url="https://example.com")
  28. 使用方法:
  29. 1. 在 Agent 初始化时调用 init_browser_session() 并指定 browser_type
  30. 2. 使用各个工具函数执行浏览器操作
  31. 3. 任务结束时调用 cleanup_browser_session()
  32. 文件操作说明:
  33. - 浏览器专用文件目录:.cache/.browser_use_files/ (在当前工作目录下)
  34. 用于存储浏览器会话产生的临时文件(下载、上传、截图等)
  35. - 一般文件操作:请使用 agent.tools.builtin 中的文件工具 (read_file, write_file, edit_file)
  36. 这些工具功能更完善,支持diff预览、智能匹配、分页读取等
  37. """
  38. import logging
  39. import sys
  40. import os
  41. import json
  42. import httpx
  43. import asyncio
  44. import aiohttp
  45. import re
  46. import base64
  47. from urllib.parse import urlparse, parse_qs, unquote
  48. from typing import Optional, List, Dict, Any, Tuple, Union
  49. from pathlib import Path
  50. from langchain_core.runnables import RunnableLambda
  51. from argparse import Namespace # 使用 Namespace 快速构造带属性的对象
  52. from langchain_core.messages import AIMessage
  53. from ....llm.openrouter import openrouter_llm_call
  54. # 将项目根目录添加到 Python 路径
  55. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  56. # 配置日志
  57. logger = logging.getLogger(__name__)
  58. # 导入框架的工具装饰器和结果类
  59. from agent.tools import tool, ToolResult
  60. from agent.tools.builtin.browser.sync_mysql_help import mysql
  61. # 导入 browser-use 的核心类
  62. from browser_use import BrowserSession, BrowserProfile
  63. from browser_use.tools.service import Tools
  64. try:
  65. from browser_use.tools.views import ReadContentAction # type: ignore
  66. except Exception:
  67. from pydantic import BaseModel
  68. class ReadContentAction(BaseModel):
  69. goal: str
  70. source: str = "page"
  71. context: str = ""
  72. from browser_use.agent.views import ActionResult
  73. from browser_use.filesystem.file_system import FileSystem
  74. # ============================================================
  75. # 无需注册的内部辅助函数
  76. # ============================================================
  77. # ============================================================
  78. # 全局浏览器会话管理
  79. # ============================================================
  80. # 全局变量:浏览器会话和工具实例
  81. _browser_session: Optional[BrowserSession] = None
  82. _browser_tools: Optional[Tools] = None
  83. _file_system: Optional[FileSystem] = None
  84. async def create_container(url: str, account_name: str = "liuwenwu") -> Dict[str, Any]:
  85. """
  86. 创建浏览器容器并导航到指定URL
  87. 按照 test.md 的要求:
  88. 1.1 调用接口创建容器
  89. 1.2 调用接口创建窗口并导航到URL
  90. Args:
  91. url: 要导航的URL地址
  92. account_name: 账户名称
  93. Returns:
  94. 包含容器信息的字典:
  95. - success: 是否成功
  96. - container_id: 容器ID
  97. - vnc: VNC访问URL
  98. - cdp: CDP协议URL(用于浏览器连接)
  99. - connection_id: 窗口连接ID
  100. - error: 错误信息(如果失败)
  101. """
  102. result = {
  103. "success": False,
  104. "container_id": None,
  105. "vnc": None,
  106. "cdp": None,
  107. "connection_id": None,
  108. "error": None
  109. }
  110. try:
  111. async with aiohttp.ClientSession() as session:
  112. # 步骤1.1: 创建容器
  113. print("📦 步骤1.1: 创建容器...")
  114. create_url = "http://47.84.182.56:8200/api/v1/container/create"
  115. create_payload = {
  116. "auto_remove": True,
  117. "need_port_binding": True,
  118. "max_lifetime_seconds": 900
  119. }
  120. async with session.post(create_url, json=create_payload) as resp:
  121. if resp.status != 200:
  122. raise RuntimeError(f"创建容器失败: HTTP {resp.status}")
  123. create_result = await resp.json()
  124. if create_result.get("code") != 0:
  125. raise RuntimeError(f"创建容器失败: {create_result.get('msg')}")
  126. data = create_result.get("data", {})
  127. result["container_id"] = data.get("container_id")
  128. result["vnc"] = data.get("vnc")
  129. result["cdp"] = data.get("cdp")
  130. print(f"✅ 容器创建成功")
  131. print(f" Container ID: {result['container_id']}")
  132. print(f" VNC: {result['vnc']}")
  133. print(f" CDP: {result['cdp']}")
  134. # 等待容器内的浏览器启动
  135. print(f"\n⏳ 等待容器内浏览器启动...")
  136. await asyncio.sleep(5)
  137. # 步骤1.2: 创建页面并导航
  138. print(f"\n📱 步骤1.2: 创建页面并导航到 {url}...")
  139. page_create_url = "http://47.84.182.56:8200/api/v1/browser/page/create"
  140. page_payload = {
  141. "container_id": result["container_id"],
  142. "url": url,
  143. "account_name": account_name,
  144. "need_wait": True,
  145. "timeout": 30
  146. }
  147. # 重试机制:最多尝试3次
  148. max_retries = 3
  149. page_created = False
  150. last_error = None
  151. for attempt in range(max_retries):
  152. try:
  153. if attempt > 0:
  154. print(f" 重试 {attempt + 1}/{max_retries}...")
  155. await asyncio.sleep(3) # 重试前等待
  156. async with session.post(page_create_url, json=page_payload, timeout=aiohttp.ClientTimeout(total=60)) as resp:
  157. if resp.status != 200:
  158. response_text = await resp.text()
  159. last_error = f"HTTP {resp.status}: {response_text[:200]}"
  160. continue
  161. page_result = await resp.json()
  162. if page_result.get("code") != 0:
  163. last_error = f"{page_result.get('msg')}"
  164. continue
  165. page_data = page_result.get("data", {})
  166. result["connection_id"] = page_data.get("connection_id")
  167. result["success"] = True
  168. page_created = True
  169. print(f"✅ 页面创建成功")
  170. print(f" Connection ID: {result['connection_id']}")
  171. break
  172. except asyncio.TimeoutError:
  173. last_error = "请求超时"
  174. continue
  175. except aiohttp.ClientError as e:
  176. last_error = f"网络错误: {str(e)}"
  177. continue
  178. except Exception as e:
  179. last_error = f"未知错误: {str(e)}"
  180. continue
  181. if not page_created:
  182. raise RuntimeError(f"创建页面失败(尝试{max_retries}次后): {last_error}")
  183. except Exception as e:
  184. result["error"] = str(e)
  185. print(f"❌ 错误: {str(e)}")
  186. return result
  187. async def init_browser_session(
  188. browser_type: str = "local",
  189. # TEMPORARY FIX (2026-03-02): 改为 True 以解决 CDP 连接时序问题
  190. # browser-use 在非 headless 模式下有时会在 Chrome 完全启动前尝试连接 CDP,
  191. # 导致 "JSONDecodeError: Expecting value" 错误
  192. # TODO: 之后改回 headless: bool = False,或在 browser-use 修复此问题后移除此注释
  193. headless: bool = True, # 原值: False
  194. url: Optional[str] = None,
  195. profile_name: str = "default",
  196. user_data_dir: Optional[str] = None,
  197. browser_profile: Optional[BrowserProfile] = None,
  198. **kwargs
  199. ) -> tuple[BrowserSession, Tools]:
  200. global _browser_session, _browser_tools, _file_system
  201. if _browser_session is not None:
  202. return _browser_session, _browser_tools
  203. valid_types = ["local", "cloud", "container"]
  204. if browser_type not in valid_types:
  205. raise ValueError(f"无效的 browser_type: {browser_type}")
  206. # --- 核心:定义本地统一存储路径 ---
  207. save_dir = Path.cwd() / ".cache/.browser_use_files"
  208. save_dir.mkdir(parents=True, exist_ok=True)
  209. # 基础参数配置
  210. session_params = {
  211. "headless": headless,
  212. # 告诉 Playwright 所有的下载临时流先存入此本地目录
  213. "downloads_path": str(save_dir),
  214. }
  215. if browser_type == "container":
  216. print("🐳 使用容器浏览器模式")
  217. if not url: url = "about:blank"
  218. container_info = await create_container(url=url, account_name=profile_name)
  219. if not container_info["success"]:
  220. raise RuntimeError(f"容器创建失败: {container_info['error']}")
  221. session_params["cdp_url"] = container_info["cdp"]
  222. await asyncio.sleep(3)
  223. elif browser_type == "cloud":
  224. print("🌐 使用云浏览器模式")
  225. session_params["use_cloud"] = True
  226. if profile_name and profile_name != "default":
  227. session_params["cloud_profile_id"] = profile_name
  228. else: # local
  229. print("💻 使用本地浏览器模式")
  230. session_params["is_local"] = True
  231. if user_data_dir is None and profile_name:
  232. user_data_dir = str(Path.home() / ".browser_use" / "profiles" / profile_name)
  233. Path(user_data_dir).mkdir(parents=True, exist_ok=True)
  234. session_params["user_data_dir"] = user_data_dir
  235. # macOS 路径兼容
  236. import platform
  237. if platform.system() == "Darwin":
  238. chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
  239. if Path(chrome_path).exists():
  240. session_params["executable_path"] = chrome_path
  241. if browser_profile:
  242. session_params["browser_profile"] = browser_profile
  243. session_params.update(kwargs)
  244. # 创建会话
  245. _browser_session = BrowserSession(**session_params)
  246. # 添加短暂延迟,确保 Chrome CDP 端点完全就绪
  247. await asyncio.sleep(1)
  248. await _browser_session.start()
  249. _browser_tools = Tools()
  250. _file_system = FileSystem(base_dir=str(save_dir))
  251. print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
  252. if browser_type in ["local", "cloud"] and url:
  253. await _browser_tools.navigate(url=url, browser_session=_browser_session)
  254. return _browser_session, _browser_tools
  255. async def get_browser_session() -> tuple[BrowserSession, Tools]:
  256. """
  257. 获取当前浏览器会话,如果不存在或连接已断开则自动重新创建
  258. Returns:
  259. (BrowserSession, Tools) 元组
  260. """
  261. global _browser_session, _browser_tools, _file_system
  262. if _browser_session is not None:
  263. # 检查底层 CDP 连接是否仍然存活
  264. # 当 runner.stop() 暂停后用户在菜单停留较久,WebSocket 可能超时断开,
  265. # 但 _browser_session 对象仍然存在,导致后续操作抛出 ConnectionClosedError
  266. alive = False
  267. try:
  268. cdp_root = getattr(_browser_session, '_cdp_client_root', None)
  269. sess_mgr = getattr(_browser_session, 'session_manager', None)
  270. if cdp_root is not None and sess_mgr is not None:
  271. cdp_session = await _browser_session.get_or_create_cdp_session()
  272. await asyncio.wait_for(
  273. cdp_session.cdp_client.send.Runtime.evaluate(
  274. params={'expression': '1+1'},
  275. session_id=cdp_session.session_id
  276. ),
  277. timeout=3.0,
  278. )
  279. alive = True
  280. except Exception:
  281. pass
  282. if not alive:
  283. print("⚠️ 浏览器会话连接已断开,正在重新初始化...")
  284. try:
  285. await cleanup_browser_session()
  286. except Exception:
  287. _browser_session = None
  288. _browser_tools = None
  289. _file_system = None
  290. if _browser_session is None:
  291. await init_browser_session()
  292. return _browser_session, _browser_tools
  293. async def cleanup_browser_session():
  294. """
  295. 清理浏览器会话
  296. 优雅地停止浏览器但保留会话状态
  297. """
  298. global _browser_session, _browser_tools, _file_system
  299. if _browser_session is not None:
  300. await _browser_session.stop()
  301. _browser_session = None
  302. _browser_tools = None
  303. _file_system = None
  304. async def kill_browser_session():
  305. """
  306. 强制终止浏览器会话
  307. 完全关闭浏览器进程
  308. """
  309. global _browser_session, _browser_tools, _file_system
  310. if _browser_session is not None:
  311. await _browser_session.kill()
  312. _browser_session = None
  313. _browser_tools = None
  314. _file_system = None
  315. # ============================================================
  316. # 辅助函数:ActionResult 转 ToolResult
  317. # ============================================================
  318. def action_result_to_tool_result(result: ActionResult, title: str = None) -> ToolResult:
  319. """
  320. 将 browser-use 的 ActionResult 转换为框架的 ToolResult
  321. Args:
  322. result: browser-use 的 ActionResult
  323. title: 可选的标题(如果不提供则从 result 推断)
  324. Returns:
  325. ToolResult
  326. """
  327. if result.error:
  328. return ToolResult(
  329. title=title or "操作失败",
  330. output="",
  331. error=result.error,
  332. long_term_memory=result.long_term_memory or result.error
  333. )
  334. return ToolResult(
  335. title=title or "操作成功",
  336. output=result.extracted_content or "",
  337. long_term_memory=result.long_term_memory or result.extracted_content or "",
  338. metadata=result.metadata or {}
  339. )
  340. def _cookie_domain_for_type(cookie_type: str, url: str) -> Tuple[str, str]:
  341. if cookie_type:
  342. key = cookie_type.lower()
  343. if key in {"xiaohongshu", "xhs"}:
  344. return ".xiaohongshu.com", "https://www.xiaohongshu.com"
  345. parsed = urlparse(url or "")
  346. domain = parsed.netloc or ""
  347. domain = domain.replace("www.", "")
  348. if domain:
  349. domain = f".{domain}"
  350. base_url = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else url
  351. return domain, base_url
  352. def _parse_cookie_string(cookie_str: str, domain: str, url: str) -> List[Dict[str, Any]]:
  353. cookies: List[Dict[str, Any]] = []
  354. if not cookie_str:
  355. return cookies
  356. parts = cookie_str.split(";")
  357. for part in parts:
  358. if not part:
  359. continue
  360. if "=" not in part:
  361. continue
  362. name, value = part.split("=", 1)
  363. cookie = {
  364. "name": str(name).strip(),
  365. "value": str(value).strip(),
  366. "domain": domain,
  367. "path": "/",
  368. "expires": -1,
  369. "httpOnly": False,
  370. "secure": True,
  371. "sameSite": "None"
  372. }
  373. if url:
  374. cookie["url"] = url
  375. cookies.append(cookie)
  376. return cookies
  377. def _normalize_cookies(cookie_value: Any, domain: str, url: str) -> List[Dict[str, Any]]:
  378. if cookie_value is None:
  379. return []
  380. if isinstance(cookie_value, list):
  381. return cookie_value
  382. if isinstance(cookie_value, dict):
  383. if "cookies" in cookie_value:
  384. return _normalize_cookies(cookie_value.get("cookies"), domain, url)
  385. if "name" in cookie_value and "value" in cookie_value:
  386. return [cookie_value]
  387. return []
  388. if isinstance(cookie_value, (bytes, bytearray)):
  389. cookie_value = cookie_value.decode("utf-8", errors="ignore")
  390. if isinstance(cookie_value, str):
  391. text = cookie_value.strip()
  392. if not text:
  393. return []
  394. try:
  395. parsed = json.loads(text)
  396. except Exception:
  397. parsed = None
  398. if parsed is not None:
  399. return _normalize_cookies(parsed, domain, url)
  400. return _parse_cookie_string(text, domain, url)
  401. return []
  402. def _extract_cookie_value(row: Optional[Dict[str, Any]]) -> Any:
  403. if not row:
  404. return None
  405. # 优先使用 cookies 字段
  406. if "cookies" in row:
  407. return row["cookies"]
  408. # 兼容其他可能的字段名
  409. for key, value in row.items():
  410. if "cookie" in key.lower():
  411. return value
  412. return None
  413. def _fetch_cookie_row(cookie_type: str) -> Optional[Dict[str, Any]]:
  414. if not cookie_type:
  415. return None
  416. try:
  417. return mysql.fetchone(
  418. "select * from agent_channel_cookies where type=%s limit 1",
  419. (cookie_type,)
  420. )
  421. except Exception:
  422. return None
  423. def _fetch_profile_id(cookie_type: str) -> Optional[str]:
  424. """从数据库获取 cloud_profile_id"""
  425. if not cookie_type:
  426. return None
  427. try:
  428. row = mysql.fetchone(
  429. "select profileId from agent_channel_cookies where type=%s limit 1",
  430. (cookie_type,)
  431. )
  432. if row and "profileId" in row:
  433. return row["profileId"]
  434. return None
  435. except Exception:
  436. return None
  437. # ============================================================
  438. # 需要注册的工具
  439. # ============================================================
  440. # ============================================================
  441. # 导航类工具 (Navigation Tools)
  442. # ============================================================
  443. @tool()
  444. async def browser_navigate_to_url(url: str, new_tab: bool = False) -> ToolResult:
  445. """
  446. 导航到指定的 URL
  447. Navigate to a specific URL
  448. 使用 browser-use 的原生导航功能,支持在新标签页打开。
  449. Args:
  450. url: 要访问的 URL 地址
  451. new_tab: 是否在新标签页中打开(默认 False)
  452. Returns:
  453. ToolResult: 包含导航结果的工具返回对象
  454. Example:
  455. navigate_to_url("https://www.baidu.com")
  456. navigate_to_url("https://www.google.com", new_tab=True)
  457. """
  458. try:
  459. browser, tools = await get_browser_session()
  460. # 使用 browser-use 的 navigate 工具
  461. result = await tools.navigate(
  462. url=url,
  463. new_tab=new_tab,
  464. browser_session=browser
  465. )
  466. return action_result_to_tool_result(result, f"导航到 {url}")
  467. except Exception as e:
  468. return ToolResult(
  469. title="导航失败",
  470. output="",
  471. error=f"Failed to navigate to {url}: {str(e)}",
  472. long_term_memory=f"导航到 {url} 失败"
  473. )
  474. @tool()
  475. async def browser_search_web(query: str, engine: str = "bing") -> ToolResult:
  476. """
  477. 使用搜索引擎搜索
  478. Search the web using a search engine
  479. Args:
  480. query: 搜索关键词
  481. engine: 搜索引擎 (google, duckduckgo, bing) - 默认: google
  482. Returns:
  483. ToolResult: 搜索结果
  484. Example:
  485. search_web("Python async programming", engine="google")
  486. """
  487. try:
  488. browser, tools = await get_browser_session()
  489. # 使用 browser-use 的 search 工具
  490. result = await tools.search(
  491. query=query,
  492. engine=engine,
  493. browser_session=browser
  494. )
  495. return action_result_to_tool_result(result, f"搜索: {query}")
  496. except Exception as e:
  497. return ToolResult(
  498. title="搜索失败",
  499. output="",
  500. error=f"Search failed: {str(e)}",
  501. long_term_memory=f"搜索 '{query}' 失败"
  502. )
  503. @tool()
  504. async def browser_go_back() -> ToolResult:
  505. """
  506. 返回到上一个页面
  507. Go back to the previous page
  508. 模拟浏览器的"后退"按钮功能。
  509. Returns:
  510. ToolResult: 包含返回操作结果的工具返回对象
  511. """
  512. try:
  513. browser, tools = await get_browser_session()
  514. result = await tools.go_back(browser_session=browser)
  515. return action_result_to_tool_result(result, "返回上一页")
  516. except Exception as e:
  517. return ToolResult(
  518. title="返回失败",
  519. output="",
  520. error=f"Failed to go back: {str(e)}",
  521. long_term_memory="返回上一页失败"
  522. )
  523. @tool()
  524. async def browser_wait(seconds: int = 3) -> ToolResult:
  525. """
  526. 等待指定的秒数
  527. Wait for a specified number of seconds
  528. 用于等待页面加载、动画完成或其他异步操作。
  529. Args:
  530. seconds: 等待时间(秒),最大30秒
  531. Returns:
  532. ToolResult: 包含等待操作结果的工具返回对象
  533. Example:
  534. wait(5) # 等待5秒
  535. """
  536. try:
  537. browser, tools = await get_browser_session()
  538. result = await tools.wait(seconds=seconds, browser_session=browser)
  539. return action_result_to_tool_result(result, f"等待 {seconds} 秒")
  540. except Exception as e:
  541. return ToolResult(
  542. title="等待失败",
  543. output="",
  544. error=f"Failed to wait: {str(e)}",
  545. long_term_memory="等待失败"
  546. )
  547. # ============================================================
  548. # 元素交互工具 (Element Interaction Tools)
  549. # ============================================================
  550. # 定义一个专门捕获下载链接的 Handler
  551. class DownloadLinkCaptureHandler(logging.Handler):
  552. def __init__(self):
  553. super().__init__()
  554. self.captured_url = None
  555. def emit(self, record):
  556. # 如果已经捕获到了(通常第一条是最完整的),就不再处理后续日志
  557. if self.captured_url:
  558. return
  559. message = record.getMessage()
  560. # 寻找包含下载信息的日志
  561. if "redirection?filename=" in message or "Failed to download" in message:
  562. # 使用更严格的正则,确保不抓取带省略号(...)的截断链接
  563. # 排除掉末尾带有三个点的干扰
  564. match = re.search(r"https?://[^\s]+(?!\.\.\.)", message)
  565. if match:
  566. url = match.group(0)
  567. # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
  568. if "..." not in url:
  569. self.captured_url = url
  570. # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
  571. @tool()
  572. async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
  573. save_dir = Path.cwd() / ".cache/.browser_use_files"
  574. save_dir.mkdir(parents=True, exist_ok=True)
  575. # 提取域名作为 Referer,这能骗过 90% 的防盗链校验
  576. from urllib.parse import urlparse
  577. parsed_url = urlparse(url)
  578. base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
  579. # 如果没传 save_name,自动从 URL 获取
  580. if not save_name:
  581. import unquote
  582. # 尝试从 URL 路径获取文件名并解码(处理中文)
  583. save_name = Path(urlparse(url).path).name or f"download_{int(time.time())}"
  584. save_name = unquote(save_name)
  585. target_path = save_dir / save_name
  586. headers = {
  587. "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",
  588. "Accept": "*/*",
  589. "Referer": base_url, # 动态设置 Referer
  590. "Range": "bytes=0-", # 有时对大文件下载有奇效
  591. }
  592. try:
  593. print(f"🚀 开始下载: {url[:60]}...")
  594. # 使用 follow_redirects=True 处理链接中的 redirection
  595. async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=60.0) as client:
  596. async with client.stream("GET", url) as response:
  597. if response.status_code != 200:
  598. print(f"❌ 下载失败,HTTP 状态码: {response.status_code}")
  599. return
  600. # 获取实际文件名(如果服务器提供了)
  601. # 这里会优先使用你指定的 save_name
  602. with open(target_path, "wb") as f:
  603. downloaded_bytes = 0
  604. async for chunk in response.aiter_bytes():
  605. f.write(chunk)
  606. downloaded_bytes += len(chunk)
  607. if downloaded_bytes % (1024 * 1024) == 0: # 每下载 1MB 打印一次
  608. print(f"📥 已下载: {downloaded_bytes // (1024 * 1024)} MB")
  609. print(f"✅ 下载完成!文件已存至: {target_path}")
  610. success_msg = f"✅ 下载完成!文件已存至: {target_path}"
  611. return ToolResult(
  612. title="直链下载成功",
  613. output=success_msg,
  614. long_term_memory=success_msg,
  615. metadata={"path": str(target_path)}
  616. )
  617. except Exception as e:
  618. # 异常捕获返回
  619. return ToolResult(
  620. title="下载异常",
  621. output="",
  622. error=f"💥 发生错误: {str(e)}",
  623. long_term_memory=f"下载任务由于异常中断: {str(e)}"
  624. )
  625. @tool()
  626. async def browser_click_element(index: int) -> ToolResult:
  627. """
  628. 点击页面元素,并自动通过拦截内部日志获取下载直链。
  629. """
  630. # 1. 挂载日志窃听器
  631. capture_handler = DownloadLinkCaptureHandler()
  632. logger = logging.getLogger("browser_use") # 拦截整个 browser_use 命名空间
  633. logger.addHandler(capture_handler)
  634. try:
  635. browser, tools = await get_browser_session()
  636. # 2. 执行原生的点击动作
  637. result = await tools.click(
  638. index=index,
  639. browser_session=browser
  640. )
  641. # 3. 检查是否有“意外收获”
  642. download_msg = ""
  643. if capture_handler.captured_url:
  644. captured_url = capture_handler.captured_url
  645. download_msg = f"\n\n⚠️ 系统检测到浏览器下载被拦截,已自动捕获准确直链:\n{captured_url}\n\n建议:你可以直接使用 browser_download_direct_url 工具下载此链接。"
  646. # 如果你想更激进一点,甚至可以在这里直接自动触发本地下载逻辑
  647. # await auto_download_file(captured_url)
  648. # 4. 转换结果并附加捕获的信息
  649. tool_result = action_result_to_tool_result(result, f"点击元素 {index}")
  650. if download_msg:
  651. # 关键:把日志里的信息塞进 output,这样 LLM 就能看到了!
  652. tool_result.output = (tool_result.output or "") + download_msg
  653. tool_result.long_term_memory = (tool_result.long_term_memory or "") + f" 捕获下载链接: {captured_url}"
  654. return tool_result
  655. except Exception as e:
  656. return ToolResult(
  657. title="点击失败",
  658. output="",
  659. error=f"Failed to click element {index}: {str(e)}",
  660. long_term_memory=f"点击元素 {index} 失败"
  661. )
  662. finally:
  663. # 5. 务必移除监听器,防止内存泄漏和日志污染
  664. logger.removeHandler(capture_handler)
  665. @tool()
  666. async def browser_input_text(index: int, text: str, clear: bool = True) -> ToolResult:
  667. """
  668. 在指定元素中输入文本
  669. Input text into an element
  670. Args:
  671. index: 元素索引(从浏览器状态中获取)
  672. text: 要输入的文本内容
  673. clear: 是否先清除现有文本(默认 True)
  674. Returns:
  675. ToolResult: 包含输入操作结果的工具返回对象
  676. Example:
  677. input_text(index=0, text="Hello World", clear=True)
  678. """
  679. try:
  680. browser, tools = await get_browser_session()
  681. result = await tools.input(
  682. index=index,
  683. text=text,
  684. clear=clear,
  685. browser_session=browser
  686. )
  687. return action_result_to_tool_result(result, f"输入文本到元素 {index}")
  688. except Exception as e:
  689. return ToolResult(
  690. title="输入失败",
  691. output="",
  692. error=f"Failed to input text into element {index}: {str(e)}",
  693. long_term_memory=f"输入文本失败"
  694. )
  695. @tool()
  696. async def browser_send_keys(keys: str) -> ToolResult:
  697. """
  698. 发送键盘按键或快捷键
  699. Send keyboard keys or shortcuts
  700. 支持发送单个按键、组合键和快捷键。
  701. Args:
  702. keys: 要发送的按键字符串
  703. - 单个按键: "Enter", "Escape", "PageDown", "Tab"
  704. - 组合键: "Control+o", "Shift+Tab", "Alt+F4"
  705. - 功能键: "F1", "F2", ..., "F12"
  706. Returns:
  707. ToolResult: 包含按键操作结果的工具返回对象
  708. Example:
  709. send_keys("Enter")
  710. send_keys("Control+A")
  711. """
  712. try:
  713. browser, tools = await get_browser_session()
  714. result = await tools.send_keys(
  715. keys=keys,
  716. browser_session=browser
  717. )
  718. return action_result_to_tool_result(result, f"发送按键: {keys}")
  719. except Exception as e:
  720. return ToolResult(
  721. title="发送按键失败",
  722. output="",
  723. error=f"Failed to send keys: {str(e)}",
  724. long_term_memory="发送按键失败"
  725. )
  726. @tool()
  727. async def browser_upload_file(index: int, path: str) -> ToolResult:
  728. """
  729. 上传文件到文件输入元素
  730. Upload a file to a file input element
  731. Args:
  732. index: 文件输入框的元素索引
  733. path: 要上传的文件路径(绝对路径)
  734. Returns:
  735. ToolResult: 包含上传操作结果的工具返回对象
  736. Example:
  737. upload_file(index=7, path="/path/to/file.pdf")
  738. Note:
  739. 文件必须存在且路径必须是绝对路径
  740. """
  741. try:
  742. browser, tools = await get_browser_session()
  743. result = await tools.upload_file(
  744. index=index,
  745. path=path,
  746. browser_session=browser,
  747. available_file_paths=[path],
  748. file_system=_file_system
  749. )
  750. return action_result_to_tool_result(result, f"上传文件: {path}")
  751. except Exception as e:
  752. return ToolResult(
  753. title="上传失败",
  754. output="",
  755. error=f"Failed to upload file: {str(e)}",
  756. long_term_memory=f"上传文件 {path} 失败"
  757. )
  758. # ============================================================
  759. # 滚动和视图工具 (Scroll & View Tools)
  760. # ============================================================
  761. @tool()
  762. async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Optional[int] = None) -> ToolResult:
  763. try:
  764. # 限制单次滚动幅度,避免 agent 一次滚 100 页
  765. MAX_PAGES = 10
  766. if pages > MAX_PAGES:
  767. pages = MAX_PAGES
  768. browser, tools = await get_browser_session()
  769. cdp_session = await browser.get_or_create_cdp_session()
  770. before_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
  771. params={'expression': 'window.scrollY'},
  772. session_id=cdp_session.session_id
  773. )
  774. before_y = before_y_result.get('result', {}).get('value', 0)
  775. # 执行滚动
  776. result = await tools.scroll(down=down, pages=pages, index=index, browser_session=browser)
  777. # 等待渲染(懒加载页面需要更长时间)
  778. await asyncio.sleep(2)
  779. after_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
  780. params={'expression': 'window.scrollY'},
  781. session_id=cdp_session.session_id
  782. )
  783. after_y = after_y_result.get('result', {}).get('value', 0)
  784. # 如果第一次检测没动,再等一轮(应对懒加载触发后的延迟滚动)
  785. if before_y == after_y and index is None:
  786. await asyncio.sleep(2)
  787. retry_result = await cdp_session.cdp_client.send.Runtime.evaluate(
  788. params={'expression': 'window.scrollY'},
  789. session_id=cdp_session.session_id
  790. )
  791. after_y = retry_result.get('result', {}).get('value', 0)
  792. if before_y == after_y and index is None:
  793. direction = "下" if down else "上"
  794. return ToolResult(
  795. title="滚动无效",
  796. output=f"页面已到达{direction}边界,无法继续滚动",
  797. error="No movement detected"
  798. )
  799. delta = abs(after_y - before_y)
  800. direction = "下" if down else "上"
  801. return action_result_to_tool_result(result, f"已向{direction}滚动 {delta}px")
  802. except Exception as e:
  803. # --- 核心修复 2: 必须补全 output 参数,否则框架会报错 ---
  804. return ToolResult(
  805. title="滚动失败",
  806. output="", # 补全这个缺失的必填参数
  807. error=str(e)
  808. )
  809. @tool()
  810. async def browser_find_text(text: str) -> ToolResult:
  811. """
  812. 查找页面中的文本并滚动到该位置
  813. Find text on the page and scroll to it
  814. 在页面中搜索指定的文本,找到后自动滚动到该位置。
  815. Args:
  816. text: 要查找的文本内容
  817. Returns:
  818. ToolResult: 包含查找结果的工具返回对象
  819. Example:
  820. find_text("Privacy Policy")
  821. """
  822. try:
  823. browser, tools = await get_browser_session()
  824. result = await tools.find_text(
  825. text=text,
  826. browser_session=browser
  827. )
  828. return action_result_to_tool_result(result, f"查找文本: {text}")
  829. except Exception as e:
  830. return ToolResult(
  831. title="查找失败",
  832. output="",
  833. error=f"Failed to find text: {str(e)}",
  834. long_term_memory=f"查找文本 '{text}' 失败"
  835. )
  836. @tool()
  837. async def browser_get_visual_selector_map() -> ToolResult:
  838. """
  839. 获取当前页面的视觉快照和交互元素索引映射。
  840. Get visual snapshot and selector map of interactive elements.
  841. 该工具会同时执行两个操作:
  842. 1. 捕捉当前页面的截图,并用 browser-use 内置方法在截图上标注元素索引号。
  843. 2. 生成页面所有可交互元素的索引字典(含 href、type 等属性信息)。
  844. Returns:
  845. ToolResult: 包含高亮截图(在 images 中)和元素列表的工具返回对象。
  846. """
  847. try:
  848. browser, _ = await get_browser_session()
  849. # 1. 构造同时包含 DOM 和 截图 的请求
  850. from browser_use.browser.events import BrowserStateRequestEvent
  851. from browser_use.browser.python_highlights import create_highlighted_screenshot_async
  852. event = browser.event_bus.dispatch(
  853. BrowserStateRequestEvent(
  854. include_dom=True,
  855. include_screenshot=True,
  856. include_recent_events=False
  857. )
  858. )
  859. # 2. 等待浏览器返回完整状态
  860. browser_state = await event.event_result(raise_if_none=True, raise_if_any=True)
  861. # 3. 提取 Selector Map
  862. selector_map = browser_state.dom_state.selector_map if browser_state.dom_state else {}
  863. # 4. 提取截图并生成带索引标注的高亮截图(通过 CDP 获取精确 DPI 和滚动偏移)
  864. screenshot_b64 = browser_state.screenshot or ""
  865. highlighted_b64 = ""
  866. if screenshot_b64 and selector_map:
  867. try:
  868. cdp_session = await browser.get_or_create_cdp_session()
  869. highlighted_b64 = await create_highlighted_screenshot_async(
  870. screenshot_b64, selector_map,
  871. cdp_session=cdp_session,
  872. filter_highlight_ids=False
  873. )
  874. except Exception:
  875. highlighted_b64 = screenshot_b64 # fallback to raw screenshot
  876. else:
  877. highlighted_b64 = screenshot_b64
  878. # 5. 构建供 Agent 阅读的完整元素列表,包含丰富的属性信息
  879. elements_info = []
  880. for index, node in selector_map.items():
  881. tag = node.tag_name
  882. attrs = node.attributes or {}
  883. desc = attrs.get('aria-label') or attrs.get('placeholder') or attrs.get('title') or node.get_all_children_text(max_depth=1) or ""
  884. # 收集有用的属性片段
  885. extra_parts = []
  886. if attrs.get('href'):
  887. extra_parts.append(f"href={attrs['href'][:60]}")
  888. if attrs.get('type'):
  889. extra_parts.append(f"type={attrs['type']}")
  890. if attrs.get('role'):
  891. extra_parts.append(f"role={attrs['role']}")
  892. if attrs.get('name'):
  893. extra_parts.append(f"name={attrs['name']}")
  894. extra = f" ({', '.join(extra_parts)})" if extra_parts else ""
  895. elements_info.append(f"Index {index}: <{tag}> \"{desc[:50]}\"{extra}")
  896. output = f"页面截图已捕获(含元素索引标注)\n找到 {len(selector_map)} 个交互元素\n\n"
  897. output += "元素列表:\n" + "\n".join(elements_info)
  898. # 6. 将高亮截图存入 images 字段,metadata 保留结构化数据
  899. images = []
  900. if highlighted_b64:
  901. images.append({"type": "base64", "media_type": "image/png", "data": highlighted_b64})
  902. return ToolResult(
  903. title="视觉元素观察",
  904. output=output,
  905. long_term_memory=f"在页面观察到 {len(selector_map)} 个元素并保存了截图",
  906. images=images,
  907. metadata={
  908. "selector_map": {k: str(v) for k, v in list(selector_map.items())[:100]},
  909. "url": browser_state.url,
  910. "title": browser_state.title
  911. }
  912. )
  913. except Exception as e:
  914. return ToolResult(
  915. title="视觉观察失败",
  916. output="",
  917. error=f"Failed to get visual selector map: {str(e)}",
  918. long_term_memory="获取视觉元素映射失败"
  919. )
  920. @tool()
  921. async def browser_screenshot() -> ToolResult:
  922. """
  923. 请求在下次观察中包含页面截图
  924. Request a screenshot to be included in the next observation
  925. 用于视觉检查页面状态,帮助理解页面布局和内容。
  926. Returns:
  927. ToolResult: 包含截图请求结果的工具返回对象
  928. Example:
  929. screenshot()
  930. Note:
  931. 截图会在下次页面观察时自动包含在结果中。
  932. """
  933. try:
  934. browser, tools = await get_browser_session()
  935. result = await tools.screenshot(browser_session=browser)
  936. return action_result_to_tool_result(result, "截图请求")
  937. except Exception as e:
  938. return ToolResult(
  939. title="截图失败",
  940. output="",
  941. error=f"Failed to capture screenshot: {str(e)}",
  942. long_term_memory="截图失败"
  943. )
  944. # ============================================================
  945. # 标签页管理工具 (Tab Management Tools)
  946. # ============================================================
  947. @tool()
  948. async def browser_switch_tab(tab_id: str) -> ToolResult:
  949. """
  950. 切换到指定标签页
  951. Switch to a different browser tab
  952. Args:
  953. tab_id: 4字符标签ID(target_id 的最后4位)
  954. Returns:
  955. ToolResult: 切换结果
  956. Example:
  957. switch_tab(tab_id="a3f2")
  958. """
  959. try:
  960. browser, tools = await get_browser_session()
  961. normalized_tab_id = tab_id[-4:] if tab_id else tab_id
  962. result = await tools.switch(
  963. tab_id=normalized_tab_id,
  964. browser_session=browser
  965. )
  966. return action_result_to_tool_result(result, f"切换到标签页 {normalized_tab_id}")
  967. except Exception as e:
  968. return ToolResult(
  969. title="切换标签页失败",
  970. output="",
  971. error=f"Failed to switch tab: {str(e)}",
  972. long_term_memory=f"切换到标签页 {tab_id} 失败"
  973. )
  974. @tool()
  975. async def browser_close_tab(tab_id: str) -> ToolResult:
  976. """
  977. 关闭指定标签页
  978. Close a browser tab
  979. Args:
  980. tab_id: 4字符标签ID
  981. Returns:
  982. ToolResult: 关闭结果
  983. Example:
  984. close_tab(tab_id="a3f2")
  985. """
  986. try:
  987. browser, tools = await get_browser_session()
  988. normalized_tab_id = tab_id[-4:] if tab_id else tab_id
  989. result = await tools.close(
  990. tab_id=normalized_tab_id,
  991. browser_session=browser
  992. )
  993. return action_result_to_tool_result(result, f"关闭标签页 {normalized_tab_id}")
  994. except Exception as e:
  995. return ToolResult(
  996. title="关闭标签页失败",
  997. output="",
  998. error=f"Failed to close tab: {str(e)}",
  999. long_term_memory=f"关闭标签页 {tab_id} 失败"
  1000. )
  1001. # ============================================================
  1002. # 下拉框工具 (Dropdown Tools)
  1003. # ============================================================
  1004. @tool()
  1005. async def browser_get_dropdown_options(index: int) -> ToolResult:
  1006. """
  1007. 获取下拉框的所有选项
  1008. Get options from a dropdown element
  1009. Args:
  1010. index: 下拉框的元素索引
  1011. Returns:
  1012. ToolResult: 包含所有选项的结果
  1013. Example:
  1014. get_dropdown_options(index=8)
  1015. """
  1016. try:
  1017. browser, tools = await get_browser_session()
  1018. result = await tools.dropdown_options(
  1019. index=index,
  1020. browser_session=browser
  1021. )
  1022. return action_result_to_tool_result(result, f"获取下拉框选项: {index}")
  1023. except Exception as e:
  1024. return ToolResult(
  1025. title="获取下拉框选项失败",
  1026. output="",
  1027. error=f"Failed to get dropdown options: {str(e)}",
  1028. long_term_memory=f"获取下拉框 {index} 选项失败"
  1029. )
  1030. @tool()
  1031. async def browser_select_dropdown_option(index: int, text: str) -> ToolResult:
  1032. """
  1033. 选择下拉框选项
  1034. Select an option from a dropdown
  1035. Args:
  1036. index: 下拉框的元素索引
  1037. text: 要选择的选项文本(精确匹配)
  1038. Returns:
  1039. ToolResult: 选择结果
  1040. Example:
  1041. select_dropdown_option(index=8, text="Option 2")
  1042. """
  1043. try:
  1044. browser, tools = await get_browser_session()
  1045. result = await tools.select_dropdown(
  1046. index=index,
  1047. text=text,
  1048. browser_session=browser
  1049. )
  1050. return action_result_to_tool_result(result, f"选择下拉框选项: {text}")
  1051. except Exception as e:
  1052. return ToolResult(
  1053. title="选择下拉框选项失败",
  1054. output="",
  1055. error=f"Failed to select dropdown option: {str(e)}",
  1056. long_term_memory=f"选择选项 '{text}' 失败"
  1057. )
  1058. # ============================================================
  1059. # 内容提取工具 (Content Extraction Tools)
  1060. # ============================================================
  1061. def scrub_search_redirect_url(url: str) -> str:
  1062. """
  1063. 自动检测并解析 Bing/Google 等搜索引擎的重定向链接,提取真实目标 URL。
  1064. """
  1065. if not url or not isinstance(url, str):
  1066. return url
  1067. try:
  1068. parsed = urlparse(url)
  1069. # 1. 处理 Bing 重定向 (特征:u 参数带 Base64)
  1070. # 示例:...&u=a1aHR0cHM6Ly96aHVhbmxhbi56aGlodS5jb20vcC8zODYxMjgwOQ&...
  1071. if "bing.com" in parsed.netloc:
  1072. u_param = parse_qs(parsed.query).get('u', [None])[0]
  1073. if u_param:
  1074. # 移除开头的 'a1', 'a0' 等标识符
  1075. b64_str = u_param[2:]
  1076. # 补齐 Base64 填充符
  1077. padding = '=' * (4 - len(b64_str) % 4)
  1078. decoded = base64.b64decode(b64_str + padding).decode('utf-8', errors='ignore')
  1079. if decoded.startswith('http'):
  1080. return decoded
  1081. # 2. 处理 Google 重定向 (特征:url 参数)
  1082. if "google.com" in parsed.netloc:
  1083. url_param = parse_qs(parsed.query).get('url', [None])[0]
  1084. if url_param:
  1085. return unquote(url_param)
  1086. # 3. 兜底:处理常见的跳转参数
  1087. for param in ['target', 'dest', 'destination', 'link']:
  1088. found = parse_qs(parsed.query).get(param, [None])[0]
  1089. if found and found.startswith('http'):
  1090. return unquote(found)
  1091. except Exception:
  1092. pass # 解析失败则返回原链接
  1093. return url
  1094. async def extraction_adapter(input_data):
  1095. # 提取字符串
  1096. if isinstance(input_data, list):
  1097. prompt = input_data[-1].content if hasattr(input_data[-1], 'content') else str(input_data[-1])
  1098. else:
  1099. prompt = str(input_data)
  1100. response = await openrouter_llm_call(
  1101. messages=[{"role": "user", "content": prompt}]
  1102. )
  1103. content = response["content"]
  1104. # --- 核心改进:URL 自动修复 ---
  1105. # 使用正则表达式匹配内容中的所有 URL,并尝试进行洗涤
  1106. urls = re.findall(r'https?://[^\s<>"\']+', content)
  1107. for original_url in urls:
  1108. clean_url = scrub_search_redirect_url(original_url)
  1109. if clean_url != original_url:
  1110. content = content.replace(original_url, clean_url)
  1111. from argparse import Namespace
  1112. return Namespace(completion=content)
  1113. @tool()
  1114. async def browser_extract_content(query: str, extract_links: bool = False,
  1115. start_from_char: int = 0) -> ToolResult:
  1116. """
  1117. 使用 LLM 从页面提取结构化数据
  1118. Extract content from the current page using LLM
  1119. Args:
  1120. query: 提取查询(告诉 LLM 要提取什么内容)
  1121. extract_links: 是否提取链接(默认 False,节省 token)
  1122. start_from_char: 从哪个字符开始提取(用于分页提取大内容)
  1123. Returns:
  1124. ToolResult: 提取的内容
  1125. Example:
  1126. extract_content(query="提取页面上所有产品的名称和价格", extract_links=True)
  1127. Note:
  1128. 需要配置 page_extraction_llm,否则会失败
  1129. 支持分页提取,最大100k字符
  1130. """
  1131. try:
  1132. browser, tools = await get_browser_session()
  1133. # 注意:extract 需要 page_extraction_llm 参数
  1134. # 这里我们假设用户会在初始化时配置 LLM
  1135. # 如果没有配置,会抛出异常
  1136. result = await tools.extract(
  1137. query=query,
  1138. extract_links=extract_links,
  1139. start_from_char=start_from_char,
  1140. browser_session=browser,
  1141. page_extraction_llm=RunnableLambda(extraction_adapter), # 需要用户配置
  1142. file_system=_file_system
  1143. )
  1144. return action_result_to_tool_result(result, f"提取内容: {query}")
  1145. except Exception as e:
  1146. return ToolResult(
  1147. title="内容提取失败",
  1148. output="",
  1149. error=f"Failed to extract content: {str(e)}",
  1150. long_term_memory=f"提取内容失败: {query}"
  1151. )
  1152. async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
  1153. """
  1154. 检测当前页面是否为 PDF,如果是则通过 CDP(浏览器内 fetch)下载到本地。
  1155. 优势:自动携带浏览器的 cookies/session,可访问需要登录的 PDF。
  1156. 返回本地文件路径,非 PDF 页面返回 None。
  1157. """
  1158. try:
  1159. current_url = await browser.get_current_page_url()
  1160. if not current_url:
  1161. return None
  1162. parsed = urlparse(current_url)
  1163. is_pdf = parsed.path.lower().endswith('.pdf')
  1164. # URL 不明显是 PDF 时,通过 CDP 检查 content-type
  1165. if not is_pdf:
  1166. try:
  1167. cdp = await browser.get_or_create_cdp_session()
  1168. ct_result = await cdp.cdp_client.send.Runtime.evaluate(
  1169. params={'expression': 'document.contentType'},
  1170. session_id=cdp.session_id
  1171. )
  1172. content_type = ct_result.get('result', {}).get('value', '')
  1173. is_pdf = 'pdf' in content_type.lower()
  1174. except Exception:
  1175. pass
  1176. if not is_pdf:
  1177. return None
  1178. # 通过浏览器内 fetch API 下载 PDF(自动携带 cookies)
  1179. cdp = await browser.get_or_create_cdp_session()
  1180. js_code = """
  1181. (async () => {
  1182. try {
  1183. const resp = await fetch(window.location.href);
  1184. if (!resp.ok) return JSON.stringify({error: 'HTTP ' + resp.status});
  1185. const blob = await resp.blob();
  1186. return new Promise((resolve, reject) => {
  1187. const reader = new FileReader();
  1188. reader.onloadend = () => resolve(JSON.stringify({data: reader.result}));
  1189. reader.onerror = () => resolve(JSON.stringify({error: 'FileReader failed'}));
  1190. reader.readAsDataURL(blob);
  1191. });
  1192. } catch(e) {
  1193. return JSON.stringify({error: e.message});
  1194. }
  1195. })()
  1196. """
  1197. result = await cdp.cdp_client.send.Runtime.evaluate(
  1198. params={
  1199. 'expression': js_code,
  1200. 'awaitPromise': True,
  1201. 'returnByValue': True,
  1202. 'timeout': 60000
  1203. },
  1204. session_id=cdp.session_id
  1205. )
  1206. value = result.get('result', {}).get('value', '')
  1207. if not value:
  1208. print("⚠️ CDP fetch PDF: 无返回值")
  1209. return None
  1210. data = json.loads(value)
  1211. if 'error' in data:
  1212. print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
  1213. return None
  1214. # 从 data URL 中提取 base64 并解码
  1215. data_url = data['data'] # data:application/pdf;base64,JVBERi0...
  1216. base64_data = data_url.split(',', 1)[1]
  1217. pdf_bytes = base64.b64decode(base64_data)
  1218. # 保存到本地
  1219. save_dir = Path.cwd() / ".cache/.browser_use_files"
  1220. save_dir.mkdir(parents=True, exist_ok=True)
  1221. filename = Path(parsed.path).name if parsed.path else ""
  1222. if not filename or not filename.lower().endswith('.pdf'):
  1223. import time
  1224. filename = f"downloaded_{int(time.time())}.pdf"
  1225. save_path = str(save_dir / filename)
  1226. with open(save_path, 'wb') as f:
  1227. f.write(pdf_bytes)
  1228. print(f"📄 PDF 已通过 CDP 下载到: {save_path} ({len(pdf_bytes)} bytes)")
  1229. return save_path
  1230. except Exception as e:
  1231. print(f"⚠️ PDF 检测/下载异常: {e}")
  1232. return None
  1233. @tool()
  1234. async def browser_read_long_content(
  1235. goal: Union[str, dict],
  1236. source: str = "page",
  1237. context: str = "",
  1238. **kwargs
  1239. ) -> ToolResult:
  1240. """
  1241. 智能读取长内容。支持自动检测并读取网页上的 PDF 文件。
  1242. 当 source="page" 且当前页面是 PDF 时,会通过 CDP 下载 PDF 并用 pypdf 解析,
  1243. 而非使用 DOM 提取(DOM 无法读取浏览器内置 PDF Viewer 的内容)。
  1244. 通过 CDP 下载可自动携带浏览器的 cookies/session,支持需要登录的 PDF。
  1245. """
  1246. try:
  1247. browser, tools = await get_browser_session()
  1248. # 1. 提取目标文本 (针对 GoalTree 字典结构)
  1249. final_goal_text = ""
  1250. if isinstance(goal, dict):
  1251. final_goal_text = goal.get("mission") or goal.get("goal") or str(goal)
  1252. else:
  1253. final_goal_text = str(goal)
  1254. # 2. 清洗业务背景 (过滤框架注入的 dict 类型 context)
  1255. business_context = context if isinstance(context, str) else ""
  1256. # 3. PDF 自动检测:当 source="page" 时检查是否为 PDF 页面
  1257. available_files = []
  1258. if source.lower() == "page":
  1259. pdf_path = await _detect_and_download_pdf_via_cdp(browser)
  1260. if pdf_path:
  1261. source = pdf_path
  1262. available_files.append(pdf_path)
  1263. # 4. 验证并实例化
  1264. action_params = ReadContentAction(
  1265. goal=final_goal_text,
  1266. source=source,
  1267. context=business_context
  1268. )
  1269. # 5. 解包参数调用底层方法
  1270. result = await tools.read_long_content(
  1271. **action_params.model_dump(),
  1272. browser_session=browser,
  1273. page_extraction_llm=RunnableLambda(extraction_adapter),
  1274. available_file_paths=available_files
  1275. )
  1276. return action_result_to_tool_result(result, f"深度读取: {source}")
  1277. except Exception as e:
  1278. return ToolResult(
  1279. title="深度读取失败",
  1280. output="",
  1281. error=f"Read long content failed: {str(e)}",
  1282. long_term_memory="参数解析或校验失败,请检查输入"
  1283. )
  1284. @tool()
  1285. async def browser_get_page_html() -> ToolResult:
  1286. """
  1287. 获取当前页面的完整 HTML
  1288. Get the full HTML of the current page
  1289. 返回当前页面的完整 HTML 源代码。
  1290. Returns:
  1291. ToolResult: 包含页面 HTML 的工具返回对象
  1292. Example:
  1293. get_page_html()
  1294. Note:
  1295. - 返回的是完整的 HTML 源代码
  1296. - 输出会被限制在 10000 字符以内(完整内容保存在 metadata 中)
  1297. """
  1298. try:
  1299. browser, tools = await get_browser_session()
  1300. # 使用 CDP 获取页面 HTML
  1301. cdp = await browser.get_or_create_cdp_session()
  1302. # 获取页面内容
  1303. result = await cdp.cdp_client.send.Runtime.evaluate(
  1304. params={'expression': 'document.documentElement.outerHTML'},
  1305. session_id=cdp.session_id
  1306. )
  1307. html = result.get('result', {}).get('value', '')
  1308. # 获取 URL 和标题
  1309. url = await browser.get_current_page_url()
  1310. title_result = await cdp.cdp_client.send.Runtime.evaluate(
  1311. params={'expression': 'document.title'},
  1312. session_id=cdp.session_id
  1313. )
  1314. title = title_result.get('result', {}).get('value', '')
  1315. # 限制输出大小
  1316. output_html = html
  1317. if len(html) > 10000:
  1318. output_html = html[:10000] + "... (truncated)"
  1319. return ToolResult(
  1320. title=f"获取 HTML: {url}",
  1321. output=f"页面: {title}\nURL: {url}\n\nHTML:\n{output_html}",
  1322. long_term_memory=f"获取 HTML: {url}",
  1323. metadata={"url": url, "title": title, "html": html}
  1324. )
  1325. except Exception as e:
  1326. return ToolResult(
  1327. title="获取 HTML 失败",
  1328. output="",
  1329. error=f"Failed to get page HTML: {str(e)}",
  1330. long_term_memory="获取 HTML 失败"
  1331. )
  1332. @tool()
  1333. async def browser_get_selector_map() -> ToolResult:
  1334. """
  1335. 获取当前页面的元素索引映射
  1336. Get the selector map of interactive elements on the current page
  1337. 返回页面所有可交互元素的索引字典,用于后续的元素操作。
  1338. Returns:
  1339. ToolResult: 包含元素映射的工具返回对象
  1340. Example:
  1341. get_selector_map()
  1342. Note:
  1343. 返回的索引可以用于 click_element, input_text 等操作
  1344. """
  1345. try:
  1346. browser, tools = await get_browser_session()
  1347. # 关键修复:先触发 BrowserStateRequestEvent 来更新 DOM 状态
  1348. # 这会触发 DOM watchdog 重新构建 DOM 树并更新 selector_map
  1349. from browser_use.browser.events import BrowserStateRequestEvent
  1350. # 触发事件并等待结果
  1351. event = browser.event_bus.dispatch(
  1352. BrowserStateRequestEvent(
  1353. include_dom=True,
  1354. include_screenshot=False, # 不需要截图,节省时间
  1355. include_recent_events=False
  1356. )
  1357. )
  1358. # 等待 DOM 更新完成
  1359. browser_state = await event.event_result(raise_if_none=True, raise_if_any=True)
  1360. # 从更新后的状态中获取 selector_map
  1361. selector_map = browser_state.dom_state.selector_map if browser_state.dom_state else {}
  1362. # 构建输出信息
  1363. elements_info = []
  1364. for index, node in list(selector_map.items())[:20]: # 只显示前20个
  1365. tag = node.tag_name
  1366. attrs = node.attributes or {}
  1367. text = attrs.get('aria-label') or attrs.get('placeholder') or attrs.get('value', '')
  1368. elements_info.append(f"索引 {index}: <{tag}> {text[:50]}")
  1369. output = f"找到 {len(selector_map)} 个交互元素\n\n"
  1370. output += "\n".join(elements_info)
  1371. if len(selector_map) > 20:
  1372. output += f"\n... 还有 {len(selector_map) - 20} 个元素"
  1373. return ToolResult(
  1374. title="获取元素映射",
  1375. output=output,
  1376. long_term_memory=f"获取到 {len(selector_map)} 个交互元素",
  1377. metadata={"selector_map": {k: str(v) for k, v in list(selector_map.items())[:100]}}
  1378. )
  1379. except Exception as e:
  1380. return ToolResult(
  1381. title="获取元素映射失败",
  1382. output="",
  1383. error=f"Failed to get selector map: {str(e)}",
  1384. long_term_memory="获取元素映射失败"
  1385. )
  1386. # ============================================================
  1387. # JavaScript 执行工具 (JavaScript Tools)
  1388. # ============================================================
  1389. @tool()
  1390. async def browser_evaluate(code: str) -> ToolResult:
  1391. """
  1392. 在页面中执行 JavaScript 代码
  1393. Execute JavaScript code in the page context
  1394. 允许在当前页面中执行任意 JavaScript 代码,用于复杂的页面操作或数据提取。
  1395. Args:
  1396. code: 要执行的 JavaScript 代码字符串
  1397. Returns:
  1398. ToolResult: 包含执行结果的工具返回对象
  1399. Example:
  1400. evaluate("document.title")
  1401. evaluate("document.querySelectorAll('a').length")
  1402. Note:
  1403. - 代码在页面上下文中执行,可以访问 DOM 和全局变量
  1404. - 返回值会被自动序列化为字符串
  1405. - 执行结果限制在 20k 字符以内
  1406. """
  1407. try:
  1408. browser, tools = await get_browser_session()
  1409. result = await tools.evaluate(
  1410. code=code,
  1411. browser_session=browser
  1412. )
  1413. return action_result_to_tool_result(result, "执行 JavaScript")
  1414. except Exception as e:
  1415. return ToolResult(
  1416. title="JavaScript 执行失败",
  1417. output="",
  1418. error=f"Failed to execute JavaScript: {str(e)}",
  1419. long_term_memory="JavaScript 执行失败"
  1420. )
  1421. @tool()
  1422. async def browser_ensure_login_with_cookies(cookie_type: str, url: str = "https://www.xiaohongshu.com") -> ToolResult:
  1423. """
  1424. 检查登录状态并在需要时注入 cookies
  1425. """
  1426. try:
  1427. browser, tools = await get_browser_session()
  1428. if url:
  1429. await tools.navigate(url=url, browser_session=browser)
  1430. await tools.wait(seconds=2, browser_session=browser)
  1431. check_login_js = """
  1432. (function() {
  1433. const loginBtn = document.querySelector('[class*="login"]') ||
  1434. document.querySelector('[href*="login"]') ||
  1435. Array.from(document.querySelectorAll('button, a')).find(el => (el.textContent || '').includes('登录'));
  1436. const userInfo = document.querySelector('[class*="user"]') ||
  1437. document.querySelector('[class*="avatar"]');
  1438. return {
  1439. needLogin: !!loginBtn && !userInfo,
  1440. hasLoginBtn: !!loginBtn,
  1441. hasUserInfo: !!userInfo
  1442. };
  1443. })()
  1444. """
  1445. result = await tools.evaluate(code=check_login_js, browser_session=browser)
  1446. status_output = result.extracted_content
  1447. if isinstance(status_output, str) and status_output.startswith("Result: "):
  1448. status_output = status_output[8:]
  1449. login_info: Dict[str, Any] = {}
  1450. if isinstance(status_output, str):
  1451. try:
  1452. login_info = json.loads(status_output)
  1453. except Exception:
  1454. login_info = {}
  1455. elif isinstance(status_output, dict):
  1456. login_info = status_output
  1457. if not login_info.get("needLogin"):
  1458. output = json.dumps({"need_login": False}, ensure_ascii=False)
  1459. return ToolResult(
  1460. title="已登录",
  1461. output=output,
  1462. long_term_memory=output
  1463. )
  1464. row = _fetch_cookie_row(cookie_type)
  1465. cookie_value = _extract_cookie_value(row)
  1466. if not cookie_value:
  1467. output = json.dumps({"need_login": True, "cookies_count": 0}, ensure_ascii=False)
  1468. return ToolResult(
  1469. title="未找到 cookies",
  1470. output=output,
  1471. error="未找到 cookies",
  1472. long_term_memory=output
  1473. )
  1474. domain, base_url = _cookie_domain_for_type(cookie_type, url)
  1475. cookies = _normalize_cookies(cookie_value, domain, base_url)
  1476. if not cookies:
  1477. output = json.dumps({"need_login": True, "cookies_count": 0}, ensure_ascii=False)
  1478. return ToolResult(
  1479. title="cookies 解析失败",
  1480. output=output,
  1481. error="cookies 解析失败",
  1482. long_term_memory=output
  1483. )
  1484. await browser._cdp_set_cookies(cookies)
  1485. if url:
  1486. await tools.navigate(url=url, browser_session=browser)
  1487. await tools.wait(seconds=2, browser_session=browser)
  1488. output = json.dumps({"need_login": True, "cookies_count": len(cookies)}, ensure_ascii=False)
  1489. return ToolResult(
  1490. title="已注入 cookies",
  1491. output=output,
  1492. long_term_memory=output
  1493. )
  1494. except Exception as e:
  1495. return ToolResult(
  1496. title="登录检查失败",
  1497. output="",
  1498. error=str(e),
  1499. long_term_memory="登录检查失败"
  1500. )
  1501. # ============================================================
  1502. # 等待用户操作工具 (Wait for User Action)
  1503. # ============================================================
  1504. @tool()
  1505. async def browser_wait_for_user_action(message: str = "Please complete the action in browser",
  1506. timeout: int = 300) -> ToolResult:
  1507. """
  1508. 等待用户在浏览器中完成操作(如登录)
  1509. Wait for user to complete an action in the browser (e.g., login)
  1510. 暂停自动化流程,等待用户手动完成某些操作(如登录、验证码等)。
  1511. Args:
  1512. message: 提示用户需要完成的操作
  1513. timeout: 最大等待时间(秒),默认 300 秒(5 分钟)
  1514. Returns:
  1515. ToolResult: 包含等待结果的工具返回对象
  1516. Example:
  1517. wait_for_user_action("Please login to Xiaohongshu", timeout=180)
  1518. wait_for_user_action("Please complete the CAPTCHA", timeout=60)
  1519. Note:
  1520. - 用户需要在浏览器窗口中手动完成操作
  1521. - 完成后按回车键继续
  1522. - 超时后会自动继续执行
  1523. """
  1524. try:
  1525. import asyncio
  1526. print(f"\n{'='*60}")
  1527. print(f"⏸️ WAITING FOR USER ACTION")
  1528. print(f"{'='*60}")
  1529. print(f"📝 {message}")
  1530. print(f"⏱️ Timeout: {timeout} seconds")
  1531. print(f"\n👉 Please complete the action in the browser window")
  1532. print(f"👉 Press ENTER when done, or wait for timeout")
  1533. print(f"{'='*60}\n")
  1534. # Wait for user input or timeout
  1535. try:
  1536. loop = asyncio.get_event_loop()
  1537. # Wait for either user input or timeout
  1538. await asyncio.wait_for(
  1539. loop.run_in_executor(None, input),
  1540. timeout=timeout
  1541. )
  1542. return ToolResult(
  1543. title="用户操作完成",
  1544. output=f"User completed: {message}",
  1545. long_term_memory=f"用户完成操作: {message}"
  1546. )
  1547. except asyncio.TimeoutError:
  1548. return ToolResult(
  1549. title="用户操作超时",
  1550. output=f"Timeout waiting for: {message}",
  1551. long_term_memory=f"等待用户操作超时: {message}"
  1552. )
  1553. except Exception as e:
  1554. return ToolResult(
  1555. title="等待用户操作失败",
  1556. output="",
  1557. error=f"Failed to wait for user action: {str(e)}",
  1558. long_term_memory="等待用户操作失败"
  1559. )
  1560. # ============================================================
  1561. # 任务完成工具 (Task Completion)
  1562. # ============================================================
  1563. @tool()
  1564. async def browser_done(text: str, success: bool = True,
  1565. files_to_display: Optional[List[str]] = None) -> ToolResult:
  1566. """
  1567. 标记任务完成并返回最终消息
  1568. Mark the task as complete and return final message to user
  1569. Args:
  1570. text: 给用户的最终消息
  1571. success: 任务是否成功完成
  1572. files_to_display: 可选的要显示的文件路径列表
  1573. Returns:
  1574. ToolResult: 完成结果
  1575. Example:
  1576. done("任务已完成,提取了10个产品信息", success=True)
  1577. """
  1578. try:
  1579. browser, tools = await get_browser_session()
  1580. result = await tools.done(
  1581. text=text,
  1582. success=success,
  1583. files_to_display=files_to_display,
  1584. file_system=_file_system
  1585. )
  1586. return action_result_to_tool_result(result, "任务完成")
  1587. except Exception as e:
  1588. return ToolResult(
  1589. title="标记任务完成失败",
  1590. output="",
  1591. error=f"Failed to complete task: {str(e)}",
  1592. long_term_memory="标记任务完成失败"
  1593. )
  1594. # ============================================================
  1595. # Cookie 持久化工具
  1596. # ============================================================
  1597. _COOKIES_DIR = Path(__file__).parent.parent.parent.parent.parent / ".cache/.cookies"
  1598. @tool()
  1599. async def browser_export_cookies(name: str = "", account: str = "") -> ToolResult:
  1600. """
  1601. 导出当前浏览器的所有 Cookie 到本地 .cookies/ 目录。
  1602. 文件命名格式:{域名}_{账号名}.json,如 bilibili.com_zhangsan.json
  1603. 登录成功后调用此工具,下次可通过 browser_load_cookies 恢复登录态。
  1604. Args:
  1605. name: 自定义文件名(可选,提供则忽略自动命名)
  1606. account: 账号名称(可选,用于区分同一网站的不同账号)
  1607. """
  1608. try:
  1609. browser, _ = await get_browser_session()
  1610. # 获取所有 Cookie(CDP 格式)
  1611. all_cookies = await browser._cdp_get_cookies()
  1612. if not all_cookies:
  1613. return ToolResult(title="Cookie 导出", output="当前浏览器没有 Cookie", long_term_memory="无 Cookie 可导出")
  1614. # 获取当前域名,用于过滤和命名
  1615. from urllib.parse import urlparse
  1616. current_url = await browser.get_current_page_url() or ''
  1617. domain = urlparse(current_url).netloc.replace("www.", "") or "default"
  1618. if not name:
  1619. name = f"{domain}_{account}" if account else domain
  1620. # 只保留当前域名的 cookie(过滤第三方)
  1621. cookies = [c for c in all_cookies if domain in c.get("domain", "").lstrip(".")]
  1622. # 保存
  1623. _COOKIES_DIR.mkdir(parents=True, exist_ok=True)
  1624. cookie_file = _COOKIES_DIR / f"{name}.json"
  1625. cookie_file.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
  1626. return ToolResult(
  1627. title="Cookie 已导出",
  1628. output=f"已保存 {len(cookies)} 条 Cookie 到 .cookies/{name}.json(从 {len(all_cookies)} 条中过滤当前域名)",
  1629. long_term_memory=f"导出 {len(cookies)} 条 Cookie 到 .cookies/{name}.json"
  1630. )
  1631. except Exception as e:
  1632. return ToolResult(title="Cookie 导出失败", output="", error=str(e), long_term_memory="导出 Cookie 失败")
  1633. @tool()
  1634. async def browser_load_cookies(url: str, name: str = "", auto_navigate: bool = True) -> ToolResult:
  1635. """
  1636. 根据目标 URL 自动查找本地 Cookie 文件,注入浏览器并导航到目标页面恢复登录态。
  1637. 如果找不到 Cookie 文件,会根据 auto_navigate 参数决定是否直接导航到目标页面。
  1638. 重要:此工具会自动完成导航,调用前不需要先调用 browser_navigate_to_url。
  1639. Args:
  1640. url: 目标 URL(必须提供,同时用于自动匹配 Cookie 文件)
  1641. name: Cookie 文件名(可选,不传则根据 URL 域名自动查找)
  1642. auto_navigate: 找不到 Cookie 时是否自动导航到目标页面(默认 True)
  1643. """
  1644. try:
  1645. browser, tools = await get_browser_session()
  1646. if not url.startswith("http"):
  1647. url = f"https://{url}"
  1648. # 根据域名自动查找 Cookie 文件
  1649. if not name:
  1650. from urllib.parse import urlparse
  1651. domain = urlparse(url).netloc.replace("www.", "")
  1652. if _COOKIES_DIR.exists():
  1653. # 尝试多种匹配模式
  1654. matches = []
  1655. # 1. 精确匹配完整域名(如 xiaohongshu.com.json)
  1656. exact_match = _COOKIES_DIR / f"{domain}.json"
  1657. if exact_match.exists():
  1658. matches.append(exact_match)
  1659. logger.info(f"Cookie 精确匹配成功: {exact_match.name}")
  1660. # 2. 匹配域名前缀(如 xiaohongshu.com*.json)
  1661. if not matches:
  1662. prefix_matches = list(_COOKIES_DIR.glob(f"{domain}*.json"))
  1663. if prefix_matches:
  1664. matches = prefix_matches
  1665. logger.info(f"Cookie 前缀匹配成功: {[m.name for m in matches]}")
  1666. # 3. 模糊匹配:提取主域名(如 xiaohongshu)
  1667. if not matches:
  1668. main_domain = domain.split('.')[0] # 提取第一部分
  1669. fuzzy_matches = list(_COOKIES_DIR.glob(f"{main_domain}*.json"))
  1670. if fuzzy_matches:
  1671. matches = fuzzy_matches
  1672. logger.info(f"Cookie 模糊匹配成功: {[m.name for m in matches]} (主域名: {main_domain})")
  1673. if matches:
  1674. cookie_file = matches[0] # 取第一个匹配的
  1675. logger.info(f"使用 Cookie 文件: {cookie_file.name}")
  1676. else:
  1677. available = [f.stem for f in _COOKIES_DIR.glob("*.json")]
  1678. logger.warning(f"未找到匹配的 Cookie 文件。域名: {domain}, 可用: {available}")
  1679. hint = f"可用的 Cookie 文件: {available}" if available else "提示:首次使用需要先手动登录,然后使用 browser_export_cookies 保存 Cookie"
  1680. # 如果启用自动导航,直接访问目标页面
  1681. if auto_navigate:
  1682. await tools.navigate(url=url, browser_session=browser)
  1683. await tools.wait(seconds=2, browser_session=browser)
  1684. return ToolResult(
  1685. title="未找到 Cookie,已导航到目标页面",
  1686. output=f"没有找到 {domain} 的 Cookie 文件,已自动导航到 {url}。\n\n{hint}\n\n建议:如需保持登录态,请手动登录后使用 browser_export_cookies 保存 Cookie。",
  1687. error=None,
  1688. long_term_memory=f"未找到 {domain} 的 Cookie,已导航到 {url}"
  1689. )
  1690. else:
  1691. return ToolResult(
  1692. title="未找到 Cookie",
  1693. output=f"没有匹配 {domain} 的 Cookie 文件。{hint}\n\n建议:使用 browser_navigate_to_url 访问 {url} 并手动登录,或使用 browser_export_cookies 保存当前 Cookie。",
  1694. error=None,
  1695. long_term_memory=f"未找到 {domain} 的 Cookie 文件"
  1696. )
  1697. else:
  1698. # Cookie 目录不存在
  1699. if auto_navigate:
  1700. await tools.navigate(url=url, browser_session=browser)
  1701. await tools.wait(seconds=2, browser_session=browser)
  1702. return ToolResult(
  1703. title="首次使用 Cookie 功能,已导航到目标页面",
  1704. output=f"这是首次使用 Cookie 功能,已自动导航到 {url}。\n\n建议:手动完成登录后,使用 browser_export_cookies 保存 Cookie 供下次使用。",
  1705. error=None,
  1706. long_term_memory="首次使用 Cookie 功能,已导航到目标页面"
  1707. )
  1708. else:
  1709. return ToolResult(
  1710. title="Cookie 目录不存在",
  1711. output=f"这是首次使用 Cookie 功能。建议:\n1. 使用 browser_navigate_to_url 访问 {url}\n2. 手动完成登录\n3. 使用 browser_export_cookies 保存 Cookie 供下次使用",
  1712. error=None,
  1713. long_term_memory="Cookie 目录不存在,这是首次使用"
  1714. )
  1715. else:
  1716. cookie_file = _COOKIES_DIR / f"{name}.json"
  1717. if not cookie_file.exists():
  1718. available = [f.stem for f in _COOKIES_DIR.glob("*.json")] if _COOKIES_DIR.exists() else []
  1719. hint = f"可用的 Cookie 文件: {available}" if available else "提示:使用 browser_export_cookies 保存 Cookie"
  1720. if auto_navigate:
  1721. await tools.navigate(url=url, browser_session=browser)
  1722. await tools.wait(seconds=2, browser_session=browser)
  1723. return ToolResult(
  1724. title="Cookie 文件不存在,已导航到目标页面",
  1725. output=f"未找到 .cookies/{name}.json,已自动导航到 {url}。\n\n{hint}",
  1726. error=None,
  1727. long_term_memory=f"未找到 {name}.json,已导航到目标页面"
  1728. )
  1729. else:
  1730. return ToolResult(
  1731. title="Cookie 文件不存在",
  1732. output=f"未找到 .cookies/{name}.json。{hint}",
  1733. error=None,
  1734. long_term_memory=f"未找到 {name}.json Cookie 文件"
  1735. )
  1736. cookies = json.loads(cookie_file.read_text(encoding="utf-8"))
  1737. # 直接注入(export 和 load 使用相同的 CDP 格式,无需标准化)
  1738. await browser._cdp_set_cookies(cookies)
  1739. # 导航到目标页面(带上刚注入的 Cookie)
  1740. if url:
  1741. if not url.startswith("http"):
  1742. url = f"https://{url}"
  1743. await tools.navigate(url=url, browser_session=browser)
  1744. await tools.wait(seconds=3, browser_session=browser)
  1745. return ToolResult(
  1746. title="Cookie 注入并导航完成",
  1747. output=f"从 {cookie_file.name} 注入 {len(cookies)} 条 Cookie,已导航到 {url}",
  1748. long_term_memory=f"已从 {cookie_file.name} 注入 Cookie 并导航到 {url},登录态已恢复"
  1749. )
  1750. except Exception as e:
  1751. return ToolResult(title="Cookie 加载失败", output="", error=str(e), long_term_memory="加载 Cookie 失败")
  1752. # ============================================================
  1753. # 导出所有工具函数(供外部使用)
  1754. # ============================================================
  1755. __all__ = [
  1756. # 会话管理
  1757. 'init_browser_session',
  1758. 'get_browser_session',
  1759. 'cleanup_browser_session',
  1760. 'kill_browser_session',
  1761. # 导航类工具
  1762. 'browser_navigate_to_url',
  1763. 'browser_search_web',
  1764. 'browser_go_back',
  1765. 'browser_wait',
  1766. # 元素交互工具
  1767. 'browser_click_element',
  1768. 'browser_input_text',
  1769. 'browser_send_keys',
  1770. 'browser_upload_file',
  1771. # 滚动和视图工具
  1772. 'browser_scroll_page',
  1773. 'browser_find_text',
  1774. 'browser_screenshot',
  1775. # 标签页管理工具
  1776. 'browser_switch_tab',
  1777. 'browser_close_tab',
  1778. # 下拉框工具
  1779. 'browser_get_dropdown_options',
  1780. 'browser_select_dropdown_option',
  1781. # 内容提取工具
  1782. 'browser_extract_content',
  1783. 'browser_get_page_html',
  1784. 'browser_read_long_content',
  1785. 'browser_download_direct_url',
  1786. 'browser_get_selector_map',
  1787. 'browser_get_visual_selector_map',
  1788. # JavaScript 执行工具
  1789. 'browser_evaluate',
  1790. 'browser_ensure_login_with_cookies',
  1791. # 等待用户操作
  1792. 'browser_wait_for_user_action',
  1793. # 任务完成
  1794. 'browser_done',
  1795. # Cookie 持久化
  1796. 'browser_export_cookies',
  1797. 'browser_load_cookies',
  1798. ]