/** * Browser Agent — управление браузером через Puppeteer * Поддерживает: навигация, скриншоты, клики, ввод текста, извлечение данных */ import puppeteer, { Browser, Page } from "puppeteer-core"; import { randomUUID } from "crypto"; import { getDb } from "./db"; import { browserSessions } from "../drizzle/schema"; import { eq } from "drizzle-orm"; import { storagePut } from "./storage"; const CHROMIUM_PATH = process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser"; // In-memory session store (browser instances) const activeSessions = new Map(); export interface BrowserAction { type: "navigate" | "click" | "type" | "extract" | "screenshot" | "scroll" | "wait" | "evaluate" | "close"; params: Record; } export interface BrowserResult { success: boolean; sessionId: string; screenshotUrl?: string; data?: any; error?: string; currentUrl?: string; title?: string; executionTimeMs: number; } /** * Создаёт новую браузерную сессию для агента */ export async function createBrowserSession(agentId: number): Promise<{ sessionId: string; error?: string }> { const sessionId = randomUUID().replace(/-/g, "").slice(0, 16); try { const browser = await puppeteer.launch({ executablePath: CHROMIUM_PATH, headless: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-accelerated-2d-canvas", "--no-first-run", "--no-zygote", "--disable-gpu", "--window-size=1280,800", ], }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.setUserAgent( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ); activeSessions.set(sessionId, { browser, page, agentId }); // Persist to DB const db = await getDb(); if (!db) throw new Error("Database not available"); await db.insert(browserSessions).values({ sessionId, agentId, status: "active", currentUrl: "about:blank", }); return { sessionId }; } catch (error: any) { return { sessionId: "", error: error.message }; } } /** * Выполняет действие в браузерной сессии */ export async function executeBrowserAction( sessionId: string, action: BrowserAction ): Promise { const start = Date.now(); const session = activeSessions.get(sessionId); if (!session) { // Try to restore from DB info return { success: false, sessionId, error: "Session not found or expired. Create a new session.", executionTimeMs: Date.now() - start, }; } const { page, agentId } = session; try { let data: any = null; let screenshotUrl: string | undefined; switch (action.type) { case "navigate": { const url = action.params.url as string; await page.goto(url, { waitUntil: action.params.waitUntil || "networkidle2", timeout: action.params.timeout || 30000, }); break; } case "click": { const selector = action.params.selector as string; await page.waitForSelector(selector, { timeout: 10000 }); await page.click(selector); if (action.params.waitAfter) { await new Promise(r => setTimeout(r, action.params.waitAfter)); } break; } case "type": { const selector = action.params.selector as string; const text = action.params.text as string; await page.waitForSelector(selector, { timeout: 10000 }); if (action.params.clear) { await page.click(selector, { clickCount: 3 }); } await page.type(selector, text, { delay: action.params.delay || 0 }); break; } case "extract": { const extractType = action.params.extractType || "text"; if (extractType === "text") { data = await page.evaluate(() => document.body.innerText); } else if (extractType === "html") { data = await page.evaluate(() => document.documentElement.outerHTML); } else if (extractType === "selector") { const selector = action.params.selector as string; data = await page.evaluate((sel) => { const elements = document.querySelectorAll(sel); return Array.from(elements).map(el => ({ text: (el as HTMLElement).innerText, html: el.innerHTML, href: (el as HTMLAnchorElement).href || null, src: (el as HTMLImageElement).src || null, })); }, selector); } else if (extractType === "links") { data = await page.evaluate(() => { return Array.from(document.querySelectorAll("a[href]")).map(a => ({ text: (a as HTMLAnchorElement).innerText.trim(), href: (a as HTMLAnchorElement).href, })); }); } else if (extractType === "tables") { data = await page.evaluate(() => { return Array.from(document.querySelectorAll("table")).map(table => { const rows = Array.from(table.querySelectorAll("tr")); return rows.map(row => Array.from(row.querySelectorAll("td, th")).map(cell => (cell as HTMLElement).innerText.trim()) ); }); }); } break; } case "screenshot": { const screenshotBuffer = await page.screenshot({ type: "png", fullPage: action.params.fullPage || false, }); // Upload to S3 const key = `browser-sessions/${sessionId}/${Date.now()}.png`; const uploadResult = await storagePut(key, screenshotBuffer as Buffer, "image/png"); screenshotUrl = uploadResult.url; data = { screenshotUrl }; break; } case "scroll": { const scrollY = action.params.y || 500; const scrollX = action.params.x || 0; await page.evaluate((x, y) => window.scrollBy(x, y), scrollX, scrollY); break; } case "wait": { const waitFor = action.params.selector ? page.waitForSelector(action.params.selector, { timeout: action.params.timeout || 10000 }) : new Promise(r => setTimeout(r, action.params.ms || 1000)); await waitFor; break; } case "evaluate": { const code = action.params.code as string; // Safety: only allow read-only operations data = await page.evaluate(new Function(code) as any); break; } case "close": { await session.browser.close(); activeSessions.delete(sessionId); const closeDb = await getDb(); if (closeDb) await closeDb.update(browserSessions) .set({ status: "closed", closedAt: new Date() }) .where(eq(browserSessions.sessionId, sessionId)); return { success: true, sessionId, data: { message: "Session closed" }, executionTimeMs: Date.now() - start, }; } } // Take automatic screenshot after navigation/click/type if (["navigate", "click", "type"].includes(action.type)) { try { const buf = await page.screenshot({ type: "png" }); const key = `browser-sessions/${sessionId}/${Date.now()}.png`; const uploadResult = await storagePut(key, buf as Buffer, "image/png"); screenshotUrl = uploadResult.url; } catch { // Screenshot is optional } } const currentUrl = page.url(); const title = await page.title().catch(() => ""); // Update DB const dbInst = await getDb(); if (dbInst) await dbInst.update(browserSessions) .set({ currentUrl, title, status: "active", screenshotUrl: screenshotUrl || undefined, lastActionAt: new Date(), }) .where(eq(browserSessions.sessionId, sessionId)); return { success: true, sessionId, screenshotUrl, data, currentUrl, title, executionTimeMs: Date.now() - start, }; } catch (error: any) { // Update DB with error const errDb = await getDb(); if (errDb) await errDb.update(browserSessions) .set({ status: "error" }) .where(eq(browserSessions.sessionId, sessionId)) .catch(() => {}); return { success: false, sessionId, error: error.message, currentUrl: page.url(), executionTimeMs: Date.now() - start, }; } } /** * Получает список активных сессий агента */ export async function getAgentSessions(agentId: number) { const db = await getDb(); if (!db) return []; return db.select().from(browserSessions) .where(eq(browserSessions.agentId, agentId)); } /** * Получает сессию по ID */ export async function getSession(sessionId: string) { const db = await getDb(); if (!db) return null; const rows = await db.select().from(browserSessions) .where(eq(browserSessions.sessionId, sessionId)); return rows[0] || null; } /** * Закрывает все сессии агента */ export async function closeAllAgentSessions(agentId: number) { for (const [sid, session] of Array.from(activeSessions.entries())) { if (session.agentId === agentId) { await session.browser.close().catch(() => {}); activeSessions.delete(sid); } } const db = await getDb(); if (!db) return; await db.update(browserSessions) .set({ status: "closed", closedAt: new Date() }) .where(eq(browserSessions.agentId, agentId)); }