/** * Distributor Lead Time & Stock Monitor * * Scrapes Mouser Electronics and Digi-Key for transceiver * availability, stock levels, and lead times. * * Lead time is a 2-4 month supply constraint indicator: * Lead time > 12 weeks → price increase likely in 6-8 weeks * Lead time < 4 weeks → excess supply, price pressure downward * In Stock / large qty → commodity phase * * No API keys required — uses public catalog search pages. */ import * as cheerio from "cheerio"; import { pool } from "../utils/db"; import { logger } from "../utils/logger"; const HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xhtml", "Accept-Language": "en-US,en;q=0.9", }; interface LeadEntry { distributor: string; formFactor: string; speedLabel: string; partNumber: string; productName: string; inStock: boolean; stockQty: number | null; leadTimeWeeks: number | null; priceUsd: number | null; productUrl: string; } // ─── Mouser ──────────────────────────────────────────────────────────────── const MOUSER_SEARCHES = [ { url: "https://www.mouser.com/c/optoelectronics/fiber-optic-components/fiber-optic-transceivers-pluggable/?q=100G&instock=y", form: "QSFP28", speed: "100G" }, { url: "https://www.mouser.com/c/optoelectronics/fiber-optic-components/fiber-optic-transceivers-pluggable/?q=400G", form: "QSFP-DD", speed: "400G" }, { url: "https://www.mouser.com/c/optoelectronics/fiber-optic-components/fiber-optic-transceivers-pluggable/?q=800G", form: "OSFP", speed: "800G" }, { url: "https://www.mouser.com/c/optoelectronics/fiber-optic-components/fiber-optic-transceivers-pluggable/?q=SFP+10G", form: "SFP+", speed: "10G" }, ]; async function scrapeMouser(entry: { url: string; form: string; speed: string }): Promise { const res = await fetch(entry.url, { headers: HEADERS, signal: AbortSignal.timeout(20000) }); if (!res.ok) throw new Error(`Mouser ${res.status}`); const $ = cheerio.load(await res.text()); const results: LeadEntry[] = []; $(".product-table tr[data-part-number]").each((_, el) => { const partNumber = $(el).attr("data-part-number") ?? ""; const productName = $(el).find(".part-description a").first().text().trim(); const productUrl = `https://www.mouser.com${$(el).find(".part-description a").first().attr("href") ?? ""}`; const priceText = $(el).find(".pricing-buy-price").first().text().replace(/,/g, "").trim(); const price = parseFloat(priceText.replace(/[^0-9.]/g, "")) || null; const availText = $(el).find(".avail-text").first().text().trim(); const qtyMatch = availText.match(/([\d,]+)\s*In Stock/i); const inStock = qtyMatch != null || availText.toLowerCase().includes("in stock"); const stockQty = qtyMatch ? parseInt(qtyMatch[1].replace(/,/g, "")) : (inStock ? 1 : null); let leadTimeWeeks: number | null = null; const leadMatch = availText.match(/(\d+)\s*(?:week|wk)/i); if (leadMatch) leadTimeWeeks = parseInt(leadMatch[1]); else if (!inStock) leadTimeWeeks = null; // non-stocked if (!partNumber && !productName) return; results.push({ distributor: "mouser", formFactor: entry.form, speedLabel: entry.speed, partNumber, productName: productName.substring(0, 200), inStock, stockQty, leadTimeWeeks, priceUsd: price, productUrl: productUrl.substring(0, 500), }); }); return results; } // ─── Digi-Key ────────────────────────────────────────────────────────────── const DIGIKEY_SEARCHES = [ { url: "https://www.digikey.com/en/products/filter/fiber-optic-transceivers-pluggable/814?q=100G&s=N4IgjCBcoA2oBhUBjEAzAhgGwM4FMBXAFwHsoBOABgDoA2AJgFYBOGoA", form: "QSFP28", speed: "100G" }, { url: "https://www.digikey.com/en/products/filter/fiber-optic-transceivers-pluggable/814?q=400G", form: "QSFP-DD", speed: "400G" }, ]; async function scrapeDigikey(entry: { url: string; form: string; speed: string }): Promise { const res = await fetch(entry.url, { headers: HEADERS, signal: AbortSignal.timeout(20000) }); if (!res.ok) throw new Error(`Digi-Key ${res.status}`); const $ = cheerio.load(await res.text()); const results: LeadEntry[] = []; // Digi-Key uses React-rendered tables — grab what's in the SSR HTML $("tr[data-partid], .product-table tbody tr").each((_, el) => { const cells = $(el).find("td"); if (cells.length < 4) return; const partLink = $(cells[0]).find("a").first(); const partNumber = partLink.text().trim() || $(cells[0]).text().trim(); const productName = $(cells[1]).text().trim(); const priceText = $(cells[2]).text().replace(/,/g, "").trim(); const price = parseFloat(priceText.replace(/[^0-9.]/g, "")) || null; const qtyText = $(cells[3]).text().replace(/,/g, "").trim(); const qty = parseInt(qtyText.replace(/[^0-9]/g, "")) || 0; if (!partNumber || partNumber.length < 3) return; results.push({ distributor: "digikey", formFactor: entry.form, speedLabel: entry.speed, partNumber: partNumber.substring(0, 100), productName: productName.substring(0, 200), inStock: qty > 0, stockQty: qty || null, leadTimeWeeks: qty === 0 ? null : 0, priceUsd: price, productUrl: `https://www.digikey.com${partLink.attr("href") ?? ""}`, }); }); return results; } // ─── RS Components ───────────────────────────────────────────────────────── async function scrapeRsComponents(): Promise { const url = "https://uk.rs-online.com/web/c/optoelectronics/fibre-optic-components/fibre-optic-transceivers/?applied-dimensions=4294958026&sortby=P_PRICE&st=400G"; const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(20000) }); if (!res.ok) throw new Error(`RS ${res.status}`); const $ = cheerio.load(await res.text()); const results: LeadEntry[] = []; $(".product-info-wrap").each((_, el) => { const partNumber = $(el).find(".product-number").first().text().trim().replace("RS Stock No.", ""); const productName = $(el).find(".product-title a").first().text().trim(); const productUrl = `https://uk.rs-online.com${$(el).find(".product-title a").attr("href") ?? ""}`; const priceText = $(el).find(".price-info .price").first().text().trim(); const price = parseFloat(priceText.replace(/[^0-9.]/g, "")) || null; const stockText = $(el).find(".stock-status").first().text().trim(); const inStock = /in stock|available/i.test(stockText); if (!productName) return; results.push({ distributor: "rs-components", formFactor: "QSFP-DD", speedLabel: "400G", partNumber: partNumber.trim(), productName: productName.substring(0, 200), inStock, stockQty: null, leadTimeWeeks: inStock ? 0 : null, priceUsd: price, // GBP, stored as-is — currency conversion handled in forecast productUrl: productUrl.substring(0, 500), }); }); return results; } export async function scrapeDistributorLeads(): Promise { logger.info("Distributor lead time scraper starting"); let recorded = 0; // Mouser for (const s of MOUSER_SEARCHES) { try { await new Promise(r => setTimeout(r, 3000)); logger.info(`Mouser: ${s.speed} ${s.form}`); const entries = await scrapeMouser(s); for (const e of entries.slice(0, 50)) { // limit to 50 per search await pool.query(` INSERT INTO distributor_lead_times (distributor, form_factor, speed_label, part_number, product_name, in_stock, stock_qty, lead_time_weeks, price_usd, product_url) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) `, [e.distributor, e.formFactor, e.speedLabel, e.partNumber, e.productName, e.inStock, e.stockQty, e.leadTimeWeeks, e.priceUsd, e.productUrl]); recorded++; } logger.info(`Mouser ${s.speed}: ${entries.length} products`); } catch (err) { logger.warn(`Mouser scrape failed: ${s.speed}`, { err }); } } // Digi-Key for (const s of DIGIKEY_SEARCHES) { try { await new Promise(r => setTimeout(r, 4000)); logger.info(`Digi-Key: ${s.speed} ${s.form}`); const entries = await scrapeDigikey(s); for (const e of entries.slice(0, 30)) { await pool.query(` INSERT INTO distributor_lead_times (distributor, form_factor, speed_label, part_number, product_name, in_stock, stock_qty, lead_time_weeks, price_usd, product_url) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) `, [e.distributor, e.formFactor, e.speedLabel, e.partNumber, e.productName, e.inStock, e.stockQty, e.leadTimeWeeks, e.priceUsd, e.productUrl]); recorded++; } logger.info(`Digi-Key ${s.speed}: ${entries.length} products`); } catch (err) { logger.warn(`Digi-Key scrape failed: ${s.speed}`, { err }); } } // RS Components try { await new Promise(r => setTimeout(r, 3000)); const rsEntries = await scrapeRsComponents(); for (const e of rsEntries.slice(0, 30)) { await pool.query(` INSERT INTO distributor_lead_times (distributor, form_factor, speed_label, part_number, product_name, in_stock, stock_qty, lead_time_weeks, price_usd, product_url) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) `, [e.distributor, e.formFactor, e.speedLabel, e.partNumber, e.productName, e.inStock, e.stockQty, e.leadTimeWeeks, e.priceUsd, e.productUrl]); recorded++; } logger.info(`RS Components: ${rsEntries.length} products`); } catch (err) { logger.warn("RS Components scrape failed", { err }); } logger.info(`Distributor lead time scraper done — ${recorded} records`); }