feat: Flexoptix order section per switch + reject generic/logo images
This commit is contained in:
parent
8de025cbcd
commit
1d50fd1c8f
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-boss": "^10.1.5",
|
||||
"playwright": "^1.50.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -307,6 +307,60 @@ export async function getCompatibleTransceivers(switchId: string) {
|
||||
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) {
|
||||
const query = type
|
||||
? `SELECT v.*,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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();
|
||||
|
||||
@ -73,3 +73,16 @@ switchRouter.get("/:id/compatibility", async (req: Request, res: Response) => {
|
||||
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,92 +4064,92 @@ async function openSwitchDetail(id) {
|
||||
buildDOM(body, dh);
|
||||
}).catch(function() {});
|
||||
|
||||
api('/api/switches/' + id + '/compatibility').then(function(cdata) {
|
||||
var txList = cdata.data || cdata.transceivers || [];
|
||||
if (txList.length === 0) return;
|
||||
// ── 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;
|
||||
|
||||
// Split: Flexoptix vs others
|
||||
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 ch = '';
|
||||
|
||||
// ── FLEXOPTIX RECOMMENDED ──────────────────────────────────────────────
|
||||
if (foList.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>';
|
||||
ch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Directly available from Flexoptix — FlexBox coding supported</div>';
|
||||
|
||||
// Split Flexoptix by verification method
|
||||
var foVendorVerified = foList.filter(function(t) { return t.verification_method === 'vendor_compat' || t.verification_method === 'vendor_matrix'; });
|
||||
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>';
|
||||
}
|
||||
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 = {};
|
||||
foToShow.forEach(function(t) {
|
||||
var key = (t.speed || '?') + ' ' + (t.form_factor || '?');
|
||||
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);
|
||||
});
|
||||
|
||||
Object.keys(foGroups).sort().forEach(function(key) {
|
||||
// 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];
|
||||
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.3rem">';
|
||||
items.slice(0, 8).forEach(function(t) {
|
||||
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 || 'USD').toUpperCase();
|
||||
var _pUSD = toUSD(_pAmt, _pCur);
|
||||
var _pCur = (t.latest_currency || 'EUR').toUpperCase();
|
||||
var _pEUR = toEUR(_pAmt, _pCur);
|
||||
priceStr = ' — ' + (_pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2))
|
||||
+ (_pEUR !== null ? ' / ' + fmtEUR(_pEUR) : '');
|
||||
var _pUSD = toUSD(_pAmt, _pCur);
|
||||
priceStr = _pEUR !== null ? fmtEUR(_pEUR) : (_pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2));
|
||||
}
|
||||
var verBadge = (t.price_verified === true)
|
||||
? '<span style="color:#2d6a4f;font-size:0.64rem;font-weight:600;margin-left:0.3rem">✓ Verified</span>' : '';
|
||||
var fullyBadge = (t.fully_verified === true)
|
||||
? '<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>' : '';
|
||||
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
|
||||
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 > 8) ch += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0">+' + (items.length - 8) + ' more Flexoptix options</div>';
|
||||
ch += '</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>';
|
||||
});
|
||||
|
||||
// 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>';
|
||||
}
|
||||
}
|
||||
el('panel-content').insertAdjacentHTML('beforeend', fch);
|
||||
}).catch(function() {});
|
||||
|
||||
// ── ALL COMPATIBLE (other vendors) ────────────────────────────────────
|
||||
if (otherList.length > 0) {
|
||||
var verifiedOthers = otherList.filter(function(t) { return t.verification_method === 'vendor_matrix'; });
|
||||
var specOthers = otherList.filter(function(t) { return t.verification_method !== 'vendor_matrix'; });
|
||||
// ── Load compatibility table (vendor-tested + competitor data) ────────────
|
||||
api('/api/switches/' + id + '/compatibility').then(function(cdata) {
|
||||
var txList = cdata.data || cdata.transceivers || [];
|
||||
if (txList.length === 0) return;
|
||||
|
||||
// Only show non-Flexoptix here — Flexoptix already shown via /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 = '';
|
||||
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>';
|
||||
|
||||
// Show vendor-tested ones with price info
|
||||
// Vendor-tested with price
|
||||
if (verifiedOthers.length > 0) {
|
||||
var groups = {};
|
||||
verifiedOthers.forEach(function(t) {
|
||||
@ -4180,7 +4180,7 @@ async function openSwitchDetail(id) {
|
||||
});
|
||||
}
|
||||
|
||||
// Show spec-match ones as compact chips
|
||||
// Spec-match 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>';
|
||||
@ -4193,7 +4193,6 @@ async function openSwitchDetail(id) {
|
||||
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);
|
||||
}).catch(function() {});
|
||||
|
||||
@ -40,7 +40,6 @@ function buildCiscoUrl(model: string): string | null {
|
||||
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`;
|
||||
}
|
||||
// Nexus modular: N9K-C9508 already covered above
|
||||
// NCS 5500/5700: NCS-57C3-MOD, NCS-5504
|
||||
if (m.startsWith("NCS-")) {
|
||||
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, "-");
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
// 7060X6-64PE → https://www.arista.com/en/products/7060x6-series/7060cx6-64pe
|
||||
// 7050CX3-32S → https://www.arista.com/en/products/7050x3-series/7050cx3-32s
|
||||
@ -163,36 +173,95 @@ const URL_BUILDERS: Record<string, (m: string) => string | null> = {
|
||||
ubiquiti: buildUbiquitiUrl,
|
||||
"fs-com": buildFsComUrl,
|
||||
supermicro: buildSupermicroUrl,
|
||||
"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 ──────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
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+content="([^"]+)"\s+property="og:image"/i);
|
||||
if (ogM) {
|
||||
const url = ogM[2] || ogM[1];
|
||||
if (url && url.startsWith("http")) return url;
|
||||
if (url && url.startsWith("/")) {
|
||||
try { return new URL(url, baseUrl).toString(); } catch { /* ignore */ }
|
||||
}
|
||||
const resolved = resolve(url);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
// Fallback: twitter:image
|
||||
const twM = html.match(/<meta\s+name="twitter:image"\s+content="([^"]+)"/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
|
||||
const imgM = html.match(/<img[^>]+src="([^"]+(?:product|hero|switch|router)[^"]*\.(?:jpg|jpeg|png|webp))"/i);
|
||||
// Fallback: large product image in <img src> with product keyword in path
|
||||
const imgM = html.match(/<img[^>]+src="([^"]+(?:product|switch|router|hardware)[^"]*\.(?:jpg|jpeg|png|webp))"/i);
|
||||
if (imgM?.[1]) {
|
||||
try {
|
||||
const abs = new URL(imgM[1], baseUrl).toString();
|
||||
if (abs.startsWith("http")) return abs;
|
||||
} catch { /* ignore */ }
|
||||
const resolved = resolve(imgM[1]);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
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