From 25101eaa6acc3f2f2cc047e2630de5815c2286a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 12:51:46 +0000 Subject: [PATCH] docs: initial design spec for preis-tracker --- .../specs/2026-05-25-preis-tracker-design.md | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-preis-tracker-design.md diff --git a/docs/superpowers/specs/2026-05-25-preis-tracker-design.md b/docs/superpowers/specs/2026-05-25-preis-tracker-design.md new file mode 100644 index 0000000..1ee6527 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-preis-tracker-design.md @@ -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 +} +``` + +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 + +```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 |