feat: EMEX HTTP decode + multi-candidate selection UI
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled

Replace Playwright browser with pure HTTP for EMEX VIN decode:
- Vehicles.aspx, QuickGroups.aspx, QuickDetails.aspx all work
  without authentication — no session/browser needed
- VIN decode drops from ~10-20s (Playwright) to ~1-5s (HTTP)
- Playwright browser kept as fallback if HTTP fails

Add multi-candidate selection for EMEX (mirrors PartsCatalogs flow):
- EmexCandidate interface matches PcatCar shape so VehicleSelectModal
  renders without changes
- decodeVinOrCandidates(): single HTTP fetch returns vehicle OR
  candidates (avoids double Vehicles.aspx request)
- decodeVinByIndex(): resolves user-selected candidate by index
- vehicles.service: emexCandidates in VinResolveResult, emexCarIndex
  param in decodeVin/resolveVin, resolveEmexCarByIndex() method
- vehicles.controller: @Body("emexCarIndex") accepted
- search.tsx: candidateSource state, handleCandidateSelect sends
  emexCarIndex or pcatCarId based on source

Test VIN NM417800006410193 (Fiat SIENA) now shows 2-option modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-05 09:46:25 +00:00
parent f29a923e6e
commit ce8784014e
5 changed files with 610 additions and 83 deletions

View File

@@ -2,11 +2,11 @@
* EMEX VIN Service
*
* NestJS service for emexdwc.ae VIN integration.
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
* and provides standardized DecodedVehicle responses.
* Primary path: pure HTTP fetch (no browser) — fast (~1-5s).
* Fallback: Playwright scraper via EmexBrowserService.
*
* Uses EmexBrowserService for a persistent singleton browser —
* each request gets a pre-created Page (tab) via acquirePage().
* emexdwc.ae does NOT require authentication for Vehicles.aspx,
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
*/
import {
@@ -30,6 +30,42 @@ import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
import { EmexBrowserService } from './emex.browser';
import { RedisService } from '../../redis/redis.service';
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;
model: string;
yearFrom: number | null;
catalogCode: string | null;
vid: string | null;
ssd: string | null;
quickGroupsUrl: string | null;
}
interface EmexHttpCategory {
gid: string | null;
name: string;
url: string;
}
/**
* Candidate vehicle returned when EMEX finds multiple matches for a VIN.
* Shape matches PcatCar so VehicleSelectModal can render without changes.
*/
export interface EmexCandidate {
/** String index into the Vehicles.aspx list — "0", "1", etc. */
id: string;
name: string;
parameters?: Array<{ key: string; idx: string; value: string }>;
catalogId: string;
/** Internal: used by resolveEmexCarByIndex to fetch the specific vehicle */
_index: number;
_quickGroupsUrl: string | null;
_ssd: string | null;
_vid: string | null;
}
// Type definition for the imported scraper module
interface EmexScraperModule {
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
@@ -183,10 +219,316 @@ export class EmexService {
}
}
// ─── HTTP-based methods (no browser required) ────────────────
/**
* 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.
* Fetch a URL from emexdwc.ae without a browser session.
* emexdwc.ae serves Vehicles.aspx, QuickGroups.aspx, and QuickDetails.aspx
* without requiring authentication cookies.
*/
private async fetchEmexHtml(url: string): Promise<string> {
const res = await fetch(url, {
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(this.timeout),
});
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
return res.text();
}
/**
* Parse Vehicles.aspx HTML — extract vehicle list from "Found vehicles" table.
*/
private parseVehiclesList(html: string): EmexHttpVehicle[] {
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, '&');
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 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,
catalogCode: c,
vid,
ssd,
quickGroupsUrl: c && vid != null && ssd
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
: null,
});
}
return vehicles;
}
/**
* Parse QuickGroups.aspx HTML — extract flat category list (QuickDetails links).
*/
private parseCategoryList(html: string): EmexHttpCategory[] {
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, '&');
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}` });
}
return cats;
}
/**
* Determine brand name from EMEX catalog code (e.g. "BMW202501" → "BMW").
*/
private brandFromCatalogCode(c: string | null): string | null {
if (!c) return null;
const upper = c.toUpperCase();
const prefixes: [string, string][] = [
['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;
}
return null;
}
/**
* VIN decode via pure HTTP (primary path — no browser needed).
* Returns null if VIN is not found in EMEX database.
*/
private async decodeVinHttp(vin: string): Promise<DecodedVehicle | null> {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
this.logger.log(`EMEX HTTP: fetching ${vinUrl}`);
const vinHtml = await this.fetchEmexHtml(vinUrl);
const vehicles = this.parseVehiclesList(vinHtml);
if (vehicles.length === 0) {
this.logger.log(`EMEX HTTP: no vehicles found for VIN ${vin}`);
return null;
}
const v = vehicles[0];
this.logger.log(`EMEX HTTP: found "${v.label}" (c=${v.catalogCode})`);
// 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';
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
try {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml);
this.logger.log(`EMEX HTTP: found ${categories.length} categories`);
} catch (err) {
this.logger.warn(`EMEX HTTP: category fetch failed: ${(err as Error).message}`);
}
}
// Build a response compatible with mapEmexResponse
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
vin,
catalogCode: v.catalogCode || '',
ssd: v.ssd || undefined,
vehicle: {
brand,
model: v.model,
year: v.yearFrom,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return mapEmexResponse(response);
}
/**
* Single-fetch combined decode: fetches Vehicles.aspx once and returns either:
* - `{ type: 'vehicle', vehicle }` — single match decoded as DecodedVehicle
* - `{ type: 'candidates', candidates }` — multiple matches, caller shows selection UI
* - `{ 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' }
> {
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 > 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 });
return {
id: String(i),
name: v.label,
parameters: params,
catalogId: v.catalogCode || '',
_index: i,
_quickGroupsUrl: v.quickGroupsUrl,
_ssd: v.ssd,
_vid: v.vid,
};
});
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';
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
try {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml);
this.logger.log(`EMEX HTTP: found ${categories.length} categories`);
} catch (err) {
this.logger.warn(`EMEX HTTP category fetch failed: ${(err as Error).message}`);
}
}
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
vin,
catalogCode: v.catalogCode || '',
ssd: v.ssd || undefined,
vehicle: {
brand,
model: v.model,
year: v.yearFrom,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return { type: 'vehicle', vehicle: mapEmexResponse(response) };
} catch (err) {
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
return { type: 'error' };
}
}
/**
* Decodes a specific EMEX candidate by its index in the Vehicles.aspx list.
* Used after the user selects a vehicle from the multi-candidate modal.
*/
async decodeVinByIndex(vin: string, index: number): Promise<DecodedVehicle | null> {
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const vinHtml = await this.fetchEmexHtml(vinUrl);
const vehicleList = this.parseVehiclesList(vinHtml);
if (index < 0 || index >= vehicleList.length) {
this.logger.warn(`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`);
return null;
}
const v = vehicleList[index];
this.logger.log(`EMEX decodeVinByIndex[${index}]: "${v.label}" (c=${v.catalogCode})`);
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
try {
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}`);
}
}
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
vin,
catalogCode: v.catalogCode || '',
ssd: v.ssd || undefined,
vehicle: {
brand,
model: v.model,
year: v.yearFrom,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return mapEmexResponse(response);
} catch (err) {
this.logger.warn(`EMEX decodeVinByIndex failed: ${(err as Error).message}`);
return null;
}
}
/**
* Decodes a VIN number.
* Primary: pure HTTP fetch (fast, ~1-5s).
* 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, '');
@@ -197,6 +539,23 @@ export class EmexService {
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
await this.touchActivity();
// ── Primary: HTTP (no browser) ──────────────────────────────
try {
const result = await this.decodeVinHttp(cleanVin);
if (result) {
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');
} catch (httpErr) {
const err = httpErr as Error;
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
}
// ── Fallback: Playwright browser scraper ────────────────────
let release: (() => Promise<void>) | null = null;
try {
@@ -211,69 +570,27 @@ export class EmexService {
if (this.debug) {
this.logger.debug(
`EMEX raw response: ${JSON.stringify(response, null, 2)}`,
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
);
}
if (!response.success) {
this.logger.warn(
`EMEX search unsuccessful: ${response.message || response.error}`,
`EMEX browser search unsuccessful: ${response.message || response.error}`,
);
if (response.vehicle && response.vehicle.brand) {
return mapEmexResponse(response);
}
return createEmptyDecodedVehicle(
cleanVin,
response.message || response.error,
);
}
// Try category tree from QuickGroups.aspx (hierarchical)
if (response.quickGroupsUrl) {
this.logger.log(`Fetching category tree from QuickGroups.aspx: ${response.quickGroupsUrl}`);
try {
const tree = await this.executeWithTimeout(
scraper.getCategoryTree(response.quickGroupsUrl),
this.timeout,
);
if (tree && tree.length > 0) {
this.logger.log(`Found ${tree.length} top-level category groups`);
response.categoryTree = tree;
}
} catch (treeError) {
const err = treeError as Error;
this.logger.warn(`Failed to fetch category tree: ${err.message}`);
}
}
// Fall back to flat categories if tree failed
if (!response.categoryTree?.length && response.quickGroupsUrl) {
this.logger.log(`Falling back to flat categories from: ${response.quickGroupsUrl}`);
try {
const categories = await this.executeWithTimeout(
scraper.getCategories(response.quickGroupsUrl),
this.timeout,
);
if (categories && categories.length > 0) {
this.logger.log(`Found ${categories.length} flat categories (on-demand parts loading enabled)`);
response.categories = categories;
}
} catch (catError) {
const err = catError as Error;
this.logger.warn(`Failed to fetch categories: ${err.message}`);
}
}
const decodedVehicle = mapEmexResponse(response);
this.logger.log(
`VIN decoded successfully: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
`EMEX browser decode OK: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
);
return decodedVehicle;
} catch (error) {
const err = error as Error;
@@ -293,17 +610,6 @@ export class EmexService {
);
}
if (
err.message?.includes('browser') ||
err.message?.includes('playwright') ||
err.message?.includes('navigation')
) {
this.logger.error(`Browser error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'EMEX servisine baglanamadi. Lutfen daha sonra tekrar deneyin.',
);
}
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'VIN sorgulama sirasinda bir hata olustu',