From e323ed0a9cdb2b3e706c4493cc25bfd7eaf1b682 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:29:15 +0000 Subject: [PATCH] feat: daily cron scrape endpoint with concurrency + alert dispatch --- src/app/api/cron/scrape/route.ts | 123 +++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/app/api/cron/scrape/route.ts diff --git a/src/app/api/cron/scrape/route.ts b/src/app/api/cron/scrape/route.ts new file mode 100644 index 0000000..e4b64a5 --- /dev/null +++ b/src/app/api/cron/scrape/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server' +import { eq, and } from 'drizzle-orm' +import { db, products, priceSnapshots, alerts } from '@/lib/db' +import { scrapeUrl } from '@/lib/scrapers' +import { ensureScrapersRegistered } from '@/lib/scrapers/register' +import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate' +import { sendPush } from '@/lib/pushover' + +export const maxDuration = 300 + +const CONCURRENCY = 2 +const FAIL_WARN_THRESHOLD = 3 + +export async function POST(req: NextRequest) { + const auth = req.headers.get('authorization') + if (auth !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + ensureScrapersRegistered() + + const productsToScrape = await db.select().from(products).where(eq(products.enabled, true)) + console.log(`[cron] scraping ${productsToScrape.length} products`) + + const queue = [...productsToScrape] + const summary = { ok: 0, failed: 0, alertsTriggered: 0 } + + async function worker() { + while (queue.length > 0) { + const p = queue.shift() + if (!p) break + try { + await processProduct(p, summary) + } catch (err) { + console.error(`[cron] product ${p.id} crashed`, err) + } + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())) + + return NextResponse.json(summary) +} + +async function processProduct(p: typeof products.$inferSelect, summary: { ok: number; failed: number; alertsTriggered: number }) { + const result = await scrapeUrl(p.url) + await db.insert(priceSnapshots).values({ + productId: p.id, + price: result.price !== null ? String(result.price) : null, + currency: result.currency, + availability: result.availability, + error: result.error ?? null, + }) + + if (result.price === null) { + const failures = p.consecutiveFailures + 1 + await db.update(products).set({ + lastScrapedAt: new Date(), + consecutiveFailures: failures, + }).where(eq(products.id, p.id)) + summary.failed++ + if (failures === FAIL_WARN_THRESHOLD) { + await sendPush({ + title: `⚠ Scrape-Fehler`, + message: `${p.name} ist ${failures}× hintereinander fehlgeschlagen: ${result.error ?? 'unknown'}`, + url: p.url, + }).catch((e) => console.error('pushover failed', e)) + } + return + } + + summary.ok++ + await db.update(products).set({ lastScrapedAt: new Date(), consecutiveFailures: 0 }).where(eq(products.id, p.id)) + + const allSnapshots = await db.select().from(priceSnapshots) + .where(and(eq(priceSnapshots.productId, p.id))) + const maxId = Math.max(...allSnapshots.map((x) => x.id)) + const history: SnapshotInput[] = allSnapshots + .filter((s) => s.price !== null) + .filter((s) => s.id !== maxId) + .map((s) => ({ price: Number(s.price), scrapedAt: s.scrapedAt })) + + const productAlerts = await db.select().from(alerts) + .where(and(eq(alerts.productId, p.id), eq(alerts.enabled, true))) + + for (const alert of productAlerts) { + const r = evaluateAlert({ + alert: { + type: alert.type as 'target_price' | 'all_time_low' | 'percent_drop', + config: alert.config as Record, + lastTriggeredAt: alert.lastTriggeredAt, + }, + currentPrice: result.price, + history, + }) + if (r.triggered) { + summary.alertsTriggered++ + await sendPush({ + title: alertTitle(alert.type as string, r.context, p.name), + message: alertMessage(alert.type as string, r.context, result.price), + url: p.url, + urlTitle: 'Zum Shop', + }).catch((e) => console.error('pushover failed', e)) + await db.update(alerts).set({ lastTriggeredAt: new Date() }).where(eq(alerts.id, alert.id)) + } + } +} + +function alertTitle(type: string, ctx: Record, name: string): string { + switch (type) { + case 'target_price': return `📉 ${name} unter ${ctx.threshold}€` + case 'all_time_low': return `🎯 Allzeit-Tief: ${name}` + case 'percent_drop': return `⬇️ ${name} −${ctx.percent}%` + default: return name + } +} + +function alertMessage(type: string, ctx: Record, price: number): string { + switch (type) { + case 'target_price': return `Jetzt ${price}€ (Ziel: ${ctx.threshold}€)` + case 'all_time_low': return `Jetzt ${price}€ (vorher min: ${ctx.prevMin}€)` + case 'percent_drop': return `Jetzt ${price}€ (vorher Ø ${ctx.avg}€ in ${ctx.percent ?? '?'}% Drop)` + default: return `Jetzt ${price}€` + } +}