fix: matchBySsd walks wizard from root SSD instead of VIN 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
VIN-decode SSDs are too long/specific — EMEX returns HTML instead of
JSON when passed to GetWizard2. New approach:
1. Start from root SSD ("")
2. Advance through wizard steps (Europe → Passenger → ...)
3. Find pathData name (e.g. "Focus CB4 2008-2011") in model options
4. Use getWizardVehicles for DB matching
For Ford Focus: wizard finds it at step 3, but Ford DB has no EU
models so it still falls back to on-demand scrape. BMW/Renault with
good DB coverage will benefit from this flow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -395,150 +395,86 @@ export class EmexCatalogService {
|
||||
|
||||
/**
|
||||
* Match a VIN-decoded vehicle to DB using wizard API flow.
|
||||
* 1. Call GetWizard2 with VIN-decode SSD → get sales designation options
|
||||
* 2. Find the option matching pathData (e.g. "Focus CB4 2008-2011")
|
||||
* 3. Use that option's key (deeper SSD) to call Vehicles.aspx?ft=findByWizard2
|
||||
* 4. Parse motor/variant names → match against emex_vehicles in DB
|
||||
* VIN-decode SSDs are too specific for GetWizard2 (returns HTML instead of JSON).
|
||||
* So we start from root SSD ("") and walk wizard steps until we find a model
|
||||
* option matching pathData (e.g. "Focus CB4 2008-2011"), then use getWizardVehicles
|
||||
* to match motor variants against 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)}`;
|
||||
async matchBySsd(catalogCode: string, _ssd: string, pathData?: string): Promise<EmexVehicleMatch | null> {
|
||||
const pathName = pathData?.replace(/^Name:\s*/i, "").trim();
|
||||
if (!pathName) {
|
||||
this.logger.log(`matchBySsd: no pathData name, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `emex:matchssd:${catalogCode}:${Buffer.from(pathName).toString("base64url").slice(0, 32)}`;
|
||||
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
// Step 1: Call wizard API with VIN-decode SSD to get sales designation options
|
||||
let wizardData: any;
|
||||
try {
|
||||
wizardData = await this.getWizard(catalogCode, ssd);
|
||||
} catch {
|
||||
this.logger.warn(`matchBySsd: wizard call failed for c=${catalogCode}`);
|
||||
return null;
|
||||
}
|
||||
// Walk wizard from root until we find pathName in model options
|
||||
let currentSsd = "";
|
||||
const maxSteps = 5;
|
||||
|
||||
const steps = Array.isArray(wizardData) ? wizardData : [];
|
||||
if (steps.length === 0) {
|
||||
this.logger.log(`matchBySsd: empty wizard response for c=${catalogCode}`);
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
let wizardData: any;
|
||||
try {
|
||||
wizardData = await this.getWizard(catalogCode, currentSsd);
|
||||
} catch {
|
||||
this.logger.warn(`matchBySsd: wizard call failed at step ${i}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Find the wizard step with options (sales designation list)
|
||||
// and find the option matching pathData name
|
||||
const pathName = pathData?.replace(/^Name:\s*/i, "").trim();
|
||||
let targetSsd: string | null = null;
|
||||
const steps = Array.isArray(wizardData) ? wizardData : [];
|
||||
if (steps.length === 0) break;
|
||||
|
||||
for (const step of steps) {
|
||||
const options = step.options as Array<{ key: string; value: string }> | undefined;
|
||||
if (!options?.length) continue;
|
||||
let found = false;
|
||||
let advanced = false;
|
||||
|
||||
if (pathName) {
|
||||
// Try exact match first
|
||||
for (const step of steps) {
|
||||
const options = step.options as Array<{ key: string; value: string }> | undefined;
|
||||
if (!options?.length) continue;
|
||||
|
||||
// Look for pathName in this step's options
|
||||
const exact = options.find((o) => o.value === pathName);
|
||||
if (exact) {
|
||||
targetSsd = exact.key;
|
||||
this.logger.log(`matchBySsd: wizard exact match "${pathName}" → ssd found`);
|
||||
break;
|
||||
this.logger.log(`matchBySsd: found "${pathName}" in wizard step "${step.name}"`);
|
||||
// Use getWizardVehicles which does DB matching with model name
|
||||
const dbVehicles = await this.getWizardVehicles(catalogCode, pathName);
|
||||
if (dbVehicles.length > 0) {
|
||||
const match: EmexVehicleMatch = {
|
||||
vehicleId: dbVehicles[0].id,
|
||||
catalogCode,
|
||||
vehicleName: pathName,
|
||||
candidates: dbVehicles,
|
||||
};
|
||||
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
|
||||
this.logger.log(`matchBySsd: DB match via wizard — "${pathName}" → ${dbVehicles.length} candidates`);
|
||||
return match;
|
||||
}
|
||||
// pathName found in wizard but no DB match — still no categories available
|
||||
this.logger.log(`matchBySsd: "${pathName}" found in wizard but no DB match`);
|
||||
return null;
|
||||
}
|
||||
// Try prefix match (pathName might be shorter)
|
||||
const prefix = options.find((o) => o.value.startsWith(pathName) || pathName.startsWith(o.value));
|
||||
if (prefix) {
|
||||
targetSsd = prefix.key;
|
||||
this.logger.log(`matchBySsd: wizard prefix match "${pathName}" ≈ "${prefix.value}" → ssd found`);
|
||||
break;
|
||||
|
||||
// Not found in this step — if step is undetermined, try advancing
|
||||
// Pick the first option that's likely correct (e.g. "Europe", "Passenger")
|
||||
if (!step.determined && !advanced) {
|
||||
// Heuristic: pick common values for region/vehicle type
|
||||
const preferred = ["Europe", "Passenger"];
|
||||
const pick = options.find((o) => preferred.includes(o.value)) || options[0];
|
||||
currentSsd = pick.key;
|
||||
advanced = true;
|
||||
this.logger.log(`matchBySsd: advancing wizard "${step.name}" → "${pick.value}"`);
|
||||
}
|
||||
|
||||
found = found || (options.length > 0);
|
||||
}
|
||||
|
||||
// No pathData or no match — use first option with allowlistvehicles
|
||||
if (step.allowlistvehicles && options.length > 0) {
|
||||
targetSsd = options[0].key;
|
||||
this.logger.log(`matchBySsd: no pathData match, using first option "${options[0].value}"`);
|
||||
break;
|
||||
}
|
||||
if (!advanced) break; // All steps determined, nowhere to advance
|
||||
}
|
||||
|
||||
if (!targetSsd) {
|
||||
this.logger.log(`matchBySsd: no wizard option found for c=${catalogCode}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 3: Fetch vehicle list using the sales designation SSD
|
||||
const vehParams = new URLSearchParams({ ft: "findByWizard2", c: catalogCode, ssd: targetSsd });
|
||||
const vehUrl = `${EmexCatalogService.EMEX_BASE}/Vehicles.aspx?${vehParams}`;
|
||||
const res = await fetch(vehUrl, {
|
||||
headers: { ...EmexCatalogService.EMEX_HDR, Accept: "text/html" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`matchBySsd: HTTP ${res.status} for Vehicles.aspx`);
|
||||
return null;
|
||||
}
|
||||
const html = await res.text();
|
||||
|
||||
// Parse vehicle names from the HTML
|
||||
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 in Vehicles.aspx for c=${catalogCode}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(`matchBySsd: found ${vehicleNames.length} vehicle names, trying DB match`);
|
||||
|
||||
// Step 4: Match vehicle names against 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,
|
||||
};
|
||||
|
||||
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 match: EmexVehicleMatch = {
|
||||
vehicleId: candidates[0].id,
|
||||
catalogCode,
|
||||
vehicleName: pathName || vName,
|
||||
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`);
|
||||
this.logger.log(`matchBySsd: "${pathName}" not found in wizard for c=${catalogCode}`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
this.logger.warn(`matchBySsd failed: ${(err as Error).message}`);
|
||||
|
||||
Reference in New Issue
Block a user