Files
preis-tracker/docs/superpowers/specs/2026-05-25-preis-tracker-design.md

13 KiB
Raw Permalink Blame History

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

  • 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

// 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

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