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 [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user