MCP Server (packages/mcp-server/src/index.ts): - Register registerSwitchDocTools (switch-docs.ts) — switch documentation lookup - Register finderTools dynamically (finder.ts) — find_flexoptix_for_switch, get_competitor_alerts - Add analyze_market_with_llm tool: qwen2.5:14b via Ollama, enriched with live hype cycle + pricing + news - Add generate_blog_post tool: fo-blog-v5 (fine-tuned) with qwen2.5:14b fallback, enriched with live pricing data - OLLAMA_BASE_URL env var (default: https://ollama.fichtmueller.org) Also includes scraper improvements (ascentoptics, atgbics, gbics, skylane, ebay-enricher), API route updates (blog, blog-sll, health, hot-topics, transceivers, queries), and dashboard hot-topics refresh.
197 lines
7.8 KiB
TypeScript
197 lines
7.8 KiB
TypeScript
import { Router, Request, Response } from "express";
|
|
import { searchTransceivers, getTransceiverById } from "../db/queries";
|
|
import { pool } from "../db/client";
|
|
|
|
export const transceiverRouter = Router();
|
|
|
|
// GET /api/transceivers — Search/list transceivers
|
|
transceiverRouter.get("/", async (req: Request, res: Response) => {
|
|
try {
|
|
const q = (p: string) => req.query[p] ? String(req.query[p]) : undefined;
|
|
const result = await searchTransceivers({
|
|
q: q("q"),
|
|
form_factor: q("form_factor"),
|
|
speed: q("speed"),
|
|
speed_gbps: q("speed_gbps") ? parseFloat(q("speed_gbps")!) : undefined,
|
|
category: q("category"),
|
|
fiber_type: q("fiber_type"),
|
|
reach_min: q("reach_min") ? parseInt(q("reach_min")!) : undefined,
|
|
reach_max: q("reach_max") ? parseInt(q("reach_max")!) : undefined,
|
|
wdm_type: q("wdm_type"),
|
|
coherent: q("coherent") === "true" ? true : q("coherent") === "false" ? false : undefined,
|
|
market_status: q("market_status"),
|
|
vendor: q("vendor"),
|
|
verified: q("verified") as "price" | "image" | "details" | "full" | undefined,
|
|
limit: q("limit") ? parseInt(q("limit")!) : 50,
|
|
offset: q("offset") ? parseInt(q("offset")!) : 0,
|
|
});
|
|
res.json({ success: true, ...result });
|
|
} catch (err) {
|
|
console.error("Search transceivers error:", err);
|
|
res.status(500).json({ success: false, error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/transceivers/:id — Get single transceiver with latest prices per vendor
|
|
transceiverRouter.get("/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
const transceiver = await getTransceiverById(String(req.params.id));
|
|
if (!transceiver) {
|
|
res.status(404).json({ success: false, error: "Transceiver not found" });
|
|
return;
|
|
}
|
|
|
|
// Latest price per source vendor — last 30 days, exclude anomalous prices
|
|
const pricesResult = await pool.query(
|
|
`SELECT DISTINCT ON (po.source_vendor_id)
|
|
po.price, po.currency, po.url, po.time, po.stock_level, po.is_verified,
|
|
v.name AS vendor_name, v.type AS vendor_type, v.website AS vendor_website
|
|
FROM price_observations po
|
|
JOIN vendors v ON po.source_vendor_id = v.id
|
|
WHERE po.transceiver_id = $1
|
|
AND po.time > NOW() - INTERVAL '30 days'
|
|
AND COALESCE(po.is_anomalous, false) = false
|
|
ORDER BY po.source_vendor_id, po.time DESC`,
|
|
[transceiver.id]
|
|
);
|
|
|
|
// Flag: price is only "verified" if observed within 7 days with real URL
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
const prices = pricesResult.rows.map((row) => ({
|
|
vendor_name: row.vendor_name,
|
|
vendor_type: row.vendor_type,
|
|
vendor_website: row.vendor_website,
|
|
price: parseFloat(row.price),
|
|
currency: row.currency,
|
|
url: row.url,
|
|
stock_level: row.stock_level,
|
|
observed_at: row.time,
|
|
is_verified: !!(row.url && new Date(row.time) > sevenDaysAgo),
|
|
is_same_product: true,
|
|
}));
|
|
|
|
// Market comparison: technically equivalent products from other vendors
|
|
// Same form_factor + speed_gbps + reach within 20% tolerance, different vendor
|
|
// Only real scraped prices (url IS NOT NULL), last 30 days, max 1 per source vendor
|
|
const comparableResult = await pool.query(
|
|
`SELECT DISTINCT ON (po.source_vendor_id)
|
|
po.price, po.currency, po.url, po.time,
|
|
sv.name AS vendor_name, sv.type AS vendor_type,
|
|
t2.part_number, t2.standard_name, t2.id AS comparable_id
|
|
FROM transceivers t1
|
|
JOIN transceivers t2 ON (
|
|
t2.form_factor = t1.form_factor
|
|
AND t2.speed_gbps = t1.speed_gbps
|
|
AND (
|
|
t1.reach_meters IS NULL OR t1.reach_meters = 0
|
|
OR t2.reach_meters IS NULL OR t2.reach_meters = 0
|
|
OR ABS(t2.reach_meters - t1.reach_meters) <= GREATEST(t1.reach_meters, 1) * 0.25
|
|
)
|
|
AND t2.id != t1.id
|
|
)
|
|
JOIN price_observations po ON po.transceiver_id = t2.id
|
|
JOIN vendors sv ON po.source_vendor_id = sv.id
|
|
WHERE t1.id = $1
|
|
AND po.time > NOW() - INTERVAL '30 days'
|
|
AND po.price > 0
|
|
AND po.url IS NOT NULL
|
|
AND COALESCE(po.is_anomalous, false) = false
|
|
-- Exclude vendors that already appear in direct prices
|
|
AND sv.id NOT IN (
|
|
SELECT source_vendor_id FROM price_observations
|
|
WHERE transceiver_id = $1 AND time > NOW() - INTERVAL '30 days'
|
|
)
|
|
ORDER BY po.source_vendor_id, po.time DESC
|
|
LIMIT 10`,
|
|
[transceiver.id]
|
|
);
|
|
|
|
const comparablePrices = comparableResult.rows.map((row) => ({
|
|
vendor_name: row.vendor_name,
|
|
vendor_type: row.vendor_type,
|
|
price: parseFloat(row.price),
|
|
currency: row.currency,
|
|
url: row.url,
|
|
observed_at: row.time,
|
|
is_verified: !!(row.url && new Date(row.time) > sevenDaysAgo),
|
|
is_same_product: false, // different SKU, same spec class
|
|
comparable_part: row.part_number || row.standard_name,
|
|
comparable_id: row.comparable_id,
|
|
}));
|
|
|
|
const allPrices = [...prices, ...comparablePrices];
|
|
|
|
// Price anomaly detection: flag if max/min ratio > 10x (same-product prices only)
|
|
const samePricesEur = allPrices
|
|
.filter((p) => p.is_same_product && p.price > 0)
|
|
.map((p) => {
|
|
// Normalize to EUR for comparison
|
|
if (p.currency === "EUR") return p.price;
|
|
if (p.currency === "USD") return p.price * 0.92;
|
|
if (p.currency === "GBP") return p.price * 1.17;
|
|
return p.price;
|
|
});
|
|
|
|
let priceAnomaly: { ratio: number; min_eur: number; max_eur: number } | null = null;
|
|
if (samePricesEur.length >= 2) {
|
|
const minEur = Math.min(...samePricesEur);
|
|
const maxEur = Math.max(...samePricesEur);
|
|
const ratio = minEur > 0 ? Math.round((maxEur / minEur) * 10) / 10 : 0;
|
|
if (ratio >= 10) {
|
|
priceAnomaly = { ratio, min_eur: Math.round(minEur * 100) / 100, max_eur: Math.round(maxEur * 100) / 100 };
|
|
}
|
|
}
|
|
|
|
// Last time ANY competitor scraper looked at this transceiver (regardless of result)
|
|
const lastScanResult = await pool.query(
|
|
`SELECT MAX(po.time) AS last_scan
|
|
FROM price_observations po
|
|
JOIN vendors v ON po.source_vendor_id = v.id
|
|
WHERE po.transceiver_id = $1
|
|
AND v.is_competitor = true`,
|
|
[transceiver.id]
|
|
);
|
|
const lastCompetitorScan = lastScanResult.rows[0]?.last_scan ?? null;
|
|
|
|
// Has any competitor ever listed a price for this exact product?
|
|
const competitorHasProduct = prices.some(
|
|
(p) => p.vendor_type !== "flexoptix" && p.price > 0
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
...transceiver,
|
|
competitor_prices: allPrices,
|
|
price_anomaly: priceAnomaly,
|
|
last_competitor_scan: lastCompetitorScan,
|
|
competitor_has_product: competitorHasProduct,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("Get transceiver error:", err);
|
|
res.status(500).json({ success: false, error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
// GET /api/transceivers/:id/compatibility — Compatible switches for a transceiver
|
|
transceiverRouter.get("/:id/compatibility", async (req: Request, res: Response) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT sw.id, sw.model, sw.series, sw.category, sw.total_ports,
|
|
sw.max_speed_gbps, sw.switching_capacity_tbps, sw.lifecycle_status,
|
|
v.name as vendor_name, c.status, c.notes as compat_notes
|
|
FROM compatibility c
|
|
JOIN switches sw ON c.switch_id = sw.id
|
|
LEFT JOIN vendors v ON sw.vendor_id = v.id
|
|
WHERE c.transceiver_id::text = $1 AND c.status = 'compatible'
|
|
ORDER BY v.name, sw.model`,
|
|
[String(req.params.id)]
|
|
);
|
|
res.json({ success: true, data: result.rows });
|
|
} catch (err) {
|
|
console.error("Get transceiver compatibility error:", err);
|
|
res.status(500).json({ success: false, error: "Internal server error" });
|
|
}
|
|
});
|