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