import type { NormalizedQuery, Offer, OfferAttribute, StoreAdapter } from "../core/types.js"; import { getBrowserHub } from "../core/browserHub.js"; // Trodo.lv — Magento 2. Its search API is gated behind Cloudflare's interactive // (managed) challenge, so this is a strictly-private, on-demand, human-cleared store // (see WALLED-STORES.md and MONETIZATION.md §7). We fetch through the resident browser // (BrowserHub) so a human-earned cf_clearance rides the same-origin request. // // The store's own SPA does a text search via: // GET /rest/V1/catalogsearch/result/0?q=&searchby=name&is_ajax=true // &product_list_order=_score&product_list_dir=desc // &customer_group_id=0¤cy=EUR&list-type=list&country=LV // Response envelope: [ { products: [...], pagination, filters, ... } ]. Like Handler, // page 1 returns the searched part plus cross-brand analogues, each with its own price; // exact-number ranking (upstream) floats the searched OEM to the top. // Fixture: fixtures/trodo/0986452041.json (recorded 2026-07-12 through a solved session). const HOME = "https://www.trodo.lv"; const IMG_BASE = "https://picdn.trodo.com/media/m2_catalog_cache/240x240"; const SEARCH = (canonical: string): string => `${HOME}/rest/V1/catalogsearch/result/0?q=${encodeURIComponent(canonical)}` + `&searchby=name&product_list_order=_score&product_list_dir=desc&is_ajax=true` + `&customer_group_id=0¤cy=EUR&list-type=list&country=LV`; // The human-facing results page for the same query. Navigating here on a cold session // renders Cloudflare's inline "Verify you are human" widget, which the user solves in // the noVNC stream; the API SEARCH() above then returns JSON on the retry. const RESULTS_PAGE = (canonical: string): string => `${HOME}/catalogsearch/result?q=${encodeURIComponent(canonical)}&searchby=name`; interface TrodoCriterion { label?: string; value?: string; } interface TrodoProduct { name?: string; url_path?: string; url_key?: string; final_price?: number; price?: number; manufacturer?: string; tecdoc_sku?: string; sku?: string; in_stock?: boolean; availability_status?: string; delivery_date?: string; media?: { src?: string }; label?: { brand?: { name?: string } }; attributes?: { criteria?: TrodoCriterion[] }; } type TrodoEnvelope = Array<{ products?: TrodoProduct[] }>; export const trodo: StoreAdapter = { id: "trodo", displayName: "Trodo.lv", storeHomepage: HOME, enabled: true, fetchMode: "onDemand", timeoutMs: 30000, // includes a possible page navigation / challenge check async fetchHtml(q: NormalizedQuery): Promise { // Ignores PoliteHttp — walled fetches go through the resident Chrome so the // human-earned cf_clearance and matching TLS fingerprint ride the request. return getBrowserHub().fetchJson(this.id, HOME, SEARCH(q.canonical), { solvePage: RESULTS_PAGE(q.canonical), }); }, parse(json: string): Offer[] { let envelope: TrodoEnvelope; try { envelope = JSON.parse(json) as TrodoEnvelope; } catch { return []; // a challenge page or any non-JSON body yields no offers } const products = envelope?.[0]?.products; if (!Array.isArray(products)) return []; const offers: Offer[] = []; for (const p of products) { const priceEur = typeof p.final_price === "number" ? p.final_price : Number(p.price); if (!Number.isFinite(priceEur) || priceEur <= 0) continue; const partNumber = (p.tecdoc_sku || p.sku || "").trim(); if (!partNumber) continue; const slug = (p.url_path || p.url_key || "").replace(/^\/+/, ""); const brand = (p.label?.brand?.name || p.manufacturer || "").trim() || null; const inStock = p.in_stock === true || p.availability_status === "in-stock"; const attributes: OfferAttribute[] = (p.attributes?.criteria ?? []) .filter((c) => c.label && c.value) .slice(0, 8) .map((c) => ({ label: c.label!.trim(), value: c.value!.trim() })); offers.push({ store: trodo.id, brand, partNumber, title: p.name?.trim() || partNumber, priceEur, availability: inStock ? "Pieejams" : null, deliveryNote: p.delivery_date ? `Piegāde: ${p.delivery_date}` : null, url: slug ? `${HOME}/${slug}` : HOME, imageUrl: p.media?.src ? `${IMG_BASE}${p.media.src}` : null, attributes, }); } return offers; }, };