transceiver-db/packages/scraper/src/utils/change-detector.ts
Rene Fichtmueller a69acc4588 feat(v0.2.0): Sales Intelligence Engine — Phase 0+A
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
2026-03-31 08:51:22 +02:00

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