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

307 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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: <name>")
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 |