318 lines
9.8 KiB
TypeScript
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));
|
|
}
|