feat: product intelligence layer — eBay enricher, community issues, datasheets+manuals API

- Migration 020: product_issues table, condition/marketplace on price_observations, features JSONB
- eBay enricher: switch features/description/refurb prices + transceiver condition pricing
- Community issues scraper: Reddit/ServeTheHome/Arista/Cisco community bug reports
- 7 pre-seeded issues (DCS-7800R3, SG350, QFX5120, CRS326, USW-Pro etc.)
- API: /switches/:id/issues + /switches/:id/documents endpoints
- Dashboard switch modal: features from DB, description, eBay refurb price, issues+docs async
- Datasheet finder for Arista/Cisco/Juniper/HPE vendor pages
- Scheduler: 4 new jobs (ebay enrichment nightly, community issues weekly)
This commit is contained in:
Rene Fichtmueller 2026-04-01 22:46:27 +02:00
parent 64074f988f
commit 4020ec77d9
8 changed files with 1272 additions and 14 deletions

View File

@ -5,6 +5,15 @@ Types: FEAT · FIX · UI · DATA · AI · INFRA
---
{"d":"2026-04-01","t":"FEAT","m":"Product Intelligence Layer (migration 020): product_issues table (forum/community bugs), condition+marketplace on price_observations, features JSONB on switches+transceivers"}
{"d":"2026-04-01","t":"FEAT","m":"eBay Enricher: scrapes eBay.de for switch/transceiver listings — extracts features, description, refurbished prices, images — nightly via pg-boss"}
{"d":"2026-04-01","t":"FEAT","m":"Community Issues Scraper: extracts known bugs/incompatibilities from Reddit, ServeTheHome, Arista Community, Cisco Community, NetworkEngineering SE"}
{"d":"2026-04-01","t":"DATA","m":"7 pre-seeded community issues: Arista QSFP28 EOS compatibility, Cisco SFP DOM bug, Juniper QFX5120 config tip, SG350 SFP speed limit, MikroTik CRS326 QoS, DCS-7800R3 QSA, UniFi third-party warning"}
{"d":"2026-04-01","t":"FEAT","m":"API: GET /api/switches/:id/issues — known community issues with severity, tags, source links; GET /api/switches/:id/documents — official datasheets+manuals"}
{"d":"2026-04-01","t":"UI","m":"Switch detail modal: shows features array from DB, description, eBay refurbished price, known issues with severity color coding, datasheets with download links"}
{"d":"2026-04-01","t":"FEAT","m":"Datasheet Finder: discovers and links official PDF datasheets/manuals from vendor sites (Arista, Cisco, Juniper, HPE) for existing switches"}
{"d":"2026-04-01","t":"DATA","m":"SMB/campus switch seed: 26 models across Cisco SG/CBS 350/550/CBS350, HPE Aruba 1820/2530/2930F, Ubiquiti UniFi Pro/Aggregation, MikroTik CRS326/354/504, Netgear M4300/M4500, Zyxel XGS"}
{"d":"2026-04-01","t":"FIX","m":"forecast.ts: fixed fiveYearProjection accessor (hype.forecast.fiveYearProjection[n] instead of hype.forecast[n])"}
{"d":"2026-04-01","t":"FEAT","m":"Procurement Intelligence Engine (WS0c): stock_snapshots, abc_classification, reorder_signals, product_lifecycle_events, market_intelligence tables"}
{"d":"2026-04-01","t":"FEAT","m":"Crawler LLM: Ollama-based two-stage extractor (page type detection + structured product extraction) with vendor profiles for 7 vendors"}
{"d":"2026-04-01","t":"FEAT","m":"ABC classification: dynamic A/B/C turnover scoring from price observations, compatibility breadth, vendor count — computed daily"}

View File

@ -228,16 +228,54 @@ export async function getSwitchById(id: string) {
export async function getSwitchDocuments(switchId: string) {
const result = await pool.query(
`SELECT pd.*, v.name as vendor_name
FROM product_documents pd
LEFT JOIN vendors v ON pd.vendor_id = v.id
WHERE pd.switch_id = $1
ORDER BY pd.doc_type, pd.title`,
`SELECT id, doc_type, title, source_url, download_url, language, version, is_official, vendor_doc_id, page_count, file_size_bytes, r2_key, created_at
FROM product_documents
WHERE switch_id = $1
ORDER BY is_official DESC, doc_type, title`,
[switchId]
);
return result.rows;
}
export async function getSwitchIssues(switchId: string) {
const result = await pool.query(
`SELECT id, source_type, source_name, source_url, title, summary, severity,
issue_tags, affected_firmware, fix_firmware, date_reported, is_resolved, confidence
FROM product_issues
WHERE switch_id = $1
ORDER BY
CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
date_reported DESC NULLS LAST`,
[switchId]
);
return result.rows;
}
export async function getTransceiverIssues(transceiverI: string) {
const result = await pool.query(
`SELECT id, source_type, source_name, source_url, title, summary, severity,
issue_tags, affected_firmware, fix_firmware, date_reported, is_resolved, confidence
FROM product_issues
WHERE transceiver_id = $1
ORDER BY
CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
date_reported DESC NULLS LAST`,
[transceiverI]
);
return result.rows;
}
export async function getTransceiverDocuments(transceiverI: string) {
const result = await pool.query(
`SELECT id, doc_type, title, source_url, download_url, language, version, is_official, vendor_doc_id, page_count, file_size_bytes, r2_key, created_at
FROM product_documents
WHERE transceiver_id = $1
ORDER BY is_official DESC, doc_type, title`,
[transceiverI]
);
return result.rows;
}
export async function getCompatibleTransceivers(switchId: string) {
const result = await pool.query(
`SELECT t.*, c.status, c.verified_by, c.notes as compat_notes,

View File

@ -1,5 +1,5 @@
import { Router, Request, Response } from "express";
import { searchSwitches, getSwitchById, getCompatibleTransceivers, getSwitchDocuments } from "../db/queries";
import { searchSwitches, getSwitchById, getCompatibleTransceivers, getSwitchDocuments, getSwitchIssues } from "../db/queries";
export const switchRouter = Router();
@ -52,6 +52,17 @@ switchRouter.get("/:id/documents", async (req: Request, res: Response) => {
}
});
// GET /api/switches/:id/issues — Known bugs, incompatibilities from community sources
switchRouter.get("/:id/issues", async (req: Request, res: Response) => {
try {
const issues = await getSwitchIssues(String(req.params.id));
res.json({ success: true, data: issues, total: issues.length });
} catch (err) {
console.error("Get switch issues error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// GET /api/switches/:id/compatibility — Compatible transceivers for a switch
switchRouter.get("/:id/compatibility", async (req: Request, res: Response) => {
try {

View File

@ -2465,16 +2465,34 @@ async function openSwitchDetail(id) {
h += '<div class="panel-section">Features</div>';
var features = [];
if (s.vxlan_support) features.push('VXLAN');
if (s.evpn_support) features.push('EVPN');
if (s.bgp_support) features.push('BGP');
if (s.mpls_support) features.push('MPLS');
if (s.openconfig_support) features.push('OpenConfig');
if (s.sonic_compatible) features.push('SONiC');
if (s.macsec_support) features.push('MACsec');
if (s.stacking_support) features.push('Stacking');
// Use JSONB features array from DB if populated, fall back to boolean flags
if (s.features && Array.isArray(s.features) && s.features.length > 0) {
features = s.features;
} else {
if (s.vxlan_support) features.push('VXLAN');
if (s.evpn_support) features.push('EVPN');
if (s.bgp_support) features.push('BGP');
if (s.mpls_support) features.push('MPLS');
if (s.openconfig_support) features.push('OpenConfig');
if (s.sonic_compatible) features.push('SONiC');
if (s.macsec_support) features.push('MACsec');
if (s.stacking_support) features.push('Stacking');
}
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + features.map(function(f) { return '<span class="b b-cyan">' + esc(f) + '</span>'; }).join('') + (features.length === 0 ? '<span class="dim">None listed</span>' : '') + '</div>';
// Description
if (s.description) {
h += '<div class="panel-section">Description</div>';
h += '<div style="font-size:0.78rem;color:var(--text-dim);line-height:1.6;padding:0.25rem 0">' + esc(s.description) + '</div>';
}
// eBay Refurbished Price
if (s.ebay_refurb_price_usd) {
h += '<div class="panel-section">Market Pricing</div>';
h += '<div class="panel-row"><span class="panel-row-label">Refurbished (eBay)</span><span class="panel-row-val" style="color:var(--yellow)">€' + parseFloat(s.ebay_refurb_price_usd).toFixed(0) + ' <span style="font-size:0.7rem;color:var(--text-dim)">(incl. warranty, market)</span></span></div>';
if (s.msrp_usd) h += '<div class="panel-row"><span class="panel-row-label">List Price (MSRP)</span><span class="panel-row-val">$' + parseFloat(s.msrp_usd).toFixed(0) + '</span></div>';
}
if (s.ports_config && Object.keys(s.ports_config).length > 0) {
h += '<div class="panel-section">Port Configuration</div>';
Object.keys(s.ports_config).forEach(function(k) {
@ -2482,6 +2500,14 @@ async function openSwitchDetail(id) {
});
}
// Known Issues placeholder (loaded async)
h += '<div class="panel-section" id="sw-issues-hdr-' + id + '" style="display:none">Known Issues <span id="sw-issues-cnt-' + id + '" style="background:#ff4d4d18;color:#ff4d4d;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-issues-body-' + id + '"></div>';
// Documents placeholder (loaded async)
h += '<div class="panel-section" id="sw-docs-hdr-' + id + '" style="display:none">Datasheets &amp; Manuals <span id="sw-docs-cnt-' + id + '" style="background:#2563eb18;color:#4287f5;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-docs-body-' + id + '"></div>';
var links = [];
if (s.product_page_url) links.push('<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Product Page</a>');
if (s.datasheet_url) links.push('<a href="' + esc(s.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Datasheet</a>');
@ -2516,6 +2542,72 @@ async function openSwitchDetail(id) {
buildDOM(el('panel-content'), h);
// Async: load known issues
api('/api/switches/' + id + '/issues').then(function(idata) {
var issues = idata.data || [];
if (issues.length === 0) return;
var hdr = document.getElementById('sw-issues-hdr-' + id);
var cnt = document.getElementById('sw-issues-cnt-' + id);
var body = document.getElementById('sw-issues-body-' + id);
if (!hdr || !body) return;
hdr.style.display = '';
if (cnt) cnt.textContent = issues.length;
var ih = '';
var severityColors = { critical: '#ff4d4d', warning: '#f59e0b', info: '#6b7280' };
var severityIcons = { critical: '🔴', warning: '⚠️', info: '' };
issues.forEach(function(issue) {
var col = severityColors[issue.severity] || '#6b7280';
var icon = severityIcons[issue.severity] || '';
ih += '<div style="border-left:3px solid ' + col + ';padding:0.5rem 0.75rem;margin:0.4rem 0;background:' + col + '10;border-radius:0 4px 4px 0">';
ih += '<div style="font-size:0.78rem;font-weight:600;color:' + col + '">' + icon + ' ' + esc(issue.title) + '</div>';
if (issue.summary) ih += '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.25rem;line-height:1.5">' + esc(issue.summary) + '</div>';
var meta = [];
if (issue.source_name) meta.push('<a href="' + esc(issue.source_url || '#') + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.68rem;text-decoration:none">' + esc(issue.source_name) + ' ↗</a>');
if (issue.affected_firmware) meta.push('<span style="font-size:0.68rem;color:var(--text-dim)">Affects: ' + esc(issue.affected_firmware) + '</span>');
if (issue.fix_firmware) meta.push('<span style="font-size:0.68rem;color:#22c55e">Fixed in: ' + esc(issue.fix_firmware) + '</span>');
if (issue.is_resolved) meta.push('<span style="font-size:0.68rem;color:#22c55e">✓ Resolved</span>');
if (issue.issue_tags && issue.issue_tags.length) {
issue.issue_tags.forEach(function(tag) { meta.push('<span style="background:#ffffff10;color:var(--text-dim);font-size:0.65rem;padding:1px 5px;border-radius:8px">' + esc(tag) + '</span>'); });
}
if (meta.length) ih += '<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.3rem;align-items:center">' + meta.join('') + '</div>';
ih += '</div>';
});
buildDOM(body, ih);
}).catch(function() {});
// Async: load datasheets & manuals
api('/api/switches/' + id + '/documents').then(function(ddata) {
var docs = ddata.data || [];
if (docs.length === 0) return;
var hdr = document.getElementById('sw-docs-hdr-' + id);
var cnt = document.getElementById('sw-docs-cnt-' + id);
var body = document.getElementById('sw-docs-body-' + id);
if (!hdr || !body) return;
hdr.style.display = '';
if (cnt) cnt.textContent = docs.length;
var docTypeLabels = { datasheet: '📄 Datasheet', user_guide: '📖 Manual', release_notes: '📋 Release Notes', app_note: '📝 App Note', product_page: '🌐 Product Page', config_guide: '⚙️ Config Guide' };
var docTypeColors = { datasheet: '#ff6600', user_guide: '#4287f5', release_notes: '#22c55e', app_note: '#f59e0b', product_page: '#8b5cf6', config_guide: '#06b6d4' };
var dh = '<div style="display:flex;flex-direction:column;gap:0.35rem;padding:0.25rem 0">';
docs.forEach(function(doc) {
var url = doc.download_url || doc.source_url;
var label = docTypeLabels[doc.doc_type] || doc.doc_type;
var col = docTypeColors[doc.doc_type] || 'var(--accent)';
var officialBadge = doc.is_official ? '<span style="background:#22c55e18;color:#22c55e;font-size:0.65rem;padding:1px 5px;border-radius:8px;margin-left:0.4rem">Official</span>' : '';
var langBadge = doc.language && doc.language !== 'en' ? '<span style="background:#ffffff10;color:var(--text-dim);font-size:0.65rem;padding:1px 5px;border-radius:8px;margin-left:0.3rem">' + esc(doc.language.toUpperCase()) + '</span>' : '';
dh += '<div style="display:flex;align-items:center;gap:0.5rem">';
if (url) {
dh += '<a href="' + esc(url) + '" target="_blank" rel="noopener" style="color:' + col + ';font-size:0.78rem;font-weight:600;text-decoration:none">' + label + '</a>';
} else {
dh += '<span style="color:' + col + ';font-size:0.78rem;font-weight:600">' + label + '</span>';
}
dh += '<span style="font-size:0.72rem;color:var(--text-dim)">' + esc(doc.title) + '</span>';
dh += officialBadge + langBadge;
dh += '</div>';
});
dh += '</div>';
buildDOM(body, dh);
}).catch(function() {});
api('/api/switches/' + id + '/compatibility').then(function(cdata) {
var txList = cdata.data || cdata.transceivers || [];
if (txList.length === 0) return;

View File

@ -70,6 +70,10 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
"scrape:market-intel",
"compute:abc",
"compute:reorder-signals",
"enrich:ebay-switches",
"enrich:ebay-transceivers",
"scrape:community-issues",
"scrape:datasheet-links",
];
for (const q of queues) {
await boss.createQueue(q).catch(() => { /* already exists */ });
@ -161,6 +165,30 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
expireInSeconds: 600,
});
// eBay switch enrichment: features, descriptions, refurb prices (nightly at 1am)
await boss.schedule("enrich:ebay-switches", "0 1 * * *", {}, {
retryLimit: 1,
expireInSeconds: 7200,
});
// eBay transceiver pricing with condition (nightly at 2am)
await boss.schedule("enrich:ebay-transceivers", "0 2 * * *", {}, {
retryLimit: 1,
expireInSeconds: 7200,
});
// Community issues scraping: Reddit/forums for known bugs (weekly on Sunday 4am)
await boss.schedule("scrape:community-issues", "0 4 * * 0", {}, {
retryLimit: 1,
expireInSeconds: 3600,
});
// Datasheet link discovery (weekly on Monday 6am)
await boss.schedule("scrape:datasheet-links", "0 6 * * 1", {}, {
retryLimit: 1,
expireInSeconds: 3600,
});
console.log("All schedules registered");
}
@ -247,5 +275,29 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
await computeReorderSignals();
});
await boss.work("enrich:ebay-switches", async (_job) => {
console.log(`[${new Date().toISOString()}] Running: eBay switch enrichment`);
const { enrichSwitchesFromEbay } = await import("./scrapers/ebay-enricher");
await withIsolatedStorage("ebay-switches", () => enrichSwitchesFromEbay(30));
});
await boss.work("enrich:ebay-transceivers", async (_job) => {
console.log(`[${new Date().toISOString()}] Running: eBay transceiver pricing`);
const { enrichTransceiversFromEbay } = await import("./scrapers/ebay-enricher");
await withIsolatedStorage("ebay-transceivers", () => enrichTransceiversFromEbay(100));
});
await boss.work("scrape:community-issues", async (_job) => {
console.log(`[${new Date().toISOString()}] Running: Community issues scraping`);
const { scrapeAllSwitchIssues } = await import("./scrapers/community-issues");
await withIsolatedStorage("community-issues", () => scrapeAllSwitchIssues(30));
});
await boss.work("scrape:datasheet-links", async (_job) => {
console.log(`[${new Date().toISOString()}] Running: Datasheet link discovery`);
const { findAndSeedDatasheetLinks } = await import("./scrapers/community-issues");
await findAndSeedDatasheetLinks(50);
});
console.log("All workers registered");
}

View File

@ -0,0 +1,391 @@
/**
* Community Issues Scraper
*
* Scrapes known issues, bugs, incompatibilities from:
* - Reddit r/networking, r/homelab, r/sysadmin
* - ServeTheHome forums
* - Arista Community / EOS Central
* - Cisco Community
* - Juniper Community
* - NetworkEngineering StackExchange
* - GitHub Issues (for SONiC, OpenConfig, etc.)
*
* Uses Crawler LLM to extract structured issue data.
*/
import { CheerioCrawler, RequestQueue } from "crawlee";
import { extractMarketIntel } from "../crawler-llm/core";
import { db as pool } from "../utils/db";
import { logger } from "../utils/logger";
interface ExtractedIssue {
productModel: string;
title: string;
summary: string;
severity: "info" | "warning" | "critical";
issueTags: string[];
affectedFirmware: string | null;
fixFirmware: string | null;
dateReported: string | null;
isResolved: boolean;
confidence: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Search URL builders per source
// ─────────────────────────────────────────────────────────────────────────────
const COMMUNITY_SOURCES: Array<{
name: string;
type: string;
buildSearchUrl: (model: string) => string;
}> = [
{
name: "Reddit r/networking",
type: "reddit",
buildSearchUrl: (model) =>
`https://www.reddit.com/r/networking/search/?q=${encodeURIComponent(model + " issue")}&sort=relevance&t=all`,
},
{
name: "Reddit r/homelab",
type: "reddit",
buildSearchUrl: (model) =>
`https://www.reddit.com/r/homelab/search/?q=${encodeURIComponent(model)}&sort=relevance&t=all`,
},
{
name: "ServeTheHome",
type: "forum",
buildSearchUrl: (model) =>
`https://forums.servethehome.com/index.php?search/1/?q=${encodeURIComponent(model)}&t=post&c[users]=&o=date`,
},
{
name: "Arista Community",
type: "vendor_kb",
buildSearchUrl: (model) =>
`https://eos.arista.com/?s=${encodeURIComponent(model)}`,
},
{
name: "Cisco Community",
type: "vendor_kb",
buildSearchUrl: (model) =>
`https://community.cisco.com/t5/forums/searchpage/tab/message?q=${encodeURIComponent(model + " transceiver issue")}&collapse_discussion=true`,
},
{
name: "NetworkEngineering SE",
type: "forum",
buildSearchUrl: (model) =>
`https://networkengineering.stackexchange.com/search?q=${encodeURIComponent(model)}`,
},
];
// ─────────────────────────────────────────────────────────────────────────────
// Determine severity from extracted intel
// ─────────────────────────────────────────────────────────────────────────────
function determineSeverity(text: string): "info" | "warning" | "critical" {
const lower = text.toLowerCase();
if (
lower.includes("security") ||
lower.includes("vulnerability") ||
lower.includes("cve") ||
lower.includes("crash") ||
lower.includes("data loss") ||
lower.includes("critical")
) return "critical";
if (
lower.includes("not working") ||
lower.includes("incompatib") ||
lower.includes("failure") ||
lower.includes("not recognized") ||
lower.includes("port down") ||
lower.includes("bug") ||
lower.includes("workaround")
) return "warning";
return "info";
}
// ─────────────────────────────────────────────────────────────────────────────
// Extract issue tags from text
// ─────────────────────────────────────────────────────────────────────────────
function extractIssueTags(text: string): string[] {
const lower = text.toLowerCase();
const tags: string[] = [];
const tagMap: [RegExp, string][] = [
[/firmware|eos|junos|nxos|iosxe/, "firmware"],
[/interop|compatibility|third.party/, "interop"],
[/macsec|encryption|security/, "macsec"],
[/temperature|thermal|overheating/, "thermal"],
[/dom|digital optical|ddm/, "dom"],
[/breakout|split|qsa|adapter/, "breakout"],
[/sfp\+?|qsfp|osfp|cfp/, "transceiver"],
[/vxlan|evpn|bgp|ospf/, "routing"],
[/poe|power/, "poe"],
[/stacking|lag|lacp/, "stacking"],
[/memory|buffer|overflow/, "memory"],
[/driver|module|kernel/, "driver"],
[/snmp|telemetry|monitoring/, "monitoring"],
[/latency|performance|throughput/, "performance"],
];
for (const [pattern, tag] of tagMap) {
if (pattern.test(lower)) tags.push(tag);
}
return [...new Set(tags)];
}
// ─────────────────────────────────────────────────────────────────────────────
// Save extracted issues to DB
// ─────────────────────────────────────────────────────────────────────────────
async function saveIssue(
issue: ExtractedIssue,
sourceUrl: string,
sourceName: string,
sourceType: string
): Promise<void> {
// Find product ID
const switchResult = await pool.query(
`SELECT id FROM switches WHERE model ILIKE $1 OR model ILIKE '%' || $2 || '%' LIMIT 1`,
[issue.productModel, issue.productModel.split("-")[0]]
);
const transceiverResult = await pool.query(
`SELECT id FROM transceivers WHERE part_number ILIKE $1 OR slug ILIKE $2 LIMIT 1`,
[issue.productModel, issue.productModel.toLowerCase().replace(/\s+/g, "-")]
);
const switchId = switchResult.rows[0]?.id || null;
const transceiverI = transceiverResult.rows[0]?.id || null;
if (!switchId && !transceiverI) {
// Unknown product — still store with model name for future lookup
logger.debug(`Issue for unknown product: ${issue.productModel}`);
}
await pool.query(
`INSERT INTO product_issues (
switch_id, transceiver_id, product_model,
source_type, source_name, source_url,
title, summary, severity, issue_tags,
affected_firmware, fix_firmware,
date_reported, is_resolved, confidence
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT DO NOTHING`,
[
switchId, transceiverI, issue.productModel,
sourceType, sourceName, sourceUrl,
issue.title, issue.summary, issue.severity, issue.issueTags,
issue.affectedFirmware, issue.fixFirmware,
issue.dateReported, issue.isResolved, issue.confidence,
]
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main: scrape community issues for given switch/transceiver models
// ─────────────────────────────────────────────────────────────────────────────
export async function scrapeProductIssues(
models: string[],
sourceLimit = 3
): Promise<void> {
const queue = await RequestQueue.open("community-issues");
// Add search requests for each model × source combination
for (const model of models) {
const sources = COMMUNITY_SOURCES.slice(0, sourceLimit);
for (const source of sources) {
await queue.addRequest({
url: source.buildSearchUrl(model),
userData: { model, sourceName: source.name, sourceType: source.type },
uniqueKey: `${source.name}-${model}`,
});
}
}
const crawler = new CheerioCrawler({
requestQueue: queue,
maxConcurrency: 2,
requestHandlerTimeoutSecs: 30,
navigationTimeoutSecs: 20,
async requestHandler({ request, $, response }) {
const { model, sourceName, sourceType } = request.userData as {
model: string; sourceName: string; sourceType: string;
};
// Extract text content for LLM analysis
// Remove nav, scripts, ads for cleaner input
$("nav, script, style, .ad, #sidebar, footer, header").remove();
const pageText = $("body").text().replace(/\s+/g, " ").substring(0, 8000);
if (pageText.length < 100) return;
// Use Crawler LLM market intel extractor to find issues
const prompt = `You are analyzing a networking community forum/search results page.
Find any reports of problems, bugs, incompatibilities, or issues specifically about the networking device "${model}".
For each issue found, extract:
- title: brief description of the issue
- summary: 1-2 sentence explanation including cause and workaround if mentioned
- severity: "critical" (security/crash/data loss), "warning" (functional problem, workaround needed), or "info" (minor/cosmetic)
- affectedFirmware: firmware version where issue occurs (or null)
- fixFirmware: firmware version where it's fixed (or null)
- isResolved: true/false
- tags: array of relevant tags from: firmware, interop, thermal, dom, breakout, performance, security, config
Page text: ${pageText}
Return valid JSON array: [{"title":"...","summary":"...","severity":"...","affectedFirmware":null,"fixFirmware":null,"isResolved":false,"tags":[]}]
If no issues found, return []`;
try {
const intelResult = await extractMarketIntel(pageText, request.url, sourceName);
if (intelResult && intelResult.title) {
const issue: ExtractedIssue = {
productModel: model,
title: intelResult.title.substring(0, 200),
summary: intelResult.description?.substring(0, 500) || "",
severity: determineSeverity(intelResult.description || intelResult.title),
issueTags: extractIssueTags(`${intelResult.title} ${intelResult.description}`),
affectedFirmware: null,
fixFirmware: null,
dateReported: intelResult.publishedDate || null,
isResolved: false,
confidence: intelResult.confidence || 0.6,
};
await saveIssue(issue, request.url, sourceName, sourceType);
logger.info(`Issue saved: ${model}${issue.title.substring(0, 60)}`);
}
} catch (err) {
logger.warn(`Issue extraction failed for ${model} from ${sourceName}`, { err });
}
},
failedRequestHandler: ({ request, error }) => {
logger.warn(`Community scraper failed: ${request.url}`, { error });
},
});
await crawler.run();
logger.info(`Community issues scraping complete for ${models.length} models`);
}
// ─────────────────────────────────────────────────────────────────────────────
// Scrape issues for all switches in DB
// ─────────────────────────────────────────────────────────────────────────────
export async function scrapeAllSwitchIssues(limit = 30): Promise<void> {
const result = await pool.query<{ model: string }>(
`SELECT sw.model FROM switches sw
WHERE NOT EXISTS (
SELECT 1 FROM product_issues pi WHERE pi.product_model = sw.model
)
ORDER BY sw.max_speed_gbps DESC
LIMIT $1`,
[limit]
);
const models = result.rows.map(r => r.model);
if (models.length === 0) {
logger.info("All switches already have issue data");
return;
}
logger.info(`Scraping community issues for ${models.length} switches`);
await scrapeProductIssues(models, 2); // 2 sources per switch to avoid rate limits
}
// ─────────────────────────────────────────────────────────────────────────────
// Scrape datasheet links for switches/transceivers
// ─────────────────────────────────────────────────────────────────────────────
interface DatasheetSource {
vendor: string;
pattern: (model: string) => string | null;
}
const DATASHEET_SOURCES: DatasheetSource[] = [
{
vendor: "Arista",
pattern: (model) => {
const series = model.match(/DCS-(\d+)/)?.[1];
if (!series) return null;
return `https://www.arista.com/en/products/fixedconfiguration/${series.toLowerCase()}`;
},
},
{
vendor: "Cisco",
pattern: (model) => {
const lower = model.toLowerCase().replace(/\s+/g, "-");
return `https://www.cisco.com/c/en/us/products/collateral/switches/search.html?q=${encodeURIComponent(model)}`;
},
},
{
vendor: "Juniper",
pattern: (model) => {
const series = model.split("-")[0]?.toLowerCase();
if (!series) return null;
return `https://www.juniper.net/documentation/product/${series}.html`;
},
},
{
vendor: "HPE Aruba",
pattern: (model) =>
`https://h20195.www2.hpe.com/v2/getpdf.aspx/a00${model.replace(/[^a-z0-9]/gi, "").toLowerCase()}.pdf`,
},
];
export async function findAndSeedDatasheetLinks(limit = 50): Promise<void> {
const result = await pool.query<{ id: string; model: string; vendor_name: string }>(
`SELECT sw.id, sw.model, v.name AS vendor_name
FROM switches sw
JOIN vendors v ON sw.vendor_id = v.id
WHERE NOT EXISTS (
SELECT 1 FROM product_documents pd WHERE pd.switch_id = sw.id
)
LIMIT $1`,
[limit]
);
for (const sw of result.rows) {
for (const source of DATASHEET_SOURCES) {
if (!sw.vendor_name.toLowerCase().includes(source.vendor.toLowerCase())) continue;
const url = source.pattern(sw.model);
if (!url) continue;
try {
// Check if URL is accessible (simple HEAD request)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const resp = await fetch(url, { method: "HEAD", signal: controller.signal });
clearTimeout(timeout);
if (resp.ok) {
const docType = url.includes(".pdf") ? "datasheet" : "product_page";
await pool.query(
`INSERT INTO product_documents (switch_id, doc_type, title, source_url, is_official, language)
VALUES ($1, $2, $3, $4, TRUE, 'en')
ON CONFLICT DO NOTHING`,
[sw.id, docType, `${sw.vendor_name} ${sw.model} ${docType.replace("_", " ")}`, url]
);
logger.info(`✓ Doc linked: ${sw.model}${url}`);
}
} catch {
// URL not accessible — skip silently
}
}
}
}
// CLI entrypoint
if (require.main === module) {
(async () => {
const cmd = process.argv[2] || "issues";
if (cmd === "issues") {
await scrapeAllSwitchIssues(parseInt(process.argv[3] || "30"));
} else if (cmd === "datasheets") {
await findAndSeedDatasheetLinks(parseInt(process.argv[3] || "50"));
}
process.exit(0);
})();
}

View File

@ -0,0 +1,450 @@
/**
* eBay Product Enricher
*
* Searches eBay for switch/transceiver models to extract:
* - Product description & features
* - Refurbished/used prices
* - Product images
* - Technical specs from listing descriptions
*
* Uses CheerioCrawler + Crawler LLM for structured extraction.
*/
import { CheerioCrawler, RequestQueue } from "crawlee";
import { scrapeWithLLM } from "../crawler-llm/core";
import { db } from "../utils/db";
import { logger } from "../utils/logger";
interface EbayListing {
title: string;
price: number;
currency: string;
condition: "new" | "refurbished" | "used";
seller: string;
warrantyMonths: number | null;
imageUrl: string | null;
listingUrl: string;
itemId: string;
description: string;
features: string[];
specs: Record<string, string>;
}
interface EnrichResult {
model: string;
listings: EbayListing[];
bestRefurbPrice: number | null;
bestNewPrice: number | null;
features: string[];
description: string;
imageUrl: string | null;
}
// eBay search URL for .de (EUR pricing, covers DE/EU market)
function buildSearchUrl(query: string, page = 1): string {
const encoded = encodeURIComponent(query);
const offset = (page - 1) * 50;
return `https://www.ebay.de/sch/i.html?_nkw=${encoded}&_sop=15&LH_ItemCondition=3000%7C1500%7C1000&_ipg=50&_pgn=${page}&_stpos=0&_from=R40`;
}
// Parse eBay condition string to our condition type
function parseCondition(condStr: string): "new" | "refurbished" | "used" {
const lower = condStr.toLowerCase();
if (lower.includes("neu") || lower.includes("new")) return "new";
if (lower.includes("refurb") || lower.includes("überholt") || lower.includes("generalüber")) return "refurbished";
return "used";
}
// Extract warranty months from listing title/description
function extractWarranty(text: string): number | null {
const patterns = [
/(\d+)\s*[-]?\s*month\s*warrant/i,
/(\d+)\s*[-]?\s*monat\s*gewähr/i,
/(\d+)\s*[-]?\s*year\s*warrant/i,
/(\d+)\s*[-]?\s*jahr\s*gewähr/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match && match[1]) {
const num = parseInt(match[1]);
return pattern.source.includes("year") || pattern.source.includes("jahr") ? num * 12 : num;
}
}
return null;
}
async function parseSearchResults($: cheerio.CheerioAPI, baseUrl: string): Promise<Array<{ title: string; url: string; price: string; condition: string; imageUrl: string }>> {
const items: Array<{ title: string; url: string; price: string; condition: string; imageUrl: string }> = [];
$(".s-item").each((_, el) => {
const titleEl = $(el).find(".s-item__title");
const priceEl = $(el).find(".s-item__price");
const condEl = $(el).find(".SECONDARY_INFO");
const linkEl = $(el).find(".s-item__link");
const imgEl = $(el).find(".s-item__image-img");
const title = titleEl.text().trim();
const price = priceEl.text().trim();
const condition = condEl.text().trim();
const url = linkEl.attr("href") || "";
const imageUrl = imgEl.attr("src") || imgEl.attr("data-src") || "";
if (title && url && !title.toLowerCase().includes("shop on ebay")) {
items.push({ title, url, price, condition, imageUrl });
}
});
return items;
}
async function enrichSwitchFromEbay(switchId: string, model: string): Promise<EnrichResult | null> {
const result: EnrichResult = {
model,
listings: [],
bestRefurbPrice: null,
bestNewPrice: null,
features: [],
description: "",
imageUrl: null,
};
const queue = await RequestQueue.open(`ebay-${switchId.substring(0, 8)}`);
await queue.addRequest({ url: buildSearchUrl(model), userData: { model, phase: "search" } });
const crawler = new CheerioCrawler({
requestQueue: queue,
maxRequestsPerCrawl: 5,
requestHandlerTimeoutSecs: 30,
async requestHandler({ request, $, crawler }) {
const { phase, model } = request.userData as { phase: string; model: string };
if (phase === "search") {
const items = await parseSearchResults($, request.url);
// Take up to 3 most relevant listings
const relevant = items.filter(item =>
item.title.toLowerCase().includes(model.toLowerCase().split("-")[0]?.toLowerCase() ?? "")
).slice(0, 3);
for (const item of relevant) {
if (item.url && item.url.startsWith("http")) {
await crawler.addRequests([{
url: item.url.split("?")[0]!,
userData: {
phase: "listing",
model,
priceStr: item.price,
conditionStr: item.condition,
imageUrl: item.imageUrl,
title: item.title,
},
}]);
}
}
} else if (phase === "listing") {
const { title, priceStr, conditionStr, imageUrl: searchImageUrl, model } = request.userData as {
title: string; priceStr: string; conditionStr: string; imageUrl: string; model: string;
};
// Use Crawler LLM to extract structured data from listing page
const html = $.html();
const extracted = await scrapeWithLLM(html, request.url, {
vendorSlug: "ebay",
extractType: "stock",
});
// Parse price from string (handle EUR format "1.234,56 EUR")
const priceClean = priceStr.replace(/[^\d,.-]/g, "").replace(".", "").replace(",", ".");
const price = parseFloat(priceClean) || 0;
const condition = parseCondition(conditionStr);
const warranty = extractWarranty(title);
// Extract image from listing page (higher quality than search thumbnail)
const listingImage = $(".ux-image-carousel-item img").first().attr("src")
|| $(".img img").first().attr("src")
|| searchImageUrl;
// Extract features from item specifics table
const features: string[] = [];
$(".ux-labels-values").each((_, el) => {
const label = $(el).find(".ux-labels-values__labels").text().trim();
const value = $(el).find(".ux-labels-values__values").text().trim();
if (label && value && value !== "Siehe Anzeige") {
features.push(`${label}: ${value}`);
}
});
// Extract description
const description = extracted?.description
|| $(".ux-textspans--BOLD").first().text().trim()
|| "";
const listing: EbayListing = {
title,
price,
currency: "EUR",
condition,
seller: $(".ux-seller-section__item--seller a").text().trim() || "unknown",
warrantyMonths: warranty,
imageUrl: listingImage || null,
listingUrl: request.url,
itemId: request.url.match(/\/itm\/(\d+)/)?.[1] || "",
description,
features,
specs: {},
};
result.listings.push(listing);
// Track best prices
if (price > 0) {
if (condition === "refurbished" || condition === "used") {
if (!result.bestRefurbPrice || price < result.bestRefurbPrice) {
result.bestRefurbPrice = price;
}
} else if (condition === "new") {
if (!result.bestNewPrice || price < result.bestNewPrice) {
result.bestNewPrice = price;
}
}
}
// Collect features for switch enrichment
if (features.length > 0 && result.features.length === 0) {
result.features = features.slice(0, 10);
}
// Use best image
if (!result.imageUrl && listingImage) {
result.imageUrl = listingImage;
}
// Use first good description
if (!result.description && description.length > 50) {
result.description = description.substring(0, 500);
}
}
},
failedRequestHandler: ({ request, error }) => {
logger.warn(`eBay enricher failed for ${request.url}: ${error}`);
},
});
try {
await crawler.run();
} catch (err) {
logger.error("eBay crawler run error", { err, model });
}
return result.listings.length > 0 ? result : null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Save enrichment results to DB
// ─────────────────────────────────────────────────────────────────────────────
async function saveEnrichment(switchId: string, result: EnrichResult): Promise<void> {
const { db: pool } = await import("../utils/db");
// Update switch: features, description, refurb price, image
const updateFields: string[] = ["ebay_enriched_at = NOW()"];
const params: unknown[] = [];
let idx = 1;
if (result.features.length > 0) {
updateFields.push(`features = $${idx}::jsonb`);
params.push(JSON.stringify(result.features));
idx++;
}
if (result.description) {
updateFields.push(`description = COALESCE(description, $${idx})`);
params.push(result.description);
idx++;
}
if (result.bestRefurbPrice) {
updateFields.push(`ebay_refurb_price_usd = $${idx}`);
params.push(result.bestRefurbPrice);
idx++;
}
if (result.imageUrl && result.imageUrl.startsWith("http")) {
// Only set image_url if not already set
updateFields.push(`image_url = COALESCE(NULLIF(image_url, ''), $${idx})`);
params.push(result.imageUrl);
idx++;
}
params.push(switchId);
await pool.query(
`UPDATE switches SET ${updateFields.join(", ")} WHERE id = $${idx}`,
params
);
// Find eBay vendor ID (create if needed)
const ebayVendorResult = await pool.query(
`INSERT INTO vendors (name, slug, type, website_url)
VALUES ('eBay Marketplace', 'ebay', 'marketplace', 'https://www.ebay.de')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id`
);
const ebayVendorId = ebayVendorResult.rows[0]?.id;
if (!ebayVendorId) return;
// For each listing that has a price, we need a transceiver_id or we skip
// (price_observations requires transceiver_id — for switches we'll use a different approach later)
// For now, just log the refurb price data
logger.info("eBay enrichment saved", {
model: result.model,
listingsCount: result.listings.length,
bestRefurb: result.bestRefurbPrice,
featuresCount: result.features.length,
hasImage: !!result.imageUrl,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Main: enrich switches that haven't been enriched yet
// ─────────────────────────────────────────────────────────────────────────────
export async function enrichSwitchesFromEbay(limit = 20): Promise<void> {
const { db: pool } = await import("../utils/db");
const switches = await pool.query<{ id: string; model: string; vendor_name: string }>(
`SELECT sw.id, sw.model, v.name AS vendor_name
FROM switches sw
JOIN vendors v ON sw.vendor_id = v.id
WHERE sw.ebay_enriched_at IS NULL
AND sw.max_speed_gbps >= 10
ORDER BY sw.max_speed_gbps DESC, sw.created_at ASC
LIMIT $1`,
[limit]
);
logger.info(`eBay enricher: processing ${switches.rows.length} switches`);
for (const sw of switches.rows) {
logger.info(`Enriching ${sw.model} from eBay...`);
try {
const result = await enrichSwitchFromEbay(sw.id, sw.model);
if (result) {
await saveEnrichment(sw.id, result);
logger.info(`${sw.model}: ${result.listings.length} listings, refurb €${result.bestRefurbPrice}`);
} else {
// Mark as tried even if no results
await pool.query("UPDATE switches SET ebay_enriched_at = NOW() WHERE id = $1", [sw.id]);
logger.info(`${sw.model}: no eBay listings found`);
}
} catch (err) {
logger.error(`${sw.model}: enrichment failed`, { err });
}
// Rate limiting — be polite to eBay
await new Promise(r => setTimeout(r, 3000 + Math.random() * 2000));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Enrich transceivers from eBay (price observations with condition)
// ─────────────────────────────────────────────────────────────────────────────
export async function enrichTransceiversFromEbay(limit = 50): Promise<void> {
const { db: pool } = await import("../utils/db");
// Find eBay vendor
const ebayVendor = await pool.query(
`INSERT INTO vendors (name, slug, type, website_url)
VALUES ('eBay Marketplace', 'ebay', 'marketplace', 'https://www.ebay.de')
ON CONFLICT (slug) DO UPDATE SET updated_at = NOW()
RETURNING id`
);
const ebayVendorId = ebayVendor.rows[0]?.id;
// Get transceivers without eBay price observations in last 30 days
const transceivers = await pool.query<{ id: string; slug: string; part_number: string; form_factor: string; speed_gbps: number }>(
`SELECT t.id, t.slug, t.part_number, t.form_factor, t.speed_gbps
FROM transceivers t
WHERE NOT EXISTS (
SELECT 1 FROM price_observations po
WHERE po.transceiver_id = t.id
AND po.marketplace = 'ebay'
AND po.time > NOW() - INTERVAL '30 days'
)
AND t.part_number IS NOT NULL
ORDER BY t.speed_gbps DESC
LIMIT $1`,
[limit]
);
logger.info(`eBay transceiver enricher: processing ${transceivers.rows.length} transceivers`);
const queue = await RequestQueue.open("ebay-transceivers");
for (const tcvr of transceivers.rows) {
const query = tcvr.part_number || `${tcvr.form_factor} ${tcvr.speed_gbps}G transceiver`;
await queue.addRequest({
url: buildSearchUrl(query),
userData: { transceiverI: tcvr.id, query, formFactor: tcvr.form_factor, speedGbps: tcvr.speed_gbps },
});
}
const crawler = new CheerioCrawler({
requestQueue: queue,
maxRequestsPerCrawl: limit,
requestHandlerTimeoutSecs: 20,
maxConcurrency: 2,
async requestHandler({ request, $ }) {
const { transceiverI, formFactor, speedGbps } = request.userData as {
transceiverI: string; query: string; formFactor: string; speedGbps: number;
};
const items = await parseSearchResults($, request.url);
const refurbItems = items.filter(i => {
const cond = i.condition.toLowerCase();
return cond.includes("refurb") || cond.includes("überholt") || cond.includes("generalüber");
});
const newItems = items.filter(i => i.condition.toLowerCase().includes("neu") || i.condition.toLowerCase().includes("new"));
const insertObs = async (item: { price: string; condition: string; imageUrl: string; title: string; url: string }, condition: "new" | "refurbished") => {
const priceClean = item.price.replace(/[^\d,.-]/g, "").replace(".", "").replace(",", ".");
const price = parseFloat(priceClean);
if (!price || price <= 0) return;
const warranty = extractWarranty(item.title);
await pool.query(
`INSERT INTO price_observations
(time, transceiver_id, source_vendor_id, price, currency, condition, marketplace, warranty_months, seller_name, listing_title, url, scrape_method, stock_level)
VALUES (NOW(), $1, $2, $3, 'EUR', $4, 'ebay', $5, $6, $7, $8, 'crawlee', 'in_stock')
ON CONFLICT DO NOTHING`,
[transceiverI, ebayVendorId, price, condition, warranty, "eBay Seller", item.title.substring(0, 200), item.url]
);
};
// Best refurbished price
if (refurbItems[0]) await insertObs(refurbItems[0], "refurbished");
// Best new price
if (newItems[0]) await insertObs(newItems[0], "new");
},
});
try {
await crawler.run();
} catch (err) {
logger.error("eBay transceiver crawler error", { err });
}
}
// CLI entrypoint
if (require.main === module) {
(async () => {
const target = process.argv[2] || "switches";
if (target === "switches") {
await enrichSwitchesFromEbay(parseInt(process.argv[3] || "20"));
} else {
await enrichTransceiversFromEbay(parseInt(process.argv[3] || "50"));
}
process.exit(0);
})();
}

View File

@ -0,0 +1,215 @@
-- Migration 020: Product Intelligence Layer
-- Adds: product_issues (forum/community issues), condition to price_observations,
-- features JSONB to switches/transceivers, document enrichment fields
-- ─────────────────────────────────────────────────────────────────────────────
-- 1. product_issues — community-reported problems, bugs, incompatibilities
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS product_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Product reference (either switch or transceiver)
switch_id UUID REFERENCES switches(id) ON DELETE CASCADE,
transceiver_id UUID REFERENCES transceivers(id) ON DELETE CASCADE,
product_model TEXT NOT NULL, -- denormalized for fast lookup
-- Source
source_type TEXT NOT NULL DEFAULT 'forum', -- forum, reddit, vendor_kb, security_advisory, interop_report
source_name TEXT NOT NULL, -- "Reddit r/networking", "ServeTheHome", "Arista Community", "Cisco Bug ID"
source_url TEXT,
-- Issue details
title TEXT NOT NULL,
summary TEXT,
severity TEXT DEFAULT 'info', -- info, warning, critical
issue_tags TEXT[] DEFAULT '{}', -- e.g. ['firmware', 'interop', 'temperature', 'macsec']
-- Affected versions / firmware
affected_firmware TEXT,
fix_firmware TEXT,
-- Dates
date_reported DATE,
date_resolved DATE,
is_resolved BOOLEAN DEFAULT FALSE,
-- Extraction metadata
confidence NUMERIC(3,2) DEFAULT 0.8,
extracted_by TEXT DEFAULT 'crawler_llm',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_product_issues_switch ON product_issues(switch_id) WHERE switch_id IS NOT NULL;
CREATE INDEX idx_product_issues_tcvr ON product_issues(transceiver_id) WHERE transceiver_id IS NOT NULL;
CREATE INDEX idx_product_issues_model ON product_issues(product_model);
CREATE INDEX idx_product_issues_severity ON product_issues(severity);
CREATE INDEX idx_product_issues_tags ON product_issues USING GIN(issue_tags);
-- ─────────────────────────────────────────────────────────────────────────────
-- 2. Add condition to price_observations (new / refurbished / used / demo)
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE price_observations
ADD COLUMN IF NOT EXISTS condition TEXT DEFAULT 'new'
CHECK (condition IN ('new', 'refurbished', 'used', 'demo', 'unknown')),
ADD COLUMN IF NOT EXISTS marketplace TEXT, -- 'ebay', 'amazon', 'alibaba', 'direct'
ADD COLUMN IF NOT EXISTS warranty_months INTEGER,
ADD COLUMN IF NOT EXISTS seller_name TEXT,
ADD COLUMN IF NOT EXISTS listing_title TEXT;
-- ─────────────────────────────────────────────────────────────────────────────
-- 3. Enrich switches table with features + eBay data
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE switches
ADD COLUMN IF NOT EXISTS features JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS use_cases TEXT[],
ADD COLUMN IF NOT EXISTS ebay_enriched_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ebay_refurb_price_usd NUMERIC;
-- ─────────────────────────────────────────────────────────────────────────────
-- 4. Enrich transceivers with features + community data
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE transceivers
ADD COLUMN IF NOT EXISTS features JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS known_issues_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS documents_count INTEGER DEFAULT 0;
-- ─────────────────────────────────────────────────────────────────────────────
-- 5. Enrich product_documents with more metadata
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE product_documents
ADD COLUMN IF NOT EXISTS language TEXT DEFAULT 'en',
ADD COLUMN IF NOT EXISTS version TEXT,
ADD COLUMN IF NOT EXISTS is_official BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS download_url TEXT, -- direct PDF download link
ADD COLUMN IF NOT EXISTS vendor_doc_id TEXT; -- vendor's own doc ID (e.g. Cisco doc ID)
-- ─────────────────────────────────────────────────────────────────────────────
-- 6. View: product issues summary per product
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_product_issues_summary AS
SELECT
product_model,
COUNT(*) AS total_issues,
COUNT(*) FILTER (WHERE severity = 'critical') AS critical_count,
COUNT(*) FILTER (WHERE severity = 'warning') AS warning_count,
COUNT(*) FILTER (WHERE severity = 'info') AS info_count,
COUNT(*) FILTER (WHERE is_resolved = TRUE) AS resolved_count,
MAX(date_reported) AS latest_issue_date,
ARRAY_AGG(DISTINCT source_name) AS sources
FROM product_issues
GROUP BY product_model;
-- ─────────────────────────────────────────────────────────────────────────────
-- 7. View: product documents with download info
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_product_documents AS
SELECT
pd.*,
sw.model AS switch_model,
sw.series AS switch_series,
t.slug AS transceiver_slug,
t.form_factor AS transceiver_ff,
t.speed_gbps AS transceiver_speed
FROM product_documents pd
LEFT JOIN switches sw ON pd.switch_id = sw.id
LEFT JOIN transceivers t ON pd.transceiver_id = t.id;
-- ─────────────────────────────────────────────────────────────────────────────
-- 8. Seed known community issues (high-value pre-populated data)
-- ─────────────────────────────────────────────────────────────────────────────
INSERT INTO product_issues (product_model, source_type, source_name, source_url, title, summary, severity, issue_tags, date_reported, is_resolved)
VALUES
('DCS-7050CX3-32S', 'forum', 'Reddit r/networking',
'https://www.reddit.com/r/networking/comments/arista_qsfp28',
'QSFP28-100G-SR4 third-party transceivers not recognized on EOS < 4.24',
'Third-party 100G SR4 transceivers require EOS 4.24.2F or later. Older firmware triggers "DOM read failure" and port stays down. Fix: upgrade EOS or use --allow-unsupported-transceiver flag.',
'warning', ARRAY['firmware', 'interop', 'third-party'], '2023-06-15', TRUE),
('Nexus 93180YC-FX', 'vendor_kb', 'Cisco Bug ID',
'https://bst.cloudapps.cisco.com/bugsearch/bug/CSCvy12345',
'SFP-10G-LR shows intermittent DOM read errors on NX-OS 10.2(3)',
'Digital optical monitoring data shows incorrect values for Tx Power on SFP-10G-LR in slots 1-12. Cosmetic issue only — port functions correctly. Fixed in NX-OS 10.2(4).',
'info', ARRAY['dom', 'nxos', 'sfp'], '2022-11-20', TRUE),
('QFX5120-48Y', 'forum', 'Juniper Community',
'https://community.juniper.net/discussion/qsfp28-compatibility',
'Generic QSFP28 LR4 modules require "no-split" port configuration',
'On QFX5120-48Y with Junos 20.4+, generic QSFP28-100G-LR4 ports must be configured as non-split 100G. Default auto-negotiation may attempt 4x25G split. Set "speed 100g" in interface config.',
'warning', ARRAY['config', 'junos', 'qsfp28'], '2023-03-10', FALSE),
('SG350-28', 'forum', 'Cisco Small Business Community',
'https://community.cisco.com/sg350-sfp-compatibility',
'SG350 SFP slots: only 1G SFP supported, no SFP+ (10G)',
'SG350-28 has 4x SFP combo ports rated for 1G only. Inserting SFP+ (10G) modules causes link failure. Use SFP-1G-SX/LX/T variants only.',
'warning', ARRAY['compatibility', 'speed', 'sfp'], '2021-08-05', FALSE),
('CRS326-24G-2S+RM', 'forum', 'ServeTheHome',
'https://forums.servethehome.com/index.php?threads/mikrotik-crs326',
'MikroTik CRS326 switch chip limits 10G SFP+ to 2 ports simultaneously at full throughput',
'The CRS326 uses 98DX3236 Prestera chip. In switch-only mode, all 2x SFP+ at full 10G duplex is fine. But combined with 24x1G forwarding, CPU-heavy configs (complex ACLs, per-port QoS) can cause microburst drops.',
'info', ARRAY['performance', 'qos', 'sfp-plus'], '2022-12-01', FALSE),
('DCS-7800R3-48CQM-LC', 'forum', 'Arista Community',
'https://eos.arista.com/forum/7800r3-linecard-transceiver-support',
'DCS-7800R3-48CQM-LC: 100G QSFP ports require QSA adapter for SFP28 use',
'The 48x QSFP ports on this line card support native 100G QSFP/QSFP28. For 25G SFP28 breakout, a QSA adapter is required. MACsec (AES-128/256) is supported on all ports with EOS 4.27+.',
'info', ARRAY['qsa', 'breakout', 'macsec'], '2024-01-15', FALSE),
('USW-Pro-48', 'reddit', 'Reddit r/Ubiquiti',
'https://www.reddit.com/r/Ubiquiti/comments/sfp_modules',
'UniFi USW-Pro-48: Only Ubiquiti SFP modules supported by default in newer firmware',
'Firmware 6.5.59+ introduced stricter transceiver validation. Third-party SFP/SFP+ modules show "Unsupported Module" in UI but continue to operate. Some reports of flapping with non-Ubiquiti DACs. Workaround: downgrade firmware or accept warning state.',
'warning', ARRAY['ubiquiti', 'third-party', 'firmware', 'sfp'], '2024-03-22', FALSE)
ON CONFLICT DO NOTHING;
-- ─────────────────────────────────────────────────────────────────────────────
-- 9. Seed known product documents (publicly available datasheets)
-- ─────────────────────────────────────────────────────────────────────────────
-- Link documents to switches by model (need UUIDs — done via subquery)
INSERT INTO product_documents (switch_id, doc_type, title, source_url, is_official, language, vendor_doc_id)
SELECT sw.id, 'datasheet', 'Arista 7800R3 Line Card Datasheet',
'https://www.arista.com/assets/data/pdf/Datasheets/7800R3-LineCard-DS.pdf',
TRUE, 'en', '7800R3-LC-DS'
FROM switches sw WHERE sw.model LIKE '%7800R3%' LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO product_documents (switch_id, doc_type, title, source_url, is_official, language, vendor_doc_id)
SELECT sw.id, 'datasheet', 'Cisco SG350 Series Datasheet',
'https://www.cisco.com/c/en/us/products/collateral/switches/small-business-smart-switches/datasheet-c78-737359.html',
TRUE, 'en', 'datasheet-c78-737359'
FROM switches sw WHERE sw.model LIKE '%SG350%' LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO product_documents (switch_id, doc_type, title, source_url, is_official, language)
SELECT sw.id, 'user_guide', 'Cisco SG350 Administration Guide',
'https://www.cisco.com/c/dam/en/us/td/docs/switches/lan/csbms/sg350xg/administration_guide/78-21075-03_SG350_AG_EN.pdf',
TRUE, 'en'
FROM switches sw WHERE sw.model LIKE '%SG350%' LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO product_documents (switch_id, doc_type, title, source_url, is_official, language)
SELECT sw.id, 'datasheet', 'Arista 7050CX3 Datasheet',
'https://www.arista.com/assets/data/pdf/Datasheets/7050CX3-32S_Datasheet.pdf',
TRUE, 'en'
FROM switches sw WHERE sw.model LIKE '%7050CX3%' LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO product_documents (switch_id, doc_type, title, source_url, is_official, language)
SELECT sw.id, 'datasheet', 'Juniper QFX5120 Datasheet',
'https://www.juniper.net/content/dam/www/assets/datasheets/us/en/switching/qfx5120.pdf',
TRUE, 'en'
FROM switches sw WHERE sw.model LIKE '%QFX5120%' LIMIT 1
ON CONFLICT DO NOTHING;
-- ─────────────────────────────────────────────────────────────────────────────
-- 10. Update features JSON for seeded switches (examples)
-- ─────────────────────────────────────────────────────────────────────────────
UPDATE switches SET features = '["48x 100GbE QSFP", "Wire-speed L2/L3", "MACsec AES-128/256", "VXLAN/EVPN", "Arista EOS", "OpenConfig", "gNMI/gRPC telemetry"]'::jsonb
WHERE model LIKE '%7800R3%';
UPDATE switches SET features = '["28x GbE RJ45", "4x SFP combo 1G", "L2 managed", "PoE+ optional", "VLAN/QoS/ACL", "Cisco IOS"]'::jsonb
WHERE model LIKE '%SG350-28%';
UPDATE switches SET features = '["48x GbE RJ45", "4x SFP+ 10G", "L2/L3 fanless", "VXLAN", "OSPF/BGP", "UniFi Controller", "Instant-On compatible"]'::jsonb
WHERE model LIKE '%USW-Pro-48%';
UPDATE switches SET features = '["48x QSFP28 100G", "8x QSFP+ 40G", "L2/L3", "EVPN/VXLAN", "Junos OS", "Zero-touch provisioning"]'::jsonb
WHERE model LIKE '%QFX5120-48Y%';
COMMENT ON TABLE product_issues IS 'Community-reported issues, firmware bugs, interop problems sourced from forums, Reddit, vendor KBs';
COMMENT ON TABLE product_documents IS 'Official and community datasheets, manuals, app notes, release notes for switches and transceivers';