# 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 ```sql -- 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 ```typescript // 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 } ``` 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 - `fetch` mit Browser-UA, Cheerio - Selektor: `[data-testid="detail-offer-price"]` oder `meta[itemprop="price"]` - Bei Cloudflare-Challenge (HTTP 403 + spezifischer Body): logge und faile ### Geizhals-Adapter - `fetch` mit Browser-UA, Cheerio - Selektor: `.gh_price` oder `span.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 1. Coolify Scheduled Task ruft `POST https://preis.kuns.dev/api/cron/scrape` mit `Authorization: Bearer $CRON_SECRET` 2. Handler holt alle `products WHERE enabled=true` 3. 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: ") 4. Nach allen Scrapes: Alert-Evaluation pro Produkt 5. 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` → generiert `code_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üft `sub` gegen 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 ```typescript // 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-tracker` auf Gitea - Coolify-App neu anlegen, Build-Pack: Dockerfile - Env Vars in Coolify: - `DATABASE_URL` (Shared PG, DB `preistracker`) - `ZITADEL_ISSUER`, `ZITADEL_CLIENT_ID`, `ALLOWED_USER_IDS` - `SESSION_PASSWORD` (32+ Char Random) - `PUSHOVER_TOKEN`, `PUSHOVER_USER` - `CRON_SECRET` - Coolify Scheduled Task: `curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/scrape`, Cron `0 6 * * *` - DB-Setup: `preistracker` DB + User in Shared PG anlegen (siehe `kuns/shared-postgres`) - Migrations via `drizzle-kit push` beim Container-Start (oder separate Migration-Stage) ## 13. Dockerfile-Skizze ```dockerfile 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 |