feat: EMEX VIN decode → catalog DB matching via pathData/SSD
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:
2026-03-06 14:46:00 +00:00
parent adfba6c543
commit 53a6296a91
5 changed files with 207 additions and 30 deletions

View File

@@ -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 [];

View File

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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>;