feat: Ford legacy support, EMEX browser pooling, collapsible sidebar
- Add PL24 Ford legacy service for fordt_parts architecture - Refactor EMEX to use persistent browser pool instead of per-call instances - Make vehicle decode resilient: fallback to PL24 when Corgi doesn't recognize VIN - Add collapsible sidebar with persistent user preference - Improve brand access guard and categories service - Add debug/test scripts for VIN e2e testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
286
apps/api/src/integrations/emex/emex.browser.ts
Normal file
286
apps/api/src/integrations/emex/emex.browser.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* EMEX Browser Service — Singleton Playwright browser manager
|
||||
*
|
||||
* Keeps a single Chromium instance alive for the app lifetime.
|
||||
* Each scraping request gets its own Page (tab) via acquirePage().
|
||||
* Session cookies are shared through a single BrowserContext.
|
||||
*
|
||||
* Features:
|
||||
* - Semaphore limits concurrent pages (default 3)
|
||||
* - Session cookie auto-refresh (25 min TTL)
|
||||
* - Crash recovery (auto-relaunch if browser disconnects)
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||
|
||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||
const MAX_CONCURRENT_PAGES = 3;
|
||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
||||
|
||||
/** Simple counting semaphore */
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.current < this.max) {
|
||||
this.current++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next(); // hand slot to next waiter
|
||||
} else {
|
||||
this.current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AcquiredPage {
|
||||
page: Page;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexBrowserService.name);
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private sessionExpiry = 0;
|
||||
private readonly semaphore: Semaphore;
|
||||
private launching: Promise<void> | null = null;
|
||||
|
||||
private readonly useProxy: boolean;
|
||||
private readonly proxyHost: string;
|
||||
private readonly proxyPortStart: number;
|
||||
private readonly proxyPortEnd: number;
|
||||
private readonly proxyUsername: string;
|
||||
private readonly proxyPassword: string;
|
||||
private startedAt = 0;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy =
|
||||
this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
|
||||
this.proxyHost = this.configService.get<string>(
|
||||
'EMEX_PROXY_HOST',
|
||||
'74.81.81.81',
|
||||
);
|
||||
this.proxyPortStart = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_START',
|
||||
10000,
|
||||
);
|
||||
this.proxyPortEnd = this.configService.get<number>(
|
||||
'EMEX_PROXY_PORT_END',
|
||||
10099,
|
||||
);
|
||||
this.proxyUsername = this.configService.get<string>(
|
||||
'EMEX_PROXY_USER',
|
||||
'1726bbe361918676d44e',
|
||||
);
|
||||
this.proxyPassword = this.configService.get<string>(
|
||||
'EMEX_PROXY_PASS',
|
||||
'f11c7b6128cc86c6',
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.launchBrowser();
|
||||
this.logger.log('Browser launched on module init');
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
this.logger.error(
|
||||
`Failed to launch browser on init: ${e.message}`,
|
||||
e.stack,
|
||||
);
|
||||
// Non-fatal — will retry on first acquirePage()
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.closeBrowser();
|
||||
this.logger.log('Browser closed on module destroy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a new Page (tab) from the shared browser.
|
||||
* Caller MUST call release() when done.
|
||||
*/
|
||||
async acquirePage(): Promise<AcquiredPage> {
|
||||
await this.semaphore.acquire();
|
||||
|
||||
try {
|
||||
await this.ensureBrowser();
|
||||
await this.ensureSession();
|
||||
|
||||
const page = await this.context!.newPage();
|
||||
|
||||
let released = false;
|
||||
const release = async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
await page.close();
|
||||
}
|
||||
} catch {
|
||||
// page may already be closed
|
||||
}
|
||||
this.semaphore.release();
|
||||
};
|
||||
|
||||
return { page, release };
|
||||
} catch (err) {
|
||||
this.semaphore.release();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for monitoring
|
||||
*/
|
||||
healthCheck(): {
|
||||
browserConnected: boolean;
|
||||
sessionValid: boolean;
|
||||
uptimeMs: number;
|
||||
} {
|
||||
return {
|
||||
browserConnected: this.browser?.isConnected() ?? false,
|
||||
sessionValid: Date.now() < this.sessionExpiry,
|
||||
uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────
|
||||
|
||||
private async launchBrowser(): Promise<void> {
|
||||
// Prevent duplicate launches
|
||||
if (this.launching) {
|
||||
return this.launching;
|
||||
}
|
||||
|
||||
this.launching = this._doLaunch();
|
||||
try {
|
||||
await this.launching;
|
||||
} finally {
|
||||
this.launching = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _doLaunch(): Promise<void> {
|
||||
// Dynamic import — playwright is a devDependency
|
||||
const { chromium } = await import('playwright');
|
||||
|
||||
const launchOptions: Record<string, unknown> = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
],
|
||||
};
|
||||
|
||||
if (this.useProxy) {
|
||||
const port = this.randomProxyPort();
|
||||
launchOptions.proxy = {
|
||||
server: `http://${this.proxyHost}:${port}`,
|
||||
username: this.proxyUsername,
|
||||
password: this.proxyPassword,
|
||||
};
|
||||
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch(launchOptions);
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
this.startedAt = Date.now();
|
||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||
|
||||
// Auto-recover on disconnect
|
||||
this.browser.on('disconnected', () => {
|
||||
this.logger.warn('Browser disconnected — will relaunch on next request');
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async closeBrowser(): Promise<void> {
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.sessionExpiry = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBrowser(): Promise<void> {
|
||||
if (this.browser?.isConnected()) return;
|
||||
this.logger.log('Browser not connected — relaunching');
|
||||
await this.launchBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit baseUrl to establish/refresh ASP.NET session cookie
|
||||
*/
|
||||
private async ensureSession(): Promise<void> {
|
||||
if (Date.now() < this.sessionExpiry) return;
|
||||
|
||||
this.logger.log('Establishing EMEX session...');
|
||||
const page = await this.context!.newPage();
|
||||
try {
|
||||
await page.goto(EMEX_BASE_URL, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const cookies = await this.context!.cookies();
|
||||
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId');
|
||||
if (session) {
|
||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||
this.logger.log('Session established, TTL 25 min');
|
||||
} else {
|
||||
this.logger.warn('No session cookie found after visiting baseUrl');
|
||||
// Still set a short TTL to avoid hammering
|
||||
this.sessionExpiry = Date.now() + 60_000;
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
private randomProxyPort(): number {
|
||||
return (
|
||||
Math.floor(
|
||||
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
|
||||
) + this.proxyPortStart
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmexBrowserService } from "./emex.browser";
|
||||
import { EmexService } from "./emex.service";
|
||||
|
||||
@Module({
|
||||
providers: [EmexService],
|
||||
providers: [EmexBrowserService, EmexService],
|
||||
exports: [EmexService],
|
||||
})
|
||||
export class EmexModule {}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* NestJS service for emexdwc.ae VIN integration.
|
||||
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
|
||||
* and provides standardized DecodedVehicle responses.
|
||||
*
|
||||
* Uses EmexBrowserService for a persistent singleton browser —
|
||||
* each request gets a pre-created Page (tab) via acquirePage().
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -12,7 +15,6 @@ import {
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
InternalServerErrorException,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
@@ -25,10 +27,11 @@ import {
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
|
||||
import { EmexBrowserService } from './emex.browser';
|
||||
|
||||
// Type definition for the imported scraper module
|
||||
interface EmexScraperModule {
|
||||
EmexVinScraper: new () => EmexVinScraperInstance;
|
||||
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
|
||||
getCatalogCode: (vin: string) => string | null;
|
||||
getYearFromVIN: (vin: string) => number | null;
|
||||
CONFIG: Record<string, unknown>;
|
||||
@@ -50,7 +53,7 @@ interface EmexVinScraperInstance {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexService implements OnModuleDestroy {
|
||||
export class EmexService {
|
||||
private readonly logger = new Logger(EmexService.name);
|
||||
private scraperModule: EmexScraperModule | null = null;
|
||||
private isInitialized = false;
|
||||
@@ -60,7 +63,10 @@ export class EmexService implements OnModuleDestroy {
|
||||
private readonly timeout: number;
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private browserService: EmexBrowserService,
|
||||
) {
|
||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
@@ -76,10 +82,6 @@ export class EmexService implements OnModuleDestroy {
|
||||
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// Nothing to clean up — each scraper instance is created and closed per-call
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily initialize the scraper module
|
||||
*/
|
||||
@@ -127,18 +129,24 @@ export class EmexService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new scraper instance and initializes browser
|
||||
* Creates a scraper instance bound to a pre-created page from the browser pool.
|
||||
* Returns { scraper, release } — caller MUST call release() in finally.
|
||||
*/
|
||||
private async createScraperInstance(): Promise<EmexVinScraperInstance> {
|
||||
private async createScraperInstance(): Promise<{
|
||||
scraper: EmexVinScraperInstance;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
await this.initializeScraper();
|
||||
|
||||
if (!this.scraperModule) {
|
||||
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
|
||||
}
|
||||
|
||||
const instance = new this.scraperModule.EmexVinScraper();
|
||||
await instance.init();
|
||||
return instance;
|
||||
const { page, release } = await this.browserService.acquirePage();
|
||||
const scraper = new this.scraperModule.EmexVinScraper({ page });
|
||||
// init() is a no-op in managed mode, but call it for consistency
|
||||
await scraper.init();
|
||||
return { scraper, release };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,19 +173,24 @@ export class EmexService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a VIN number using EMEX scraper
|
||||
* Decodes a VIN number using EMEX scraper.
|
||||
* Works with or without a known catalog code — the scraper
|
||||
* falls back to VIN URL search which doesn't require one.
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
|
||||
|
||||
this.validateVin(cleanVin);
|
||||
|
||||
this.logger.log(`Decoding VIN: ${cleanVin}`);
|
||||
const supported = this.isSupported(cleanVin);
|
||||
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
const instance = await this.createScraperInstance();
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const response = await this.executeWithTimeout(
|
||||
scraper.searchByVIN(cleanVin),
|
||||
@@ -270,7 +283,7 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
if (
|
||||
err.message?.includes('browser') ||
|
||||
err.message?.includes('puppeteer') ||
|
||||
err.message?.includes('playwright') ||
|
||||
err.message?.includes('navigation')
|
||||
) {
|
||||
this.logger.error(`Browser error: ${err.message}`, err.stack);
|
||||
@@ -284,12 +297,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
'VIN sorgulama sirasinda bir hata olustu',
|
||||
);
|
||||
} finally {
|
||||
if (scraper) {
|
||||
if (release) {
|
||||
try {
|
||||
await scraper.close();
|
||||
await release();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
this.logger.warn(`Error releasing page: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,10 +376,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
const instance = await this.createScraperInstance();
|
||||
const scraper = instance.scraper;
|
||||
release = instance.release;
|
||||
|
||||
const result = await this.executeWithTimeout(
|
||||
scraper.getParts(categoryUrl),
|
||||
@@ -387,12 +402,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||
} finally {
|
||||
if (scraper) {
|
||||
if (release) {
|
||||
try {
|
||||
await scraper.close();
|
||||
await release();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
this.logger.warn(`Error releasing page: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
|
||||
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
|
||||
WF0: { code: 'FORD202201', brand: 'Ford' },
|
||||
NM0: { code: 'FORD202201', brand: 'Ford' },
|
||||
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
SHH: { code: 'HONDA00', brand: 'Honda' },
|
||||
@@ -256,4 +257,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
WP1: { code: 'PO799', brand: 'Porsche' },
|
||||
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
|
||||
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user