import { parsePriceEur } from "../core/normalize.js"; import type { NormalizedQuery, Offer, OfferAttribute, StoreAdapter } from "../core/types.js"; import type { PoliteHttp } from "../core/politeHttp.js"; // EOLTAS — Baltic parts chain (LT/LV/EE, since 1993). Vue SPA backed by a JSON REST API, // so we skip the HTML entirely. Two steps, mirroring xparts: // 1. GET /lv-lv/rest/search//2 -> { exactProducts[], analogs[], ... } // The trailing "2" is the by-code search mode. The endpoint 404s unless the // request carries X-Requested-With: XMLHttpRequest (AJAX-only gate) — verified // 2026-07-10 (with header -> 200 + results, without -> 404). // 2. POST /lv-lv/rest/product-price body {products:[,...]} -> [{hash,userPrice}] // Prices come separately, keyed by each product's opaque `hash`; userPrice is in // integer cents (741 -> €7.41). // exactProducts are matches for the searched number, analogs are cross-references — both // map to Offers; the UI re-derives exact-vs-analog from the (canonicalized) part number, // which for these is `brandCode` ("W 712/52" -> "W71252"). // Reachable over plain HTTP/2 (no Cloudflare challenge), unlike rd24/trodo/euautodalas/autodoc. // Fixture: fixtures/eoltas/W71252.json (search JSON with each product's price merged in). const BASE = "https://www.eoltas.lv"; const SEARCH = (code: string): string => `${BASE}/lv-lv/rest/search/${encodeURIComponent(code)}/2`; const PRICE_URL = `${BASE}/lv-lv/rest/product-price`; const XHR = { "X-Requested-With": "XMLHttpRequest", Accept: "application/json" } as const; interface EoltasPrice { hash: string; userPrice: number; // cents } interface EoltasProduct { hash?: string; code?: string; brandCode?: string; name?: string; detailsUrl?: string; brand?: { name?: string }; state?: { name?: string; available?: boolean }; images?: { thumbnail?: string }[]; attributes?: { name?: string; value?: string }[]; /** Injected by fetchHtml from the price call so parse() stays a pure function. */ _price?: EoltasPrice; } interface EoltasSearch { exactProducts?: EoltasProduct[]; analogs?: EoltasProduct[]; } export const eoltas: StoreAdapter = { id: "eoltas", displayName: "EOLTAS.lv", storeHomepage: "https://www.eoltas.lv", enabled: true, // GET + 2s politeness gap + POST, each over the network — needs more than the 8s default. timeoutMs: 15000, fixtureExt: "json", async fetchHtml(q: NormalizedQuery, http: PoliteHttp): Promise { const searchBody = await http.get(this.id, SEARCH(q.canonical), { headers: { ...XHR } }); const data = JSON.parse(searchBody) as EoltasSearch; const products = [...(data.exactProducts ?? []), ...(data.analogs ?? [])]; const hashes = products.map((p) => p.hash).filter((h): h is string => !!h); if (hashes.length > 0) { const priceBody = await http.postJson(this.id, PRICE_URL, { products: hashes }, { headers: { ...XHR } }); const prices = JSON.parse(priceBody) as EoltasPrice[]; const byHash = new Map(prices.map((p) => [p.hash, p])); for (const p of products) { const price = p.hash ? byHash.get(p.hash) : undefined; if (price) p._price = price; } } return JSON.stringify(data); }, parse(json: string): Offer[] { const data = JSON.parse(json) as EoltasSearch; const products = [...(data.exactProducts ?? []), ...(data.analogs ?? [])]; const offers: Offer[] = []; for (const p of products) { // No price = nothing to compare on; drop it rather than emit an invalid offer. const priceEur = p._price ? parsePriceEur(String(p._price.userPrice / 100)) : NaN; if (!Number.isFinite(priceEur) || priceEur <= 0) continue; const attributes: OfferAttribute[] = (p.attributes ?? []) .filter((a) => a.name || a.value) .map((a) => ({ label: (a.name ?? "").trim(), value: (a.value ?? "").trim() })); // detailsUrl is already percent-encoded ("W%20712%252F52") — don't re-encode. const url = p.detailsUrl ? BASE + p.detailsUrl : ""; const available = p.state?.available === true; offers.push({ store: eoltas.id, brand: p.brand?.name?.trim() || null, partNumber: (p.brandCode || p.code || "").trim(), title: p.name?.trim() || "", priceEur, availability: available ? p.state?.name?.trim() || "Pieejams" : null, deliveryNote: null, url, imageUrl: p.images?.[0]?.thumbnail?.trim() || null, attributes, }); } return offers; }, };