feat: Flexoptix order section per switch + reject generic/logo images

This commit is contained in:
Rene Fichtmueller 2026-04-20 23:31:36 +02:00
parent 0a60d821fb
commit a2492d833b
17 changed files with 322 additions and 138 deletions

1
.claude/launch.json Normal file
View File

@ -0,0 +1 @@
{"version":"0.0.1","configurations":[{"name":"dashboard","runtimeExecutable":"npx","runtimeArgs":["serve","-p","5555","packages/dashboard"],"port":5555}]}

1
lama2/01_health.l2 Normal file
View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/health

View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/transceivers?q=100G&limit=10

1
lama2/03_vendors.l2 Normal file
View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/vendors

View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/search?q=coherent+400G&limit=10

1
lama2/05_hype_cycle.l2 Normal file
View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/hype-cycle?technology=400G

View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/competitor-alerts?limit=20

1
lama2/07_hot_topics.l2 Normal file
View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/hot-topics?limit=10

1
lama2/08_blog.l2 Normal file
View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/blog?limit=10

1
lama2/09_news.l2 Normal file
View File

@ -0,0 +1 @@
GET https://tip.fichtmueller.org/api/news?limit=20

9
lama2/10_finder.l2 Normal file
View File

@ -0,0 +1,9 @@
POST https://tip.fichtmueller.org/api/finder
Content-Type: application/json
{
"switch_vendor": "Cisco",
"switch_model": "Nexus 9300",
"speed_gbps": 100,
"fiber_type": "SM"
}

1
package-lock.json generated
View File

@ -6386,6 +6386,7 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
"playwright": "^1.50.0", "playwright": "^1.50.0",
"socks-proxy-agent": "^8.0.5",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -307,6 +307,60 @@ export async function getCompatibleTransceivers(switchId: string) {
return result.rows; return result.rows;
} }
/**
* getFlexoptixSuggestions returns Flexoptix transceivers that physically fit
* the switch's port slots, derived from ports_config JSONB keys.
* Works even before the compat scraper has processed the switch.
*/
export async function getFlexoptixSuggestions(switchId: string) {
const result = await pool.query(
`WITH switch_form_factors AS (
SELECT DISTINCT
CASE
WHEN k ILIKE '%QSFP-DD800%' THEN 'QSFP-DD800'
WHEN k ILIKE '%QSFP-DD%' THEN 'QSFP-DD'
WHEN k ILIKE '%OSFP224%' THEN 'OSFP224'
WHEN k ILIKE '%OSFP%' THEN 'OSFP'
WHEN k ILIKE '%QSFP28%' THEN 'QSFP28'
WHEN k ILIKE '%QSFP+%' THEN 'QSFP+'
WHEN k ILIKE '%QSFP%' THEN 'QSFP+'
WHEN k ILIKE '%SFP28%' THEN 'SFP28'
WHEN k ILIKE '%SFP+%' THEN 'SFP+'
WHEN k ILIKE '%SFP%' THEN 'SFP+'
WHEN k ILIKE '%CFP2%' THEN 'CFP2'
WHEN k ILIKE '%CFP4%' THEN 'CFP4'
WHEN k ILIKE '%CFP%' THEN 'CFP'
END AS form_factor
FROM switches sw,
jsonb_object_keys(sw.ports_config) AS k
WHERE sw.id = $1 AND sw.ports_config IS NOT NULL
)
SELECT t.id, t.slug, t.part_number, t.standard_name, t.form_factor,
t.speed, t.speed_gbps, t.reach_meters, t.reach_label,
t.fiber_type, t.wavelength_nm, t.market_status,
t.product_page_url, t.image_url,
t.price_verified_eur, t.price_verified_at, t.price_verified_usd,
v.name AS vendor_name, v.website AS vendor_website,
COALESCE(t.price_verified_eur,
(SELECT po.price FROM price_observations po
WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1)
) AS latest_price,
CASE WHEN t.price_verified_eur IS NOT NULL THEN 'EUR'
ELSE (SELECT po.currency FROM price_observations po
WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1)
END AS latest_currency
FROM transceivers t
JOIN vendors v ON t.vendor_id = v.id
WHERE LOWER(v.name) = 'flexoptix'
AND t.form_factor IN (
SELECT form_factor FROM switch_form_factors WHERE form_factor IS NOT NULL
)
ORDER BY t.speed_gbps DESC NULLS LAST, t.reach_meters ASC NULLS LAST`,
[switchId]
);
return result.rows;
}
export async function listVendors(type?: string) { export async function listVendors(type?: string) {
const query = type const query = type
? `SELECT v.*, ? `SELECT v.*,

View File

@ -1,5 +1,5 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { searchSwitches, getSwitchById, getCompatibleTransceivers, getSwitchDocuments, getSwitchIssues } from "../db/queries"; import { searchSwitches, getSwitchById, getCompatibleTransceivers, getFlexoptixSuggestions, getSwitchDocuments, getSwitchIssues } from "../db/queries";
export const switchRouter = Router(); export const switchRouter = Router();
@ -73,3 +73,16 @@ switchRouter.get("/:id/compatibility", async (req: Request, res: Response) => {
res.status(500).json({ success: false, error: "Internal server error" }); res.status(500).json({ success: false, error: "Internal server error" });
} }
}); });
// GET /api/switches/:id/flexoptix — Flexoptix transceivers by form factor (always available)
// Returns Flexoptix catalog items that physically fit the switch's port slots,
// derived from ports_config keys — works before the compat scraper has run.
switchRouter.get("/:id/flexoptix", async (req: Request, res: Response) => {
try {
const suggestions = await getFlexoptixSuggestions(String(req.params.id));
res.json({ success: true, data: suggestions, total: suggestions.length });
} catch (err) {
console.error("Get Flexoptix suggestions error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});

View File

@ -4064,135 +4064,134 @@ async function openSwitchDetail(id) {
buildDOM(body, dh); buildDOM(body, dh);
}).catch(function() {}); }).catch(function() {});
// ── Load Flexoptix orderable transceivers (form-factor based, always works) ──
api('/api/switches/' + id + '/flexoptix').then(function(foData) {
var foAll = foData.data || [];
if (foAll.length === 0) return;
var fch = '';
fch += '<div class="panel-section" style="color:#ff6600;margin-top:1rem;display:flex;align-items:center;gap:0.5rem">'
+ '<span>Bei Flexoptix bestellen</span>'
+ '<span style="background:#ff660018;color:#ff6600;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px">' + foAll.length + '</span>'
+ '</div>';
fch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.6rem">Passend für diesen Switch — FlexBox-Codierung möglich</div>';
// Group by speed class
var foGroups = {};
foAll.forEach(function(t) {
var key = (t.speed_gbps ? t.speed_gbps + 'G' : (t.speed || '?')) + ' ' + (t.form_factor || '?');
if (!foGroups[key]) foGroups[key] = [];
foGroups[key].push(t);
});
// Sort speed groups descending (highest speed first)
var foKeys = Object.keys(foGroups).sort(function(a, b) {
var ga = parseFloat(a) || 0, gb = parseFloat(b) || 0;
return gb - ga;
});
foKeys.forEach(function(key) {
var items = foGroups[key];
fch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.8rem;color:var(--text-bright)">'
+ esc(key) + '<span style="font-weight:400;font-size:0.72rem;color:var(--text-dim);margin-left:0.35rem">(' + items.length + ')</span></div>';
fch += '<div style="display:flex;flex-direction:column;gap:0.3rem">';
items.slice(0, 10).forEach(function(t) {
var priceStr = '';
if (t.latest_price) {
var _pAmt = parseFloat(t.latest_price);
var _pCur = (t.latest_currency || 'EUR').toUpperCase();
var _pEUR = toEUR(_pAmt, _pCur);
var _pUSD = toUSD(_pAmt, _pCur);
priceStr = _pEUR !== null ? fmtEUR(_pEUR) : (_pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2));
}
var shopHref = t.product_page_url || ('https://www.flexoptix.net/en/search/ajax/suggest/?q=' + encodeURIComponent(t.part_number || t.standard_name || ''));
var reach = t.reach_label ? '<span style="color:var(--text-dim);font-size:0.68rem;margin-left:0.25rem">' + esc(t.reach_label) + '</span>' : '';
fch += '<div style="display:flex;align-items:center;padding:0.35rem 0.5rem;background:rgba(255,102,0,0.05);border:1px solid rgba(255,102,0,0.2);border-radius:6px;gap:0.35rem;cursor:pointer" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<div style="flex:1;min-width:0">'
+ '<span style="font-weight:600;font-size:0.8rem;color:var(--text-bright)">' + esc(t.part_number || t.standard_name || t.slug) + '</span>'
+ reach
+ '</div>'
+ (priceStr
? '<span style="font-weight:700;font-size:0.78rem;color:#ff6600;white-space:nowrap">' + priceStr + '</span>'
: '<span style="font-size:0.68rem;color:var(--text-dim)">Preis anfragen</span>')
+ '<a href="' + esc(shopHref) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="background:#ff6600;color:#fff;font-size:0.65rem;font-weight:700;padding:2px 7px;border-radius:4px;text-decoration:none;white-space:nowrap;flex-shrink:0">Bestellen ↗</a>'
+ '</div>';
});
if (items.length > 10) {
fch += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0.5rem">+' + (items.length - 10) + ' weitere Flexoptix-Optionen</div>';
}
fch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', fch);
}).catch(function() {});
// ── Load compatibility table (vendor-tested + competitor data) ────────────
api('/api/switches/' + id + '/compatibility').then(function(cdata) { api('/api/switches/' + id + '/compatibility').then(function(cdata) {
var txList = cdata.data || cdata.transceivers || []; var txList = cdata.data || cdata.transceivers || [];
if (txList.length === 0) return; if (txList.length === 0) return;
// Split: Flexoptix vs others // Only show non-Flexoptix here — Flexoptix already shown via /flexoptix
var foList = txList.filter(function(t) { return (t.vendor_name || '').toLowerCase() === 'flexoptix'; });
var otherList = txList.filter(function(t) { return (t.vendor_name || '').toLowerCase() !== 'flexoptix'; }); var otherList = txList.filter(function(t) { return (t.vendor_name || '').toLowerCase() !== 'flexoptix'; });
if (otherList.length === 0) return;
var verifiedOthers = otherList.filter(function(t) {
return t.verification_method === 'vendor_matrix' || t.verification_method === 'vendor_compat';
});
var specOthers = otherList.filter(function(t) {
return t.verification_method !== 'vendor_matrix' && t.verification_method !== 'vendor_compat';
});
var ch = ''; var ch = '';
ch += '<div class="panel-section">Competitor Transceivers <span class="b b-green" style="margin-left:0.5rem">' + otherList.length + '</span>'
+ (verifiedOthers.length > 0 ? '<span style="font-size:0.67rem;color:#888;margin-left:0.5rem">(' + verifiedOthers.length + ' vendor-tested)</span>' : '') + '</div>';
// ── FLEXOPTIX RECOMMENDED ────────────────────────────────────────────── // Vendor-tested with price
if (foList.length > 0) { if (verifiedOthers.length > 0) {
ch += '<div class="panel-section" style="color:#ff6600;margin-top:1rem">Flexoptix Recommended <span style="background:#ff660018;color:#ff6600;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem">' + foList.length + '</span></div>'; var groups = {};
ch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Directly available from Flexoptix — FlexBox coding supported</div>'; verifiedOthers.forEach(function(t) {
var key = (t.form_factor || '?') + ' ' + (t.speed || '?');
// Split Flexoptix by verification method if (!groups[key]) groups[key] = [];
var foVendorVerified = foList.filter(function(t) { return t.verification_method === 'vendor_compat' || t.verification_method === 'vendor_matrix'; }); groups[key].push(t);
var foSpecMatch = foList.filter(function(t) { return t.verification_method !== 'vendor_compat' && t.verification_method !== 'vendor_matrix'; });
// Show explicitly verified first
var foToShow = foVendorVerified.length > 0 ? foVendorVerified : foList;
if (foVendorVerified.length > 0 && foSpecMatch.length > 0) {
ch += '<div style="font-size:0.67rem;color:#16a34a;font-weight:600;margin-bottom:0.4rem">✓ Vendor-tested compatibility (' + foVendorVerified.length + ')</div>';
}
// Group by speed class
var foGroups = {};
foToShow.forEach(function(t) {
var key = (t.speed || '?') + ' ' + (t.form_factor || '?');
if (!foGroups[key]) foGroups[key] = [];
foGroups[key].push(t);
}); });
Object.keys(groups).sort().forEach(function(key) {
Object.keys(foGroups).sort().forEach(function(key) { var items = groups[key];
var items = foGroups[key]; ch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.76rem;color:var(--text-bright)">' + esc(key) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.78rem;color:var(--text-bright)">' + esc(key) + ' <span class="dim" style="font-weight:400;font-size:0.72rem">(' + items.length + ')</span></div>'; ch += '<div style="display:flex;flex-direction:column;gap:0.25rem">';
ch += '<div style="display:flex;flex-direction:column;gap:0.3rem">'; items.slice(0, 6).forEach(function(t) {
items.slice(0, 8).forEach(function(t) {
var priceStr = ''; var priceStr = '';
if (t.latest_price) { if (t.latest_price) {
var _pAmt = parseFloat(t.latest_price); var _pAmt = parseFloat(t.latest_price);
var _pCur = (t.latest_currency || 'USD').toUpperCase(); var _pCur = (t.latest_currency || 'USD').toUpperCase();
var _pUSD = toUSD(_pAmt, _pCur); var _pUSD = toUSD(_pAmt, _pCur);
var _pEUR = toEUR(_pAmt, _pCur); priceStr = _pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2);
priceStr = ' — ' + (_pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2))
+ (_pEUR !== null ? ' / ' + fmtEUR(_pEUR) : '');
} }
var verBadge = (t.price_verified === true) ch += '<div style="display:flex;align-items:center;gap:0.4rem;padding:0.25rem 0.4rem;background:var(--surface2);border-radius:5px;cursor:pointer;font-size:0.72rem" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
? '<span style="color:#2d6a4f;font-size:0.64rem;font-weight:600;margin-left:0.3rem">✓ Verified</span>' : ''; + '<span style="font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.standard_name) + '</span>'
var fullyBadge = (t.fully_verified === true) + '<span style="color:var(--text-dim)">' + esc(t.vendor_name || '') + '</span>'
? '<span style="background:linear-gradient(135deg,#1b4332,#2d6a4f);color:#fff;font-size:0.6rem;font-weight:700;padding:1px 5px;border-radius:4px;margin-left:0.3rem">★ 100%</span>' : ''; + (priceStr ? '<span style="color:#f59e0b;margin-left:auto">' + priceStr + '</span>' : '')
var foUrl = t.product_page_url
? '<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.65rem;margin-left:0.4rem;text-decoration:none">Shop ↗</a>' : '';
ch += '<div style="display:flex;align-items:center;padding:0.3rem 0.5rem;background:rgba(255,102,0,0.05);border:1px solid rgba(255,102,0,0.15);border-radius:6px;cursor:pointer;gap:0.3rem" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<span style="font-weight:600;font-size:0.78rem;color:var(--text-bright)">' + esc(t.part_number || t.standard_name || t.slug) + '</span>'
+ '<span style="color:var(--text-dim);font-size:0.7rem">' + esc(t.reach_label || '') + priceStr + '</span>'
+ fullyBadge + verBadge + foUrl
+ '</div>'; + '</div>';
}); });
if (items.length > 8) ch += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0">+' + (items.length - 8) + ' more Flexoptix options</div>'; if (items.length > 6) ch += '<div style="font-size:0.68rem;color:var(--text-dim)">+' + (items.length - 6) + ' more</div>';
ch += '</div>'; ch += '</div>';
}); });
// Form-factor matches (spec_match) — collapsed summary
if (foSpecMatch.length > 0) {
ch += '<details style="margin-top:0.5rem">'
+ '<summary style="font-size:0.67rem;color:var(--text-dim);cursor:pointer">+ ' + foSpecMatch.length + ' more by form factor match</summary>'
+ '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.3rem;padding:0.3rem 0.5rem;background:var(--surface2);border-radius:4px">'
+ foSpecMatch.slice(0, 20).map(function(t) {
return '<span style="cursor:pointer;color:var(--text);margin-right:0.5rem" onclick="openTxDetail(\'' + esc(t.id) + '\')" title="' + esc((t.form_factor||'') + ' ' + (t.speed||'')) + '">'
+ esc(t.standard_name || t.part_number) + '</span>';
}).join('')
+ (foSpecMatch.length > 20 ? '<span style="color:var(--text-dim)">+' + (foSpecMatch.length - 20) + ' more</span>' : '')
+ '</div></details>';
}
} }
// ── ALL COMPATIBLE (other vendors) ──────────────────────────────────── // Spec-match as compact chips
if (otherList.length > 0) { if (specOthers.length > 0) {
var verifiedOthers = otherList.filter(function(t) { return t.verification_method === 'vendor_matrix'; }); ch += '<div style="margin-top:0.5rem">';
var specOthers = otherList.filter(function(t) { return t.verification_method !== 'vendor_matrix'; }); ch += '<div style="font-size:0.67rem;color:var(--text-dim);margin-bottom:0.3rem">Form factor compatible</div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.25rem">';
ch += '<div class="panel-section">Competitor Transceivers <span class="b b-green" style="margin-left:0.5rem">' + otherList.length + '</span>' specOthers.slice(0, 20).forEach(function(t) {
+ (verifiedOthers.length > 0 ? '<span style="font-size:0.67rem;color:#888;margin-left:0.5rem">(' + verifiedOthers.length + ' vendor-tested)</span>' : '') + '</div>'; var fullyBadge = (t.fully_verified === true) ? '★ ' : '';
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.68rem" onclick="openTxDetail(\'' + esc(t.id) + '\')" title="' + esc(t.vendor_name || '') + (t.reach_label ? ' · ' + t.reach_label : '') + '">'
// Show vendor-tested ones with price info + fullyBadge + esc(t.standard_name || t.slug || t.part_number) + '</span>';
if (verifiedOthers.length > 0) { });
var groups = {}; if (specOthers.length > 20) ch += '<span class="dim" style="font-size:0.68rem">+' + (specOthers.length - 20) + ' more</span>';
verifiedOthers.forEach(function(t) { ch += '</div></div>';
var key = (t.form_factor || '?') + ' ' + (t.speed || '?');
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
Object.keys(groups).sort().forEach(function(key) {
var items = groups[key];
ch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.76rem;color:var(--text-bright)">' + esc(key) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-direction:column;gap:0.25rem">';
items.slice(0, 6).forEach(function(t) {
var priceStr = '';
if (t.latest_price) {
var _pAmt = parseFloat(t.latest_price);
var _pCur = (t.latest_currency || 'USD').toUpperCase();
var _pUSD = toUSD(_pAmt, _pCur);
priceStr = _pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2);
}
ch += '<div style="display:flex;align-items:center;gap:0.4rem;padding:0.25rem 0.4rem;background:var(--surface2);border-radius:5px;cursor:pointer;font-size:0.72rem" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<span style="font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.standard_name) + '</span>'
+ '<span style="color:var(--text-dim)">' + esc(t.vendor_name || '') + '</span>'
+ (priceStr ? '<span style="color:#f59e0b;margin-left:auto">' + priceStr + '</span>' : '')
+ '</div>';
});
if (items.length > 6) ch += '<div style="font-size:0.68rem;color:var(--text-dim)">+' + (items.length - 6) + ' more</div>';
ch += '</div>';
});
}
// Show spec-match ones as compact chips
if (specOthers.length > 0) {
ch += '<div style="margin-top:0.5rem">';
ch += '<div style="font-size:0.67rem;color:var(--text-dim);margin-bottom:0.3rem">Form factor compatible</div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.25rem">';
specOthers.slice(0, 20).forEach(function(t) {
var fullyBadge = (t.fully_verified === true) ? '★ ' : '';
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.68rem" onclick="openTxDetail(\'' + esc(t.id) + '\')" title="' + esc(t.vendor_name || '') + (t.reach_label ? ' · ' + t.reach_label : '') + '">'
+ fullyBadge + esc(t.standard_name || t.slug || t.part_number) + '</span>';
});
if (specOthers.length > 20) ch += '<span class="dim" style="font-size:0.68rem">+' + (specOthers.length - 20) + ' more</span>';
ch += '</div></div>';
}
} }
el('panel-content').insertAdjacentHTML('beforeend', ch); el('panel-content').insertAdjacentHTML('beforeend', ch);

View File

@ -40,7 +40,6 @@ function buildCiscoUrl(model: string): string | null {
const slug = m.replace("N9K-C", "").toLowerCase().replace(/[^a-z0-9]/g, "-"); const slug = m.replace("N9K-C", "").toLowerCase().replace(/[^a-z0-9]/g, "-");
return `https://www.cisco.com/c/en/us/products/switches/nexus-${slug}-switch/index.html`; return `https://www.cisco.com/c/en/us/products/switches/nexus-${slug}-switch/index.html`;
} }
// Nexus modular: N9K-C9508 already covered above
// NCS 5500/5700: NCS-57C3-MOD, NCS-5504 // NCS 5500/5700: NCS-57C3-MOD, NCS-5504
if (m.startsWith("NCS-")) { if (m.startsWith("NCS-")) {
const num = m.replace("NCS-", "").toLowerCase().replace(/[^a-z0-9]/g, "-"); const num = m.replace("NCS-", "").toLowerCase().replace(/[^a-z0-9]/g, "-");
@ -51,9 +50,20 @@ function buildCiscoUrl(model: string): string | null {
const slug = m.toLowerCase().replace(/[^a-z0-9]/g, "-"); const slug = m.toLowerCase().replace(/[^a-z0-9]/g, "-");
return `https://www.cisco.com/c/en/us/products/switches/catalyst-${slug}/index.html`; return `https://www.cisco.com/c/en/us/products/switches/catalyst-${slug}/index.html`;
} }
// Cisco 8000 SP series: 8101-32FH, 8202-32FH, 8608
if (/^8[0-9]{3}/.test(m)) {
const slug = m.toLowerCase().replace(/[^a-z0-9]/g, "-");
return `https://www.cisco.com/c/en/us/products/routers/8000-series-routers/${slug}/index.html`;
}
return null; return null;
} }
function buildAlcatelLucentUrl(model: string): string | null {
// OmniSwitch 6900-X72, OmniSwitch 9900-C32D
const slug = model.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
return `https://www.al-enterprise.com/en/products/switches/${slug}`;
}
function buildAristaUrl(model: string): string | null { function buildAristaUrl(model: string): string | null {
// 7060X6-64PE → https://www.arista.com/en/products/7060x6-series/7060cx6-64pe // 7060X6-64PE → https://www.arista.com/en/products/7060x6-series/7060cx6-64pe
// 7050CX3-32S → https://www.arista.com/en/products/7050x3-series/7050cx3-32s // 7050CX3-32S → https://www.arista.com/en/products/7050x3-series/7050cx3-32s
@ -147,52 +157,111 @@ function buildAsterfusionUrl(model: string): string | null {
// ── URL dispatcher by vendor slug ─────────────────────────────────────────── // ── URL dispatcher by vendor slug ───────────────────────────────────────────
const URL_BUILDERS: Record<string, (m: string) => string | null> = { const URL_BUILDERS: Record<string, (m: string) => string | null> = {
cisco: buildCiscoUrl, cisco: buildCiscoUrl,
arista: buildAristaUrl, arista: buildAristaUrl,
juniper: buildJuniperUrl, juniper: buildJuniperUrl,
"nvidia-networking": buildNvidiaUrl, "nvidia-networking": buildNvidiaUrl,
edgecore: buildEdgecoreUrl, edgecore: buildEdgecoreUrl,
celestica: buildCelesticaUrl, celestica: buildCelesticaUrl,
asterfusion: buildAsterfusionUrl, asterfusion: buildAsterfusionUrl,
dell: buildDellUrl, dell: buildDellUrl,
"hpe-aruba": buildHpeArubaUrl, "hpe-aruba": buildHpeArubaUrl,
huawei: buildHuaweiUrl, huawei: buildHuaweiUrl,
nokia: buildNobelUrl, nokia: buildNobelUrl,
extreme: buildExtremeUrl, extreme: buildExtremeUrl,
mikrotik: buildMikroTikUrl, mikrotik: buildMikroTikUrl,
ubiquiti: buildUbiquitiUrl, ubiquiti: buildUbiquitiUrl,
"fs-com": buildFsComUrl, "fs-com": buildFsComUrl,
supermicro: buildSupermicroUrl, supermicro: buildSupermicroUrl,
wistron: (_m) => null, // no public product pages "alcatel-lucent": buildAlcatelLucentUrl,
"ale": buildAlcatelLucentUrl,
wistron: (_m) => null, // no public product pages
}; };
// ── Generic marketing image detector ────────────────────────────────────────
// Rejects URLs that are clearly stock photos, homepages, lifestyle shots or
// any other non-product image. Patterns found from real-world scrapes.
const GENERIC_IMAGE_PATTERNS: RegExp[] = [
// ── Logo / brand marks (never product photos) ────────────────────────────
/[-/_]logo[-_.]|\/logos?\//i,
/cisco[-_]?logo/i,
/juniper[-_]networks[-_]logo/i,
/arista[-_]?logo/i,
/brand[-_]?logo/i,
/company[-_]?logo/i,
// SVG logos often have these in path
/\/svg\//i,
/\.svg(\?|$)/i,
// ── Alcatel-Lucent Enterprise generic hero images ────────────────────────
/naas-homepag/i,
/al-enterprise.*\/images\/naas/i,
// ── Generic OG / social sharing defaults ─────────────────────────────────
/og[-_]default/i,
/default[-_](?:og|social|share|image)/i,
/site[-_](?:default|image|og)/i,
/social[-_](?:default|share)/i,
/twitter[-_]default/i,
/default[-_]thumbnail/i,
// ── Homepage / banner / lifestyle ────────────────────────────────────────
/\/homepage\//i,
/hero[-_](?:banner|bg|background|image)/i,
/banner[-_](?:bg|background)/i,
/lifestyle/i,
/stock[-_]?photo/i,
/people[-_](?:at|in|with)/i,
// ── Placeholder / fallback ────────────────────────────────────────────────
/placeholder/i,
/no[-_]?image/i,
/image[-_]?not[-_]?found/i,
/\/fallback[/-]/i,
/missing[-_]image/i,
// ── Generic about/press/brand pages ──────────────────────────────────────
/\/press[-_]kit/i,
/\/media[-_]kit/i,
];
function isGenericImage(url: string): boolean {
return GENERIC_IMAGE_PATTERNS.some((re) => re.test(url));
}
// ── og:image extractor ────────────────────────────────────────────────────── // ── og:image extractor ──────────────────────────────────────────────────────
function extractOgImage(html: string, baseUrl: string): string | null { function extractOgImage(html: string, baseUrl: string): string | null {
const resolve = (url: string): string | null => {
if (!url) return null;
let abs = url;
if (url.startsWith("/")) {
try { abs = new URL(url, baseUrl).toString(); } catch { return null; }
}
if (!abs.startsWith("http")) return null;
if (isGenericImage(abs)) return null; // ← reject logos/marketing images
return abs;
};
// Primary: og:image // Primary: og:image
const ogM = html.match(/<meta\s+(?:property="og:image"\s+content|content="([^"]+)"\s+property="og:image")="([^"]+)"/i) const ogM = html.match(/<meta\s+(?:property="og:image"\s+content|content="([^"]+)"\s+property="og:image")="([^"]+)"/i)
|| html.match(/<meta\s+property="og:image"\s+content="([^"]+)"/i) || html.match(/<meta\s+property="og:image"\s+content="([^"]+)"/i)
|| html.match(/<meta\s+content="([^"]+)"\s+property="og:image"/i); || html.match(/<meta\s+content="([^"]+)"\s+property="og:image"/i);
if (ogM) { if (ogM) {
const url = ogM[2] || ogM[1]; const url = ogM[2] || ogM[1];
if (url && url.startsWith("http")) return url; const resolved = resolve(url);
if (url && url.startsWith("/")) { if (resolved) return resolved;
try { return new URL(url, baseUrl).toString(); } catch { /* ignore */ }
}
} }
// Fallback: twitter:image // Fallback: twitter:image
const twM = html.match(/<meta\s+name="twitter:image"\s+content="([^"]+)"/i) const twM = html.match(/<meta\s+name="twitter:image"\s+content="([^"]+)"/i)
|| html.match(/<meta\s+content="([^"]+)"\s+name="twitter:image"/i); || html.match(/<meta\s+content="([^"]+)"\s+name="twitter:image"/i);
if (twM?.[1]?.startsWith("http")) return twM[1]; if (twM?.[1]) {
const resolved = resolve(twM[1]);
if (resolved) return resolved;
}
// Fallback: large product image in <img src> with product hint // Fallback: large product image in <img src> with product keyword in path
const imgM = html.match(/<img[^>]+src="([^"]+(?:product|hero|switch|router)[^"]*\.(?:jpg|jpeg|png|webp))"/i); const imgM = html.match(/<img[^>]+src="([^"]+(?:product|switch|router|hardware)[^"]*\.(?:jpg|jpeg|png|webp))"/i);
if (imgM?.[1]) { if (imgM?.[1]) {
try { const resolved = resolve(imgM[1]);
const abs = new URL(imgM[1], baseUrl).toString(); if (resolved) return resolved;
if (abs.startsWith("http")) return abs;
} catch { /* ignore */ }
} }
return null; return null;

View File

@ -0,0 +1,29 @@
-- Migration 043 — Clear generic / logo images from switches
-- Removes image_url entries that are clearly logos or marketing photos,
-- not actual product hardware images. The scraper will re-fetch with the
-- improved isGenericImage() filter on the next daily run.
UPDATE switches
SET image_url = NULL, assets_scraped_at = NULL
WHERE image_url IS NOT NULL
AND (
-- Generic logos (Cisco, vendor brand marks)
image_url ILIKE '%logo%'
OR image_url ILIKE '%.svg'
-- Alcatel-Lucent Enterprise lifestyle / naas hero images
OR image_url ILIKE '%naas-homepag%'
OR image_url ILIKE '%al-enterprise%naas%'
-- Generic OG defaults
OR image_url ILIKE '%og-default%'
OR image_url ILIKE '%default-social%'
OR image_url ILIKE '%og_default%'
-- Placeholder/fallback images
OR image_url ILIKE '%placeholder%'
OR image_url ILIKE '%no-image%'
OR image_url ILIKE '%noimage%'
-- Generic homepage images
OR image_url ILIKE '%/homepage/%'
-- Lifestyle / stock photos (common CDN path patterns)
OR image_url ILIKE '%lifestyle%'
OR image_url ILIKE '%stock-photo%'
);