- 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
246 lines
7.4 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|