307 lines
13 KiB
Markdown
307 lines
13 KiB
Markdown
# 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 |
|