feat(browser): add POST /open with session restore and reader mode

- restores cookies and localStorage from DB before page navigation
- readerMode flag extracts plain text via @mozilla/readability (Firefox reader engine)
- add localStorage default '{}' on session entity for clean schema migrations
This commit is contained in:
2026-04-07 12:27:34 +03:00
parent 41dbddd2a1
commit 9e5a813e9d
10 changed files with 688 additions and 4 deletions
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { BrowserModule } from './browser/browser.module';
import { SessionEntity } from './session/session.entity';
@Module({
@@ -21,6 +22,7 @@ import { SessionEntity } from './session/session.entity';
}),
}),
AuthModule,
BrowserModule,
],
controllers: [HealthController],
})
+29
View File
@@ -0,0 +1,29 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { BrowserService, OpenResult } from './browser.service';
import { OpenDto } from './dto/open.dto';
@ApiTags('browser')
@Controller()
export class BrowserController {
constructor(private readonly browserService: BrowserService) {}
@Post('open')
@ApiOperation({ summary: 'Open a URL with a stored session (cookies + localStorage)' })
@ApiResponse({
status: 201,
description: 'Page loaded successfully',
schema: {
properties: {
url: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
},
},
})
@ApiResponse({ status: 400, description: 'Session not found or invalid input' })
@ApiResponse({ status: 500, description: 'Browser automation failed' })
open(@Body() dto: OpenDto): Promise<OpenResult> {
return this.browserService.open(dto.sessionName, dto.url, dto.readerMode ?? false);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { BrowserController } from './browser.controller';
import { BrowserService } from './browser.service';
import { SessionModule } from '../session/session.module';
@Module({
imports: [SessionModule],
controllers: [BrowserController],
providers: [BrowserService],
})
export class BrowserModule {}
+89
View File
@@ -0,0 +1,89 @@
import {
Injectable,
BadRequestException,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { chromium } from 'playwright';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { SessionService } from '../session/session.service';
export interface OpenResult {
url: string;
title: string;
content: string;
}
@Injectable()
export class BrowserService {
private readonly logger = new Logger(BrowserService.name);
constructor(private readonly sessionService: SessionService) {}
async open(sessionName: string, url: string, readerMode = false): Promise<OpenResult> {
const session = await this.sessionService.findBySessionName(sessionName);
if (!session) {
throw new BadRequestException(`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();
// Restore cookies
await context.addCookies(cookies);
const page = await context.newPage();
// Restore localStorage before the page makes any authenticated requests
// by injecting it via init script
await context.addInitScript((entries: Record<string, string>) => {
for (const [k, v] of Object.entries(entries)) {
window.localStorage.setItem(k, v);
}
}, localStorageData);
this.logger.log(`[${sessionName}] 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 (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(`[${sessionName}] Loaded: ${finalUrl} — "${title}"${readerMode ? ' (reader mode)' : ''}`);
return { url: finalUrl, title, content };
} catch (err) {
if (
err instanceof BadRequestException ||
err instanceof InternalServerErrorException
) {
throw err;
}
this.logger.error(`[${sessionName}] Open failed: ${(err as Error).message}`);
throw new InternalServerErrorException(
`Browser open failed: ${(err as Error).message}`,
);
} finally {
await browser.close();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator';
export class OpenDto {
@ApiProperty({
description: 'Session name previously created by POST /login',
example: 'test-session-1',
})
@IsString()
sessionName: string;
@ApiProperty({
description: 'URL to open with the authenticated session',
example: 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages',
})
@IsUrl({ require_tld: true, require_protocol: true })
url: string;
@ApiPropertyOptional({
description: 'When true, return a plain-text reader-mode summary instead of raw HTML',
default: false,
})
@IsOptional()
@IsBoolean()
readerMode?: boolean;
}
+1 -1
View File
@@ -20,7 +20,7 @@ export class SessionEntity {
@Column('text')
cookies: string; // JSON-serialised Cookie[] from Playwright
@Column('text')
@Column('text', { default: '{}' })
localStorage: string; // JSON-serialised Record<string, string> from Playwright
@CreateDateColumn()