New API routes: - GET /api/finder — Switch→Flexoptix transceiver finder with FlexBox coding - GET /api/competitor-alerts — Competitor intelligence (price changes, new products, stock) - GET /api/forecast/:technology — Sales forecast 3/9/12/18 months + buy/wait/hold signal - POST /api/transport/plan — Transport system planner (city→city BOM with fiber providers) New MCP tools: - find_flexoptix_for_switch — Customer switch → Flexoptix products - get_competitor_alerts — Competitor monitoring - plan_transport — Network transport planning - forecast_sales — Volume/revenue prediction - generate_blog — Enhanced blog generation New DB tables (migration 013): - competitor_alerts, price_changes, flexoptix_product_map - sales_forecasts, fiber_providers, fiber_routes, cities - generated_datasheets, blog_series - Views: v_price_coverage, v_image_coverage, v_switch_flexoptix_finder Seed data (migration 014): - 25 European cities with IX/DC locations + coordinates - 15 fiber providers (euNetworks, Telia, DTAG, Colt, Zayo, etc.) - 16 fiber routes with pricing (Germany focus) Infrastructure: - Scraper scheduler: 2h Flexoptix, 4h FS.com/Optcore (was 6-8h) - Change detector for competitor price/stock monitoring - Image downloader utility with coverage tracking
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
/**
|
|
* WS4: Competitor Change Detection
|
|
*
|
|
* Compares current scrape results with previous observations
|
|
* and generates alerts for price changes, new products, stock changes.
|
|
*/
|
|
import { Pool } from "pg";
|
|
|
|
const pool = new Pool({
|
|
host: process.env.POSTGRES_HOST || "localhost",
|
|
port: parseInt(process.env.POSTGRES_PORT || "5433"),
|
|
database: process.env.POSTGRES_DB || "transceiver_db",
|
|
user: process.env.POSTGRES_USER || "tip",
|
|
password: process.env.POSTGRES_PASSWORD || "tip_dev_2026",
|
|
max: 3,
|
|
});
|
|
|
|
interface PriceObservation {
|
|
transceiver_id: string;
|
|
vendor_id: string;
|
|
price: number;
|
|
currency: string;
|
|
stock_level?: string;
|
|
part_number?: string;
|
|
product_name?: string;
|
|
form_factor?: string;
|
|
speed_gbps?: number;
|
|
source_url?: string;
|
|
}
|
|
|
|
/**
|
|
* After a scraper run, call this to detect changes and generate alerts.
|
|
*/
|
|
export async function detectChanges(
|
|
vendorId: string,
|
|
currentObservations: PriceObservation[]
|
|
): Promise<{ alerts: number; priceChanges: number; newProducts: number }> {
|
|
let alerts = 0;
|
|
let priceChanges = 0;
|
|
let newProducts = 0;
|
|
|
|
for (const obs of currentObservations) {
|
|
try {
|
|
// Get last known price for this transceiver from this vendor
|
|
const prev = await pool.query(
|
|
`SELECT price, currency, stock_level
|
|
FROM price_observations
|
|
WHERE transceiver_id = $1 AND source_vendor_id = $2
|
|
ORDER BY time DESC LIMIT 1`,
|
|
[obs.transceiver_id, obs.vendor_id]
|
|
);
|
|
|
|
if (prev.rows.length === 0) {
|
|
// New product alert
|
|
await pool.query(
|
|
`INSERT INTO competitor_alerts (vendor_id, transceiver_id, alert_type, severity,
|
|
new_price, currency, part_number, product_name, form_factor, speed_gbps, source_url)
|
|
VALUES ($1, $2, 'new_product', 'medium', $3, $4, $5, $6, $7, $8, $9)`,
|
|
[obs.vendor_id, obs.transceiver_id, obs.price, obs.currency,
|
|
obs.part_number, obs.product_name, obs.form_factor, obs.speed_gbps, obs.source_url]
|
|
);
|
|
newProducts++;
|
|
alerts++;
|
|
continue;
|
|
}
|
|
|
|
const prevPrice = parseFloat(prev.rows[0].price);
|
|
const prevStock = prev.rows[0].stock_level;
|
|
|
|
// Price change detection (>2% threshold to avoid noise)
|
|
if (Math.abs(obs.price - prevPrice) / prevPrice > 0.02) {
|
|
const delta = obs.price - prevPrice;
|
|
const deltaPct = (delta / prevPrice) * 100;
|
|
const alertType = delta < 0 ? 'price_drop' : 'price_increase';
|
|
const severity = Math.abs(deltaPct) > 15 ? 'high' : Math.abs(deltaPct) > 5 ? 'medium' : 'low';
|
|
|
|
// Insert alert
|
|
await pool.query(
|
|
`INSERT INTO competitor_alerts (vendor_id, transceiver_id, alert_type, severity,
|
|
old_price, new_price, price_delta, price_pct, currency,
|
|
part_number, product_name, form_factor, speed_gbps, source_url)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
|
[obs.vendor_id, obs.transceiver_id, alertType, severity,
|
|
prevPrice, obs.price, delta, deltaPct, obs.currency,
|
|
obs.part_number, obs.product_name, obs.form_factor, obs.speed_gbps, obs.source_url]
|
|
);
|
|
|
|
// Insert price change record
|
|
await pool.query(
|
|
`INSERT INTO price_changes (transceiver_id, vendor_id, old_price, new_price, delta, delta_pct, currency)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
[obs.transceiver_id, obs.vendor_id, prevPrice, obs.price, delta, deltaPct, obs.currency]
|
|
);
|
|
|
|
priceChanges++;
|
|
alerts++;
|
|
}
|
|
|
|
// Stock change detection
|
|
if (prevStock && obs.stock_level && prevStock !== obs.stock_level) {
|
|
if (obs.stock_level === 'out_of_stock' && prevStock !== 'out_of_stock') {
|
|
await pool.query(
|
|
`INSERT INTO competitor_alerts (vendor_id, transceiver_id, alert_type, severity,
|
|
part_number, product_name, form_factor, speed_gbps, source_url)
|
|
VALUES ($1, $2, 'out_of_stock', 'low', $3, $4, $5, $6, $7)`,
|
|
[obs.vendor_id, obs.transceiver_id, obs.part_number, obs.product_name,
|
|
obs.form_factor, obs.speed_gbps, obs.source_url]
|
|
);
|
|
alerts++;
|
|
} else if (prevStock === 'out_of_stock' && obs.stock_level !== 'out_of_stock') {
|
|
await pool.query(
|
|
`INSERT INTO competitor_alerts (vendor_id, transceiver_id, alert_type, severity,
|
|
new_price, currency, part_number, product_name, form_factor, speed_gbps, source_url)
|
|
VALUES ($1, $2, 'back_in_stock', 'low', $3, $4, $5, $6, $7, $8, $9)`,
|
|
[obs.vendor_id, obs.transceiver_id, obs.price, obs.currency,
|
|
obs.part_number, obs.product_name, obs.form_factor, obs.speed_gbps, obs.source_url]
|
|
);
|
|
alerts++;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`Change detection error for ${obs.part_number}:`, err);
|
|
}
|
|
}
|
|
|
|
console.log(`Change detection: ${alerts} alerts (${priceChanges} price changes, ${newProducts} new products)`);
|
|
return { alerts, priceChanges, newProducts };
|
|
}
|