feat: daily cron scrape endpoint with concurrency + alert dispatch
This commit is contained in:
123
src/app/api/cron/scrape/route.ts
Normal file
123
src/app/api/cron/scrape/route.ts
Normal 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}€`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user