feat: Flexoptix order section per switch + reject generic/logo images
This commit is contained in:
parent
0a60d821fb
commit
a2492d833b
1
.claude/launch.json
Normal file
1
.claude/launch.json
Normal 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
1
lama2/01_health.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/health
|
||||||
1
lama2/02_transceivers_search.l2
Normal file
1
lama2/02_transceivers_search.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/transceivers?q=100G&limit=10
|
||||||
1
lama2/03_vendors.l2
Normal file
1
lama2/03_vendors.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/vendors
|
||||||
1
lama2/04_semantic_search.l2
Normal file
1
lama2/04_semantic_search.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/search?q=coherent+400G&limit=10
|
||||||
1
lama2/05_hype_cycle.l2
Normal file
1
lama2/05_hype_cycle.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/hype-cycle?technology=400G
|
||||||
1
lama2/06_competitor_alerts.l2
Normal file
1
lama2/06_competitor_alerts.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/competitor-alerts?limit=20
|
||||||
1
lama2/07_hot_topics.l2
Normal file
1
lama2/07_hot_topics.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/hot-topics?limit=10
|
||||||
1
lama2/08_blog.l2
Normal file
1
lama2/08_blog.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/blog?limit=10
|
||||||
1
lama2/09_news.l2
Normal file
1
lama2/09_news.l2
Normal file
@ -0,0 +1 @@
|
|||||||
|
GET https://tip.fichtmueller.org/api/news?limit=20
|
||||||
9
lama2/10_finder.l2
Normal file
9
lama2/10_finder.l2
Normal 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
1
package-lock.json
generated
@ -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": {
|
||||||
|
|||||||
@ -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.*,
|
||||||
|
|||||||
@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
29
sql/043-clear-generic-switch-images.sql
Normal file
29
sql/043-clear-generic-switch-images.sql
Normal 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%'
|
||||||
|
);
|
||||||
Loading…
x
Reference in New Issue
Block a user