feat: EMEX HTTP decode + multi-candidate selection UI
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled

Replace Playwright browser with pure HTTP for EMEX VIN decode:
- Vehicles.aspx, QuickGroups.aspx, QuickDetails.aspx all work
  without authentication — no session/browser needed
- VIN decode drops from ~10-20s (Playwright) to ~1-5s (HTTP)
- Playwright browser kept as fallback if HTTP fails

Add multi-candidate selection for EMEX (mirrors PartsCatalogs flow):
- EmexCandidate interface matches PcatCar shape so VehicleSelectModal
  renders without changes
- decodeVinOrCandidates(): single HTTP fetch returns vehicle OR
  candidates (avoids double Vehicles.aspx request)
- decodeVinByIndex(): resolves user-selected candidate by index
- vehicles.service: emexCandidates in VinResolveResult, emexCarIndex
  param in decodeVin/resolveVin, resolveEmexCarByIndex() method
- vehicles.controller: @Body("emexCarIndex") accepted
- search.tsx: candidateSource state, handleCandidateSelect sends
  emexCarIndex or pcatCarId based on source

Test VIN NM417800006410193 (Fiat SIENA) now shows 2-option modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-05 09:46:25 +00:00
parent f29a923e6e
commit ce8784014e
5 changed files with 610 additions and 83 deletions

147
scripts/emex-http-test.js Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* EMEX HTTP-only VIN decode test
* Tests replacing Playwright scraper with pure fetch + HTML parsing
* Run: node scripts/emex-http-test.js <VIN>
*/
const vin = process.argv[2] || "NM417800006410193";
const BASE = "https://emexdwc.ae";
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
async function fetchHtml(url) {
const t0 = Date.now();
const res = await fetch(url, {
headers: { "User-Agent": UA, "Accept": "text/html,application/xhtml+xml" },
redirect: "follow",
});
const html = await res.text();
const ms = Date.now() - t0;
console.log(` [${res.status}] ${url.replace(BASE, "")}${html.length} bytes, ${ms}ms`);
return { html, status: res.status, ms };
}
/**
* Parse Vehicles.aspx response — extract vehicle list
*/
function parseVehiclesList(html) {
// Match <tr onclick="window.location = 'Vehicle.aspx?...'" ...>
// <td><a href="Vehicle.aspx?c=X&vid=Y&ssd=Z&...">LABEL</a></td>
const linkRx = /href="(Vehicle\.aspx\?[^"]+)">([^<]+)<\/a>/g;
const seen = new Set();
const vehicles = [];
let m;
while ((m = linkRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, "&");
const label = m[2].trim();
if (seen.has(href)) continue;
seen.add(href);
const params = new URLSearchParams(href.replace("Vehicle.aspx?", ""));
const c = params.get("c");
const vid = params.get("vid");
const ssd = params.get("ssd");
// Parse model and year range from label like "SIENA [SIENA EXTRA EUROPA REST.(2002-2012)]"
const modelMatch = label.match(/^([^\[]+)/);
const yearMatch = label.match(/\((\d{4})/);
vehicles.push({
label,
catalogCode: c,
vid,
ssd,
model: modelMatch ? modelMatch[1].trim() : label,
yearFrom: yearMatch ? parseInt(yearMatch[1]) : null,
quickGroupsUrl: c && vid != null && ssd
? `${BASE}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
: null,
});
}
return vehicles;
}
/**
* Parse QuickGroups.aspx response — extract flat category list
*/
function parseCategories(html) {
// Category links go to QuickDetails.aspx?c=X&gid=Y&vid=Z&ssd=...
const catRx = /href="(QuickDetails\.aspx\?[^"]+)">([^<]+)<\/a>/g;
const seen = new Set();
const cats = [];
let m;
while ((m = catRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, "&");
const label = m[2].trim();
if (label.length < 2 || seen.has(href)) continue;
seen.add(href);
const params = new URLSearchParams(href.replace("QuickDetails.aspx?", ""));
cats.push({
gid: params.get("gid"),
name: label,
url: `${BASE}/${href}`,
});
}
return cats;
}
/**
* Determine brand from catalog code (EMEX catalog code → brand)
*/
function brandFromCatalogCode(c) {
if (!c) return null;
const map = {
BMW: "BMW", MB: "Mercedes-Benz", AU: "Audi", VW: "Volkswagen",
FIAT: "Fiat", RFIAT: "Alfa Romeo", FORD: "Ford", RENAULT: "Renault",
TOYOTA: "Toyota", HONDA: "Honda", KIA: "Kia", HYUNDAI: "Hyundai",
PORSCHE: "Porsche", SUBARU: "Subaru", MAZDA: "Mazda", PEUGEOT: "Peugeot",
CPSA: "Citroën/Peugeot",
};
for (const [prefix, brand] of Object.entries(map)) {
if (c.toUpperCase().includes(prefix)) return brand;
}
// Extract letters from code like "FFIAT84" → FIAT
const letterMatch = c.replace(/[0-9]/g, "").replace(/^[A-Z]/, "");
return letterMatch || c;
}
async function decodeVin(vin) {
console.log(`\n=== EMEX HTTP VIN Decode: ${vin} ===`);
const t0 = Date.now();
// Step 1: VIN search — no session/login needed!
const { html: vinHtml } = await fetchHtml(`${BASE}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`);
const vehicles = parseVehiclesList(vinHtml);
console.log(`\nFound ${vehicles.length} vehicle(s):`);
vehicles.forEach((v, i) => console.log(` [${i}] ${v.label} (c=${v.catalogCode})`));
if (vehicles.length === 0) {
console.log("\n✗ No vehicles found — VIN not in EMEX database");
return null;
}
const v = vehicles[0];
const brand = brandFromCatalogCode(v.catalogCode);
console.log(`\nVehicle data:`);
console.log(` Brand: ${brand}`);
console.log(` Model: ${v.model}`);
console.log(` Year: ${v.yearFrom}`);
console.log(` CatCode: ${v.catalogCode}`);
console.log(` QG URL: ${v.quickGroupsUrl}`);
// Step 2: Fetch categories from QuickGroups.aspx
let categories = [];
if (v.quickGroupsUrl) {
const { html: qgHtml } = await fetchHtml(v.quickGroupsUrl);
categories = parseCategories(qgHtml);
console.log(`\nCategories (${categories.length}):`);
categories.slice(0, 10).forEach((c) => console.log(` [${c.gid}] ${c.name}`));
if (categories.length > 10) console.log(` ... and ${categories.length - 10} more`);
}
const total = Date.now() - t0;
console.log(`\n✓ Total time: ${total}ms`);
console.log(` (Playwright baseline was ~10-20s)`);
return { brand, model: v.model, year: v.yearFrom, catalogCode: v.catalogCode, ssd: v.ssd, quickGroupsUrl: v.quickGroupsUrl, categories };
}
decodeVin(vin).catch(console.error);