feat: EMEX HTTP decode + multi-candidate selection UI
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:
147
scripts/emex-http-test.js
Normal file
147
scripts/emex-http-test.js
Normal 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(/&/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(/&/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);
|
||||
Reference in New Issue
Block a user