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:
Sase Dev
2026-02-14 22:25:06 +00:00
parent 7b024df4d5
commit 4a06ba5fdb
53 changed files with 17053 additions and 682 deletions

View 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
);
}
}

View File

@@ -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 {}

View File

@@ -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}`);
}
}
}

View File

@@ -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' },
};