Coverage for agentos/system/browser.py: 24%

191 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 23:17 +0800

1""" 

2浏览器自动化模块 — 通过 CDP (Chrome DevTools Protocol) 控制浏览器。 

3 

4设计: 

5- 底层使用 Playwright 连接 Chromium 浏览器 

6- 操作统一为 BrowserAction 结构 

7- 支持导航、点击、填表、截图、提取文本、执行JS 

8- 权限级别: BROWSER (需用户授权) 

9""" 

10 

11from __future__ import annotations 

12 

13import asyncio 

14from dataclasses import dataclass 

15 

16from agentos.system.permissions import ( 

17 PermissionDenied, 

18 PermissionTier, 

19 SystemPermissionManager, 

20) 

21 

22# ── 浏览器动作定义 ───────────────────────────────────────────── 

23 

24 

25@dataclass 

26class BrowserAction: 

27 """浏览器操作定义。""" 

28 

29 action_type: str # navigate / click / type / screenshot / extract / js / wait / scroll 

30 url: str = "" # 导航目标 URL 

31 selector: str = "" # CSS/XPath 选择器 

32 value: str = "" # 输入值 / JS 代码 / 等待时间 

33 screenshot_path: str = "" # 截图保存路径 

34 wait_until: str = "load" # 等待条件: load / networkidle / domcontentloaded 

35 

36 

37@dataclass 

38class BrowserResult: 

39 """浏览器操作结果。""" 

40 

41 success: bool 

42 action: str 

43 url: str = "" 

44 text: str = "" # 提取的文本 

45 html: str = "" # 页面 HTML 

46 screenshot_path: str = "" # 截图文件路径 

47 title: str = "" # 页面标题 

48 error: str = "" 

49 duration_ms: float = 0 

50 

51 

52# ── CDP 浏览器会话 ───────────────────────────────────────────── 

53 

54 

55class BrowserSession: 

56 """基于 Playwright 的浏览器会话,封装 CDP 底层协议。 

57 

58 使用方式: 

59 async with BrowserSession() as browser: 

60 await browser.navigate("https://example.com") 

61 text = await browser.extract_text("body") 

62 await browser.screenshot("page.png") 

63 """ 

64 

65 def __init__( 

66 self, 

67 headless: bool = True, 

68 slow_mo: int = 0, 

69 viewport_width: int = 1280, 

70 viewport_height: int = 720, 

71 ): 

72 self._headless = headless 

73 self._slow_mo = slow_mo 

74 self._viewport = {"width": viewport_width, "height": viewport_height} 

75 self._playwright = None 

76 self._browser = None 

77 self._page = None 

78 self._current_url = "" 

79 

80 async def __aenter__(self): 

81 await self.start() 

82 return self 

83 

84 async def __aexit__(self, *args): 

85 await self.close() 

86 

87 async def start(self) -> None: 

88 """启动浏览器实例。""" 

89 try: 

90 from playwright.async_api import async_playwright 

91 except ImportError: 

92 raise ImportError( 

93 "浏览器自动化需要 playwright。安装: pip install playwright && playwright install chromium" 

94 ) 

95 

96 self._playwright = await async_playwright().start() 

97 self._browser = await self._playwright.chromium.launch( 

98 headless=self._headless, 

99 slow_mo=self._slow_mo, 

100 args=[ 

101 "--no-sandbox", 

102 "--disable-setuid-sandbox", 

103 "--disable-dev-shm-usage", 

104 "--disable-gpu", 

105 ], 

106 ) 

107 self._page = await self._browser.new_page(viewport=self._viewport) 

108 

109 async def close(self) -> None: 

110 """关闭浏览器。""" 

111 if self._browser: 

112 await self._browser.close() 

113 if self._playwright: 

114 await self._playwright.stop() 

115 

116 # ── 核心操作 ── 

117 

118 async def navigate(self, url: str, wait_until: str = "load") -> BrowserResult: 

119 """导航到指定 URL。""" 

120 import time 

121 

122 t0 = time.time() 

123 try: 

124 resp = await self._page.goto(url, wait_until=wait_until, timeout=30000) 

125 self._current_url = self._page.url 

126 title = await self._page.title() 

127 duration = (time.time() - t0) * 1000 

128 return BrowserResult( 

129 success=resp and resp.ok, 

130 action="navigate", 

131 url=self._current_url, 

132 title=title, 

133 duration_ms=duration, 

134 ) 

135 except Exception as e: 

136 return BrowserResult( 

137 success=False, 

138 action="navigate", 

139 url=url, 

140 error=str(e), 

141 duration_ms=(time.time() - t0) * 1000, 

142 ) 

143 

144 async def click(self, selector: str) -> BrowserResult: 

145 """点击元素。""" 

146 import time 

147 

148 t0 = time.time() 

149 try: 

150 await self._page.click(selector, timeout=10000) 

151 return BrowserResult( 

152 success=True, 

153 action="click", 

154 url=self._page.url, 

155 selector=selector, 

156 duration_ms=(time.time() - t0) * 1000, 

157 ) 

158 except Exception as e: 

159 return BrowserResult( 

160 success=False, 

161 action="click", 

162 selector=selector, 

163 error=str(e), 

164 duration_ms=(time.time() - t0) * 1000, 

165 ) 

166 

167 async def type_text(self, selector: str, text: str) -> BrowserResult: 

168 """在输入框中输入文本。""" 

169 import time 

170 

171 t0 = time.time() 

172 try: 

173 await self._page.fill(selector, text, timeout=10000) 

174 return BrowserResult( 

175 success=True, 

176 action="type", 

177 url=self._page.url, 

178 selector=selector, 

179 text=text, 

180 duration_ms=(time.time() - t0) * 1000, 

181 ) 

182 except Exception as e: 

183 return BrowserResult( 

184 success=False, 

185 action="type", 

186 selector=selector, 

187 error=str(e), 

188 duration_ms=(time.time() - t0) * 1000, 

189 ) 

190 

191 async def extract_text(self, selector: str = "body") -> BrowserResult: 

192 """提取页面文本。""" 

193 import time 

194 

195 t0 = time.time() 

196 try: 

197 element = await self._page.query_selector(selector) 

198 if element: 

199 text = await element.inner_text() 

200 else: 

201 text = "" 

202 return BrowserResult( 

203 success=True, 

204 action="extract", 

205 url=self._page.url, 

206 text=text, 

207 duration_ms=(time.time() - t0) * 1000, 

208 ) 

209 except Exception as e: 

210 return BrowserResult( 

211 success=False, 

212 action="extract", 

213 error=str(e), 

214 duration_ms=(time.time() - t0) * 1000, 

215 ) 

216 

217 async def extract_html(self) -> BrowserResult: 

218 """获取完整 HTML。""" 

219 import time 

220 

221 t0 = time.time() 

222 try: 

223 html = await self._page.content() 

224 return BrowserResult( 

225 success=True, 

226 action="extract", 

227 url=self._page.url, 

228 html=html, 

229 duration_ms=(time.time() - t0) * 1000, 

230 ) 

231 except Exception as e: 

232 return BrowserResult( 

233 success=False, 

234 action="extract", 

235 error=str(e), 

236 duration_ms=(time.time() - t0) * 1000, 

237 ) 

238 

239 async def screenshot(self, path: str = "", full_page: bool = True) -> BrowserResult: 

240 """截取页面截图。""" 

241 import time 

242 

243 t0 = time.time() 

244 save_path = path or f"/tmp/agentos_screenshot_{int(t0)}.png" 

245 try: 

246 await self._page.screenshot(path=save_path, full_page=full_page) 

247 return BrowserResult( 

248 success=True, 

249 action="screenshot", 

250 url=self._page.url, 

251 screenshot_path=save_path, 

252 duration_ms=(time.time() - t0) * 1000, 

253 ) 

254 except Exception as e: 

255 return BrowserResult( 

256 success=False, 

257 action="screenshot", 

258 error=str(e), 

259 duration_ms=(time.time() - t0) * 1000, 

260 ) 

261 

262 async def execute_js(self, code: str) -> BrowserResult: 

263 """在页面中执行 JavaScript。""" 

264 import time 

265 

266 t0 = time.time() 

267 try: 

268 result = await self._page.evaluate(code) 

269 return BrowserResult( 

270 success=True, 

271 action="js", 

272 url=self._page.url, 

273 text=str(result), 

274 duration_ms=(time.time() - t0) * 1000, 

275 ) 

276 except Exception as e: 

277 return BrowserResult( 

278 success=False, 

279 action="js", 

280 error=str(e), 

281 duration_ms=(time.time() - t0) * 1000, 

282 ) 

283 

284 async def wait(self, selector: str = "", milliseconds: int = 1000) -> BrowserResult: 

285 """等待元素出现或等待指定毫秒。""" 

286 import time 

287 

288 t0 = time.time() 

289 try: 

290 if selector: 

291 await self._page.wait_for_selector(selector, timeout=10000) 

292 else: 

293 await asyncio.sleep(milliseconds / 1000) 

294 return BrowserResult( 

295 success=True, 

296 action="wait", 

297 url=self._page.url, 

298 selector=selector, 

299 duration_ms=(time.time() - t0) * 1000, 

300 ) 

301 except Exception as e: 

302 return BrowserResult( 

303 success=False, 

304 action="wait", 

305 selector=selector, 

306 error=str(e), 

307 duration_ms=(time.time() - t0) * 1000, 

308 ) 

309 

310 async def scroll(self, direction: str = "down", amount: int = 500) -> BrowserResult: 

311 """滚动页面。""" 

312 import time 

313 

314 t0 = time.time() 

315 try: 

316 if direction == "down": 

317 await self._page.evaluate(f"window.scrollBy(0, {amount})") 

318 elif direction == "up": 

319 await self._page.evaluate(f"window.scrollBy(0, -{amount})") 

320 elif direction == "bottom": 

321 await self._page.evaluate("window.scrollTo(0, document.body.scrollHeight)") 

322 elif direction == "top": 

323 await self._page.evaluate("window.scrollTo(0, 0)") 

324 return BrowserResult( 

325 success=True, 

326 action="scroll", 

327 url=self._page.url, 

328 text=f"已滚动 {direction}", 

329 duration_ms=(time.time() - t0) * 1000, 

330 ) 

331 except Exception as e: 

332 return BrowserResult( 

333 success=False, 

334 action="scroll", 

335 error=str(e), 

336 duration_ms=(time.time() - t0) * 1000, 

337 ) 

338 

339 @property 

340 def current_url(self) -> str: 

341 return self._page.url if self._page else "" 

342 

343 

344# ── CDP 浏览器管理器 ─────────────────────────────────────────── 

345 

346 

347class CDPBrowser: 

348 """浏览器管理器 — 带权限控制的浏览器自动化入口。 

349 

350 使用: 

351 pm = SystemPermissionManager() 

352 browser = CDPBrowser(pm, "session-123") 

353 

354 async with browser.session() as sess: 

355 await sess.navigate("https://example.com") 

356 text = await sess.extract_text() 

357 """ 

358 

359 def __init__( 

360 self, perm_manager: SystemPermissionManager, session_id: str, headless: bool = True 

361 ): 

362 self._pm = perm_manager 

363 self._sid = session_id 

364 self._headless = headless 

365 self._current_session: BrowserSession | None = None 

366 

367 def session(self, headless: bool | None = None) -> BrowserSession: 

368 """创建浏览器会话(上下文管理器)。""" 

369 # 权限检查 

370 try: 

371 self._pm.require(self._sid, PermissionTier.BROWSER, "browser:*") 

372 except PermissionDenied as e: 

373 raise PermissionDenied( 

374 PermissionTier.BROWSER, 

375 "browser:*", 

376 f"浏览器自动化需要 BROWSER 权限: {e}", 

377 ) 

378 

379 hl = headless if headless is not None else self._headless 

380 self._current_session = BrowserSession(headless=hl) 

381 return self._current_session 

382 

383 async def quick_fetch(self, url: str, extract_text: bool = True) -> BrowserResult: 

384 """快速抓取页面(自动打开关闭浏览器)。""" 

385 async with self.session() as sess: 

386 nav = await sess.navigate(url) 

387 if not nav.success: 

388 return nav 

389 if extract_text: 

390 return await sess.extract_text() 

391 return await sess.extract_html() 

392 

393 async def quick_screenshot(self, url: str, save_path: str) -> BrowserResult: 

394 """快速截图页面。""" 

395 async with self.session() as sess: 

396 nav = await sess.navigate(url) 

397 if not nav.success: 

398 return nav 

399 return await sess.screenshot(save_path) 

400 

401 async def execute_action(self, action: BrowserAction) -> BrowserResult: 

402 """执行单个浏览器动作。""" 

403 if not self._current_session: 

404 raise RuntimeError("没有活跃的浏览器会话,请使用 async with browser.session()") 

405 

406 sess = self._current_session 

407 

408 if action.action_type == "navigate": 

409 return await sess.navigate(action.url, action.wait_until) 

410 elif action.action_type == "click": 

411 return await sess.click(action.selector) 

412 elif action.action_type == "type": 

413 return await sess.type_text(action.selector, action.value) 

414 elif action.action_type == "screenshot": 

415 return await sess.screenshot(action.screenshot_path) 

416 elif action.action_type == "extract": 

417 return await sess.extract_text(action.selector or "body") 

418 elif action.action_type == "js": 

419 return await sess.execute_js(action.value) 

420 elif action.action_type == "wait": 

421 ms = int(action.value) if action.value.isdigit() else 1000 

422 return await sess.wait(action.selector, ms) 

423 elif action.action_type == "scroll": 

424 return await sess.scroll(action.value or "down") 

425 else: 

426 return BrowserResult( 

427 success=False, action=action.action_type, error=f"未知动作: {action.action_type}" 

428 )