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