/** * OEM Reference Price Scraper — Mouser Electronics API * * Source: api.mouser.com (free REST API, no bot-detection) * Target: Juniper, Cisco, Arista OEM transceiver PIDs already in our DB * Stores: price_observations (marketplace='mouser', condition='new') * * API key: Free registration at mouser.com/api — set MOUSER_API_KEY env var * endpoint: POST https://api.mouser.com/api/v1.0/search/keyword * * Rate limit: 30 req/min on free tier → 2s delay between requests * * Note: DigiKey + Arrow both require Playwright to bypass Cloudflare/Akamai. * Mouser offers a free REST API with the same data — hence this implementation. */ import { pool, ensureVendor, upsertPriceObservation } from "../utils/db"; import { contentHash } from "../utils/hash"; const MOUSER_API_BASE = "https://api.mouser.com/api/v1.0"; const MOUSER_API_KEY = process.env["MOUSER_API_KEY"] ?? ""; const DELAY_MS = 2_100; // ≤ 30 req/min on free tier // ── Types ───────────────────────────────────────────────────────────────────── interface MouserPriceBreak { Quantity: number; Price: string; // e.g. "1,234.56" or "1234.56" Currency: string; // e.g. "EUR" } interface MouserPart { ManufacturerPartNumber: string; MouserPartNumber: string; Availability: string; // e.g. "117 auf Lager" DataSheetUrl: string; Description: string; LeadTime: string; // e.g. "10 Weeks" Min: string; // min order qty ProductDetailUrl: string; PriceBreaks: MouserPriceBreak[]; AvailabilityInStock: string; } interface MouserSearchResponse { Errors: Array<{ Code: string; Message: string }>; SearchResults: { NumberOfResult: number; Parts: MouserPart[]; } | null; } // ── Helpers ─────────────────────────────────────────────────────────────────── function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** Parse Mouser price string "1.234,56" (DE locale) or "1234.56" (US) */ function parseMouserPrice(raw: string, currency: string): number | null { if (!raw || raw === "") return null; // German locale uses comma decimal, dot thousands → "1.234,56" // US locale uses dot decimal → "1234.56" const cleaned = currency === "EUR" ? raw.replace(/\./g, "").replace(",", ".") // "1.234,56" → "1234.56" : raw.replace(/,/g, ""); // "1,234.56" → "1234.56" const n = parseFloat(cleaned); return Number.isFinite(n) && n > 0 ? n : null; } /** Extract quantity from availability string like "117 auf Lager" or "117 In Stock" */ function parseAvailability(avail: string): { qty: number; stockLevel: string } { if (!avail) return { qty: 0, stockLevel: "out_of_stock" }; const lower = avail.toLowerCase(); // Check for discontinued / not available if (lower.includes("nicht verfügbar") || lower.includes("not available") || lower.includes("obsolete")) { return { qty: 0, stockLevel: "discontinued" }; } // Extract number const match = avail.match(/(\d[\d,.]*)(?:\s|$)/); const qty = match ? parseInt(match[1].replace(/[,.]/, ""), 10) : 0; if (qty === 0) return { qty: 0, stockLevel: "out_of_stock" }; if (qty < 10) return { qty, stockLevel: "low_stock" }; return { qty, stockLevel: "in_stock" }; } /** Get the 1-unit price (or lowest break price) in EUR */ function extractPrice(part: MouserPart): { price: number; currency: string } | null { const breaks = part.PriceBreaks; if (!breaks || breaks.length === 0) return null; // Sort by quantity ascending, take qty=1 or first available const sorted = [...breaks].sort((a, b) => a.Quantity - b.Quantity); const first = sorted[0]; if (!first) return null; const currency = (first.Currency ?? "EUR").toUpperCase(); const price = parseMouserPrice(first.Price, currency); if (price === null) return null; return { price, currency }; } // ── API call ────────────────────────────────────────────────────────────────── async function searchMouser(partNumber: string): Promise { if (!MOUSER_API_KEY) return null; const url = `${MOUSER_API_BASE}/search/keyword?apiKey=${MOUSER_API_KEY}&langId=1&searchWithSapnningRows=false`; let resp: Response; try { resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ SearchByKeywordRequest: { keyword: partNumber, records: 5, startingRecord: 0, searchOptions: "1", // Exact match preferred searchWithSapnningRows: false, }, }), signal: AbortSignal.timeout(15_000), }); } catch (err: unknown) { console.warn(` [Mouser] Fetch error for ${partNumber}: ${(err as Error).message.slice(0, 60)}`); return null; } if (!resp.ok) { if (resp.status === 429) { console.warn(` [Mouser] Rate limited — backing off 30s`); await sleep(30_000); } else { console.warn(` [Mouser] HTTP ${resp.status} for ${partNumber}`); } return null; } const data = (await resp.json()) as MouserSearchResponse; if (data.Errors && data.Errors.length > 0) { const errMsg = data.Errors.map((e) => e.Message).join("; "); console.warn(` [Mouser] API error for ${partNumber}: ${errMsg.slice(0, 80)}`); return null; } const parts = data.SearchResults?.Parts ?? []; if (parts.length === 0) return null; const norm = partNumber.toUpperCase().trim(); // Prefer exact MPN match const exact = parts.find((p) => (p.ManufacturerPartNumber ?? "").toUpperCase().trim() === norm); return exact ?? parts[0] ?? null; } // ── Main ────────────────────────────────────────────────────────────────────── export async function scrapeMouser(): Promise { console.log("=== OEM Reference Price Scraper (Mouser Electronics API) ===\n"); if (!MOUSER_API_KEY) { console.error( " ERROR: MOUSER_API_KEY not set.\n" + " Register free at https://www.mouser.com/api-hub/ → get API key → set env var.\n" + " Free tier: 1000 queries/month — enough for 475 Juniper PIDs." ); return; } // Register Mouser as a vendor (distributor = reseller type) const vendorId = await ensureVendor( "Mouser Electronics", "reseller", "https://www.mouser.de", "https://www.mouser.de/Search/Refine?Keyword=" ); console.log(` Vendor ID: ${vendorId}`); // Load OEM transceiver PIDs const TARGET_VENDORS = ["Juniper Networks", "Cisco Systems", "Arista Networks", "FS.COM", "SmartOptics"]; const { rows: transceivers } = await pool.query<{ id: string; part_number: string; form_factor: string; speed: string; vendor_name: string; }>( `SELECT t.id, t.part_number, t.form_factor, t.speed, v.name AS vendor_name FROM transceivers t JOIN vendors v ON v.id = t.vendor_id WHERE v.name = ANY($1) AND t.part_number IS NOT NULL AND t.part_number NOT ILIKE '%Transceiver%' AND t.part_number NOT ILIKE '%-Transceivers' AND LENGTH(t.part_number) BETWEEN 4 AND 35 ORDER BY v.name, t.part_number`, [TARGET_VENDORS] ); console.log(` Found ${transceivers.length} OEM PIDs to price-check\n`); let found = 0; let notFound = 0; let errors = 0; for (let i = 0; i < transceivers.length; i++) { const tx = transceivers[i]; if (i > 0 && i % 20 === 0) { console.log( ` [${i}/${transceivers.length}] found=${found} not_found=${notFound} errors=${errors}` ); } const part = await searchMouser(tx.part_number); if (!part) { notFound++; await sleep(DELAY_MS); continue; } const priceData = extractPrice(part); if (!priceData) { notFound++; await sleep(DELAY_MS); continue; } const { qty, stockLevel } = parseAvailability(part.Availability); const productUrl = part.ProductDetailUrl ? `https://www.mouser.de${part.ProductDetailUrl}` : `https://www.mouser.de/Search/Refine?Keyword=${encodeURIComponent(tx.part_number)}`; const hash = contentHash( `mouser:${tx.id}:${priceData.price}:${priceData.currency}:${stockLevel}` ); try { await upsertPriceObservation({ transceiverId: tx.id, sourceVendorId: vendorId, price: priceData.price, currency: priceData.currency, stockLevel, quantityAvailable: qty, url: productUrl, contentHash: hash, }); found++; console.log( ` ✓ ${tx.part_number.padEnd(32)} ${priceData.currency} ${priceData.price.toFixed(2).padStart(9)} ${stockLevel.padEnd(13)} qty=${qty}` ); } catch (err: unknown) { errors++; console.warn( ` ✗ DB error ${tx.part_number}: ${(err as Error).message.slice(0, 60)}` ); } await sleep(DELAY_MS); } console.log(`\n=== Mouser OEM Scraper Complete ===`); console.log(` Processed: ${transceivers.length}`); console.log(` Found: ${found}`); console.log(` Not found: ${notFound}`); console.log(` DB errors: ${errors}\n`); } // ── CLI ─────────────────────────────────────────────────────────────────────── if (require.main === module) { scrapeMouser() .then(() => pool.end()) .catch((err: unknown) => { console.error("Fatal:", err); pool.end(); process.exit(1); }); }