feat(FN-094): add comment line for deployment verification
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

- Added a comment line to main.ts for deployment verification purposes
This commit is contained in:
Fusion
2026-05-11 02:07:03 +00:00
parent c72f063a25
commit f4fea1e429
274 changed files with 20712 additions and 6305 deletions

View File

@@ -11,18 +11,13 @@
* - 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';
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { 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';
const EMEX_BASE_URL = "https://emexdwc.ae";
/** Simple counting semaphore */
class Semaphore {
@@ -76,47 +71,28 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
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',
);
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
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');
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,
);
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');
this.logger.log("Browser closed on module destroy");
}
/**
@@ -130,7 +106,8 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
await this.ensureBrowser();
await this.ensureSession();
const page = await this.context!.newPage();
if (!this.context) throw new Error("EMEX browser context not initialized");
const page = await this.context.newPage();
let released = false;
const release = async () => {
@@ -186,16 +163,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private async _doLaunch(): Promise<void> {
// Dynamic import — playwright is a devDependency
const { chromium } = await import('playwright');
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',
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
],
};
@@ -213,15 +190,15 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
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',
"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.on("disconnected", () => {
this.logger.warn("Browser disconnected — will relaunch on next request");
this.browser = null;
this.context = null;
this.sessionExpiry = 0;
@@ -243,7 +220,7 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private async ensureBrowser(): Promise<void> {
if (this.browser?.isConnected()) return;
this.logger.log('Browser not connected — relaunching');
this.logger.log("Browser not connected — relaunching");
await this.launchBrowser();
}
@@ -253,21 +230,23 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private async ensureSession(): Promise<void> {
if (Date.now() < this.sessionExpiry) return;
this.logger.log('Establishing EMEX session...');
const page = await this.context!.newPage();
this.logger.log("Establishing EMEX session...");
if (!this.context) throw new Error("EMEX browser context not initialized");
const ctx = this.context;
const page = await ctx.newPage();
try {
await page.goto(EMEX_BASE_URL, {
waitUntil: 'networkidle',
waitUntil: "networkidle",
timeout: 30000,
});
const cookies = await this.context!.cookies();
const session = cookies.find((c) => c.name === 'ASP.NET_SessionId');
const cookies = await ctx.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');
this.logger.log("Session established, TTL 25 min");
} else {
this.logger.warn('No session cookie found after visiting baseUrl');
this.logger.warn("No session cookie found after visiting baseUrl");
// Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000;
}
@@ -278,9 +257,8 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private randomProxyPort(): number {
return (
Math.floor(
Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1),
) + this.proxyPortStart
Math.floor(Math.random() * (this.proxyPortEnd - this.proxyPortStart + 1)) +
this.proxyPortStart
);
}
}

View File

@@ -1,293 +1,90 @@
/**
* EMEX Response Mapper
*
* Transforms raw EmexVinScraper responses into standardized DecodedVehicle format.
* Includes Turkish translation support for common automotive terms.
* Transforms raw EmexVinScraper responses into the standardized DecodedVehicle
* format. Vehicle attribute translations (body type, engine type, transmission,
* drive type) live here. Category and part name translations now live in
* TranslationsService (apps/api/src/translations/translations.service.ts) —
* mapCategories returns nameTr=null and the insertion path
* (categories.service.ts) calls translationsService.translateMany() before
* persisting.
*/
import {
EmexScraperResponse,
EmexCategory,
EmexCategoryTreeNode,
DecodedVehicle,
DecodedCategory,
CATALOG_MAP,
} from './emex.types';
type DecodedCategory,
type DecodedVehicle,
type EmexCategory,
type EmexScraperResponse,
} from "./emex.types";
// ==================== TURKISH TRANSLATIONS ====================
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
/**
* Turkish translations for common automotive terms
*/
const TR_TRANSLATIONS = {
// Body types
bodyTypes: {
sedan: 'Sedan',
coupe: 'Coupe',
hatchback: 'Hatchback',
wagon: 'Station Wagon',
'station wagon': 'Station Wagon',
estate: 'Station Wagon',
convertible: 'Ustu Acik',
cabriolet: 'Kabriyole',
suv: 'SUV',
crossover: 'Crossover',
pickup: 'Pikap',
van: 'Minivan',
minivan: 'Minivan',
mpv: 'Cok Amacli Arac',
roadster: 'Roadster',
sedan: "Sedan",
coupe: "Coupe",
hatchback: "Hatchback",
wagon: "Station Wagon",
"station wagon": "Station Wagon",
estate: "Station Wagon",
convertible: "Ustu Acik",
cabriolet: "Kabriyole",
suv: "SUV",
crossover: "Crossover",
pickup: "Pikap",
van: "Minivan",
minivan: "Minivan",
mpv: "Cok Amacli Arac",
roadster: "Roadster",
} as Record<string, string>,
// Engine types
engineTypes: {
gasoline: 'Benzin',
petrol: 'Benzin',
benzin: 'Benzin',
diesel: 'Dizel',
electric: 'Elektrik',
hybrid: 'Hibrit',
'plug-in hybrid': 'Sarjli Hibrit',
phev: 'Sarjli Hibrit',
lpg: 'LPG',
cng: 'CNG',
hydrogen: 'Hidrojen',
gasoline: "Benzin",
petrol: "Benzin",
benzin: "Benzin",
diesel: "Dizel",
electric: "Elektrik",
hybrid: "Hibrit",
"plug-in hybrid": "Sarjli Hibrit",
phev: "Sarjli Hibrit",
lpg: "LPG",
cng: "CNG",
hydrogen: "Hidrojen",
} as Record<string, string>,
// Transmission types
transmissions: {
automatic: 'Otomatik',
manual: 'Manuel',
'semi-automatic': 'Yari Otomatik',
dct: 'Cift Kavramali',
cvt: 'CVT',
'dual clutch': 'Cift Kavramali',
dsg: 'DSG',
tiptronic: 'Tiptronic',
steptronic: 'Steptronic',
at: 'Otomatik',
mt: 'Manuel',
automatic: "Otomatik",
manual: "Manuel",
"semi-automatic": "Yari Otomatik",
dct: "Cift Kavramali",
cvt: "CVT",
"dual clutch": "Cift Kavramali",
dsg: "DSG",
tiptronic: "Tiptronic",
steptronic: "Steptronic",
at: "Otomatik",
mt: "Manuel",
} as Record<string, string>,
// Drive types
driveTypes: {
fwd: 'Ondan Cekis',
rwd: 'Arkadan Itis',
awd: 'Dort Ceker',
'4wd': 'Dort Ceker',
'4x4': 'Dort Ceker',
'front-wheel drive': 'Ondan Cekis',
'rear-wheel drive': 'Arkadan Itis',
'all-wheel drive': 'Dort Ceker',
quattro: 'Quattro (Dort Ceker)',
xdrive: 'xDrive (Dort Ceker)',
'4matic': '4MATIC (Dort Ceker)',
} as Record<string, string>,
// Common part categories
categories: {
engine: 'Motor',
brake: 'Fren Sistemi',
brakes: 'Fren Sistemi',
suspension: 'Suspansiyon',
steering: 'Direksiyon',
transmission: 'Sanziman',
exhaust: 'Egzoz Sistemi',
cooling: 'Sogutma Sistemi',
electrical: 'Elektrik Sistemi',
interior: 'Ic Aksam',
exterior: 'Dis Aksam',
body: 'Kaporta',
lighting: 'Aydinlatma',
lights: 'Aydinlatma',
wheels: 'Jantlar',
tires: 'Lastikler',
fuel: 'Yakit Sistemi',
'fuel system': 'Yakit Sistemi',
air: 'Hava Sistemi',
'air conditioning': 'Klima',
climate: 'Klima',
filters: 'Filtreler',
oil: 'Yag',
battery: 'Akku',
alternator: 'Alternator',
starter: 'Mars Motoru',
clutch: 'Debriyaj',
gearbox: 'Vites Kutusu',
axle: 'Aks',
differential: 'Diferansiyel',
driveshaft: 'Saft',
'cv joint': 'Aks Kafasi',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'control arm': 'Salincak',
shock: 'Amortisor',
'shock absorber': 'Amortisor',
spring: 'Yay',
strut: 'Makfersan',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'wheel bearing': 'Bilyali Rulman',
hub: 'Porya',
mirror: 'Ayna',
bumper: 'Tampon',
fender: 'Camurluk',
hood: 'Kaput',
bonnet: 'Kaput',
trunk: 'Bagaj',
boot: 'Bagaj',
door: 'Kapi',
window: 'Cam',
windshield: 'On Cam',
windscreen: 'On Cam',
wiper: 'Silecek',
headlight: 'Far',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
indicator: 'Sinyal Lambasi',
seat: 'Koltuk',
dashboard: 'Gosterge Paneli',
'steering wheel': 'Direksiyon Simidi',
pedal: 'Pedal',
carpet: 'Hali',
mat: 'Paspas',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
radiator: 'Radyator',
fan: 'Fan',
hose: 'Hortum',
belt: 'Kayis',
'timing belt': 'Eksantrik Kayisi',
'timing chain': 'Eksantrik Zinciri',
'serpentine belt': 'V Kayis',
gasket: 'Conta',
seal: 'Simir',
'o-ring': 'O-Ring',
sensor: 'Sensor',
oxygen: 'Oksijen Sensoru',
abs: 'ABS Sensoru',
airbag: 'Hava Yastigi',
horn: 'Korna',
relay: 'Role',
fuse: 'Sigorta',
switch: 'Dugme',
motor: 'Motor',
pump: 'Pompa',
compressor: 'Kompresor',
condenser: 'Kondenser',
evaporator: 'Evaporator',
heater: 'Isitici',
blower: 'Ufleyici',
'spark plug': 'Buji',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel filter': 'Yakit Filtresi',
'air filter': 'Hava Filtresi',
'oil filter': 'Yag Filtresi',
'cabin filter': 'Polen Filtresi',
'pollen filter': 'Polen Filtresi',
} as Record<string, string>,
// Common part names
parts: {
'oil filter': 'Yag Filtresi',
'air filter': 'Hava Filtresi',
'fuel filter': 'Yakit Filtresi',
'cabin filter': 'Polen Filtresi',
'spark plug': 'Buji',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
'timing belt': 'Eksantrik Kayisi',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
alternator: 'Alternator',
starter: 'Mars Motoru',
battery: 'Akku',
radiator: 'Radyator',
'shock absorber': 'Amortisor',
'control arm': 'Salincak',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'cv joint': 'Aks Kafasi',
clutch: 'Debriyaj',
'clutch kit': 'Debriyaj Seti',
flywheel: 'Volan',
'wheel bearing': 'Bilyali Rulman',
'hub bearing': 'Porya Rulmani',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'slave cylinder': 'Yardimci Merkez',
'brake hose': 'Fren Hortumu',
'brake line': 'Fren Borusu',
'abs sensor': 'ABS Sensoru',
'oxygen sensor': 'Oksijen Sensoru',
'crankshaft sensor': 'Krank Sensoru',
'camshaft sensor': 'Eksantrik Sensoru',
'coolant sensor': 'Su Isisi Sensoru',
'oil pressure sensor': 'Yag Basinci Sensoru',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel injector': 'Yakit Enjektoru',
gasket: 'Conta',
'head gasket': 'Silindir Kapagi Contasi',
'valve cover gasket': 'Kulbtor Kapagi Contasi',
'oil pan gasket': 'Karter Contasi',
'intake manifold gasket': 'Emme Manifoldu Contasi',
'exhaust manifold gasket': 'Egzoz Manifoldu Contasi',
'serpentine belt': 'V Kayis',
'drive belt': 'Tahrik Kayisi',
tensioner: 'Gerdirici',
'belt tensioner': 'Kayis Gerdirici',
idler: 'Avare',
'idler pulley': 'Avare Kasnak',
pulley: 'Kasnak',
'crankshaft pulley': 'Krank Kasnagi',
'power steering pump': 'Hidrolik Direksiyon Pompasi',
'power steering hose': 'Hidrolik Direksiyon Hortumu',
'steering rack': 'Kremayer',
'steering gear': 'Direksiyon Kutusu',
'cv boot': 'Aks Korfezi',
'drive shaft': 'Saft',
'axle shaft': 'Aks Mili',
'wheel hub': 'Porya',
'wheel stud': 'Bijon',
'lug nut': 'Bijon Somunu',
headlight: 'Far',
'headlight bulb': 'Far Ampulu',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
'fog light': 'Sis Lambasi',
mirror: 'Ayna',
'side mirror': 'Yan Ayna',
'rear view mirror': 'Ic Ayna',
wiper: 'Silecek',
'wiper blade': 'Silecek Lastigi',
'wiper motor': 'Silecek Motoru',
'window regulator': 'Cam Mekanizmasi',
'window motor': 'Cam Motoru',
'door handle': 'Kapi Kolu',
'door lock': 'Kapi Kilidi',
'door hinge': 'Kapi Mentesesi',
bumper: 'Tampon',
grille: 'Izgara',
hood: 'Kaput',
fender: 'Camurluk',
'splash guard': 'Paclama',
mudguard: 'Camurluk',
fwd: "Ondan Cekis",
rwd: "Arkadan Itis",
awd: "Dort Ceker",
"4wd": "Dort Ceker",
"4x4": "Dort Ceker",
"front-wheel drive": "Ondan Cekis",
"rear-wheel drive": "Arkadan Itis",
"all-wheel drive": "Dort Ceker",
quattro: "Quattro (Dort Ceker)",
xdrive: "xDrive (Dort Ceker)",
"4matic": "4MATIC (Dort Ceker)",
} as Record<string, string>,
};
// ==================== TRANSLATION HELPERS ====================
/**
* Translates a term to Turkish if available
*/
function translateToTurkish(
term: string | null | undefined,
dictionary: Record<string, string>,
@@ -297,52 +94,22 @@ function translateToTurkish(
return dictionary[normalized] || null;
}
/**
* Translates body type to Turkish
*/
export function translateBodyType(bodyType: string | null): string | null {
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
}
/**
* Translates engine type to Turkish
*/
export function translateEngineType(engineType: string | null): string | null {
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
}
/**
* Translates transmission type to Turkish
*/
export function translateTransmission(
transmission: string | null,
): string | null {
export function translateTransmission(transmission: string | null): string | null {
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
}
/**
* Translates drive type to Turkish
*/
export function translateDriveType(driveType: string | null): string | null {
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
}
/**
* Translates category name to Turkish
*/
export function translateCategoryName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.categories[normalized] || name;
}
/**
* Translates part name to Turkish
*/
export function translatePartName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.parts[normalized] || name;
}
// ==================== MAPPER FUNCTIONS ====================
/**
@@ -354,11 +121,11 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
// Get brand from catalog map or use the one from response
const wmi = response.vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || vehicle.brand || 'Unknown';
const brand = catalogEntry?.brand || vehicle.brand || "Unknown";
return {
brand: brand.toUpperCase(),
model: vehicle.model || 'Unknown',
model: vehicle.model || "Unknown",
year: vehicle.year || extractYearFromVin(response.vin),
series: vehicle.series || null,
bodyType: vehicle.bodyType || null,
@@ -379,15 +146,15 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
function extractYearFromVin(vin: string): number {
const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = {
'1': 2001,
'2': 2002,
'3': 2003,
'4': 2004,
'5': 2005,
'6': 2006,
'7': 2007,
'8': 2008,
'9': 2009,
"1": 2001,
"2": 2002,
"3": 2003,
"4": 2004,
"5": 2005,
"6": 2006,
"7": 2007,
"8": 2008,
"9": 2009,
A: 2010,
B: 2011,
C: 2012,
@@ -417,11 +184,9 @@ function extractYearFromVin(vin: string): number {
* Builds the raw response object for storage
* Includes category URLs for on-demand parts fetching
*/
function buildRawResponse(
response: EmexScraperResponse,
): Record<string, unknown> {
function buildRawResponse(response: EmexScraperResponse): Record<string, unknown> {
return {
source: 'emex', // Explicit source identifier for on-demand loading
source: "emex", // Explicit source identifier for on-demand loading
method: response.method,
vin: response.vin,
catalogCode: response.catalogCode,
@@ -432,28 +197,26 @@ function buildRawResponse(
message: response.message,
parsedOptions: response.parsedOptions,
rawResponse: response.rawResponse,
emexVehicleName: response.vehicle?.model || null,
emexLabel: response.vehicleLabel || null,
emexVid: response.vid || null,
emexPathData: response.pathData || null,
// Store category tree for hierarchical insertion (QuickGroups.aspx)
emexCategoryTree: response.categoryTree || [],
// Store flat category URLs for on-demand parts fetching (fallback)
emexCategories: response.categories?.map((cat) => ({
gid: cat.gid,
name: cat.name,
url: cat.url,
})) || [],
emexCategories:
response.categories?.map((cat) => ({
gid: cat.gid,
name: cat.name,
url: cat.url,
})) || [],
};
}
/**
* Maps EMEX categories to standardized DecodedCategory format
* NOTE: Parts are NOT included here - they will be fetched on-demand when user clicks a category
* Maps EMEX categories to standardized DecodedCategory format.
* nameTr is left null on purpose the consumer (categories.service.ts)
* runs nameEn through TranslationsService.translateMany() before insert.
* Parts are NOT included here; they are fetched on-demand when the user
* clicks a category.
*/
function mapCategories(
categories?: EmexCategory[],
): DecodedCategory[] {
function mapCategories(categories?: EmexCategory[]): DecodedCategory[] {
if (!categories || categories.length === 0) {
return [];
}
@@ -462,7 +225,7 @@ function mapCategories(
return {
code: cat.gid || `CAT_${index}`,
nameEn: cat.name,
nameTr: translateCategoryName(cat.name),
nameTr: undefined,
description: null,
iconName: deriveIconName(cat.name),
schemaImageUrl: null,
@@ -478,27 +241,27 @@ function deriveIconName(categoryName: string): string | null {
const normalized = categoryName.toLowerCase();
const iconMap: Record<string, string> = {
engine: 'engine',
motor: 'engine',
brake: 'brake',
brakes: 'brake',
suspension: 'suspension',
steering: 'steering',
transmission: 'transmission',
gearbox: 'transmission',
exhaust: 'exhaust',
cooling: 'cooling',
electrical: 'electrical',
interior: 'interior',
exterior: 'exterior',
body: 'body',
lighting: 'lighting',
lights: 'lighting',
wheels: 'wheels',
fuel: 'fuel',
air: 'air',
climate: 'climate',
filters: 'filters',
engine: "engine",
motor: "engine",
brake: "brake",
brakes: "brake",
suspension: "suspension",
steering: "steering",
transmission: "transmission",
gearbox: "transmission",
exhaust: "exhaust",
cooling: "cooling",
electrical: "electrical",
interior: "interior",
exterior: "exterior",
body: "body",
lighting: "lighting",
lights: "lighting",
wheels: "wheels",
fuel: "fuel",
air: "air",
climate: "climate",
filters: "filters",
};
for (const [key, icon] of Object.entries(iconMap)) {
@@ -513,16 +276,13 @@ function deriveIconName(categoryName: string): string | null {
/**
* Creates an empty/default DecodedVehicle for error cases
*/
export function createEmptyDecodedVehicle(
vin: string,
errorMessage?: string,
): DecodedVehicle {
export function createEmptyDecodedVehicle(vin: string, errorMessage?: string): DecodedVehicle {
const wmi = vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi];
return {
brand: catalogEntry?.brand?.toUpperCase() || 'UNKNOWN',
model: 'Unknown',
brand: catalogEntry?.brand?.toUpperCase() || "UNKNOWN",
model: "Unknown",
year: extractYearFromVin(vin),
series: null,
bodyType: null,
@@ -534,8 +294,8 @@ export function createEmptyDecodedVehicle(
colorCode: null,
raw: {
vin,
error: errorMessage || 'Vehicle data not found',
source: 'emexdwc.ae',
error: errorMessage || "Vehicle data not found",
source: "emexdwc.ae",
},
categories: [],
};

View File

@@ -9,30 +9,31 @@
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
*/
import * as path from "node:path";
import {
Injectable,
Logger,
BadRequestException,
ServiceUnavailableException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import { ProxyAgent, fetch as undiciFetch } from 'undici';
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ProxyAgent } from "undici";
import { RedisService } from "../../redis/redis.service";
import { EmexBrowserService } from "./emex.browser";
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
import {
EmexScraperResponse,
EmexCategoryTreeNode,
EmexPartsResult,
DecodedVehicle,
CATALOG_MAP,
} from './emex.types';
import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
import { EmexBrowserService } from './emex.browser';
import { RedisService } from '../../redis/redis.service';
type DecodedVehicle,
type EmexCategoryTreeNode,
type EmexPartsResult,
type EmexScraperResponse,
} from "./emex.types";
const EMEX_BASE_URL = 'https://emexdwc.ae';
const EMEX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const EMEX_BASE_URL = "https://emexdwc.ae";
const EMEX_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
interface EmexHttpVehicle {
label: string;
@@ -42,7 +43,6 @@ interface EmexHttpVehicle {
vid: string | null;
ssd: string | null;
quickGroupsUrl: string | null;
pathData: string | null;
}
interface EmexHttpCategory {
@@ -101,7 +101,7 @@ export class EmexService {
private readonly scraperPath: string;
private readonly timeout: number;
private readonly debug: boolean;
private readonly proxyUrl: string | null;
private readonly proxyAgent: ProxyAgent | null;
constructor(
private configService: ConfigService,
@@ -110,27 +110,29 @@ export class EmexService {
) {
// __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, '..', '..', '..', '..', '..');
const defaultPath = path.resolve(monorepoRoot, 'scripts/emex-vin-scraper.js');
this.scraperPath = this.configService.get<string>(
'EMEX_SCRAPER_PATH',
defaultPath,
);
const monorepoRoot = path.resolve(__dirname, "..", "..", "..", "..", "..");
const defaultPath = path.resolve(monorepoRoot, "scripts/emex-vin-scraper.js");
this.scraperPath = this.configService.get<string>("EMEX_SCRAPER_PATH", defaultPath);
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false);
this.timeout = this.configService.get<number>("EMEX_TIMEOUT", 60000);
this.debug = this.configService.get<boolean>("EMEX_DEBUG", false);
// Proxy config — same env vars as emex.browser.ts
const useProxy = this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
const useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
if (useProxy) {
const host = this.configService.get<string>('EMEX_PROXY_HOST', '') || '74.81.81.81';
const port = this.configService.get<number>('EMEX_PROXY_PORT_START', 10000) || 10000;
const user = this.configService.get<string>('EMEX_PROXY_USER', '') || '1726bbe361918676d44e';
const pass = this.configService.get<string>('EMEX_PROXY_PASS', '') || 'f11c7b6128cc86c6';
this.proxyUrl = `http://${user}:${pass}@${host}:${port}`;
const host = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
const portStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
const portEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
const user = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
const pass = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
this.proxyAgent = new ProxyAgent({
uri: `http://${user}:${pass}@${host}:${port}`,
connect: { timeout: 30000 },
requestTls: { timeout: 30000 },
});
this.logger.log(`EMEX HTTP proxy enabled: ${host}:${port}`);
} else {
this.proxyUrl = null;
this.proxyAgent = null;
}
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
@@ -165,7 +167,7 @@ export class EmexService {
try {
this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`);
const fs = require('fs');
const fs = require("node:fs");
if (!fs.existsSync(this.scraperPath)) {
this.logger.error(`Scraper file not found at: ${this.scraperPath}`);
this.logger.error(`Current working directory: ${process.cwd()}`);
@@ -177,17 +179,12 @@ export class EmexService {
// eslint-disable-next-line @typescript-eslint/no-var-requires
this.scraperModule = require(this.scraperPath) as EmexScraperModule;
this.logger.log('EMEX scraper module loaded successfully');
this.logger.log("EMEX scraper module loaded successfully");
this.isInitialized = true;
} catch (error) {
const err = error as Error;
this.logger.error(
`Failed to load EMEX scraper module: ${err.message}`,
err.stack,
);
throw new InternalServerErrorException(
'EMEX servis modulu yuklenemedi',
);
this.logger.error(`Failed to load EMEX scraper module: ${err.message}`, err.stack);
throw new InternalServerErrorException("EMEX servis modulu yuklenemedi");
}
}
@@ -202,7 +199,7 @@ export class EmexService {
await this.initializeScraper();
if (!this.scraperModule) {
throw new InternalServerErrorException('EMEX scraper modulu yuklenemedi');
throw new InternalServerErrorException("EMEX scraper modulu yuklenemedi");
}
const { page, release } = await this.browserService.acquirePage();
@@ -217,20 +214,18 @@ export class EmexService {
*/
private validateVin(vin: string): void {
if (!vin) {
throw new BadRequestException('VIN numarasi gereklidir');
throw new BadRequestException("VIN numarasi gereklidir");
}
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
if (cleanVin.length !== 17) {
throw new BadRequestException(
'VIN numarasi 17 karakter olmalidir',
);
throw new BadRequestException("VIN numarasi 17 karakter olmalidir");
}
if (/[IOQ]/i.test(cleanVin)) {
throw new BadRequestException(
'VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)',
"VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)",
);
}
}
@@ -243,19 +238,11 @@ export class EmexService {
* without requiring authentication cookies.
*/
private async fetchEmexHtml(url: string): Promise<string> {
const headers = { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' };
const signal = AbortSignal.timeout(this.timeout);
let res: Response;
if (this.proxyUrl) {
// Use undici's fetch which supports the dispatcher option for proxy
res = await undiciFetch(url, {
headers,
signal,
dispatcher: new ProxyAgent(this.proxyUrl),
}) as unknown as Response;
} else {
res = await fetch(url, { headers, signal });
}
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout),
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
} as RequestInit);
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
@@ -269,38 +256,28 @@ export class EmexService {
const linkRx = /href="(Vehicle\.aspx\?[^"]+)">([^<]+)<\/a>/g;
const seen = new Set<string>();
const vehicles: EmexHttpVehicle[] = [];
let m: RegExpExecArray | null;
while ((m = linkRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, '&');
for (const m of html.matchAll(linkRx)) {
const href = m[1].replace(/&amp;/g, "&");
if (seen.has(href)) continue;
seen.add(href);
const label = m[2].trim();
const params = new URLSearchParams(href.replace('Vehicle.aspx?', ''));
const c = params.get('c');
const vid = params.get('vid');
const ssd = params.get('ssd');
const rawPathData = params.get('path_data');
let pathData: string | null = null;
if (rawPathData) {
try {
pathData = Buffer.from(rawPathData, 'base64').toString('utf-8');
} catch {
pathData = rawPathData;
}
}
const params = new URLSearchParams(href.replace("Vehicle.aspx?", ""));
const c = params.get("c");
const vid = params.get("vid");
const ssd = params.get("ssd");
const modelMatch = label.match(/^([^\[]+)/);
const yearMatch = label.match(/\((\d{4})/);
vehicles.push({
label,
model: modelMatch ? modelMatch[1].trim() : label,
yearFrom: yearMatch ? parseInt(yearMatch[1], 10) : null,
yearFrom: yearMatch ? Number.parseInt(yearMatch[1], 10) : null,
catalogCode: c,
vid,
ssd,
quickGroupsUrl: c && vid != null && ssd
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
: null,
pathData,
quickGroupsUrl:
c && vid != null && ssd
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
: null,
});
}
return vehicles;
@@ -313,14 +290,13 @@ export class EmexService {
const catRx = /href="(QuickDetails\.aspx\?[^"]+)">([^<]+)<\/a>/g;
const seen = new Set<string>();
const cats: EmexHttpCategory[] = [];
let m: RegExpExecArray | null;
while ((m = catRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, '&');
for (const m of html.matchAll(catRx)) {
const href = m[1].replace(/&amp;/g, "&");
const name = m[2].trim();
if (name.length < 2 || seen.has(href)) continue;
seen.add(href);
const params = new URLSearchParams(href.replace('QuickDetails.aspx?', ''));
cats.push({ gid: params.get('gid'), name, url: `${EMEX_BASE_URL}/${href}` });
const params = new URLSearchParams(href.replace("QuickDetails.aspx?", ""));
cats.push({ gid: params.get("gid"), name, url: `${EMEX_BASE_URL}/${href}` });
}
return cats;
}
@@ -332,23 +308,25 @@ export class EmexService {
if (!c) return null;
const upper = c.toUpperCase();
const prefixes: [string, string][] = [
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['MBS', 'Smart'],
['AU', 'Audi'], ['VW', 'Volkswagen'],
['FFIAT', 'Fiat'], ['CFIAT', 'Abarth'], ['RFIAT', 'Alfa Romeo'],
['LFIAT', 'Lancia'], ['TFIAT', 'Fiat'],
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['DACIA', 'Dacia'],
['TOYOTA', 'Toyota'], ['LEXUS', 'Lexus'],
['HONDA', 'Honda'], ['KIA', 'Kia'], ['HYUNDAI', 'Hyundai'],
['PO', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'],
['MMC', 'Mitsubishi'], ['NISSAN', 'Nissan'], ['INFINITI', 'Infiniti'],
['PEUGEOT', 'Peugeot'], ['CITROEN', 'Citroen'],
['VOLVO', 'Volvo'], ['JAGUAR', 'Jaguar'], ['LRE', 'Land Rover'],
['MINI', 'Mini'], ['RR', 'Rolls-Royce'],
['GM_OP', 'Opel'], ['GM_VX', 'Vauxhall'], ['GM_C', 'Chevrolet'],
['GM_B', 'Buick'], ['GM_K', 'Cadillac'], ['GM_G', 'GMC'],
['SK', 'Skoda'], ['SE', 'Seat'], ['SY', 'SsangYong'],
['ISUZU', 'Isuzu'], ['SUZUKI', 'Suzuki'],
['CHRYSLER', 'Chrysler'], ['DODGE', 'Dodge'], ['JEEP', 'Jeep'], ['RAM', 'Ram'],
["BMW", "BMW"],
["MB", "Mercedes-Benz"],
["AU", "Audi"],
["VW", "Volkswagen"],
["FFIAT", "Fiat"],
["RFIAT", "Alfa Romeo"],
["FORD", "Ford"],
["RENAULT", "Renault"],
["TOYOTA", "Toyota"],
["HONDA", "Honda"],
["KIA", "Kia"],
["HYUNDAI", "Hyundai"],
["PORSCHE", "Porsche"],
["SUBARU", "Subaru"],
["MAZDA", "Mazda"],
["CPSA", "Citroën/Peugeot"],
["VOLVO", "Volvo"],
["NISSAN", "Nissan"],
["OPEL", "Opel"],
];
for (const [prefix, brand] of prefixes) {
if (upper.startsWith(prefix)) return brand;
@@ -378,7 +356,7 @@ export class EmexService {
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
let categories: EmexHttpCategory[] = [];
@@ -395,14 +373,11 @@ export class EmexService {
// Build a response compatible with mapEmexResponse
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
source: "emexdwc.ae",
method: "vin_url",
vin,
catalogCode: v.catalogCode || '',
catalogCode: v.catalogCode || "",
ssd: v.ssd || undefined,
vehicleLabel: v.label,
vid: v.vid || undefined,
pathData: v.pathData || undefined,
vehicle: {
brand,
model: v.model,
@@ -415,7 +390,7 @@ export class EmexService {
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
@@ -430,43 +405,45 @@ export class EmexService {
* - `{ type: 'notFound' }` — VIN not in EMEX
* - `{ type: 'error' }` — fetch failed
*/
async decodeVinOrCandidates(vin: string): Promise<
| { type: 'vehicle'; vehicle: DecodedVehicle }
| { type: 'candidates'; candidates: EmexCandidate[] }
| { type: 'notFound' }
| { type: 'error' }
async decodeVinOrCandidates(
vin: string,
): Promise<
| { type: "vehicle"; vehicle: DecodedVehicle }
| { type: "candidates"; candidates: EmexCandidate[] }
| { type: "notFound" }
| { type: "error" }
> {
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const html = await this.fetchEmexHtml(vinUrl);
const vehicleList = this.parseVehiclesList(html);
if (vehicleList.length === 0) return { type: 'notFound' };
if (vehicleList.length === 0) return { type: "notFound" };
if (vehicleList.length > 1) {
const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
const params: Array<{ key: string; idx: string; value: string }> = [];
if (v.yearFrom) params.push({ key: 'year', idx: '0', value: String(v.yearFrom) });
if (v.catalogCode) params.push({ key: 'catalog', idx: '1', value: v.catalogCode });
if (v.yearFrom) params.push({ key: "year", idx: "0", value: String(v.yearFrom) });
if (v.catalogCode) params.push({ key: "catalog", idx: "1", value: v.catalogCode });
return {
id: String(i),
name: v.label,
parameters: params,
catalogId: v.catalogCode || '',
catalogId: v.catalogCode || "",
_index: i,
_quickGroupsUrl: v.quickGroupsUrl,
_ssd: v.ssd,
_vid: v.vid,
};
});
return { type: 'candidates', candidates };
return { type: "candidates", candidates };
}
// Single result — decode directly
const v = vehicleList[0];
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
@@ -481,14 +458,11 @@ export class EmexService {
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
source: "emexdwc.ae",
method: "vin_url",
vin,
catalogCode: v.catalogCode || '',
catalogCode: v.catalogCode || "",
ssd: v.ssd || undefined,
vehicleLabel: v.label,
vid: v.vid || undefined,
pathData: v.pathData || undefined,
vehicle: {
brand,
model: v.model,
@@ -501,15 +475,15 @@ export class EmexService {
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return { type: 'vehicle', vehicle: mapEmexResponse(response) };
return { type: "vehicle", vehicle: mapEmexResponse(response) };
} catch (err) {
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
return { type: 'error' };
return { type: "error" };
}
}
@@ -524,7 +498,9 @@ export class EmexService {
const vehicleList = this.parseVehiclesList(vinHtml);
if (index < 0 || index >= vehicleList.length) {
this.logger.warn(`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`);
this.logger.warn(
`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`,
);
return null;
}
@@ -533,7 +509,7 @@ export class EmexService {
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || "Unknown";
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
@@ -541,20 +517,19 @@ export class EmexService {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml);
} catch (err) {
this.logger.warn(`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`);
this.logger.warn(
`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`,
);
}
}
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
source: "emexdwc.ae",
method: "vin_url",
vin,
catalogCode: v.catalogCode || '',
catalogCode: v.catalogCode || "",
ssd: v.ssd || undefined,
vehicleLabel: v.label,
vid: v.vid || undefined,
pathData: v.pathData || undefined,
vehicle: {
brand,
model: v.model,
@@ -567,7 +542,7 @@ export class EmexService {
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categories: categories.map((c) => ({ gid: c.gid || "", name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
@@ -585,7 +560,7 @@ export class EmexService {
* Fallback: Playwright browser scraper (slower, used if HTTP fails).
*/
async decodeVin(vin: string): Promise<DecodedVehicle> {
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
this.validateVin(cleanVin);
@@ -597,13 +572,11 @@ export class EmexService {
try {
const result = await this.decodeVinHttp(cleanVin);
if (result) {
this.logger.log(
`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`,
);
this.logger.log(`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`);
return result;
}
// VIN not in EMEX — return empty rather than hitting browser
return createEmptyDecodedVehicle(cleanVin, 'Vehicle not found in EMEX database');
return createEmptyDecodedVehicle(cleanVin, "Vehicle not found in EMEX database");
} catch (httpErr) {
const err = httpErr as Error;
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
@@ -617,28 +590,18 @@ export class EmexService {
const scraper = instance.scraper;
release = instance.release;
const response = await this.executeWithTimeout(
scraper.searchByVIN(cleanVin),
this.timeout,
);
const response = await this.executeWithTimeout(scraper.searchByVIN(cleanVin), this.timeout);
if (this.debug) {
this.logger.debug(
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
);
this.logger.debug(`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`);
}
if (!response.success) {
this.logger.warn(
`EMEX browser search unsuccessful: ${response.message || response.error}`,
);
if (response.vehicle && response.vehicle.brand) {
this.logger.warn(`EMEX browser search unsuccessful: ${response.message || response.error}`);
if (response.vehicle?.brand) {
return mapEmexResponse(response);
}
return createEmptyDecodedVehicle(
cleanVin,
response.message || response.error,
);
return createEmptyDecodedVehicle(cleanVin, response.message || response.error);
}
const decodedVehicle = mapEmexResponse(response);
@@ -657,17 +620,15 @@ export class EmexService {
throw err;
}
if (err.message?.includes('timeout') || err.name === 'TimeoutError') {
if (err.message?.includes("timeout") || err.name === "TimeoutError") {
this.logger.error(`VIN decode timeout for: ${cleanVin}`);
throw new ServiceUnavailableException(
'EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.',
"EMEX servisi zaman asimina ugradi. Lutfen tekrar deneyin.",
);
}
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'VIN sorgulama sirasinda bir hata olustu',
);
throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu");
} finally {
if (release) {
try {
@@ -683,52 +644,27 @@ export class EmexService {
/**
* Executes a promise with timeout
*/
private async executeWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timeoutId: NodeJS.Timeout;
private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`Operation timed out after ${timeoutMs}ms`);
error.name = 'TimeoutError';
error.name = "TimeoutError";
reject(error);
}, timeoutMs);
});
try {
const result = await Promise.race([promise, timeoutPromise]);
clearTimeout(timeoutId!);
if (timeoutId) clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId!);
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
}
/**
* Fetch categories from QuickGroups.aspx using catalog code + SSD.
* Used as a shortcut when wizard identifies the model but DB has no match.
* Returns flat category list with gid/name/url, or empty array on failure.
*/
async fetchQuickGroupsBySsd(
catalogCode: string,
ssd: string,
): Promise<Array<{ gid: string | null; name: string; url: string }>> {
const url = `${EMEX_BASE_URL}/QuickGroups.aspx?c=${catalogCode}&vid=0&ssd=${encodeURIComponent(ssd)}`;
this.logger.log(`EMEX QuickGroups shortcut: ${url.slice(0, 100)}...`);
try {
const html = await this.fetchEmexHtml(url);
const cats = this.parseCategoryList(html);
this.logger.log(`EMEX QuickGroups shortcut: ${cats.length} categories`);
return cats;
} catch (err) {
this.logger.warn(`EMEX QuickGroups shortcut failed: ${(err as Error).message}`);
return [];
}
}
/**
* Gets the catalog code for a VIN
*/
@@ -764,7 +700,7 @@ export class EmexService {
*/
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> {
if (!categoryUrl) {
this.logger.warn('fetchCategoryParts called with empty URL');
this.logger.warn("fetchCategoryParts called with empty URL");
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
}
@@ -778,10 +714,7 @@ export class EmexService {
const scraper = instance.scraper;
release = instance.release;
const result = await this.executeWithTimeout(
scraper.getParts(categoryUrl),
this.timeout,
);
const result = await this.executeWithTimeout(scraper.getParts(categoryUrl), this.timeout);
if (result && result.parts.length > 0) {
this.logger.log(`Fetched ${result.parts.length} parts from category`);
@@ -818,15 +751,15 @@ export class EmexService {
const yearChar = vin.charAt(9).toUpperCase();
const yearMap: Record<string, number> = {
'1': 2001,
'2': 2002,
'3': 2003,
'4': 2004,
'5': 2005,
'6': 2006,
'7': 2007,
'8': 2008,
'9': 2009,
"1": 2001,
"2": 2002,
"3": 2003,
"4": 2004,
"5": 2005,
"6": 2006,
"7": 2007,
"8": 2008,
"9": 2009,
A: 2010,
B: 2011,
C: 2012,

View File

@@ -126,7 +126,7 @@ export interface EmexWizardOption {
export interface EmexScraperResponse {
success: boolean;
source: string;
method: 'api' | 'vin_url' | 'wizard' | 'html_parse' | 'fallback';
method: "api" | "vin_url" | "wizard" | "html_parse" | "fallback";
vin: string;
catalogCode: string;
ssd?: string;
@@ -235,102 +235,102 @@ export interface CatalogEntry {
*/
export const CATALOG_MAP: Record<string, CatalogEntry> = {
// BMW
WBA: { code: 'BMW202501', brand: 'BMW' },
WBS: { code: 'BMW202501', brand: 'BMW' },
WBY: { code: 'BMW202501', brand: 'BMW' },
WBA: { code: "BMW202501", brand: "BMW" },
WBS: { code: "BMW202501", brand: "BMW" },
WBY: { code: "BMW202501", brand: "BMW" },
// Mercedes-Benz
WDB: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDD: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDC: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDF: { code: 'MB201810', brand: 'Mercedes-Benz' },
WDB: { code: "MB201810", brand: "Mercedes-Benz" },
WDD: { code: "MB201810", brand: "Mercedes-Benz" },
WDC: { code: "MB201810", brand: "Mercedes-Benz" },
WDF: { code: "MB201810", brand: "Mercedes-Benz" },
// Audi
WAU: { code: 'AU1587', brand: 'Audi' },
TRU: { code: 'AU1587', brand: 'Audi' },
WAU: { code: "AU1587", brand: "Audi" },
TRU: { code: "AU1587", brand: "Audi" },
// Volkswagen
WVW: { code: 'VW1587', brand: 'Volkswagen' },
WVG: { code: 'VW1587', brand: 'Volkswagen' },
WV2: { code: 'VW1587', brand: 'Volkswagen' },
WVW: { code: "VW1587", brand: "Volkswagen" },
WVG: { code: "VW1587", brand: "Volkswagen" },
WV2: { code: "VW1587", brand: "Volkswagen" },
// Renault
VF1: { code: 'RENAULT201910', brand: 'Renault' },
VF1: { code: "RENAULT201910", brand: "Renault" },
// Peugeot
VF3: { code: 'PEUGEOT00', brand: 'Peugeot' },
VF3: { code: "PEUGEOT00", brand: "Peugeot" },
// Citroen/Peugeot (VF7 shared — Peugeot more common)
VF7: { code: 'PEUGEOT00', brand: 'Peugeot' },
VF7: { code: "PEUGEOT00", brand: "Peugeot" },
// Fiat
ZFA: { code: 'FFIAT84', brand: 'Fiat' },
ZFA: { code: "FFIAT84", brand: "Fiat" },
// Alfa Romeo
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
ZAR: { code: "RFIAT84", brand: "Alfa Romeo" },
// Ford
WF0: { code: 'FORD202201', brand: 'Ford' },
NM0: { code: 'FORD202201', brand: 'Ford' },
WF0: { code: "FORD202201", brand: "Ford" },
NM0: { code: "FORD202201", brand: "Ford" },
// Toyota
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
JTN: { code: 'TOYOTA00', brand: 'Toyota' },
JTD: { code: "TOYOTA00", brand: "Toyota" },
JTE: { code: "TOYOTA00", brand: "Toyota" },
JTN: { code: "TOYOTA00", brand: "Toyota" },
// Lexus
JTH: { code: 'LEXUS00', brand: 'Lexus' },
JTJ: { code: 'LEXUS00', brand: 'Lexus' },
JTH: { code: "LEXUS00", brand: "Lexus" },
JTJ: { code: "LEXUS00", brand: "Lexus" },
// Honda
SHH: { code: 'HONDA2017', brand: 'Honda' },
SHH: { code: "HONDA2017", brand: "Honda" },
// Hyundai
KMH: { code: 'HYUNDAI202404', brand: 'Hyundai' },
KNM: { code: 'HYUNDAI202404', brand: 'Hyundai' },
KMH: { code: "HYUNDAI202404", brand: "Hyundai" },
KNM: { code: "HYUNDAI202404", brand: "Hyundai" },
// Kia
KNA: { code: 'KIA202404', brand: 'Kia' },
KNE: { code: 'KIA202404', brand: 'Kia' },
KNA: { code: "KIA202404", brand: "Kia" },
KNE: { code: "KIA202404", brand: "Kia" },
// Porsche
WP0: { code: 'PO799', brand: 'Porsche' },
WP1: { code: 'PO799', brand: 'Porsche' },
WP0: { code: "PO799", brand: "Porsche" },
WP1: { code: "PO799", brand: "Porsche" },
// Subaru
JF1: { code: 'SUBARU201802', brand: 'Subaru' },
JF2: { code: 'SUBARU201802', brand: 'Subaru' },
JF1: { code: "SUBARU201802", brand: "Subaru" },
JF2: { code: "SUBARU201802", brand: "Subaru" },
// Mazda
JMZ: { code: 'MAZDA2020', brand: 'Mazda' },
JM1: { code: 'MAZDA2020', brand: 'Mazda' },
JM3: { code: 'MAZDA2020', brand: 'Mazda' },
JMZ: { code: "MAZDA2020", brand: "Mazda" },
JM1: { code: "MAZDA2020", brand: "Mazda" },
JM3: { code: "MAZDA2020", brand: "Mazda" },
// Mitsubishi
JMY: { code: 'MMC202501', brand: 'Mitsubishi' },
JMB: { code: 'MMC202501', brand: 'Mitsubishi' },
JA3: { code: 'MMC202501', brand: 'Mitsubishi' },
JA4: { code: 'MMC202501', brand: 'Mitsubishi' },
JA7: { code: 'MMC202501', brand: 'Mitsubishi' },
JMY: { code: "MMC202501", brand: "Mitsubishi" },
JMB: { code: "MMC202501", brand: "Mitsubishi" },
JA3: { code: "MMC202501", brand: "Mitsubishi" },
JA4: { code: "MMC202501", brand: "Mitsubishi" },
JA7: { code: "MMC202501", brand: "Mitsubishi" },
// Nissan
JN1: { code: 'NISSAN201809', brand: 'Nissan' },
JN8: { code: 'NISSAN201809', brand: 'Nissan' },
VSK: { code: 'NISSAN201809', brand: 'Nissan' },
JN1: { code: "NISSAN201809", brand: "Nissan" },
JN8: { code: "NISSAN201809", brand: "Nissan" },
VSK: { code: "NISSAN201809", brand: "Nissan" },
// Volvo
YV1: { code: 'VOLVO201410', brand: 'Volvo' },
YV4: { code: 'VOLVO201410', brand: 'Volvo' },
YV1: { code: "VOLVO201410", brand: "Volvo" },
YV4: { code: "VOLVO201410", brand: "Volvo" },
// MINI
WMW: { code: 'MINI202501', brand: 'Mini' },
WMW: { code: "MINI202501", brand: "Mini" },
// Jaguar
SAJ: { code: 'JAGUAR201701', brand: 'Jaguar' },
SAJ: { code: "JAGUAR201701", brand: "Jaguar" },
// Land Rover
SAL: { code: 'LRE201412', brand: 'Land Rover' },
SAL: { code: "LRE201412", brand: "Land Rover" },
// Skoda
TMB: { code: 'SK1119', brand: 'Skoda' },
TMB: { code: "SK1119", brand: "Skoda" },
// SEAT
VSS: { code: 'SE1113', brand: 'Seat' },
VSS: { code: "SE1113", brand: "Seat" },
// Dacia
UU1: { code: 'DACIA201910', brand: 'Dacia' },
UU1: { code: "DACIA201910", brand: "Dacia" },
// Suzuki
JSA: { code: 'SUZUKI201905', brand: 'Suzuki' },
TSM: { code: 'SUZUKI201905', brand: 'Suzuki' },
JSA: { code: "SUZUKI201905", brand: "Suzuki" },
TSM: { code: "SUZUKI201905", brand: "Suzuki" },
// Isuzu
JAA: { code: 'ISUZU201702', brand: 'Isuzu' },
JAA: { code: "ISUZU201702", brand: "Isuzu" },
// Opel
W0L: { code: 'GM_OP201809', brand: 'Opel' },
W0L: { code: "GM_OP201809", brand: "Opel" },
// Chevrolet
KL1: { code: 'GM_C201809', brand: 'Chevrolet' },
KL1: { code: "GM_C201809", brand: "Chevrolet" },
// SsangYong
KPT: { code: 'SY201502', brand: 'SsangYong' },
KPT: { code: "SY201502", brand: "SsangYong" },
// Chrysler/Jeep/Dodge/RAM
'1C4': { code: 'JEEP202402', brand: 'Jeep' },
'3C4': { code: 'CHRYSLER202402', brand: 'Chrysler' },
"1C4": { code: "JEEP202402", brand: "Jeep" },
"3C4": { code: "CHRYSLER202402", brand: "Chrysler" },
// Rolls-Royce
SCA: { code: 'RR202501', brand: 'Rolls-Royce' },
SCA: { code: "RR202501", brand: "Rolls-Royce" },
// Smart
WME: { code: 'MBS201810', brand: 'Smart' },
WME: { code: "MBS201810", brand: "Smart" },
// Infiniti
JNK: { code: 'INFINITI201809', brand: 'Infiniti' },
JNK: { code: "INFINITI201809", brand: "Infiniti" },
};