feat(browser): add POST /exec for custom Playwright code execution
- accepts sessionName, optional url, and arbitrary JS code string - restores cookies and localStorage from session before execution - exposes page and context to user code as async function arguments - session not found now returns 404 instead of 400
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { BrowserService, OpenResult } from './browser.service';
|
import { BrowserService, ExecResult, OpenResult } from './browser.service';
|
||||||
import { OpenDto } from './dto/open.dto';
|
import { OpenDto } from './dto/open.dto';
|
||||||
|
import { ExecDto } from './dto/exec.dto';
|
||||||
|
|
||||||
@ApiTags('browser')
|
@ApiTags('browser')
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -21,9 +22,27 @@ export class BrowserController {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Session not found or invalid input' })
|
@ApiResponse({ status: 400, description: 'Invalid input' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Session not found' })
|
||||||
@ApiResponse({ status: 500, description: 'Browser automation failed' })
|
@ApiResponse({ status: 500, description: 'Browser automation failed' })
|
||||||
open(@Body() dto: OpenDto): Promise<OpenResult> {
|
open(@Body() dto: OpenDto): Promise<OpenResult> {
|
||||||
return this.browserService.open(dto.sessionName, dto.url, dto.readerMode ?? false);
|
return this.browserService.open(dto.sessionName, dto.url, dto.readerMode ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('exec')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Execute custom Playwright JavaScript within a stored session',
|
||||||
|
description: 'The `code` string is executed as an async function body with `page` (Playwright Page) and `context` (BrowserContext) in scope. The return value is serialised and returned as `result`.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Code executed successfully',
|
||||||
|
schema: { properties: { result: {} } },
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid input' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Session not found' })
|
||||||
|
@ApiResponse({ status: 500, description: 'Execution failed' })
|
||||||
|
exec(@Body() dto: ExecDto): Promise<ExecResult> {
|
||||||
|
return this.browserService.exec(dto.sessionName, dto.code, dto.url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { Readability } from '@mozilla/readability';
|
import { Readability } from '@mozilla/readability';
|
||||||
@@ -15,6 +16,10 @@ export interface OpenResult {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExecResult {
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrowserService {
|
export class BrowserService {
|
||||||
private readonly logger = new Logger(BrowserService.name);
|
private readonly logger = new Logger(BrowserService.name);
|
||||||
@@ -24,7 +29,7 @@ export class BrowserService {
|
|||||||
async open(sessionName: string, url: string, readerMode = false): Promise<OpenResult> {
|
async open(sessionName: string, url: string, readerMode = false): Promise<OpenResult> {
|
||||||
const session = await this.sessionService.findBySessionName(sessionName);
|
const session = await this.sessionService.findBySessionName(sessionName);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new BadRequestException(`Session not found: ${sessionName}`);
|
throw new NotFoundException(`Session not found: ${sessionName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cookies: any[];
|
let cookies: any[];
|
||||||
@@ -74,6 +79,7 @@ export class BrowserService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof BadRequestException ||
|
err instanceof BadRequestException ||
|
||||||
|
err instanceof NotFoundException ||
|
||||||
err instanceof InternalServerErrorException
|
err instanceof InternalServerErrorException
|
||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -86,4 +92,60 @@ export class BrowserService {
|
|||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exec(sessionName: string, code: string, url?: string): Promise<ExecResult> {
|
||||||
|
const session = await this.sessionService.findBySessionName(sessionName);
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException(`Session not found: ${sessionName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookies: any[];
|
||||||
|
let localStorageData: Record<string, string>;
|
||||||
|
try {
|
||||||
|
cookies = JSON.parse(session.cookies);
|
||||||
|
localStorageData = JSON.parse(session.localStorage);
|
||||||
|
} catch {
|
||||||
|
throw new InternalServerErrorException('Failed to deserialize session data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
await context.addCookies(cookies);
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await context.addInitScript((entries: Record<string, string>) => {
|
||||||
|
for (const [k, v] of Object.entries(entries)) {
|
||||||
|
window.localStorage.setItem(k, v);
|
||||||
|
}
|
||||||
|
}, localStorageData);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
this.logger.log(`[${sessionName}] exec: navigating to ${url}`);
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const fn = new Function('page', 'context', `return (async (page, context) => { ${code} })(page, context)`);
|
||||||
|
this.logger.log(`[${sessionName}] exec: running user code`);
|
||||||
|
const result = await fn(page, context);
|
||||||
|
|
||||||
|
this.logger.log(`[${sessionName}] exec: done`);
|
||||||
|
return { result };
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof BadRequestException ||
|
||||||
|
err instanceof NotFoundException ||
|
||||||
|
err instanceof InternalServerErrorException
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.logger.error(`[${sessionName}] exec failed: ${(err as Error).message}`);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Browser exec failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
|
export class ExecDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Session name previously created by POST /login',
|
||||||
|
example: 'test-session-1',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
sessionName: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'URL to navigate to before executing code. Skipped if omitted.',
|
||||||
|
example: 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_tld: true, require_protocol: true })
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'JavaScript code to execute. Receives `page` (Playwright Page) and `context` (BrowserContext) as arguments. May be async. Return value is serialised and returned.',
|
||||||
|
example: 'return await page.title();',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user