13 KiB
preis-tracker — Design Spec
Datum: 2026-05-25
Status: Approved
Domain: preis.kuns.dev
Repo: git.kuns.dev/kuns/preis-tracker
1. Zweck
Eine Single-User-Webapp, die Produktpreise bei Amazon, Idealo und Geizhals täglich trackt, den Preisverlauf visualisiert und bei konfigurierbaren Bedingungen Pushover-Notifications sendet.
2. Erfolgskriterien
- URL eines Produkts einfügen → Shop wird erkannt, Produkt-Metadaten (Name, Bild) und initialer Preis werden gespeichert
- Daily Cron um 06:00 scraped alle aktiven Produkte
- Preisverlauf-Chart pro Produkt sichtbar
- Drei Alert-Typen funktionieren: Zielpreis, Allzeit-Tief, prozentualer Drop
- Pushover-Notification kommt innerhalb 1 Min nach Trigger an
- Nach 30 Tagen Betrieb: weniger als 5% Scrape-Failures pro Shop
3. Stack
| Komponente | Wahl | Begründung |
|---|---|---|
| Framework | Next.js 15 (App Router) + TypeScript | Konsistent mit tank-lotse, geld-held Frontend |
| Styling | Tailwind CSS 4 | Standard im Projekt |
| Charts | Recharts | Leichtgewichtig, React-nativ |
| DB | Shared PostgreSQL (preistracker DB) |
Bestehende Infra |
| ORM | Drizzle | Konsistent mit tank-lotse |
| Auth | Zitadel OIDC (manueller PKCE) | Feedback-Memory: kein oidc-client-ts |
| Session | iron-session (encrypted cookie) | Stateless, kein Redis nötig |
| Scraping | Playwright (Amazon) + Cheerio (Idealo, Geizhals) | Amazon braucht JS-Rendering |
| Hosting | Coolify-App | Standard-Deploy |
| Runtime | Bun für Dev, Node 22 für Container | Playwright-Image basiert auf Node |
4. Datenmodell
-- Drizzle-Schema, hier als SQL skizziert
products (
id uuid pk default gen_random_uuid(),
url text not null unique,
shop text not null check (shop in ('amazon','idealo','geizhals')),
name text not null,
image_url text,
created_at timestamptz not null default now(),
enabled boolean not null default true,
last_scraped_at timestamptz,
consecutive_failures int not null default 0
)
price_snapshots (
id bigserial pk,
product_id uuid not null references products(id) on delete cascade,
price numeric(10,2), -- null bei Scrape-Fehler
currency text not null default 'EUR',
availability text, -- 'in_stock' | 'out_of_stock' | 'unknown'
error text, -- gefuellt wenn price=null
scraped_at timestamptz not null default now()
)
create index on price_snapshots (product_id, scraped_at desc);
alerts (
id uuid pk default gen_random_uuid(),
product_id uuid not null references products(id) on delete cascade,
type text not null check (type in ('target_price','all_time_low','percent_drop')),
config jsonb not null, -- siehe unten
enabled boolean not null default true,
last_triggered_at timestamptz,
created_at timestamptz not null default now()
)
Alert-Config-Schemas
| Type | Config-Beispiel | Trigger |
|---|---|---|
target_price |
{"threshold": 400} |
price <= threshold |
all_time_low |
{} |
price < min(price) aller vorherigen erfolgreichen snapshots |
percent_drop |
{"lookback_days": 7, "percent": 10} |
price <= avg(price[lookback]) * (1 - percent/100) |
Dedup: Alert nur senden wenn last_triggered_at null oder älter als 24h.
5. Architektur
┌─────────────────────────────────────────────────────────┐
│ Browser │
│ ├─ / (Dashboard, Produktliste + Sparklines) │
│ ├─ /products/[id] (Detail + Chart + Alert-Settings) │
│ └─ /add (URL-Eingabe, initialer Scrape) │
└──────────────────┬──────────────────────────────────────┘
│ (iron-session cookie)
┌──────────────────┴──────────────────────────────────────┐
│ Next.js App Router │
│ ├─ middleware.ts (Auth-Check ausser /api/auth, /api/cron) │
│ ├─ /api/auth/* (PKCE Login/Callback/Logout) │
│ ├─ /api/products (CRUD) │
│ ├─ /api/alerts (CRUD) │
│ ├─ /api/scrape/[id] (manueller Refresh) │
│ └─ /api/cron/scrape (Coolify Scheduled Task, Secret-Header)│
└──────────────────┬──────────────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌──┴───┐ ┌────┴────┐ ┌────┴─────┐
│ DB │ │ Scraper │ │ Pushover │
│ (PG) │ │ Adapter │ │ Client │
└──────┘ └────┬────┘ └──────────┘
│
┌──────────┼──────────┐
┌────┴────┐ ┌───┴───┐ ┌────┴────┐
│ Amazon │ │Idealo │ │Geizhals │
│(Playwr.)│ │(Cheer)│ │ (Cheer) │
└─────────┘ └───────┘ └─────────┘
6. Scraper-Adapter
// lib/scrapers/types.ts
export interface ScrapeResult {
price: number | null
currency: string
availability: 'in_stock' | 'out_of_stock' | 'unknown'
name?: string // nur beim ersten Scrape relevant
imageUrl?: string
error?: string
}
export interface PriceScraper {
shop: 'amazon' | 'idealo' | 'geizhals'
match(url: string): boolean // Hostname-Check
scrape(url: string): Promise<ScrapeResult>
}
Registry in lib/scrapers/index.ts. scrapeUrl(url) waehlt passenden Adapter; wirft wenn kein Match.
Amazon-Adapter
- Playwright headless Chromium, deutsche Locale, realistischer UA
- Selektoren (Fallback-Kette):
#corePrice_feature_div .a-offscreen,#priceblock_ourprice,.a-price .a-offscreen - Name:
#productTitle, Image:#landingImage - Bei Captcha-Detektion (
form[action*="validateCaptcha"]):error="captcha", retry am naechsten Tag
Idealo-Adapter
fetchmit Browser-UA, Cheerio- Selektor:
[data-testid="detail-offer-price"]odermeta[itemprop="price"] - Bei Cloudflare-Challenge (HTTP 403 + spezifischer Body): logge und faile
Geizhals-Adapter
fetchmit Browser-UA, Cheerio- Selektor:
.gh_priceoderspan.gh_price strong - Wenig Anti-Bot, sollte stabil sein
Concurrency-Limit beim Daily-Scrape: max 2 parallel (RAM-Schutz, Playwright ist hungrig).
7. Daily-Scraping-Flow
- Coolify Scheduled Task ruft
POST https://preis.kuns.dev/api/cron/scrapemitAuthorization: Bearer $CRON_SECRET - Handler holt alle
products WHERE enabled=true - Pro Produkt (max 2 parallel):
- Adapter scraped
- Snapshot wird geschrieben (auch bei Fehler, mit
price=null, error=...) - Bei Erfolg:
consecutive_failures=0,last_scraped_at=now() - Bei Fehler:
consecutive_failures++. Wenn>=3: Pushover-Warning an Owner („Scrape failed 3x: ")
- Nach allen Scrapes: Alert-Evaluation pro Produkt
- Trigger-fähige Alerts → Pushover senden,
last_triggered_at=now()
Cron-Zeit: 0 6 * * * (Europe/Berlin) — vor dem Frühstück sehe ich Notifications.
8. Auth (Zitadel)
- Neue Anwendung in Zitadel-Projekt: Type „Web", Auth-Method „PKCE"
- Redirect URI:
https://preis.kuns.dev/api/auth/callback - Post-Logout-URI:
https://preis.kuns.dev/ - Env:
ZITADEL_ISSUER,ZITADEL_CLIENT_ID,ALLOWED_USER_IDS(comma-separated Sub-Claims) - Flow:
/api/auth/login→ generiertcode_verifier+code_challenge, speichert verifier im signed cookie, redirect zu Zitadel/api/auth/callback→ tauscht Code gegen Token, validiert ID-Token (issuer, audience, exp, signature via JWKS), prüftsubgegen Whitelist, setzt iron-session/api/auth/logout→ löscht Session, redirect zu Zitadel End-Session-Endpoint
- Middleware: alle Routen außer
/api/auth/*und/api/cron/*brauchen gültige Session /api/cron/*schützt Bearer-Token (CRON_SECRET)
9. Pushover-Integration
// lib/pushover.ts
async function sendPush(opts: {
title: string
message: string
url?: string
urlTitle?: string
priority?: -2 | -1 | 0 | 1 | 2
}) {
await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
body: new URLSearchParams({
token: process.env.PUSHOVER_TOKEN!,
user: process.env.PUSHOVER_USER!,
...opts,
priority: String(opts.priority ?? 0),
}),
})
}
Templates:
- Target:
📉 {name} unter {threshold}€ — jetzt {price}€ ({shop}) - ATL:
🎯 Allzeit-Tief! {name} {price}€ (vorher min: {prevMin}€) - Drop:
⬇️ {name} −{percent}% in {lookback}d — jetzt {price}€
url = Produkt-URL des Shops. Notifications führen zum Kauf.
10. UI-Skizze
Dashboard (/):
- Header mit „+ Add Product" Button und User-Avatar (Logout)
- Card-Grid: pro Produkt 1 Card mit Bild, Name, Shop-Badge, aktueller Preis, Δ zum Tief, Mini-Sparkline (letzte 30 Tage)
- Klick auf Card → Detail-Seite
Detail (/products/[id]):
- Hero: Bild + Name + Shop + Link „zum Shop ↗"
- Großer Line-Chart Preisverlauf (umschaltbar 30d / 90d / All)
- Alert-Sektion: Liste bestehender Alerts + „+ Alert hinzufügen"
- Aktionen: „Jetzt aktualisieren" (manueller Scrape), „Deaktivieren", „Löschen"
Add (/add):
- Einfacher Textfield für URL
- Submit → POST
/api/products→ Backend erkennt Shop, scraped initial → Redirect auf Detail-Seite
11. Fehlerbehandlung
| Fehlerfall | Behandlung |
|---|---|
| Unbekannter Shop beim Add | 400 mit Liste der unterstützten Hosts |
| Initialer Scrape failed | Produkt wird trotzdem gespeichert, UI zeigt Banner „Erster Scrape fehlgeschlagen, retry morgen" |
| Scrape-Fehler im Daily-Run | Snapshot mit price=null, error=..., Produkt bleibt enabled |
| 3 consecutive failures | Pushover-Warning, Produkt bleibt enabled (User entscheidet) |
| DB down | API gibt 503, Cron logged und exit 1 (Coolify retry) |
| Pushover-API down | Error wird gelogged, kein Retry (nächster Tag bringt frische Chance) |
| Zitadel down | Login schlägt fehl, bestehende Sessions weiter gültig bis Cookie-Expiry (7 Tage) |
12. Deployment
- Repo
kuns/preis-trackerauf Gitea - Coolify-App neu anlegen, Build-Pack: Dockerfile
- Env Vars in Coolify:
DATABASE_URL(Shared PG, DBpreistracker)ZITADEL_ISSUER,ZITADEL_CLIENT_ID,ALLOWED_USER_IDSSESSION_PASSWORD(32+ Char Random)PUSHOVER_TOKEN,PUSHOVER_USERCRON_SECRET
- Coolify Scheduled Task:
curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/scrape, Cron0 6 * * * - DB-Setup:
preistrackerDB + User in Shared PG anlegen (siehekuns/shared-postgres) - Migrations via
drizzle-kit pushbeim Container-Start (oder separate Migration-Stage)
13. Dockerfile-Skizze
FROM mcr.microsoft.com/playwright:v1.50.0-jammy AS base
WORKDIR /app
FROM base AS deps
COPY package.json bun.lockb ./
RUN npm install -g bun && bun install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm install -g bun && bun run build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
next.config.ts mit output: 'standalone'.
14. Out of Scope (v1)
- Mehrere User / Multi-Tenancy
- Andere Notification-Kanäle (Email, Telegram, Webhooks)
- Browser-Extension oder Bookmarklet
- Multi-Variant-Tracking (Größe, Farbe, Konfiguration)
- Affiliate-Link-Wrapping
- Preisvorhersage / Trend-ML
- Vergleich des gleichen Produkts über mehrere Shops
- Export (CSV/JSON)
15. Offene Fragen
Keine. Alle Entscheidungen sind getroffen.
16. Risiken
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|---|---|---|---|
| Amazon-Captcha bei Daily Scrapes | Mittel | Hoch | Realistischer UA, deutsche Locale, daily reicht (nicht häufiger). Notfalls Keepa-API. |
| Selector-Drift bei Shops | Hoch (langfristig) | Mittel | Fallback-Selektor-Kette, klare Error-Logs, manuelles Anpassen erwartet 2-3x/Jahr |
| RAM-Engpass durch Playwright | Mittel | Mittel | Concurrency=2, Browser nach Scrape schließen, --single-process |
| Zitadel-Konfig-Drift | Niedrig | Hoch (Aussperrung) | CRON_SECRET als Notfall-Backdoor, Recovery via direktem DB-Zugriff |