feat: EMEX VIN decode → catalog DB matching via pathData/SSD
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Parse path_data from Vehicle.aspx URLs (Base64-decoded vehicle name), persist vehicleLabel/vid/pathData in rawData, and add 4-step matching cascade for category resolution: 1. pathData name → matchByName (DB-only, ~50ms) 2. emexVehicleName → matchByName (DB-only, ~50ms) 3. ssd + catalogCode → matchBySsd (wizard HTTP + DB, ~2-3s) 4. Fallback → on-demand EMEX scrape (unchanged, ~6-13s) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -393,6 +393,108 @@ export class EmexCatalogService {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a VIN-decoded vehicle to DB using wizard API flow.
|
||||
* Fetches Vehicles.aspx?ft=findByWizard2 to get motor/variant-level names,
|
||||
* then matches them against emex_vehicles in DB.
|
||||
*/
|
||||
async matchBySsd(catalogCode: string, ssd: string, pathData?: string): Promise<EmexVehicleMatch | null> {
|
||||
const cacheKey = `emex:matchssd:${catalogCode}:${Buffer.from(ssd).toString("base64url").slice(0, 32)}`;
|
||||
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
// Fetch vehicle list from wizard SSD via Vehicles.aspx
|
||||
const params = new URLSearchParams({ ft: "findByWizard2", c: catalogCode, ssd });
|
||||
const url = `${EmexCatalogService.EMEX_BASE}/Vehicles.aspx?${params}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { ...EmexCatalogService.EMEX_HDR, Accept: "text/html" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`matchBySsd: HTTP ${res.status} for ${url}`);
|
||||
return null;
|
||||
}
|
||||
const html = await res.text();
|
||||
|
||||
// Parse vehicle names from the HTML (same link pattern as VIN decode)
|
||||
const linkRx = /href="Vehicle\.aspx\?[^"]*">([^<]+)<\/a>/g;
|
||||
const vehicleNames: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRx.exec(html)) !== null) {
|
||||
const name = m[1].trim();
|
||||
if (name && !vehicleNames.includes(name)) vehicleNames.push(name);
|
||||
}
|
||||
|
||||
if (vehicleNames.length === 0) {
|
||||
this.logger.log(`matchBySsd: no vehicles found for c=${catalogCode} ssd=${ssd.slice(0, 30)}...`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(`matchBySsd: found ${vehicleNames.length} vehicle names from wizard, trying DB match`);
|
||||
|
||||
// Find catalog in DB
|
||||
const [catalog] = await this.db
|
||||
.select({ id: emexCatalogs.id })
|
||||
.from(emexCatalogs)
|
||||
.where(eq(emexCatalogs.catalogId, catalogCode))
|
||||
.limit(1);
|
||||
|
||||
if (!catalog) return null;
|
||||
|
||||
const vehicleCols = {
|
||||
id: emexVehicles.id,
|
||||
vehicleId: emexVehicles.vehicleId,
|
||||
name: emexVehicles.name,
|
||||
engine: emexVehicles.engine,
|
||||
engineCode: emexVehicles.engineCode,
|
||||
bodyType: emexVehicles.bodyType,
|
||||
transmission: emexVehicles.transmission,
|
||||
driveType: emexVehicles.driveType,
|
||||
fuelType: emexVehicles.fuelType,
|
||||
yearFrom: emexVehicles.yearFrom,
|
||||
yearTo: emexVehicles.yearTo,
|
||||
optionsRaw: emexVehicles.optionsRaw,
|
||||
};
|
||||
|
||||
// Try each vehicle name against DB
|
||||
for (const vName of vehicleNames) {
|
||||
// Extract model part before brackets: "M 103 [123.981] (1985-1992)" → "M 103"
|
||||
const nameMatch = vName.match(/^([^\[]+)/);
|
||||
const cleanName = nameMatch ? nameMatch[1].trim() : vName;
|
||||
|
||||
const candidates = await this.db
|
||||
.select(vehicleCols)
|
||||
.from(emexVehicles)
|
||||
.where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, `${cleanName}%`)))
|
||||
.orderBy(emexVehicles.optionsRaw)
|
||||
.limit(50);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
const displayName = pathData
|
||||
? pathData.replace(/^Name:\s*/i, "").trim()
|
||||
: vName;
|
||||
|
||||
const match: EmexVehicleMatch = {
|
||||
vehicleId: candidates[0].id,
|
||||
catalogCode,
|
||||
vehicleName: displayName,
|
||||
candidates,
|
||||
};
|
||||
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
|
||||
this.logger.log(`matchBySsd: DB match found — "${cleanName}" → ${candidates.length} candidates`);
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`matchBySsd: no DB match for any of ${vehicleNames.length} wizard vehicles`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
this.logger.warn(`matchBySsd failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async searchByOem(query: string): Promise<EmexSearchResult[]> {
|
||||
if (!query || query.length < 3) return [];
|
||||
|
||||
|
||||
@@ -205,46 +205,94 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
// If still no categories, try EMEX pre-scraped match first, then on-demand fallback
|
||||
// If still no categories, try EMEX: pathData → name → ssd → on-demand fallback
|
||||
if (dbCategories.length === 0 && vehicle.vin) {
|
||||
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX pre-scraped match`);
|
||||
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX catalog match`);
|
||||
|
||||
// Try pre-scraped emex catalog match
|
||||
const vRawData = vehicle.rawData as Record<string, unknown> | null;
|
||||
const catalogCode = (vRawData?.catalogCode as string) || this.emexService.getCatalogCode(vehicle.vin);
|
||||
const vehicleName = (vRawData?.emexVehicleName as string) || (vRawData?.model as string);
|
||||
|
||||
if (catalogCode && vehicleName) {
|
||||
try {
|
||||
const match = await this.emexCatalogService.matchByName(catalogCode, vehicleName);
|
||||
if (match) {
|
||||
this.logger.log(`EMEX pre-scraped match found for ${vehicle.vin}: ${match.vehicleId}`);
|
||||
const groups = await this.emexCatalogService.getVehicleGroups(match.vehicleId);
|
||||
if (groups.length > 0) {
|
||||
// Convert emex groups to categories format for tree building
|
||||
const insertData = groups.map((g) => ({
|
||||
vehicleId,
|
||||
catalogVehicleId: null as string | null,
|
||||
name: g.name,
|
||||
nameOriginal: g.nameOriginal || g.name,
|
||||
parentId: null as string | null,
|
||||
externalId: g.groupId,
|
||||
linkPath: `emex-catalog:${match.vehicleId}:${g.id}`,
|
||||
linkWid: null as string | null,
|
||||
source: "emex" as const,
|
||||
}));
|
||||
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
|
||||
this.logger.log(`Stored ${dbCategories.length} EMEX pre-scraped categories for ${vehicle.vin}`);
|
||||
// Helper: insert emex catalog groups from a match into DB
|
||||
const insertEmexMatch = async (match: { vehicleId: string; catalogCode: string }) => {
|
||||
const groups = await this.emexCatalogService.getVehicleGroups(match.vehicleId);
|
||||
if (groups.length > 0) {
|
||||
const insertData = groups.map((g) => ({
|
||||
vehicleId,
|
||||
catalogVehicleId: null as string | null,
|
||||
name: g.name,
|
||||
nameOriginal: g.nameOriginal || g.name,
|
||||
parentId: null as string | null,
|
||||
externalId: g.groupId,
|
||||
linkPath: `emex-catalog:${match.vehicleId}:${g.id}`,
|
||||
linkWid: null as string | null,
|
||||
source: "emex" as const,
|
||||
}));
|
||||
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
|
||||
this.logger.log(`Stored ${dbCategories.length} EMEX catalog categories for ${vehicle.vin}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: pathData → extract vehicle name → matchByName (DB-only, ~50ms)
|
||||
if (catalogCode && dbCategories.length === 0) {
|
||||
const pathData = vRawData?.emexPathData as string | undefined;
|
||||
if (pathData) {
|
||||
const pathName = pathData.replace(/^Name:\s*/i, "").trim();
|
||||
if (pathName) {
|
||||
try {
|
||||
this.logger.log(`EMEX step 1: matchByName via pathData "${pathName}"`);
|
||||
const match = await this.emexCatalogService.matchByName(catalogCode, pathName);
|
||||
if (match) {
|
||||
this.logger.log(`EMEX pathData match found: ${match.vehicleId}`);
|
||||
await insertEmexMatch(match);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX pathData match failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX pre-scraped match failed for ${vehicle.vin}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If pre-scraped match didn't work, try on-demand EMEX scrape
|
||||
// Step 2: emexVehicleName → matchByName (DB-only, ~50ms)
|
||||
if (catalogCode && dbCategories.length === 0) {
|
||||
const vehicleName = (vRawData?.emexVehicleName as string) || (vRawData?.model as string);
|
||||
if (vehicleName) {
|
||||
try {
|
||||
this.logger.log(`EMEX step 2: matchByName via vehicleName "${vehicleName}"`);
|
||||
const match = await this.emexCatalogService.matchByName(catalogCode, vehicleName);
|
||||
if (match) {
|
||||
this.logger.log(`EMEX vehicleName match found: ${match.vehicleId}`);
|
||||
await insertEmexMatch(match);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX vehicleName match failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: ssd + catalogCode → matchBySsd (1-2 HTTP + DB, ~2-3s)
|
||||
if (catalogCode && dbCategories.length === 0) {
|
||||
const ssd = vRawData?.ssd as string | undefined;
|
||||
if (ssd) {
|
||||
try {
|
||||
this.logger.log(`EMEX step 3: matchBySsd (c=${catalogCode}, ssd=${ssd.slice(0, 30)}...)`);
|
||||
const match = await this.emexCatalogService.matchBySsd(
|
||||
catalogCode,
|
||||
ssd,
|
||||
(vRawData?.emexPathData as string) || undefined,
|
||||
);
|
||||
if (match) {
|
||||
this.logger.log(`EMEX SSD match found: ${match.vehicleId}`);
|
||||
await insertEmexMatch(match);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX SSD match failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Fallback → on-demand EMEX scrape (existing behavior, ~6-13s)
|
||||
if (dbCategories.length === 0) {
|
||||
this.logger.log(`No pre-scraped match for ${vehicle.vin}, trying EMEX on-demand fallback`);
|
||||
this.logger.log(`EMEX step 4: on-demand fallback for ${vehicle.vin}`);
|
||||
try {
|
||||
const emexResult = await this.emexService.decodeVin(vehicle.vin);
|
||||
if (emexResult) {
|
||||
@@ -341,7 +389,7 @@ export class CategoriesService {
|
||||
} catch (emexErr) {
|
||||
this.logger.warn(`EMEX category fallback failed for ${vehicle.vin}: ${(emexErr as Error).message}`);
|
||||
}
|
||||
} // end: if (dbCategories.length === 0) — on-demand fallback
|
||||
} // end: step 4 on-demand fallback
|
||||
}
|
||||
|
||||
// Build tree
|
||||
|
||||
@@ -432,6 +432,10 @@ 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)
|
||||
|
||||
@@ -42,6 +42,7 @@ interface EmexHttpVehicle {
|
||||
vid: string | null;
|
||||
ssd: string | null;
|
||||
quickGroupsUrl: string | null;
|
||||
pathData: string | null;
|
||||
}
|
||||
|
||||
interface EmexHttpCategory {
|
||||
@@ -278,6 +279,15 @@ export class EmexService {
|
||||
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 modelMatch = label.match(/^([^\[]+)/);
|
||||
const yearMatch = label.match(/\((\d{4})/);
|
||||
vehicles.push({
|
||||
@@ -290,6 +300,7 @@ export class EmexService {
|
||||
quickGroupsUrl: c && vid != null && ssd
|
||||
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
|
||||
: null,
|
||||
pathData,
|
||||
});
|
||||
}
|
||||
return vehicles;
|
||||
@@ -379,6 +390,9 @@ export class EmexService {
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
ssd: v.ssd || undefined,
|
||||
vehicleLabel: v.label,
|
||||
vid: v.vid || undefined,
|
||||
pathData: v.pathData || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
model: v.model,
|
||||
@@ -462,6 +476,9 @@ export class EmexService {
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
ssd: v.ssd || undefined,
|
||||
vehicleLabel: v.label,
|
||||
vid: v.vid || undefined,
|
||||
pathData: v.pathData || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
model: v.model,
|
||||
@@ -525,6 +542,9 @@ export class EmexService {
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
ssd: v.ssd || undefined,
|
||||
vehicleLabel: v.label,
|
||||
vid: v.vid || undefined,
|
||||
pathData: v.pathData || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
model: v.model,
|
||||
|
||||
@@ -131,6 +131,9 @@ export interface EmexScraperResponse {
|
||||
catalogCode: string;
|
||||
ssd?: string;
|
||||
vehicle: EmexVehicleData;
|
||||
vehicleLabel?: string;
|
||||
vid?: string;
|
||||
pathData?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
rawResponse?: Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user