Plan: Website-Analyse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 09:18:23 +00:00
1 parent 29986cf697
commit a1ce46672a
2 files changed
+138 -1

No files matched your search

@@ -0,0 +1,137 @@
# Website-Analyse – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kunden mit Wartungs-Abo schalten im Portal eine Website-Analyse ein; Banner + Skript werden vom
Runner eingesetzt, ein eigener Sammeldienst zählt datenschutzkonform, das Portal zeigt Zahlen und Heatmap.
**Architecture:** Neuer Dienst `analytics/` (Node 24, `node:sqlite`, keine Abhängigkeiten) unter
`https://project.webklar.com/a/…`; Client-Skripte `w.js`/`h.js` liefert er aus. Der Runner setzt das
Skript beim Veröffentlichen ein. Das Portal verwaltet Freischaltung (portalFeatures), spricht den
Sammeldienst per Admin-Token an und zeigt die Auswertung. WOMS bekommt einen Schalter.
**Tech Stack:** Node 24 (Sammeldienst), Node 20 (Portal/Runner), vanilla JS (Portal-UI, Client-Skript),
React (WOMS), Playwright (Prüfung).
**Spec:** `docs/superpowers/specs/2026-10-01-website-analyse-design.md`
**Worktrees:** Portal/Runner/Analytics `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse`
(Branch `feature/website-analyse`). WOMS: `git -C /home/webklar/apps/ticket.webklar.com/build worktree add
-b feature/website-analyse ../dev-analyse origin/test`, node_modules skip-worktree + eigenes `npm install`.
## Global Constraints
- Nur Wartungs-Abo: `productKey === 'wartung'` und Status ∈ `ACTIVE_STATUSES` (stripeBilling.js). Hosting ausgeschlossen.
- Nur öffentliche Projekte (`isPublic === true`) mit Subdomain; Domains der Website = `<subdomain>.project.webklar.com` + `liveDomain` (falls gesetzt).
- Ereignisse exakt laut Spec-Tabelle (`pv`, `bn`, `en`, `ck`, `ct`) mit genau den dort genannten Feldern; `bn` ohne weitere Felder; `en`/`ck`/`ct` nur nach Annehmen.
- Keine IP, kein User-Agent gespeichert; keine Cookies; Banner-Wahl nur `localStorage['wk-consent'] = {v:1,a,t}`, 6 Monate gültig; GPC/DNT ⇒ wie Ablehnen ohne Banner.
- Besucher-Kennung `HMAC-SHA256(tagesSalz, siteKey|ip|ua)` → 16 Hex, Salz nur RAM, Wechsel 00:00 Europe/Berlin.
- Rohdaten/Klicks 30 Tage; Websites mit `offSince` > 60 Tage komplett löschen; Job täglich 02:30 Europe/Berlin.
- Limits: Body ≤ 4 KB, path ≤ 300, sel ≤ 200, sec 0–3600, scroll 0–100, x/y 0–1, 120 Ereignisse/Min je (site, IP).
- Banner: Leiste unten, „Ablehnen“ und „Annehmen“ gleich groß/gleich auffällig, Link „Datenschutz“ → `/datenschutz-analyse.html`; Akzentfarbe nur für „Annehmen“ (Kontrast ≥ 4,5:1, sonst Fallback `#111111`); Handy (< 768 px) kompakte Karte; Footer-Link „Cookie-Einstellungen“.
- siteKey `^[a-z0-9]{16,40}$`, Farbe `^#[0-9a-f]{6}$`.
- Heatmap-Link: HMAC-Token, 30 Minuten gültig, enthält siteKey, path, vp, exp.
- Schalter `ANALYSE_ENABLED` (Vorgabe off) ⇒ Kachel bleibt „Kommt bald“, Routen 409.
- Texte deutsch (Du-Form im Portal), kein `confirm()`. Nichts pushen/live schalten.
## Review Focus
1. Ohne „Annehmen“ verlässt kein `en`/`ck`/`ct` den Browser (auch nicht nach Widerruf). → Task 6
2. Fremde Origin oder unbekannter siteKey ⇒ nichts gespeichert. → Task 2
3. Analyse aus / Abo weg ⇒ nach Neubau kein Skript und keine Zusatzseite mehr im Ordner. → Task 7, 10
4. Heatmap-Token einer Website liefert keine Punkte einer anderen Website. → Task 4
5. Kontakt-Klick speichert nie Telefonnummer/E-Mail-Adresse. → Task 6
---
## Sammeldienst (`analytics/`)
### Task 1: Gerüst, Datenbank, Website-Register
**Files:** `analytics/package.json` (type module, test `node --test test/*.test.js`), `src/config.js`, `src/db.js`, `src/server.js`, `src/index.js`, `test/sites.test.js`.
**Produces:** `loadConfig(env) ⇒ { port: 8090, dataDir: '/srv/webklar-analytics', adminToken, heatSecret, geoFile, baseHost: 'project.webklar.com' }`; `openDb(path) ⇒ db` (Schema: `sites(siteKey PK, projectId, domains JSON, active INT, offSince TEXT)`, `events_raw(id, site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)`, `clicks(id, site, ts, path, vp, x, y, sel)`, `daily(site, day, dim, key, count, PK(site,day,dim,key))`); Admin-Routen (Bearer adminToken, timingSafeEqual): `PUT /a/admin/sites/:siteKey {projectId, domains[], active}`, `DELETE /a/admin/sites/:siteKey`, `GET /a/health`.
- [ ] Tests: 401 ohne/mit falschem Token; PUT legt an/ändert; active=false setzt offSince einmalig; ungültiger siteKey 400; Domains nur Hostnamen.
- [ ] FAIL → implementieren → PASS → Commit „Analytics: Geruest, Datenbank, Website-Register“
### Task 2: Annahme `POST /a/e`
**Files:** `src/ingest.js`, `src/ua.js`, `src/salt.js`, `test/ingest.test.js`.
**Produces:** `classifyUa(ua) ⇒ { bot:boolean, device:'m'|'t'|'d' }` (kleine eigene Regeln, gängige Bots); `createSalt({ now }) ⇒ { visitorId(site, ip, ua) }` (Rotation 00:00 Berlin); Handler: Body ≤ 4 KB text/plain JSON; Website aktiv + Origin/Referer-Host ∈ domains, sonst 204 ohne Speichern; Felder streng nach Global Constraints validieren/kürzen; Land über `geo.lookup(ip)` (Task 3, injizierbar); Limit 120/Min je (site, ip); CORS-Header nur für passende Origin; Antwort 204; nie `Set-Cookie`.
- [ ] Tests: pv gespeichert ohne visitor; bn nur mit a; en/ck/ct mit visitor; fremde Origin ⇒ nichts; Bot ⇒ nichts; 121. Ereignis ⇒ verworfen; Query/Hash aus path entfernt; ref nur Host und nicht eigene Domain; kein IP/UA in der Datenbank (Tabelleninhalte prüfen); Salz wechselt um Mitternacht Berlin.
- [ ] FAIL → implementieren → PASS → Commit „Analytics: Ereignisse annehmen“
### Task 3: Länder (DB-IP Lite)
**Files:** `src/geo.js`, `scripts/geo-update.sh` (lädt monatliche CSV `dbip-country-lite-YYYY-MM.csv.gz` nach `dataDir/geo.csv`), `test/geo.test.js`.
**Produces:** `loadGeo(file) ⇒ { lookup(ip) ⇒ 'DE'|…|'' }` – IPv4 und IPv6 als BigInt-Bereiche, sortiert, Binärsuche; fehlende Datei ⇒ '' für alles.
- [ ] Tests mit kleiner Fixture-CSV (v4, v6, Grenzen, unbekannt). FAIL → implementieren → PASS → Commit „Analytics: Laenderzuordnung“
### Task 4: Verdichtung, Löschung, Statistik, Heatmap-Daten
**Files:** `src/jobs.js`, `src/stats.js`, `src/heat.js`, `test/stats.test.js`.
**Produces:** `runNightly(db, { now })` (Rohdaten > 1 Tag in `daily` verdichten: dims `pv_path`, `pv_ref`, `pv_country`, `pv_device`, `pv_total`, `visitors`, `en_sec_sum`, `en_count`, `scroll_sum`, `ct_kind`, `bn_shown|accept|reject`; Rohdaten/Klicks > 30 Tage löschen; Websites offSince > 60 Tage: alle Zeilen löschen); `GET /a/admin/stats/:siteKey?range=7|30|90` ⇒ `{ totals:{pv, visitors, contacts, consentRate}, series:[{day,pv}], pages, refs, countries, devices, contacts, banner }` (heute aus Rohdaten + Vergangenheit aus daily); `GET /a/heat?token=` (Token HMAC heatSecret: siteKey|path|vp|exp, abgelaufen/falsch ⇒ 403) ⇒ `{ points:[{x,y}] }` max 5000 neueste.
- [ ] Tests: Verdichtung + idempotent; 31 Tage alte Rohdaten weg; Website nach 61 Tagen weg; Stats-Form; consentRate = accept/(accept+reject); Token fremder Website/abgelaufen ⇒ 403.
- [ ] FAIL → implementieren → PASS → Commit „Analytics: Verdichtung, Statistik, Heatmap-Daten“
### Task 5: Auslieferung der Client-Skripte
**Files:** `src/assets.js`, Route `GET /a/w.js`, `GET /a/h.js` (Cache-Control `public, max-age=3600`, `Content-Type: text/javascript`), Quellen in `client/` (Task 6).
- [ ] Test: beide Dateien ausgeliefert, richtige Header, ETag. → Commit „Analytics: Skripte ausliefern“
### Task 6: Client-Skript `w.js` und Overlay `h.js`
**Files:** `analytics/client/w.js`, `analytics/client/h.js`, `analytics/client/core.js` (reine Funktionen, auch von Tests importiert), `test/client.test.js`.
**Produces (core):** `vpClass(width)`, `cleanPath(href)`, `refHost(referrer, ownHost)`, `cssPath(el)` (≤ 200, ohne IDs mit Zahlen-Hashes), `contactKind(a)` ⇒ `'tel'|'mail'|'wa'|'out'|null` (wa: wa.me / api.whatsapp.com), `readConsent(storage, now)`, `contrastOk(hex)`.
`w.js`: liest `data-site`/`data-color` aus dem eigenen `<script>`; GPC/DNT ⇒ reject ohne Banner; sendet `pv`; Banner laut Global Constraints (zugänglich: Buttons, Fokus, `role="dialog"`, `aria-label`); `bn` bei Anzeige/Klick; nach accept Listener für Klick (`ck`, `ct`), Verlassen (`en` via `visibilitychange`/`pagehide`); Footer-Link bzw. `[data-webklar-consent]` öffnet Banner erneut; Widerruf entfernt Listener sofort; `?__wk_heat=` ⇒ lädt `h.js`, entfernt den Parameter per `history.replaceState`, sendet in diesem Modus nichts.
`h.js`: holt Punkte, zeichnet Canvas-Overlay (radiale Verläufe, Deckkraft), Leiste oben mit Gerätewahl-Hinweis und „Schließen“.
- [ ] Tests (Node, core): alle reinen Funktionen inkl. Grenzfälle. Verhalten von w.js/h.js prüft Task 15 im Browser.
- [ ] Commit „Analytics: Banner-Skript und Heatmap-Overlay“
## Runner
### Task 7: Einsetzen beim Veröffentlichen
**Files:** `runner/src/validate.js` (Feld `analytics` streng prüfen: siteKey, color, privacy {company ≤ 200, address ≤ 300, email ≤ 200, kein Steuerzeichen}), `runner/src/analytics-inject.js`, Aufruf in `jobs.js publish` (auf dem `.new`-Ordner vor dem Tausch), `runner/test/analytics-inject.test.js`.
**Produces:** `injectAnalytics(dir, analytics, { baseUrl })`: in jede `*.html` (rekursiv, ohne node_modules) vor `</head>` (sonst am Anfang von `<body>`, sonst Dateianfang) genau einmal `<script src="https://project.webklar.com/a/w.js" data-site=".." data-color=".." defer></script>`; erzeugt `datenschutz-analyse.html` aus Vorlage `runner/src/templates/datenschutz-analyse.html` (Platzhalter HTML-maskiert; Task 7 legt die Vorlage mit vorläufigem Text und allen Platzhaltern an, Task 13 schreibt den endgültigen Inhalt); ohne `analytics` nichts tun (ein früher eingesetztes Skript existiert im neuen Ordner ohnehin nicht, da aus der Quelle neu gebaut).
- [ ] Tests: Einsetzen in mehrere Dateien, kein Doppel, ohne head, Maskierung (`<script>` im Firmennamen), ohne analytics unverändert, Dev-Modus unberührt.
- [ ] Commit „Runner: Analyse-Skript und Datenschutz-Zusatzseite einsetzen“
## Portal
### Task 8: Schema, Konfiguration, Analytics-Client
**Files:** `scripts/analyse-setup.mjs` (portalFeatures: `accentColor` 7, `avvVersion` 20, `avvAcceptedAt` 40, `avvAcceptedBy` 100, `activatedAt` 40, `offSince` 40, `privacyTicketId` 40; limit 200), `server/config.js` (`analyse: { enabled: ANALYSE_ENABLED==='on', url: ANALYTICS_URL||'http://webklar-analytics:8090', adminToken, heatSecret, avvVersion: '2026-10-01' }`), `server/services/analyticsClient.js` (`putSite`, `deleteSite`, `getStats`, `heatToken(siteKey,path,vp,now)`), Tests.
- [ ] Commit „Analyse: Schema, Konfiguration, Client“
### Task 9: Freischalt-Logik
**Files:** `server/services/analyse.js`, `test/analyse.test.js`.
**Produces:** `analyseEligibility(customerId, projectId) ⇒ { ok, reason }` (Eigentum, Wartungs-Abo aktiv, isPublic, Subdomain); `activateAnalyse({ customerId, projectId, accentColor, consent, by })` (siteKey aus portalFeatures-Dokument `featureKey:'analyse'` wiederverwenden/neu, Zustand active, AVV-Felder, `putSite` mit Domains, Neubau über `runDeploy(..., { project })`, Ticket über `createFeatureTicket` mit `featureKey:'analyse'` und Topic „Datenschutzerklärung von <Projekt> um Hinweis auf Analyse ergänzen“, Mail K18 „Deine Website-Analyse ist aktiv“); `deactivateAnalyse(...)` (off, offSince, putSite active false, Neubau); `runAboCheck({ now })` (täglich: aktiv ohne Wartungs-Abo ⇒ deactivate + Mail K19 „Website-Analyse beendet“). Idempotent.
- [ ] Tests: ohne Abo/Hosting-Abo ⇒ abgelehnt; nicht öffentlich ⇒ abgelehnt; AVV fehlt ⇒ 400; doppelt aktivieren ⇒ ein Ticket; deaktivieren ⇒ offSince; Abo-Ende-Job.
- [ ] Commit „Analyse: Freischalten, Abschalten, Abo-Pruefung“
### Task 10: Runner-Auftrag mit Analyse-Feld
**Files:** `server/services/previewDeploy.js` (runDeployViaRunner + registerDevRound unverändert für dev), Test.
Im Build-Auftrag `analytics` setzen, wenn portalFeatures(analyse) `active` und Projekt öffentlich: `{ siteKey, color, privacy: { company: customer.companyName||customer.name, address: street + PLZ Ort, email: customer.email } }`; sonst `null`.
- [ ] Tests: aktiv+öffentlich ⇒ Feld; aus ⇒ null; nicht öffentlich ⇒ null. Commit „Analyse: Runner-Auftrag“
### Task 11: Routen
**Files:** `server/routes/funktionen.js` (`GET` liefert Analyse-Zustand je Projekt + eligibility; `POST /analyse/activate`, `/analyse/deactivate`, `GET /analyse/stats?projectId&range`, `POST /analyse/heatmap-link {projectId,path,vp}` ⇒ `{ url }`), `server/routes/admin/analyse.js` (`POST /api/admin/analyse/:projectId { on, avvConfirmedVerbally }`), Schalter aus ⇒ 409; Tests.
- [ ] Commit „Analyse: Routen fuer Portal und WOMS“
### Task 12: Portal-Oberfläche
**Files:** `public/funktionen.js` (+ CSS), neue Ansicht `#view-analyse` (Hash `#analyse`, App-Unterseite wie News).
Kachel: Zustände (kein Abo / nicht öffentlich / aus / aktiv); Einschalt-Dialog (Erklärung, Farbwahl + Live-Vorschau des Banners, AVV-Haken mit Link zur AVV-Fassung, Knopf); Auswertung: Zeitraum, Kennzahl-Karten, SVG-Liniendiagramm, Listen (Seiten, Herkunft, Länder mit Flaggen-Emoji, Geräte, Kontakt-Klicks, Banner), Hinweis zu Einwilligung, Heatmap-Auswahl (Seite aus Top-Seiten, Gerät) + Knopf; DB-IP-Namensnennung klein unten; „Analyse ausschalten“ mit In-Seite-Bestätigung. 390 px tauglich.
- [ ] `npm test` grün; Commit „Analyse: Portal-Kachel und Auswertung“
## Rechtstexte und WOMS
### Task 13: Rechtstexte (Entwürfe)
**Files:** `docs/rechtliches/avv-website-analyse.md`, `leistungsbeschreibung-website-analyse.md`, `abo-bedingungen-ergaenzung-analyse.md`, Vorlage `runner/src/templates/datenschutz-analyse.html` (Inhalt laut Spec, Platzhalter `{{company}}`, `{{address}}`, `{{email}}`), Absatz in `public/datenschutz.html` (WEBklar als Auftragsverarbeiter für Kunden-Websites).
- [ ] Commit „Analyse: Rechtstexte (Entwuerfe, anwaltlich zu pruefen)“
### Task 14: WOMS-Schalter und Services
**Files (WOMS):** `src/lib/analyseApi.js`, Zeile in `src/components/AssignedProjectCard.jsx` („Website-Analyse: an/aus“, Einschalten nur mit Haken „Kunde hat AVV bestätigt“), Services-Statuslabel für `analyse` aktiv.
- [ ] `npm test` + Build; Commit „Analyse: Schalter im WOMS“
### Task 15: Browser-Prüfung (Playwright)
Testseite + Sammeldienst lokal (Testdaten-Verzeichnis), echtes `w.js`: ohne Annehmen nur `pv`+`bn` im Netzwerk; Annehmen ⇒ `ck`/`ct`/`en`; Ablehnen + Widerruf; GPC; Banner 390 px; Heatmap-Overlay mit Token; Portal-Ansichten mit Fixtures. Screenshots `/root/reports/website-analyse-2026-10-01/`.
## Livegang (jeder Schritt mit OK des Nutzers)
- **L1** `/srv/webklar-analytics`, `.env` (`ANALYTICS_ADMIN_TOKEN`, `ANALYTICS_HEAT_SECRET`), Compose-Dienst `analytics` (node:24-alpine, Code ro, webklar-network, Traefik-Router `Host(project.webklar.com) && PathPrefix(/a/)` Priorität > Portal, kein IP-Access-Log), `geo-update.sh` + monatlicher Cron, Schema-Skript, `deploy.sh` Neustart bei `analytics/`-Änderung.
- **L2** Portal/Runner/Analytics pushen (`ANALYSE_ENABLED=off`).
- **L3** Probe: öffentliche Wegwerf-Website, Analyse per Admin an, Banner/Ereignisse/Statistik/Heatmap prüfen, Abschalten ⇒ Skript weg, aufräumen.
- **L4** WOMS pushen; Nutzer lädt Rechtstexte in „Rechtliches“; `ANALYSE_ENABLED=on`.
- **L5** Live-Test des Nutzers mit einer echten Kunden-Website mit Wartungs-Abo.
@@ -18,7 +18,7 @@ Klick-Heatmap und Kontakt-Klicks. Alles läuft auf dem eigenen Server, ohne Goog
| # | Entscheidung |
|---|---|
| W1 | Nur mit **Wartungs-Abo** (`productKey === 'wartung'`, Status in `ACTIVE_STATUSES`). Ohne Abo: Kachel „Im Wartungs-Abo enthalten“ mit Link zum Abo. *Offen zur Bestätigung: Hosting-Abo (`hosting`) ausgeschlossen.* |
| W1 | Nur mit **Wartungs-Abo** (`productKey === 'wartung'`, Status in `ACTIVE_STATUSES`). Ohne Abo: Kachel „Im Wartungs-Abo enthalten“ mit Link zum Abo. Hosting-Abo (`hosting`) ausgeschlossen (bestätigt 01.10.). |
| W2 | **Kunde schaltet selbst ein** (Portal, AVV-Haken); WOMS kann ebenfalls schalten (Ausnahmen, protokolliert). |
| W3 | Umfang V1 wie unten (Abschnitt 2); nicht: Session-Aufzeichnung, Formularinhalte, Ladezeiten/JS-Fehler, Trichter/UTM. |
| W4 | Banner: **schlichte Leiste unten**, „Ablehnen“ und „Annehmen“ gleichwertig, Link „Datenschutz“, eine wählbare Akzentfarbe; Handy: kompakte Karte. Footer-Link „Cookie-Einstellungen“. Entscheidung gilt 6 Monate. Deutsch. |