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:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user