Files
GoClaw/server/browser-agent.ts
2026-03-20 17:34:20 -04:00

318 lines
9.8 KiB
TypeScript

/**
* 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<string, { browser: Browser; page: Page; agentId: number }>();
export interface BrowserAction {
type: "navigate" | "click" | "type" | "extract" | "screenshot" | "scroll" | "wait" | "evaluate" | "close";
params: Record<string, any>;
}
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<BrowserResult> {
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));
}