import { Injectable, HttpException, InternalServerErrorException, } from "@nestjs/common"; import { TraceLogger } from "../common/trace-logger"; import { chromium } from "playwright"; import type { BrowserContext } from "playwright"; import { Readability } from "@mozilla/readability"; import { JSDOM } from "jsdom"; import { SessionContextService } from "../session/session-context.service"; import { SessionService } from "../session/session.service"; import { CodeExecutorService } from "../code-executor/code-executor.service"; import type { ExecResult, ScriptLogger } from "../code-executor/code-executor.service"; import { SnippetService } from "../snippet/snippet.service"; import type { Cookie } from "playwright"; import type { EnvironmentData } from "../environment/environment.entity"; export type { ExecResult } from "../code-executor/code-executor.service"; export interface OpenResult { url: string; title: string; content: string; } @Injectable() export class BrowserService { private readonly logger = new TraceLogger(BrowserService.name); constructor( private readonly sessionContextService: SessionContextService, private readonly sessionService: SessionService, private readonly codeExecutor: CodeExecutorService, private readonly snippetService: SnippetService, ) {} private parseCookies(raw: string | null | undefined): Cookie[] { if (!raw) return []; try { const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? (parsed as Cookie[]) : []; } catch { return []; } } private async getOrCreateNamedHandle(sessionName: string) { try { return await this.sessionContextService.getHandle(sessionName); } catch { this.logger.log( `[${sessionName}] creating session context automatically`, ); const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, }); const context = await browser.newContext(); const page = await context.newPage(); const existing = await this.sessionService.findBySessionName(sessionName); const cookies = this.parseCookies(existing?.cookies); if (cookies.length > 0) { await context.addCookies(cookies); } this.sessionContextService.register(sessionName, browser, context, page); await this.sessionService.upsert( sessionName, existing?.token ?? `auto:${sessionName}`, await context.cookies(), {}, ); return { browser, context, page }; } } private rethrow(err: unknown, label: string, operation: string): never { if (err instanceof HttpException) { throw err; } throw new InternalServerErrorException( `[${label}] Browser ${operation} failed: ${(err as Error).message}`, { cause: err }, ); } private async extractContent( context: BrowserContext, url: string, readerMode: boolean, selector?: string, ): Promise { const page = await context.newPage(); try { this.logger.log(`Opening ${url}`); await page.goto(url, { waitUntil: "networkidle" }); const finalUrl = page.url(); const title = await page.title(); const rawHtml = await page.content(); let content: string; if (selector) { const dom = new JSDOM(rawHtml, { url: finalUrl }); const el = dom.window.document.querySelector(selector); content = readerMode ? (el?.textContent?.replace(/\s+/g, " ").trim() ?? "") : (el?.outerHTML ?? ""); } else if (readerMode) { const dom = new JSDOM(rawHtml, { url: finalUrl }); const article = new Readability(dom.window.document).parse(); content = article ? article.textContent.replace(/\s+/g, " ").trim() : rawHtml; } else { content = rawHtml; } this.logger.log( `Loaded: ${finalUrl} — "${title}"${readerMode ? " (reader mode)" : ""}${selector ? ` (selector: ${selector})` : ""}`, ); return { url: finalUrl, title, content }; } finally { await page.close(); } } async open( sessionName: string | undefined, url: string, readerMode = false, selector?: string, ): Promise { const label = sessionName ?? "anonymous"; this.logger.log(`[${label}] open: ${url}`); if (sessionName) { const { context } = await this.getOrCreateNamedHandle(sessionName); try { return await this.extractContent(context, url, readerMode, selector); } catch (err) { this.rethrow(err, label, "open"); } } // Anonymous — ephemeral browser const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, }); try { const context = await browser.newContext(); return await this.extractContent(context, url, readerMode, selector); } catch (err) { this.rethrow(err, label, "open"); } finally { await browser.close(); } } async exec( sessionName: string | undefined, code: string, url?: string, environment?: EnvironmentData, credentials?: Record, ): Promise { const label = sessionName ?? "anonymous"; if (sessionName) { const { page, context } = await this.getOrCreateNamedHandle(sessionName); this.logger.log(`[${label}] exec: using persistent context`); const snippets = await this.snippetService .buildSnippetMap() .catch(() => ({}) as Record); try { if (url) { this.logger.log(`[${label}] exec: navigating to ${url}`); await page.goto(url, { waitUntil: "networkidle" }); } this.logger.log(`[${label}] exec: running user code`); const scriptLogger: ScriptLogger = (level, msg) => this.logger[level](`[${label}] script: ${msg}`); const result = await this.codeExecutor.execute( page, context, code, scriptLogger, undefined, credentials, environment, snippets, ); this.logger.log(`[${label}] exec: done`); return result; } catch (err) { this.rethrow(err, label, "exec"); } } // Anonymous — ephemeral browser const snippetMap = await this.snippetService .buildSnippetMap() .catch(() => ({}) as Record); const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, }); try { const context = await browser.newContext(); const page = await context.newPage(); if (url) { this.logger.log(`[${label}] exec: navigating to ${url}`); await page.goto(url, { waitUntil: "networkidle" }); } this.logger.log(`[${label}] exec: running user code`); const scriptLogger: ScriptLogger = (level, msg) => this.logger[level](`[${label}] script: ${msg}`); const result = await this.codeExecutor.execute( page, context, code, scriptLogger, undefined, credentials, environment, snippetMap, ); this.logger.log(`[${label}] exec: done`); return result; } catch (err) { this.rethrow(err, label, "exec"); } finally { await browser.close(); } } }