docs: initial design spec for preis-tracker

This commit is contained in:
2026-05-25 12:51:46 +00:00
commit 25101eaa6a

View File

@@ -0,0 +1,306 @@
# 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 |