/** * BrowserWindowManager — 单例浏览器窗口管理器 * * 提供网页加载、截图、JS 执行、内容提取、交互操作能力。 * 单例懒加载,首次调用时创建,browserClose() 显式销毁。 * * @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计 */ import { BrowserWindow } from 'electron'; import log from 'electron-log'; export interface BrowserOpenOptions { url: string; waitSelector?: string; } export interface BrowserOpenResult { title: string; url: string; } export interface ScreenshotOptions { fullPage?: boolean; selector?: string; } export interface ScreenshotResult { data: string; // Base64 PNG width: number; height: number; } export interface ExtractResult { text: string; links: Array<{ text: string; url: string }>; } export interface ScrollOptions { direction?: 'down' | 'up' | 'top' | 'bottom'; selector?: string; } export interface WaitOptions { selector?: string; timeMs?: number; } export class BrowserWindowManager { private win: BrowserWindow | null = null; private currentUrl: string | null = null; /** 窗口是否就绪 */ get ready(): boolean { return this.win !== null && !this.win.isDestroyed(); } // ===== browserOpen ===== async open(options: BrowserOpenOptions): Promise { // 若已有窗口加载了不同 URL → 先关闭重建 if (this.win && this.currentUrl !== options.url) { this.destroy(); } if (!this.win) { this.win = new BrowserWindow({ width: 1280, height: 800, show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: false, }, }); } await this.loadURLWithTimeout(this.win, options.url, 30_000); this.currentUrl = options.url; // 可选等待选择器 if (options.waitSelector) { await this.waitForSelector(options.waitSelector, 10_000); } const title = await this.evaluate('document.title'); return { title: String(title ?? ''), url: options.url }; } // ===== browserScreenshot ===== async screenshot(options: ScreenshotOptions = {}): Promise { this.ensureReady(); const win = this.win!; // 元素截图 if (options.selector) { const rect = await win.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(options.selector)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })()`, true, ) as { x: number; y: number; width: number; height: number } | null; if (!rect) throw new Error(`Element not found: ${options.selector}`); const image = await win.webContents.capturePage({ x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), }); return { data: image.toPNG().toString('base64'), width: rect.width, height: rect.height }; } // 全页截图 if (options.fullPage) { const dims = await win.webContents.executeJavaScript( `({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })`, true, ) as { width: number; height: number }; // 先滚动到底部触发懒加载 await win.webContents.executeJavaScript( `window.scrollTo(0, document.body.scrollHeight)`, true, ); await this.sleep(500); await win.webContents.executeJavaScript( `window.scrollTo(0, 0)`, true, ); await this.sleep(300); const image = await win.webContents.capturePage({ x: 0, y: 0, width: dims.width, height: dims.height, }); return { data: image.toPNG().toString('base64'), width: dims.width, height: dims.height }; } // 视口截图 const image = await win.webContents.capturePage(); const size = win.getContentSize(); return { data: image.toPNG().toString('base64'), width: size[0], height: size[1] }; } // ===== browserEvaluate ===== async evaluate(js: string): Promise { this.ensureReady(); const result = await this.win!.webContents.executeJavaScript(js, true); if (typeof result === 'string') return result; return result; } // ===== browserExtract ===== async extract(selector?: string): Promise { this.ensureReady(); const result = await this.win!.webContents.executeJavaScript( `(() => { const root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.body'}; if (!root) return null; const clone = root.cloneNode(true); clone.querySelectorAll('script, style, noscript, iframe, svg, nav, header, footer, aside').forEach(el => el.remove()); const text = (clone.innerText || '').trim(); const links = []; const anchors = (selector ? root : document).querySelectorAll('a[href^="http"]'); for (const a of anchors) { if (links.length >= 50) break; const text = (a.textContent || '').trim(); if (text && a.href) links.push({ text, url: a.href }); } return { text, links }; })()`, true, ) as { text: string; links: Array<{ text: string; url: string }> } | null; if (!result) throw new Error(selector ? `Element not found: ${selector}` : 'No body content'); return { text: result.text, links: result.links }; } // ===== browserClick ===== async click(selector: string, wait = false): Promise { this.ensureReady(); if (wait) { await this.waitForSelector(selector, 10_000); } const found = await this.win!.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return true; })()`, true, ); if (!found) throw new Error(`Element not found: ${selector}`); await this.sleep(300); await this.win!.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return; el.click(); })()`, true, ); await this.sleep(500); } // ===== browserType ===== async type(selector: string, text: string, options: { clear?: boolean; submit?: boolean } = {}): Promise { this.ensureReady(); const found = await this.win!.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.focus(); return true; })()`, true, ); if (!found) throw new Error(`Element not found: ${selector}`); await this.sleep(300); // 清空 + 赋值 + 触发事件(兼容 React/Vue) await this.win!.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return; ${options.clear ? 'el.value = "";' : ''} el.value += ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`, true, ); if (options.submit) { await this.win!.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return; if (el.form) el.form.submit(); else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); })()`, true, ); await this.sleep(1_000); } else { await this.sleep(300); } } // ===== browserScroll ===== async scroll(options: ScrollOptions = {}): Promise { this.ensureReady(); if (options.selector) { await this.win!.webContents.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(options.selector)}); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); })()`, true, ); return; } const direction = options.direction ?? 'down'; const js = { down: 'window.scrollBy(0, 500)', up: 'window.scrollBy(0, -500)', top: 'window.scrollTo(0, 0)', bottom: 'window.scrollTo(0, document.body.scrollHeight)', }[direction] ?? 'window.scrollBy(0, 500)'; await this.win!.webContents.executeJavaScript(js, true); await this.sleep(300); } // ===== browserWait ===== async wait(options: WaitOptions = {}): Promise { if (options.selector) { await this.waitForSelector(options.selector, options.timeMs ?? 10_000); } else { await this.sleep(options.timeMs ?? 1_000); } } // ===== browserClose ===== close(): void { this.destroy(); } // ===== 内部辅助 ===== private ensureReady(): void { if (!this.ready) { throw new Error('Browser window not ready. Call browser_open first.'); } } /** 带超时的 loadURL(Electron 原生不支持 timeout 选项) */ private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise { let timer: NodeJS.Timeout | null = null; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs); }); try { await Promise.race([ win.loadURL(url), timeoutPromise, ]); } catch (e) { // v0.3.0 修复: 超时后停止页面加载,避免后台继续消耗网络和 CPU 资源 if (!win.isDestroyed()) { try { win.webContents.stop(); } catch { /* ignore */ } } throw e; } finally { if (timer) clearTimeout(timer); } } private async waitForSelector(selector: string, timeoutMs: number): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { const found = await this.win!.webContents.executeJavaScript( `!!document.querySelector(${JSON.stringify(selector)})`, true, ); if (found) return; await this.sleep(300); } throw new Error(`Timeout waiting for selector: ${selector}`); } private destroy(): void { if (this.win && !this.win.isDestroyed()) { try { this.win.close(); } catch { // 忽略关闭错误 } } this.win = null; this.currentUrl = null; } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 应用退出时清理 */ static cleanup(manager: BrowserWindowManager | null): void { if (manager) manager.close(); log.info('[BrowserWindowManager] Cleaned up'); } }