Files
liqa/server/src/browser/browser.service.ts
T
ars9 1a2e786ca8 feat(browser): add environment, credentials, and script logger to POST /exec
- ExecDto gains optional `environment` and `credentials` fields
- BrowserService.exec forwards both to CodeExecutorService.execute so
  helpers.env, helpers.getEnvUrl(), and helpers.getCredential() work
  identically to scenario-scheduler steps
- script console.log/warn/error now routed through TraceLogger with
  session label prefix
- integration tests cover env/creds injection and missing-alias error
2026-04-14 16:57:06 +03:00

246 lines
7.4 KiB
TypeScript

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<OpenResult> {
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<OpenResult> {
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<string, unknown>,
): Promise<ExecResult> {
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<string, string>);
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<string, string>);
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();
}
}
}