fix: matchBySsd wizard flow — use GetWizard2 to find sales designation SSD
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

The VIN-decode SSD is model-level, not variant-level. matchBySsd now:
1. Calls GetWizard2 with VIN SSD → gets sales designation options
2. Finds option matching pathData (e.g. "Focus CB4 2008-2011")
3. Uses that option's key SSD for Vehicles.aspx?ft=findByWizard2
4. Matches motor/variant names against DB

Note: Ford Focus still falls back to on-demand scrape because Ford
DB coverage is only 55 US models (no EU models like Focus/Fiesta).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:56:36 +00:00
parent 53a6296a91
commit 2780609082

View File

@@ -395,8 +395,10 @@ export class EmexCatalogService {
/**
* 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.
* 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
*/
async matchBySsd(catalogCode: string, ssd: string, pathData?: string): Promise<EmexVehicleMatch | null> {
const cacheKey = `emex:matchssd:${catalogCode}:${Buffer.from(ssd).toString("base64url").slice(0, 32)}`;
@@ -404,20 +406,74 @@ export class EmexCatalogService {
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, {
// 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;
}
const steps = Array.isArray(wizardData) ? wizardData : [];
if (steps.length === 0) {
this.logger.log(`matchBySsd: empty wizard response for c=${catalogCode}`);
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;
for (const step of steps) {
const options = step.options as Array<{ key: string; value: string }> | undefined;
if (!options?.length) continue;
if (pathName) {
// Try exact match first
const exact = options.find((o) => o.value === pathName);
if (exact) {
targetSsd = exact.key;
this.logger.log(`matchBySsd: wizard exact match "${pathName}" → ssd found`);
break;
}
// 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;
}
}
// 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 (!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 ${url}`);
this.logger.warn(`matchBySsd: HTTP ${res.status} for Vehicles.aspx`);
return null;
}
const html = await res.text();
// Parse vehicle names from the HTML (same link pattern as VIN decode)
// Parse vehicle names from the HTML
const linkRx = /href="Vehicle\.aspx\?[^"]*">([^<]+)<\/a>/g;
const vehicleNames: string[] = [];
let m: RegExpExecArray | null;
@@ -427,13 +483,13 @@ export class EmexCatalogService {
}
if (vehicleNames.length === 0) {
this.logger.log(`matchBySsd: no vehicles found for c=${catalogCode} ssd=${ssd.slice(0, 30)}...`);
this.logger.log(`matchBySsd: no vehicles in Vehicles.aspx for c=${catalogCode}`);
return null;
}
this.logger.log(`matchBySsd: found ${vehicleNames.length} vehicle names from wizard, trying DB match`);
this.logger.log(`matchBySsd: found ${vehicleNames.length} vehicle names, trying DB match`);
// Find catalog in DB
// Step 4: Match vehicle names against DB
const [catalog] = await this.db
.select({ id: emexCatalogs.id })
.from(emexCatalogs)
@@ -457,7 +513,6 @@ export class EmexCatalogService {
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(/^([^\[]+)/);
@@ -471,14 +526,10 @@ export class EmexCatalogService {
.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,
vehicleName: pathName || vName,
candidates,
};
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);