feat: daily cron scrape endpoint with concurrency + alert dispatch

This commit is contained in:
2026-05-25 14:29:15 +00:00
parent 8dcae4d60f
commit e323ed0a9c

View File

@@ -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<string, unknown>,
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<string, number | string>, 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<string, number | string>, 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}`
}
}