docs: initial design spec for preis-tracker
This commit is contained in:
306
docs/superpowers/specs/2026-05-25-preis-tracker-design.md
Normal file
306
docs/superpowers/specs/2026-05-25-preis-tracker-design.md
Normal 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 |
|
||||||
Reference in New Issue
Block a user