diff --git a/packages/scraper/src/robots/flexoptix-detail-enricher.ts b/packages/scraper/src/robots/flexoptix-detail-enricher.ts index 287f2bc..de934e0 100644 --- a/packages/scraper/src/robots/flexoptix-detail-enricher.ts +++ b/packages/scraper/src/robots/flexoptix-detail-enricher.ts @@ -69,6 +69,8 @@ interface FxApiProduct { } interface ParsedSpecs { + formFactor: string | null; + speedGbps: number | null; complianceCode: string | null; laserType: string | null; receiverType: string | null; @@ -186,6 +188,67 @@ function parseModulation(text: string | null): string | null { return match ? match[1].toUpperCase().replace("PAM-4", "PAM4") : text.trim(); } +/** + * Authoritative form factor from the structured "Form Factor" spec (NOT the name). + */ +function normalizeFormFactorSpec(v: string | null): string | null { + if (!v) return null; + const t = v.toUpperCase(); + const order = ["QSFP-DD800","QSFP-DD","QSFP112","QSFP56","QSFP28","QSFP+", + "OSFP224","OSFP112","OSFP","SFP56","SFP28","CSFP","SFP+","SFP", + "XFP","CFP4","CFP2","CFP","GBIC"]; + for (const ff of order) if (t.includes(ff)) return ff === "CSFP" ? "SFP" : ff; + return null; +} + +/** Map a datasheet line rate (Gbit/s) to the nominal standard speed. */ +function mapLineRateToNominal(u: number): number { + const table: Array<[number, number]> = [ + [0.2, 0.1], [1.5, 1], [3.5, 2.5], [6, 5], [13, 10], [20, 16], + [33, 25], [46, 40], [60, 50], [120, 100], [240, 200], [480, 400], + [960, 800], [2000, 1600], + ]; + for (const [ceil, nom] of table) if (u <= ceil) return nom; + return Math.round(u); +} + +/** + * Authoritative nominal speed (Gbps) sourced from the datasheet specs only: + * primary = highest Ethernet rate named in "Supported Protocols"; + * fallback = upper bound of "Bandwidth" line rate mapped to nominal. + * Never derived from the product name. + */ +function nominalSpeedFromSpecs(specs: FxApiSpec[]): number | null { + const protocols = specArray(specs, "Supported Protocols"); + let best: number | null = null; + for (const p of protocols) { + const t = p.toLowerCase(); + let sp: number | null = null; + if (/\bfast ethernet\b/.test(t)) sp = 0.1; + else if (/\bgigabit ethernet\b/.test(t)) sp = 1; + else { + const m = t.match(/\b(\d+(?:\.\d+)?)\s*g(?:b|be|ig)?\s*ethernet\b/) || t.match(/\b(\d+)\s*gbe\b/); + if (m) sp = parseFloat(m[1]); + } + if (sp !== null && (best === null || sp > best)) best = sp; + } + if (best !== null) return best; + const bw = specValue(specs, "Bandwidth"); + if (bw) { + const gbits = Array.from(bw.matchAll(/([\d.]+)\s*gbit\/s/gi)).map(m => parseFloat(m[1])); + if (gbits.length) return mapLineRateToNominal(Math.max(...gbits)); + } + return null; +} + +/** Format a nominal Gbps value as a display label ("1G", "100G", "1.6T"). */ +function speedLabelGbps(g: number | null): string | null { + if (g === null) return null; + if (g >= 1000) return `${g / 1000}T`; + if (g < 1) return `${Math.round(g * 1000)}M`; + return `${g}G`; +} + /** * Parse the flat specifications array into structured fields. */ @@ -194,6 +257,8 @@ function parseSpecs(specs: FxApiSpec[]): ParsedSpecs { const rxPowers = parseDbm(specValue(specs, "Receiver min/max per lane")); return { + formFactor: normalizeFormFactorSpec(specValue(specs, "Form Factor")), + speedGbps: nominalSpeedFromSpecs(specs), complianceCode: specValue(specs, "Compliance Code"), laserType: specValue(specs, "Laser"), receiverType: specValue(specs, "Receiver Type"), @@ -349,6 +414,9 @@ async function writeDetails( await pool.query(` UPDATE transceivers SET + form_factor = COALESCE($23, form_factor), + speed_gbps = COALESCE($24, speed_gbps), + speed = COALESCE($25, speed), fx_specifications = $1, fx_compatibilities = $2, compliance_code = COALESCE(compliance_code, $3), @@ -396,6 +464,9 @@ async function writeDetails( product.image ?? null, // $20 product.url ?? null, // $21 transceiverId, // $22 + parsed.formFactor, // $23 + parsed.speedGbps, // $24 + speedLabelGbps(parsed.speedGbps), // $25 ]); }