Rene Fichtmueller 1ea73112c6 feat: Flexoptix compatibility scraper + transceiver issue scanner
- Add flexoptix-compat.ts: maps switch models to compatible Flexoptix transceivers
  via search API (vendor_compat) with form-factor fallback (spec_match)
  Scheduled daily at 09:00 UTC as scrape:compat:flexoptix
- Enhance community-issues.ts: add vendor advisory sources (Cisco Field Notices,
  Juniper KB, SONiC GitHub Issues) + new scrapeTransceiverCompatIssues() that
  searches for switch+transceiver combination problems specifically
- Scheduler: 59 schedules, 78 workers
2026-04-20 22:50:57 +02:00

250 lines
8.6 KiB
TypeScript

/**
* Flexoptix Switch Compatibility Scraper
*
* Populates the `compatibility` table by querying Flexoptix search/product API
* with each switch model number. Flexoptix names compatible transceivers like
* "Cisco N9K-C9364C compatible QSFP28 100GBASE-LR4" — so searching by switch
* model returns all transceivers that are explicitly marked compatible.
*
* Strategy:
* 1. For each switch in DB, query Flexoptix AJAX search
* 2. Parse returned transceiver names for form-factor and vendor hints
* 3. Match against transceivers table (Flexoptix vendor)
* 4. Insert into compatibility with status = 'compatible'
*
* Fallback (when search returns nothing):
* Form-factor matching — for each port type in switch.ports_config,
* find Flexoptix transceivers matching that form factor.
* Used as spec_match rather than vendor_compat — shown in dashboard
* as "by form factor" rather than "explicitly verified".
*
* Rate limit: 1 req/1.5sec.
*/
import { pool } from "../utils/db";
const BASE = "https://www.flexoptix.net";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; TIP-Bot/1.0; internal-flexoptix)",
Accept: "application/json, text/html",
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ── Ports-config → form factor map ─────────────────────────────────────────
const PORT_KEY_TO_FORM_FACTOR: Array<[RegExp, string]> = [
[/QSFP-DD/i, "QSFP-DD"],
[/QSFP-DD800/i, "QSFP-DD800"],
[/OSFP224/i, "OSFP224"],
[/OSFP/i, "OSFP"],
[/QSFP28/i, "QSFP28"],
[/QSFP\+/i, "QSFP+"],
[/QSFP/i, "QSFP+"],
[/SFP28/i, "SFP28"],
[/SFP\+/i, "SFP+"],
[/SFP/i, "SFP"],
[/CFP2/i, "CFP2"],
[/CFP4/i, "CFP4"],
[/CFP/i, "CFP"],
];
function portsConfigToFormFactors(portsConfig: Record<string, number>): string[] {
const factors = new Set<string>();
for (const key of Object.keys(portsConfig)) {
for (const [pattern, ff] of PORT_KEY_TO_FORM_FACTOR) {
if (pattern.test(key)) {
factors.add(ff);
break;
}
}
}
return [...factors];
}
// ── Flexoptix search API ────────────────────────────────────────────────────
interface FlexoptixSuggestion {
name: string;
url: string;
sku?: string;
image?: string;
price?: string;
}
async function searchFlexoptix(query: string): Promise<FlexoptixSuggestion[]> {
const url = `${BASE}/en/search/ajax/suggest/?q=${encodeURIComponent(query)}`;
try {
const resp = await fetch(url, {
headers: HEADERS,
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return [];
const data = await resp.json() as unknown;
// Magento suggest returns array or object with products key
if (Array.isArray(data)) return data as FlexoptixSuggestion[];
const obj = data as Record<string, unknown>;
if (obj["products"] && Array.isArray(obj["products"])) {
return obj["products"] as FlexoptixSuggestion[];
}
return [];
} catch {
return [];
}
}
// ── Main scraper ────────────────────────────────────────────────────────────
export async function scrapeFlexoptixCompatibility(): Promise<void> {
console.log("=== Flexoptix Compatibility Scraper ===\n");
// Get all switches
const { rows: switches } = await pool.query<{
id: string;
model: string;
vendor_name: string;
ports_config: Record<string, number> | null;
}>(
`SELECT sw.id, sw.model, v.name AS vendor_name, sw.ports_config
FROM switches sw
JOIN vendors v ON v.id = sw.vendor_id
ORDER BY sw.max_speed_gbps DESC`,
);
console.log(` ${switches.length} switches to process\n`);
// Get Flexoptix vendor ID
const { rows: [flexoptixVendor] } = await pool.query<{ id: string }>(
`SELECT id FROM vendors WHERE UPPER(name) LIKE '%FLEXOPTIX%' LIMIT 1`,
);
if (!flexoptixVendor) {
console.error(" [FAIL] Flexoptix vendor not found in DB — run flexoptix-catalog scraper first");
return;
}
const flexoptixVendorId = flexoptixVendor.id;
let totalCompatAdded = 0;
let totalSwitchesProcessed = 0;
for (const sw of switches) {
console.log(`\n[${sw.vendor_name}] ${sw.model}`);
const addedForSwitch: string[] = [];
// ── Strategy 1: Search Flexoptix by switch model ──────────────────────
await sleep(1500);
const suggestions = await searchFlexoptix(sw.model);
const matchedBySku = new Set<string>();
if (suggestions.length > 0) {
console.log(` Search: ${suggestions.length} results`);
for (const suggestion of suggestions) {
if (!suggestion.sku && !suggestion.name) continue;
const sku = suggestion.sku?.trim();
const name = suggestion.name?.trim();
if (!sku && !name) continue;
// Find matching transceiver in DB
let txRow: { id: string } | undefined;
if (sku) {
const r = await pool.query<{ id: string }>(
`SELECT id FROM transceivers WHERE vendor_id = $1 AND (part_number = $2 OR slug = $3) LIMIT 1`,
[flexoptixVendorId, sku, sku.toLowerCase().replace(/[^a-z0-9]/g, "-")],
);
txRow = r.rows[0];
}
if (!txRow && name) {
// Try matching by name similarity to part_number
const r = await pool.query<{ id: string }>(
`SELECT id FROM transceivers
WHERE vendor_id = $1
AND (part_number ILIKE $2 OR standard_name ILIKE $3)
LIMIT 1`,
[flexoptixVendorId, `%${name.slice(0, 20)}%`, `%${name.slice(0, 20)}%`],
);
txRow = r.rows[0];
}
if (txRow) {
matchedBySku.add(txRow.id);
await upsertCompat(sw.id, txRow.id, "Flexoptix", "vendor_compat");
addedForSwitch.push(txRow.id);
}
}
console.log(` Vendor-compat matches: ${addedForSwitch.length}`);
}
// ── Strategy 2: Form-factor fallback ─────────────────────────────────
if (sw.ports_config) {
const formFactors = portsConfigToFormFactors(sw.ports_config);
console.log(` Form factors: ${formFactors.join(", ")}`);
for (const ff of formFactors) {
const { rows: txRows } = await pool.query<{ id: string }>(
`SELECT t.id
FROM transceivers t
WHERE t.vendor_id = $1
AND t.form_factor = $2
AND NOT EXISTS (
SELECT 1 FROM compatibility c
WHERE c.switch_id = $3 AND c.transceiver_id = t.id
)`,
[flexoptixVendorId, ff, sw.id],
);
for (const tx of txRows) {
if (matchedBySku.has(tx.id)) continue; // already added via search
await upsertCompat(sw.id, tx.id, "Form Factor Match", "spec_match");
addedForSwitch.push(tx.id);
}
if (txRows.length > 0) {
console.log(` Form-factor ${ff}: +${txRows.length} transceivers`);
}
}
}
totalCompatAdded += addedForSwitch.length;
totalSwitchesProcessed++;
console.log(` → Added ${addedForSwitch.length} compat entries`);
}
console.log(`\n=== Flexoptix Compatibility Scraper Complete ===`);
console.log(` Switches processed: ${totalSwitchesProcessed}`);
console.log(` Compatibility entries added: ${totalCompatAdded}`);
}
async function upsertCompat(
switchId: string,
transceiverId: string,
verifiedBy: string,
verificationMethod: string,
): Promise<void> {
await pool.query(
`INSERT INTO compatibility (switch_id, transceiver_id, verified_by, verification_method, status, source_url)
VALUES ($1, $2, $3, $4, 'compatible', 'https://www.flexoptix.net')
ON CONFLICT (switch_id, transceiver_id) DO UPDATE SET
verified_by = CASE
WHEN EXCLUDED.verification_method = 'vendor_compat' THEN EXCLUDED.verified_by
ELSE compatibility.verified_by
END,
verification_method = CASE
WHEN EXCLUDED.verification_method = 'vendor_compat' THEN EXCLUDED.verification_method
ELSE compatibility.verification_method
END`,
[switchId, transceiverId, verifiedBy, verificationMethod],
);
}
if (require.main === module) {
scrapeFlexoptixCompatibility()
.then(() => pool.end())
.catch((err) => { console.error("Fatal:", err); pool.end(); process.exit(1); });
}