/** * 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 }; }