Initial: Mesh-Configs vom 03.09.2026 01:23

This commit is contained in:
root
2026-09-03 01:23:55 +02:00
commit 4b65134f8c
797 changed files with 146197 additions and 0 deletions
@@ -0,0 +1,401 @@
---
name: web-hosting-deployment
description: "Nginx Multi-Domain-Hosting mit Cloudflare DNS, Let's Encrypt SSL und Mail-Preservation – Deployment-Workflow für mehrere Domains auf einem Server"
version: 1.0.0
author: Agent A (netcup VPS Deployer)
platforms: [linux]
---
# Web-Hosting Deployment – Multi-Domain-Setup
Richtet mehrere Domains mit name-based virtual hosting auf einem einzelnen nginx-Server ein, inklusive Cloudflare-DNS-Verwaltung per API, Let's-Encrypt-SSL und sicherer MX-Record-Migration.
## Workflow-Übersicht
1. **Server prüfen** → nginx installiert? Ports 80/443 frei? UFW aktiv?
2. **DNS prüfen** → Wo zeigen die Domains hin? Welcher Provider?
3. **nginx VHosts anlegen** → Eine Config pro Domain in `sites-available/`
4. **DNS umstellen** → Per Cloudflare-API oder manuell A-Records auf Server-IP
5. **SSL per certbot** → Let's Encrypt für alle Domains gleichzeitig
6. **Mail schützen** → MX-Records 1:1 spiegeln, niemals proxien
---
## Schritt 1: nginx installieren & Basis-Check
```bash
apt-get update -qq && apt-get install -y nginx certbot python3-certbot-nginx
systemctl enable --now nginx
ufw allow 80/tcp && ufw allow 443/tcp && ufw allow 22/tcp
ufw --force enable
```
**Prüfen:**
```bash
ss -tlnp | grep -E ':80 |:443 ' # Ports frei?
nginx -t # Config ok?
```
---
## Schritt 2: Domain-Verzeichnisse & Platzhalter-Seiten
```bash
# Für jede Domain:
mkdir -p /var/www/example.de/{html,logs}
chown -R www-data:www-data /var/www/example.de
chmod -R 755 /var/www/
```
> ⚠️ **Pitfall: 403 Forbidden** – Ohne `chown www-data:www-data` kann nginx die Dateien nicht lesen. Sofort nach `mkdir` ausführen!
>
> ⚠️ **Pitfall: `write_file` erstellt root-only Dateien!** Hermes' write_file-Tool legt Dateien mit `root:root` und `600`-Permissions an. nginx (www-data) bekommt 403. **Nach JEDEM write_file auf /var/www/ sofort:** `chown www-data:www-data <datei> && chmod 644 <datei>`
---
## Schritt 3: nginx Virtual Hosts (Name-based)
Pro Domain eine Config in `/etc/nginx/sites-available/`:
```nginx
server {
listen 80;
listen [::]:80;
server_name example.de www.example.de *.example.de;
root /var/www/example.de/html;
index index.html index.htm;
access_log /var/www/example.de/logs/access.log;
error_log /var/www/example.de/logs/error.log;
location / {
try_files $uri $uri/ =404;
}
}
```
> ⚠️ **Pitfall: Wildcard-Subdomains** – `*.example.de` in `server_name` nicht vergessen, sonst landen Subdomains auf der nginx-Defaultseite!
Aktivieren:
```bash
ln -sf /etc/nginx/sites-available/example.de /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
```
**Lokal testen (vor DNS-Umstellung):**
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Host: example.de" http://localhost/
curl -s -H "Host: example.de" http://localhost/ | grep -oP '<title>\K[^<]+'
```
---
## Schritt 4: Cloudflare DNS per API verwalten
### Zonen auflisten
```bash
curl -s "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" | python3 -c "
import json, sys
for z in json.load(sys.stdin)['result']:
print(f\"{z['name']:35s} | {z['id']}\")
"
```
### Neue Zone anlegen
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"example.de","account":{"id":"ACCOUNT_ID"},"jump_start":false,"type":"full"}'
```
### DNS-Einträge abrufen
```bash
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" | python3 -c "
import json, sys
for r in json.load(sys.stdin)['result']:
print(f\"{r['type']:6s} {r['name']:40s} → {r['content']:25s} proxied={r['proxied']}\")
"
```
### A-Record setzen/aktualisieren (NEU)
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"A","name":"example.de","content":"SERVER_IP","proxied":false,"ttl":1}'
```
### A-Record aktualisieren (BESTEHEND)
```bash
curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"SERVER_IP","proxied":false,"type":"A","name":"example.de","ttl":1}'
```
> ⚠️ **Pitfall: Cloudflare-Proxy (orangene Wolke)** – `"proxied":true` versteckt die echte Server-IP hinter Cloudflare. Das bricht:
> - **Let's Encrypt HTTP-Challenges** (Zertifikat kann nicht validiert werden)
> - **Direkte Server-Erreichbarkeit** für nicht-HTTP-Dienste
> - **Mail-Server** (niemals MX-Records proxien!)
>
> **Regel:** Für Webserver, die Let's Encrypt nutzen: `"proxied":false`. Nach SSL-Erhalt kann Proxy optional wieder aktiviert werden (dann aber TLS-Mode auf "Full (strict)").
---
## Schritt 5: Let's Encrypt SSL
### Alle Domains gleichzeitig (ein Zertifikat)
```bash
certbot --nginx \
-d domain1.de,www.domain1.de \
-d domain2.de,www.domain2.de \
--non-interactive --agree-tos \
--email admin@domain1.de \
--redirect
```
> ⚠️ **Pitfall: CNAME/Proxy blockiert Challenge** – Wenn certbot mit `unauthorized` fehlschlägt:
> 1. Prüfen ob CNAME-Records `proxied=true` haben → auf `false` setzen
> 2. Sicherstellen dass ALLE Domains im Zertifikat auf den Server zeigen
> 3. `dig +short A www.example.de` muss die Server-IP liefern, NICHT Cloudflare/GitHub-IPs
**Auto-Renewal prüfen:**
```bash
systemctl status certbot.timer
certbot renew --dry-run
```
---
## Schritt 6: MX-Records bei DNS-Migration schützen
**Goldene Regel:** MX-Records **niemals verändern**, nur **1:1 spiegeln**.
### Vor der Migration: Aktuelle MX-Daten sichern
```bash
dig +short MX example.de # MX-Records
dig +short A mail.example.de # Mailserver-IP
```
### In Cloudflare exakt spiegeln
```bash
# Mailserver A-Record (WICHTIG: proxied=false!)
curl -s -X POST ".../dns_records" -d '{
"type":"A","name":"mail.example.de",
"content":"MAIL_SERVER_IP","proxied":false,"ttl":1
}'
# MX-Record (darf NIE geproxied werden)
curl -s -X POST ".../dns_records" -d '{
"type":"MX","name":"example.de",
"content":"mail.example.de","priority":10,
"proxied":false,"ttl":1
}'
```
> ⚠️ **Pitfall: Mail-Ausfall** – MX-Records auch nur kurzzeitig zu verlieren bedeutet E-Mail-Bounce. Immer VOR dem Nameserver-Umzug sicherstellen, dass MX + Mail-A-Records in der neuen Zone existieren.
---
## Nameserver-Umzug: Schritt-für-Schritt
1. Zone bei Cloudflare anlegen
2. **ALLE bestehenden DNS-Einträge spiegeln** (A, AAAA, MX, CNAME, TXT, SRV)
3. MX & Mail-A-Records doppelt prüfen
4. Cloudflare-Nameserver notieren (z.B. `jay.ns.cloudflare.com`, `perla.ns.cloudflare.com`)
5. Beim alten Provider Nameserver ändern
6. 1–48h auf Propagation warten
7. SSL per certbot einrichten
---
## Troubleshooting
### 403 Forbidden nach VHost-Erstellung
```bash
chown -R www-data:www-data /var/www/
chmod -R 755 /var/www/
```
### Wildcard-Subdomains zeigen Default-Seite
`server_name` muss `*.example.de` enthalten. Prüfen:
```bash
grep server_name /etc/nginx/sites-enabled/example.de
curl -sI -H "Host: test.example.de" http://localhost/
```
### certbot: unauthorized / Invalid response
- Alle Domains im Zertifikat auf `proxied=false` prüfen
- `www`-CNAME darf nicht auf GitHub Pages o.ä. zeigen
- Sicherstellen dass ALLE Domains auf die Server-IP auflösen
### ⚠️ PITFALL – Certbot überschreibt SSL-Pfade in BESTEHENDEN vhosts
Siehe `nginx-vhost-deployment/SKILL.md` → **PITFALL – Certbot überschreibt SSL-Pfade in BESTEHENDEN vhosts**.
Kurzfassung: Nach jedem `certbot --nginx -d NEUE.DOMAIN.de` prüfen ob alle bestehenden Domains
noch ihr RICHTIGES Zertifikat haben. Certbot ändert ggf. `ssl_certificate`-Pfade in anderen
vhost-Dateien. Test: `curl -sk https://alte-domain.de/` → HTTP 200? Wenn nicht: SSL-Pfade
in der betroffenen Config zurücksetzen.
### ERR_CERT_COMMON_NAME_INVALID: vhost nutzt fremdes Zertifikat (2026-08-04)
**Symptom:** `curl https://<sub>...` ohne `-k` schlägt fehl; Browser meldet `ERR_CERT_COMMON_NAME_INVALID`, auch wenn eine frühere `curl -k`-Probe 200 lieferte. Grund: Der Subdomain-vhost referenziert ein **anderes** Zertifikat, in dessen SANs der eigene Hostname **fehlt**.
**Diagnose (immer zuerst, dauert 2 Sekunden):**
```bash
# Welches Zert referenziert der vhost?
grep -iE "ssl_certificate" /etc/nginx/sites-available/<sub>.perfekt-ohne-en.de
# Welche Hostnames deckt DIESES Zert ab?
openssl x509 -in /etc/letsencrypt/live/<cert>/fullchain.pem -noout -text \
| grep -A1 "Subject Alternative Name"
```
→ `tictactoe.perfekt-ohne-en.de` war auf das `perfekt-ohne-en.de`-Zert gelinkt (San der `fitness.`/`clownfischserver.` etc.), `tictactoe.` fehlte → Browser-Warnung.
**Fix: eigenes Zertifikat NUR für die Subdomain anlegen** (statt `--expand` aufs alte Zert):
```bash
certbot --nginx -d <sub>.perfekt-ohne-en.de \
--non-interactive --agree-tos -m <mail> --redirect
```
Erzeugt `/etc/letsencrypt/live/<sub>.perfekt-ohne-en.de/`, deployt automatisch in den vhost, Auto-Renew läuft. Danach ohne `-k` verifizieren:
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://<sub>.perfekt-ohne-en.de/
openssl x509 -in /etc/letsencrypt/live/<sub>.perfekt-ohne-en.de/fullchain.pem -noout -text \
| grep -A1 "Alternative"
```
> ⚠️ **Warum NICHT `certbot --expand` aufs alte Zert?** Das alte Zert trägt ggf. Hostnames inaktiver/archivierter vhosts (z.B. eingestampfte Sites). `--expand` muss alle aufgelisteten Domains erneut validieren — scheitert leicht an deaktivierten vhosts und "budelt" in Sachen, die ruhen sollen. Ein frisches, schlankes Subdomain-Zert ist sauberer und kapselt die Domain. Faustregel: **ein Ziel-Hostname → eigenes Zert; nur verwandte aktive Hostnames in ein Zert bündeln.**
### Cloudflare API: Invalid API Token
- Token ist ggf. Zonen-beschränkt → kann keine neuen Zonen erstellen
- `/user/tokens/verify` funktioniert nur mit Account-weitem Token
- Zonen-Operationen (DNS) funktionieren trotzdem mit Zonen-Token
### Neue Subdomain: certbot --nginx scheitert, wenn SSL-vhost schon auf fehlendes Zert zeigt
**Situation:** Für eine neue Subdomain legt man einen vhost an und schreibt die
`ssl_certificate`-Zeilen **sofort** (aus Vorlage) hinein. `nginx -t` schlägt dann
fehl: `cannot load certificate "…/live/<sub>/fullchain.pem": No such file` — und
weil `nginx -t` bricht, wirft **`certbot --nginx` denselben Config-Fehler** (MisconfigurationError), bekommt kein Zert und es geht nicht weiter.
**Lösung (Reihenfolge zählt):**
1. vhost-Backup: `cp sites-available/<sub>.… sites-available/<sub>.…bak`
2. vhost auf **reines HTTP-80** ohne jede `ssl_certificate`-Zeile ersetzen →
`nginx -t` wieder ok → `systemctl reload nginx`
3. Zert **per webroot-Challenge** holen (KEIN `--nginx`-Plugin, das braucht eine
ladende Config):
```bash
certbot certonly --webroot -w /var/www/<sub> -d <sub>.perfekt-ohne-en.de \
--non-interactive --agree-tos --register-unsafely-without-email
```
4. SSL-vhost (aus dem `.bak`) zurückspielen → `nginx -t` + reload
5. Mit `curl -s -o /dev/null -w "%{http_code}" https://<sub>/` verifizieren
(HTTP 000 + Exit 60 = nur curl-Zert-Warnung, kein Serverfehler; `-k` zeigt dann 200).
### Statische datengetriebene Website mit PDF-Download je Eintrag
Für JSON-datengetriebene Listen-Seiten mit per-Eintrag-PDF (wkhtmltopdf) →
**`references/static-data-site-pdf.md`** (Datei-Struktur, Slug-Helper, PDF-Generator-Skript, Verifikation).
### Mehrstufige (Drill-Down) datengetriebene Website: Flask + SQLite + Reverse-Proxy
Für Sites mit verschachtelter Navigation über mehrere Ebenen (URL `/2/<stadt>`,
Klick auf ein Ziel erzeugt die nächste Liste) → **nicht statisch bauen**, sondern
**Flask + SQLite hinter nginx-Reverse-Proxy** (sonst ~1000–2000 statische Dateien).
Deckung: systemd-Service, Slug↔Umlaut-Match, PDF-on-demand (**je Einzel-Verbindung
`von→nach`, nicht Liste** — Nutzererwartung 2026-08-09), `/healthz`. Details →
**`references/flask-sqlite-drilldown-site.md`**.
### Docker-Interne-Dienste deployen (Gitea, Vaultwarden)
Für Docker-basierte Web-Apps (Gitea, Vaultwarden, Passwort-Manager, Git-Server)
hinter nginx-Reverse-Proxy mit Let's Encrypt SSL:
**`references/docker-internal-services.md`** — Deployment-Workflow, Setup-Schritte,
Admin-Token-Sicherung, Registrierung öffnen/schließen, Mirror auf zweitem Server.
### Echte Fahrplan-/Zugdaten automatisieren: DB blockt Bots
Für Reise-/Verbindungs-Sites mit realen deutschen Nahverkehrs-Zeiten: bahn.de +
DB-API blocken den Server-Zugriff hart (Bot-Erkennung Fehler 753, `OPS_BLOCKED`),
VRR-EFA deckt nur den eigenen Verbund, andere GTFS-Endpunkte waren unerreichbar.
Funktionierende Wege: regionale EFA, Nutzer liefert echte Zeiten manuell, oder
klar-labelierte Schätzwerte ("vor Fahrt auf bahn.de prüfen"). Nie erfundene Zeiten
als real ausgeben. Details → **`references/deutsche-bahn-timetable-data.md`**.
### Subdomain mit Wildcard-DNS schnell deployen
Wenn die Hauptdomain bereits per Wildcard (`*.example.de`) auf den Server zeigt, braucht eine neue Subdomain KEINE DNS-Änderung:
```bash
# 1. Verzeichnis + nginx-Config
mkdir -p /var/www/sub.example.de/{html,logs}
chown -R www-data:www-data /var/www/sub.example.de
cat > /etc/nginx/sites-available/sub.example.de << 'NGINX'
server {
server_name sub.example.de;
root /var/www/sub.example.de/html;
index index.html;
access_log /var/www/sub.example.de/logs/access.log;
error_log /var/www/sub.example.de/logs/error.log;
location / { try_files $uri $uri/ =404; }
listen 80; listen [::]:80;
}
NGINX
ln -sf /etc/nginx/sites-available/sub.example.de /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
# 2. HTML erstellen + Permissions fixen (CRITICAL!)
# ...write_file oder echo in index.html...
chown www-data:www-data /var/www/sub.example.de/html/index.html
chmod 644 /var/www/sub.example.de/html/index.html
# 3. SSL
certbot --nginx -d sub.example.de --non-interactive --agree-tos \
--email admin@example.de --redirect
```
Lokal testen: `curl -s -H "Host: sub.example.de" http://localhost/ | grep '<title>'`
### Multi-Site-Pattern: Verlinkte Websites mit Datenübergabe
Für mehrere zusammenhängende Single-Page-Apps auf Subdomains (z.B. Fitness-Rechner + Kalorienrechner) mit Cross-Linking, URL-Parameter-Datenübergabe, PDF-Export und Sponsor-Badge → **`references/multi-site-pattern.md`**
### Site einstampfen / deaktivieren / archivieren (2026-08-04)
Nutzer-Anweisung „einstampfen, ins Archiv, ruhen lassen" → **deaktivieren, nicht zerstören** (nicht-destruktive Grundregel):
```bash
# 1. nginx vhost deaktivieren (Symlink raus; available bleibt als Sicherung)
rm /etc/nginx/sites-enabled/<domain>
nginx -t && systemctl reload nginx
# 2. Code ins Archiv verschieben (nicht löschen!)
mkdir -p /srv/work/archiv/<projekt>
mv /root/<projekt> /srv/work/archiv/<projekt>/repo
mv /root/<projekt>_extracted /srv/work/archiv/<projekt>/extracted # falls vorhanden
# 3. LIESMICH.md mit Repo-Link + Reaktivierungsweg ablegen
# (Status, GitHub-URL, wie wieder aktivieren: Symlink + Code zurück)
```
- **Keinen Symlink/Code löschen** — nur deaktivieren + ins Archiv legen. Reaktivierung = Symlink zurück + Code zurück.
- **GitHub-Repo bleibt unangetastet** (extern); nur lokale Deployment-Artefakte werden archiviert. Repo-Link in `LIESMICH.md` festhalten.
- Nach Abschluss: `mesh-log.sh`-Eintrag + **Memory aktualisieren** (Projektstatus ändert sich von „läuft" zu „eingestampft/archiviert") — sonst halluziniert ein späterer Session-Start fälschlich „läuft noch stabil".
### Autogenerierte Report-/Archiv-Websites (Cron-regeneriertes HTML, 2026-08-03)
Wenn eine Subdomain **automatisch generierte Inhalte** zeigt (Live-Panel, Tages-Archive), die ein Script periodisch neu schreibt:
- **Kein PHP nötig.** Ein Shell-Script (z.B. `duo-master-report.sh`) baut die komplette `index.html` mit einem Heredoc zusammen und läuft per Cron (z.B. alle 60s für Live-Panel, täglich 23:50 für Tagesarchiv).
- Struktur: `Live-Panel` (dokumentationen.*) zeigt aktuelle Zustände; `Archiv` (archiv.*) speichert pro Datum eine HTML-Seite `JJJJ-MM-TT.html` PLUS eine `index.html` mit Linkliste auf alle Archive.
- Nach jedem Write: `chown www-data:www-data <webroot>` — sonst 403 (Hermes write_file erzeugt root:root).
- Daily-Archiv-Name = ISO-Datum (`$(date +%Y-%m-%d).html`), Linkliste wird aus vorhandenen `.html`-Dateien (ausgenommen `index.html`) aufgebaut.
- ⚠️ **Bash-Pitfall unter `set -euo pipefail`:** `ls /pfad/*.json | wc -l` bricht mit Exit 2 ab, wenn keine Dateien matchen (leerer Glob). Vorher `shopt -s nullglob` setzen (und danach `shopt -u nullglob`), sonst endet das gesamte Skript scheinbar ohne Fehler. Gleiches gilt für `ls "$WEB"/*.html` in der Linkliste.
@@ -0,0 +1,81 @@
# Cloudflare API Quick Reference
## Authentifizierung
```
Header: Authorization: Bearer <API_TOKEN>
Header: Content-Type: application/json
```
## API-Endpunkte
| Operation | Methode | URL |
|-----------|---------|-----|
| Zonen auflisten | GET | `/client/v4/zones` |
| Zone anlegen | POST | `/client/v4/zones` |
| Zone löschen | DELETE | `/client/v4/zones/:zone_id` |
| DNS-Records abrufen | GET | `/client/v4/zones/:zone_id/dns_records` |
| DNS-Record anlegen | POST | `/client/v4/zones/:zone_id/dns_records` |
| DNS-Record ändern | PATCH | `/client/v4/zones/:zone_id/dns_records/:record_id` |
| DNS-Record löschen | DELETE | `/client/v4/zones/:zone_id/dns_records/:record_id` |
## Zone anlegen (Minimal-Body)
```json
{
"name": "example.de",
"account": {"id": "ACCOUNT_ID"},
"jump_start": false,
"type": "full"
}
```
**Wichtig:** `jump_start: true` scannt bestehende DNS-Einträge, kann aber unerwünschte Records importieren. `false` startet mit leerer Zone.
## DNS-Record anlegen (Minimal-Body)
```json
{
"type": "A",
"name": "example.de",
"content": "92.60.38.175",
"proxied": false,
"ttl": 1
}
```
- `name: "@"` für Root-Domain, `name: "www"` für Subdomain
- `proxied: false` = graue Wolke (DNS-only), `true` = orangene Wolke (Proxy)
- `ttl: 1` = Auto-TTL
## MX-Record (Besonderheit)
```json
{
"type": "MX",
"name": "example.de",
"content": "mail.example.de",
"priority": 10,
"proxied": false,
"ttl": 1
}
```
- `priority`: Zahl (10 = Standard), niedriger = höhere Priorität
- **proxied MUSS false sein** – Cloudflare kann MX nicht proxien!
## Antwort parsen (python3 one-liner)
```bash
curl ... | python3 -c "
import json, sys
data = json.load(sys.stdin)
if data['success']:
for r in data['result']:
print(f\"{r['name']} → {r['content']}\")
else:
for e in data['errors']:
print(f\"ERROR: {e['message']}\")
"
```
## Typische Fehler
| Fehler | Ursache | Lösung |
|--------|---------|--------|
| `Invalid API Token` | Token falsch/abgelaufen | Neuen Token erstellen |
| `Zone already exists` | Zone bereits angelegt | Statt POST → GET + PATCH |
| `Invalid proxied mode for MX` | `proxied:true` bei MX | MX MUSS `proxied:false` sein |
| `Could not route to ...` | Zone-ID falsch | `GET /zones` für korrekte ID |
@@ -0,0 +1,133 @@
# Deutsche Bahn / Fahrplan-Datenabruf: Realität & Workarounds (2026-08-08)
Nützlich beim Bau von Reise-/Verbindungs-Websites (z.B. Zugverbindungs-Liste
urlaub.perfekt-ohne-en.de), die echte Fahrtzeiten abfragen müssen.
## Kernbefund: DB automatisiert NICHT von einem Server aus
Beim Versuch, echte deutsche Nahverkehrs-Verbindungen (RE/RB/S-Bahn) automatisiert
zu bekommen, schlugen ALLE Server-seitigen Wege fehl:
| Weg | Ergebnis |
|-----|----------|
| `https://www.bahn.de/` im **Browserbase-Browser** | ❌ **Bot-Erkennung**: Fehler 753 „Verhalten Ihres Browsers ähnelt dem eines Bots" — auch im echten Browser (stealth-Warnung: „Running WITHOUT residential proxies"). |
| DB-Web-API (`/web/api/reiseloesung`) | ❌ `OPS_BLOCKED` |
| VRR EFA (`efa.vrr.de`) | ⚠️ Funktionierte für **Verbund-Haltestellen** (Radevormwald/Remscheid), liefert aber **keine Fernziele** (Frankfurt/Hannover außerhalb des VRR-Gebiets) — nur lokale Verbunde. |
| `api.transport.rest` / GTFS | ❌ nicht erreichbar (DNS/Timeout) |
| Web-Suche (Firecrawl) | ❌ nicht konfiguriert |
**Warum es so ist:** bahn.de + DB-API betreiben aktive Anti-Bot-Abwehr, unabhängig
davon ob der Zugriff von einem Python-Skript oder einem headless-Browser kommt.
Das ist eine bewusste, dauerhafte Serverseite — kein Setup-Fehler, der sich fixen lässt.
## Funktionierende Workarounds
1. **Regionale EFA (Verbund-API) für lokale Strecken**: `https://efa.vrr.de/std3/`
(JSON via `XSLT_STOPFINDER_REQUEST` / `XSLT_TRIP_REQUEST2`) funktioniert für
Haltestellen im jeweiligen Verkehrsverbund. Nur für Nahziele im Verbund nutzen,
nicht für Fernziele.
2. **Nutzer liefert die Zeiten manuell** (bewährt): Auf bahn.de / DB-Navigator
funktioniert die Suche für **menschliche Nutzer** einwandfrei. Bei 1-2 Zielen:
Nutzer gibt die schnellste Verbindung durch ☞ Master trägt die echten Zeiten ein.
Das ist zuverlässig + kostenlos + umgeht die Bot-Sperre.
3. **Schätz-/Richtwerte mit ehrlichem Hinweis** (Standard-Fall für viele Ziele):
Wenn keine echte Verifikation möglich ist, Netzwerk-Takte/Geographie als
**Richtwert** aufführen und auf der Seite + im PDF deutlich markieren:
„vor der Fahrt gegen bahn.de/DB-Navigator prüfen". NICHT als echte Fahrzeit
ausgeben.
4. **DB-Deep-Link pro Verbindung** (Nutzer-Idee): statt die Zeiten automatisiert
zu verifizieren, einen Link auf die DB-Suche VORBEFÜLLEN, den der Nutzer in
seinem Browser öffnet. Da der Nutzer keine Bot-Sperre hat, sieht er dort die
echten aktuellen Zeiten.
⚠️ **WICHTIG — LIVE-VERIFIZIERT am 2026-08-08 (Muss über den früheren Stand):
Die Deutsche Bahn bietet 2026 KEIN stabiles URL-Deep-Link-Format mehr an.**
Alle vorher als „stabil" geltenden Schemata sind empirisch tot:
- ❌ `reiseauskunft.bahn.de/bin/query.exe/dn?...` → **toter DNS** (`DNS_PROBE_FINISHED_NXDOMAIN`), Domain existiert nicht mehr.
- ❌ `reiseauskunft.de/bin/query.exe/...` → toter Pfad („Seite existiert nicht mehr").
- ❌ `www.bahn.de/buchung/fahrplan/suche?...` mit Query-Parametern → DB-SPA übernimmt die Eingaben NICHT (zeigt nur leere Suchseite).
- ❌ `www.bahn.de/buchung/fahrplan/suche#query=start:…;destination:…` → undokumentierte interne SPA-Fragmentroute, bricht (Einträge werden nicht übernommen, nur Fehlermeldung → leere Suche).
✅ **Funktionierende, ehrliche Lösung 2026-08-08:** Ein einfacher, zuverlässiger
Link auf die **noch erreichbare Sucheseite** `https://www.bahn.de/buchung/fahrplan/suche`
auf einer Zwischenseite, die Start+Ziel (readonly) anzeigt + einen Button
„In DB-Suche öffnen" (neuer Tab) + Fallback-Link. Der Nutzer tippt Start/Ziel
dort manuell ein (10 Sekunden) — aber der Link landet garantiert auf einer
funktonierenden Seite statt auf einem Deadlink. **Verifiziere jede DB-URL mit
`curl -s -o /dev/null -w "%{http_code}"` VOR dem Einsatz** — Sub-Agenten-Reviews
können veraltete Formate als „korrekt/getestet" bestätigen, ohne es live geprüft
zu haben (passiert hier real). Eine muntere Vorbefüllung ist 2026 nicht mehr
zuverlässig möglich; wer sie trotzdem versucht, scheitert an der SPA.
⚠️ Die offizielle DB-API (api.deutschebahn.com) wäre die robusteste Quelle für
programmatische Zeiten, braucht aber einen API-Key — und ist weiterhin
vorbehaltlich Bot/Key-Zugang zu prüfen.
## Google Maps als ÖPNV-Quelle? NEIN — ICE lässt sich nicht rausfiltern (2026-08-08)
Nutzer-Idee beim Bau einer Verbindungs-Site: „Kann man Google Maps-Öffis-Daten
nehmen?" — ehrliche Antwort, die in Zukunft nicht neu hergeleitet werden muss:
- Google Maps **zeigt** ÖPNV (Bus, S-Bahn, RE, ICE, IC), aber als schnellste Route
schlägt es **standardmäßig ICE/IC vor**.
- Es gibt **KEINEN sauberen „nur Regionalverkehr / Deutschlandticket"-Filter** in
Google Maps (nur ein grober Transit-Filter). `regional-only` von ICE zu trennen
ist nicht zuverlässig möglich.
- Dieselben GTFS-Fahrpläne wie überall (DB/Verkehrsverbünde) — also nichts Besseres
/qchlechteres als reiseauskunft.
- Google Maps blockt zudem Bots (wie die DB), sodass saubere Automatisierung nicht
klappt.
- **Fazit:** Für eine „kein ICE / nur Deutschlandticket“-Anforderung ist unser
DB-Deep-Link zur noch erreichbaren Sucheseite (`https://www.bahn.de/buchung/fahrplan/suche`,
ohne Vorbefüllung, siehe Abschnitt 4) besser — die Seite hat einen expliziten
Deutschlandticket-Filter, den der Nutzer einen Klick entfernt sieht. (Der früher
genannte `query.exe/dn`-Deep-Link ist tot — siehe Abschnitt 4.)
## Label nach ECHTER Fahrtzeit, nicht nach Wunsch-Kategorie (2026-08-08)
Nutzer: „Nicht EINE Fahrt 5-7h in der Liste, ich sehe nur <5h oder >7h." Ursache war
**zweifach**, beides wartbar:
1. **Label-Mismatch-Bug:** 168 Ebene-2-Zeilen waren als `Kernziel (5-7h)` markiert,
obwohl ihre Fahrtzeiten real 0,3–4h waren. Beim Seeden wird gern „5-7h" als Wunsch-Category
an die Fernziele gehängt. **Fix: Kategorie aus der echten Fahrtzeit ableiten**
(Parser `~h` → float, dann `<5 → unter 5h`, `5–7 → Kernziel (5-7h)`, `>7 → über 7h`),
nicht als Design-Wunsch beim INSERT setzen.
2. **Geographische Realität:** per reinem Nahverkehr sind echte 5–7h-Ziele selten
(viele Großstädte entweder <3h Nahzone oder >8h Fernzone). Das ist kein Bug,
sondern Physik — die Seite soll alle Kategorien ehrlich anzeigen und „5-7h" nur
dort führen, wo es sie wirklich gibt (ab weiter südlich/östlich gelegenen Städten:
Frankfurt→München/Dresden, Hannover→Stuttgart etc.).
## Ehrlichkeits-Regel (Nutzer erwartet dies)
- Niemals fabrizierte „fahrhatige" Verbindungen oder konkrete Uhrzeiten als real
ausgeben, wenn sie nur geschätzt sind.
- Schätzwerte IMMER als Bereich (`~5-5,5 h`) + mit Verweis auf bahn.de labeln.
## RAPTOR statt Dijkstra für echte GTFS-Routing (2026-08-19, Team-Unraid)
Für GTFS-Verbindungssuche auf großen Knoten ist **einfacher Dijkstra instabil** —
bei benachbarten Zielen kamen absurde Differenzen (Mainz→Berlin 11h vs. Nachbar
Wiesbaden 3h). Standard-Lösung ist **RAPTOR** (Round-Based Public Transit Optimized
Route): erweitert die Reichweite Runde um Runde über echte Trips und behandelt
"Zug nehmen → aussteigen → umsteigen" natürlich, statt auf einem statischen
Graphen zu kürzen.
- **Stop-Mehrdeutigkeit** (ein Bahnhof, mehrere stop_ids, nur 1 aktiv in `st`) →
eindeutig über `parent_station`/Geo-Mapping auflösen.
- **Umstiegsbuffer** (4 min) + sauberes Ziel-Stop-Matching verhindern die
"benachbarte Ziele, extrem abweichende Zeiten"-Inkonsistenz.
- Verifizieren mit Plausibilitäts-Ankern (z.B. Trier→Saarbrücken ~1h, Köln→Aachen
~50min) — nie den Algorithmus blind vertrauen.
- Wenn der Nutzer einen Konkreten echten Wert liefert (z.B. „Trier 6h"), ihn
bevorzugt übernehmen statt der Schätzung.
## Fakten zur Ziel-Menge (Zugverbindungs-Website, Kontext)
- Schwelle kann variieren (250k → 100k Einwohner → weit mehr Ziele, ~33 in dieser Site).
- Reichweite per **reinem Nahverkehr** (kein ICE) ab Bergisch-Land/Ruhrgebiet:
die meisten Großstädte sind entweder **<3h** (Nahzone: Köln, Düsseldorf, Ruhrgebiet)
oder **>8h** (Fernzone: Berlin, München). Echte **5–7h**-Ziele sind selten — das sind
genau die Grenzfälle (Frankfurt ~5h, Wiesbaden ~5,5h, Hannover ~5,5–6,5h, Kassel ~6,5–7h).
Regional-Agenten (SwitchHermes/LaptopHermes) lieferten dazu gute Geographie-Analysen.
@@ -0,0 +1,158 @@
# Docker Internal Services Deployment (Gitea + Vaultwarden)
## Anwendungsfall
Interne Dienste (Git-Server, Passwort-Manager, sonstige Web-Apps) auf einem VPS
hinter nginx-Reverse-Proxy mit Let's Encrypt SSL betreiben. Dienste laufen als
Docker-Container, sind nur für den Eigentümer bestimmt (oder registrierungsbeschränkt).
## Beispiel-Stack
| Dienst | Docker-Image | Port | Subdomain | Zweck |
|--------|-------------|------|-----------|-------|
| **Gitea** | `gitea/gitea:latest` | 3000 | `git.mehlzoerwer.de` | Eigener Git-Server für Skills/Configs |
| **Vaultwarden** | `vaultwarden/server:latest` | 4433 | `vault.perfekt-ohne-en.de` | Passwort-Manager (Bitwarden-kompatibel) |
## Deployment-Workflow
### 1. Docker-Container starten
```bash
# Verzeichnisse
mkdir -p /opt/gitea/data /opt/vaultwarden/data
# Gitea
docker run -d --name gitea --restart unless-stopped \
-p 3000:3000 -p 2222:22 \
-v /opt/gitea/data:/data \
-e USER_UID=1000 -e USER_GID=1000 \
gitea/gitea:latest
# Vaultwarden
ADMIN_TOKEN=$(openssl rand -base64 48)
docker run -d --name vaultwarden --restart unless-stopped \
-p 4433:80 \
-v /opt/vaultwarden/data:/data \
-e SIGNUPS_ALLOWED=true \
-e ADMIN_TOKEN=$ADMIN_TOKEN \
-e DOMAIN=https://vault.perfekt-ohne-en.de \
vaultwarden/server:latest
# Token sichern
echo "$ADMIN_TOKEN" > /opt/vaultwarden/admin_token.txt
chmod 600 /opt/vaultwarden/admin_token.txt
```
### 2. nginx-Reverse-Proxy pro Dienst
```nginx
# /etc/nginx/sites-available/git.mehlzoerwer.de
server {
listen 80;
listen [::]:80;
server_name git.mehlzoerwer.de;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
```nginx
# /etc/nginx/sites-available/vault.perfekt-ohne-en.de
server {
listen 80;
listen [::]:80;
server_name vault.perfekt-ohne-en.de;
location / {
proxy_pass http://127.0.0.1:4433;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket-Support für Browser-Extension
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
### 3. SSL (DNS muss bereits auf den Server zeigen)
```bash
certbot --nginx -d git.mehlzoerwer.de --non-interactive --agree-tos -m admin@domain.de
certbot --nginx -d vault.perfekt-ohne-en.de --non-interactive --agree-tos -m admin@domain.de
```
### 4. Nach SSL prüfen
```bash
# Prüfen ob bestehende Domains noch funktionieren (Certbot-Kollateral-Pitfall!)
for url in https://git.mehlzoerwer.de/ https://vault.perfekt-ohne-en.de/ https://mehlzoerwer.de/; do
echo "$url → $(curl -sk -o /dev/null -w '%{http_code}' $url)"
done
```
## Vaultwarden Setup
| Schritt | Aktion |
|---------|--------|
| 1. Registrierung öffnen | `SIGNUPS_ALLOWED=true` setzen, Container neustarten |
| 2. Account erstellen | `https://vault.perfekt-ohne-en.de/` → "Create Account" |
| 3. Registrierung schließen | Container stoppen, mit `SIGNUPS_ALLOWED=false` neustarten |
| 4. Admin-Panel | `https://vault.perfekt-ohne-en.de/admin` → Admin-Token |
| 5. Clients verbinden | Bitwarden-Apps → eigene Server-URL eintragen |
## Gitea Mirror auf zweitem Server
```bash
# Auf UnraidHermes (zweiter Server)
sudo mkdir -p /opt/gitea-mirror/data
sudo tee /opt/gitea-mirror/docker-compose.yml > /dev/null << 'YAML'
services:
gitea:
image: gitea/gitea:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "3001:3000"
volumes:
- /opt/gitea-mirror/data:/data
environment:
- USER_UID=1000
- USER_GID=1000
YAML
cd /opt/gitea-mirror && sudo docker compose up -d
```
## Pitfalls
### ⚠️ Certbot überschreibt bestehende SSL-Pfade
Nach `certbot --nginx -d NEUE.DOMAIN.de` prüfen ob bestehnde Domains noch ihr richtiges
Zertifikat haben. Certbot kann `ssl_certificate`-Pfade in anderen vhosts überschreiben.
→ `nginx-vhost-deployment` Skill: "Certbot überschreibt SSL-Pfade in BESTEHENDEN vhosts"
### ⚠️ Vaultwarden Admin-Token sichern
`openssl rand -base64 48` erzeugt einen Token. Dieser wird NUR beim ersten Start
ausgegeben (in den Logs). Danach nie wieder. **Sofort speichern:**
```bash
echo "$ADMIN_TOKEN" > /opt/vaultwarden/admin_token.txt
chmod 600 /opt/vaultwarden/admin_token.txt
```
Alternativ: `-e ADMIN_TOKEN_FILE=/data/admin_token` — dann reicht die Datei im
Volume-Verzeichnis, Container-Neustart überlebt den Token.
### ⚠️ Vaultwarden SIGNUPS_ALLOWED
Initial auf `true` setzen, damit der Eigentümer einen Account erstellen kann.
Nach dem ersten Account: Container stoppen, mit `SIGNUPS_ALLOWED=false` neustarten.
Kein Fremder kann sich danach registrieren.
### ⚠️ Docker-Gruppe für sudo-lose Ausführung
Sub-Agenten (UnraidHermes) haben `mehlzoerwer` nicht in der `docker`-Gruppe.
Fix: `sudo usermod -aG docker $USER && newgrp docker` (einmalig, danach
docker ohne sudo).
@@ -0,0 +1,215 @@
# Flask + SQLite: Mehrstufige "Drill-Down"-Website mit nginx-Reverse-Proxy
Bewährt für datengetriebene Sites, die über mehrere Ebenen verschachtelt navigierbar
sind, z.B. `urlaub.perfekt-ohne-en.de`: Ebene1 (Start Radevormwald → 33 Ziele ≥100k),
Klick auf ein Ziel → Ebene2 (Liste ab dieser Stadt, ≥50k), Klick → Ebene3.
## Warum Backend (Flask+SQLite) statt statischer HTML
Für 3 Ebenen wären statische Seiten **~1000–2000 Dateien** (jede Ebene-2-Zielstadt
wieder eine Liste + PDFs) — unwartbar bei häufigen Datenänderungen. Ein Backend mit
DB: eine `UPDATE` → alle Seiten/Pfade sofort korrekt. SQLite reicht locker
(<100 Queries/Tag, persönliches Projekt). Team-Empfehlung (UnraidHermes) bestätigt.
## Architektur
```
nginx (Subdomain vhost) → proxy_pass 127.0.0.1:8040 → Flask (app.py)
└─ SQLite (urlaub.db)
```
- Flask läuft als **systemd-Service** (`/opt/<app>/venv/bin/python app.py`).
- nginx-`location / { proxy_pass http://127.0.0.1:8040; proxy_set_header Host $host; ... }`
- PDF on-demand: Flask-Route rendert Template → `wkhtmltopdf` → Response(pdf).
## Kern-Implementation (app.py)
- SQLite-Verbindung je Request (`sqlite3.connect`, `row_factory = sqlite3.Row`).
- Slugs für URLs: `frankfurt-am-main`, `osnabrueck`, `goettingen`.
- **Slug↔Stadtname-Match muss Fallback/Transkription akzeptieren** — URL ist
lowercase slug mit Bindestrichen, DB speichert echten Namen ("Frankfurt am Main",
"Osnabrück"). Match mit `LOWER(replace(nach,' ',''))=LOWER(replace(?,' ',''))`
UND Umlaut-Rücktranskription: `ue→ü/ue/u`, `oe→ö/oe/o`, `ae→ä/ae/a`, `ss→ß/ss/s`.
- `slug()` muss Umlaute VOR `ascii-encode` expandieren: `ä→ae, ö→oe, ü→ue, ß→ss`.
- Ebene-Route: `@app.route('/<int:ebene>/<slug_name>')` — `ebene in (2,3)`, sonst 404.
- Startstadt = die angematchte Stadt; Ziele = `WHERE ebene=? AND von=?`.
- Leere Ebene → eigenes `leer.html`-Template ("Noch keine Ziele…"), kein 404.
- `/healthz` → `"ok"` für systemd/nginx-Readiness.
## Deployment-Ablauf
1. `python3 -m venv venv && venv/bin/pip install flask` (sqlite3 in stdlib).
2. `venv/bin/python -c "import flask"` (Syntax-/Install-Check NICHT mit `||`-Fehler
in derselben Python-Zeile verwechseln).
3. DB-Init + Seed (Skripte `db_init.py`, `seed_ebene2.py`).
4. Testen: `./venv/bin/python app.py` im Hintergrund → `curl 127.0.0.1:8040/healthz`
und Routen. Dann killen, Start an systemd übergeben:
`ExecStart=/opt/<app>/venv/bin/python /opt/<app>/app.py`, `Restart=always`.
5. nginx: Proxy gegen `127.0.0.1:8040` einrichten (siehe Haupt-SKILL: vhost via
`/tmp` + `cp` — write_file weigert sich bei `/etc/nginx/*`; Backup-Regel vorher).
6. `nginx -t && systemctl reload nginx`, dann über `https://<sub>` verifizieren.
## ⚠️ Änderungen an Templates/Code erfordern einen Service-Neustart (2026-08-08)
Flask läuft hier mit `debug=False` (systemd). Das heißt: Template-Edits (`*.html`)
und `app.py`-Änderungen werden **NICHT automatisch neu geladen**. Symptom aus dem
Live-Betrieb: Nachdem eine neue Kategorie-Gruppe (z.B. `unter 5h`) ins Template
kam, zeigte die laufende Seite sie **nicht** — erst `systemctl restart urlaub.service`
brachte sie sichtbar. **Regel: nach jeder Template/Code-Änderung IMMER den systemd-
Service neu starten** und dann die Seite per `curl` gegen die erwartete neue Markierung
prüfen (z.B. `grep -oE '<h2>[^<]+</h2>'`). Nur DB-Seed-/Update-Skripte (die `urlaub.db`
schreiben) wirken sofort, weil jede Request frisch auf SQLite liest.
## Verifikation (immer durchführen)
```bash
curl -sk -o /dev/null -w "/ → %{http_code}\n" https://<sub>/
curl -sk -o /dev/null -w "/2/<stadt> → %{http_code}\n" https://<sub>/2/<stadt>
curl -sk -o /dev/null -w "/3/<stadt> → %{http_code}\n" https://<sub>/3/<stadt>
curl -sk -o /dev/null -w "/pdf/<stadt> → %{http_code}\n" https://<sub>/pdf/<stadt>
```
HTTP 000 + curl-Exit 60 = nur Zert-Warnung, `-k` zeigt 200.
## Datenquellen für Ebene-2/3 am sinnvollsten
- **Start mit kleinen, konkreten Team-Tasks** ("gib je Startstadt Ziele ≥50k im
Format Stadt|~h|Ziel1,Ziel2") — NICHT breit formulieren; Sub-Agenten liefern sonst
nur Enzyklopädie/Regionen statt präziser Stadt-zu-Stadt-Zuordnung (wiederholtes Muster).
- Der Master kuratiert die Team-Rohdaten gegen Geographie (keine offensichtlichen
Fehler), markiert alles als **Richtwerte**.
- Ebene-3-Daten optional; Route existiert und zeigt sauber `leer.html` bis befüllt.
## ⚠️ Datenqualität: NIE Platzhalter-Zahlen als echtes Datum (2026-08-08)
Beim Seeden von Ebene-2-Städten wurde `einwohner=150000` pauschal für ALLE gesetzt.
Der Nutzer bemerkte sofort "es gibt keine 50k–100k-Städte in der Liste" — korrekt:
alle kleineren Städte (Würzburg, Koblenz, Hameln …) zeigten fälschlich 150k.
**Regeln:**
- Jeder Eintrag braucht **echte, individuell korrekte** Werte. Unbekannte als
`NULL`/"k.A." führen — **niemals** einen plausiblen Platzhalter wie `150000`.
- Nach dem Seed IMMER einen Duplikat-/Platzhalter-Check laufen lassen:
`SELECT nach FROM verkehr WHERE einwohner=<platzhalter>` → darf 0 Treffer liefern.
- Wenn echte Zahlen fehlen (kein Webzugriff), schätzen mit **Bereich + Label**
("Richtwert"), nicht als exakte Zahl. Beim späteren Update echte Werte nachtragen.
- 50k–100k-Ziele sind der eigentliche Kern einer kleinere-Schwelle-Liste — sie
explizit ergänzen (Team lieferte konkrete: ab Trier→Worms/Bad Kreuznach/
Idar-Oberstein/Neunkirchen; ab Hannover→Hameln/Celle/Goslar/Lüneburg/Delmenhorst).
## ⚠️ Finale Ebene darf keine Dangling-Links auf nicht-existente Unter-Ebene erzeugen (2026-08-08)
Das Drill-Down-Template verlinkte jedes Ziel auf `/<ebene+1>/<slug>` — auch auf der
**letzten** Ebene. Bei 3 Ebenen zeigte Ebene-3 daher Ziele, die auf **`/4/<stadt>`**
verwiesen, eine Route die es nicht gibt → **404** (und Verstoß gegen die Nutzer-Erwartung
„jede Ebene hat eine Unter-Ebene… bis die es gibt«). User bemerkte es sofort.
**Fix / Regel:** Das Template muss die **letzte Ebene als final markieren** statt
einen toten Weiter-Link zu rendern:
```jinja
{% if ebene <= MAX_EBENE_DEPTH %}
<td><a href="/{{ ebene+1 }}/{{ z.nach|lower }}">{{ z.nach }}</a></td>
{% else %}
<td>{{ z.nach }} <small>(final)</small></td>
{% endif %}
```
Generell: **Jeder `href` auf eine `<ebene+1>`-Route darf nur in Ebenen gerendert
werden, die wirklich existieren** (≤ Tiefe). Vor dem Livegehen alle Funktions­pfade
durchklicken (curl auf `/1/ `/2/ `/3/`) und darauf achten, DASS kein Link auf eine
NICHT eingerichtete Ebene zeigt (`grep -oE 'href="/4/'` darf leer sein).
## Umlaut-Slug-Match: auch umgekehrte Einzelbuchstaben berücksichtigen (2026-08-08)
Der Slug für `Köln` ist `koln` (nur `ö→o` beim Sluggen), während ein Nutzer/anderer
Kandidat `koeln` (Transkription) verwenden könnte und die DB `Köln` speichert. Der
Stadtname-Match muss ALSO **beide^ Richtungen** anbieten:
- Transkription: `ue→ü, oe→ö, ae→ä, ss→ß` (für Slugs, die voll transkribieren).
- **Einzelbuchstaben umgekehrt:** `o→ö, u→ü, a→ä, s→ß` (für URL-Slugs wie `koln`,
`wuerzburg`→`würzburg`), damit `koln`→`Köln` matcht.
Jeder Fund-Kandidat wird gegen `LOWER(replace(nach,' ',''))` geprüft; der erste
Treffer gewinnt. Ohne den Einzelbuchstaben-Fall bleiben `koln`, `rossleben`-artige
URLs auf 404, obwohl die Stadt existiert. Beispiel-Kandidatenliste für `koln`:
`koln, köln, koeln, koln` (alle Versuche) — der `ö`-Versuch matcht die DB.
## ⚠️ Generator: nie nur die NÄCHSTEN Ziele nehmen — sonst verschwinden Fernziele (2026-08-09)
Beim automatischen Seeden von Drill-Down-Ebenen (Distanz-basiert, z.B. Ebene-2-Ziele
aus einer Städte-Referenzliste mit Haversine) ist die größte Daten-Falle das
**Abschneiden des fernen Endes**:
```python
# FALSCH: sortiert nach Distanz (nahe zuerst) und nimmt die ersten N
ziele.sort(key=lambda x: x[0]/70) # d/70 = h
for nach, ... in ziele[:N]: # N = z.B. 26
...
```
→ Das nimmt nur die **nächsten** Städte. Alle **5-7h- und >7h-Fernziele** fallen
raus, weil sie weiter weg sind. Symfony: Trier-Ebene2 hatte danach **nur "unter 5h"**
(alle 26), 0 Kernziele, 0 Fernziele. Der Nutzer bemerkte sofort: „Jetzt sind nur noch
die kleinen da — was ist mit den 5-7h und >7h passiert?"
**Fix — pro Kategorie die nahsten N, damit ALLE Kategorien vertreten bleiben:**
```python
kat = {"unter 5h":1, "Kernziel (5-7h)":2, "über 7h":3}[...] # h aus d/70
kategorien[kat].append(...)
# danach je Kategorie sortieren + je Kategorie max_pro_kat nehmen
for kat in ["unter 5h","Kernziel (5-7h)","über 7h"]:
kategorien[kat].sort(key=lambda x: dist)
ziele += kategorien[kat][:max_pro_kat] # z.B. max_pro_kat=9
```
Gib dadurch pro Startstadt z.B. 26 unter5h + 26 kern + 17 über7h statt nur 26 nah.
**Zweite Fall-Hälfte (Kategorie aus echter Zeit ableiten, NICHT aus Wunschbandbreite):**
Die Kategorie muss aus der **berechneten Stundenzahl** (`h<5 → unter5h`, `5≤h≤7 →
Kernziel(5-7h)`, `h>7 → über7h`) kommen. Vorher wurde `d<350km → 5-7h` als Distanz-
Bandbreite gesetzt, was Ziele mit `~3h` als "Kernziel 5-7h" labelte — also Label und
angezeigte Fahrtzeit widersprachen sich (Dinslaken ~3h als "5-7h"). Einheitlich: erst
`h = d/70` berechnen, dann Kategorie aus h. Prüf-Check nach dem Seed:
```sql
SELECT kategorie,COUNT(*) FROM verkehr WHERE ebene=2 GROUP BY kategorie -- alle 3 gefüllt?
```
Erwartung: nie eine Ebene, die nur 1 Kategorie enthält, solange es Fernziele <700km
gibt.
## ⚠️ PDF-Erwartung: je EINZELNE Verbindung, nicht Liste aller Ziele (2026-08-09)
Nutzer beim Betrachten der urlaub-Site: „Die PDF finde ich blöd. Ich habe gedacht
die PDF wäre für die Verbindung von → nach, nicht nur eine PDF mit allem." — Der
erste Entwurf erzeugte **eine PDF pro Startstadt mit ALLEN Zielen** (Liste). Erwartung
war aber: **eine PDF je konkreter Fahrt (Start → Ziel)**.
**Fix:** Route auf Paar umbauen `@app.route('/pdf/<von>/<nach>')` mit einem *losen*
`pdf_einzel.html`-Template (Abfahrt-Zeit/Ziel/Fahrtzeit/Weg/Ticket), das **genau eine**
Verbindung beschreibt. Links in der Tabelle zeigen dann auf
`/pdf/<von>/<nach>` (je Zeile), nicht auf `/pdf/<startstadt>`. Wichtig:
- Beide Slugs über den `resolve()`-Helper mit Umlaut-/Fallback-Match gegen DB auflösen.
- Existiert die Verbindung nicht in der DB, trotzdem eine saubere Wegzeile rendern
(„Richtwert (DB prüfen)") statt 404 — der Nutzer soll immer ein PDF bekommen.
- Nach Umbau die alte Listen-Route entfernen (`/pdf/<stadt>` → 404 bestätigen), sonst
bleibt die gewohnte Liste erreichbar und verwirrt.
- Dateiname je Verbindung: `<von>--<nach>.pdf` (vermeidet Überschreiben bei mehreren Fahrten).
**Grundsatz:** Bei "PDF pro X" immer zuerst klären, ob der Nutzer *ein Dokument je
Datensatz* (hier: Fahrt) oder *ein Sammeldokument je Gruppe* (Startstadt) will.
Im Zweifel Richtung "je einzelner Eintrag/Fahrt" gehen — das ist die intuitive Erwartung.
## Team-Audit: Sub-Agenten können VPS-Dateien nicht lesen (2026-08-09)
Ein Audit-Task, das einem Sub-Agenten nur einen Pfad gibt (`/opt/urlaub/app.py`,
`urlaub.db`) scheitert stets: Rechen-Agenten haben **keinen VPS-Lesezugriff** und
finden die Datei nicht → sie können den Code nicht gegenprüfen und brechen ab
(korrekt, statt zu erfinden). Für Code-/Daten-Audits musst du als Master die
**relevanten Inhalte in den Task einbetten** (Schema, Codesnippets, Beispiel-Zeilen)
oder den Audit selbst anhand des echten Systemstands durchführen. Sonst läuft ein
„Team-Audit" ins Leere. Dieses Session-Thema: 1 von 3 Audit-Tasks brachte nichts,
weil der Agent nur den Pfad bekam.
## ⚠️ Backend-Code gehört ebenfalls ins Team-Review (2026-08-08)
Der Nutzer fragte nach dem Bau zu Recht: „Habt ihr euch gegengecheckt?" — Die
Ebene-2-DATEN kamen vom Team, aber der **Flask-Code/PDF/DB-Link war solo gebaut
ohne Review**. Die Review-Runde fand SOFORT echte Bugs:
- DB-Deep-Link `#query=`-Format war falsch (→ korrektes `query.exe/dn`, siehe
`deutsche-bahn-timetable-data.md`).
- Einwohner-Platzhalter (s.o.).
**Lektion:** Nicht nur fachliche Daten durchs Team prüfen lassen, sondern auch den
Deployment-Code. Ein kurzer Review-Task mit eingebettetem Kontext („Bitte analysiere
den Deep-Link + Platzhalter, nenne Fixes") findet solche Fehler günstig und schnell —
genau das „4 Agents > 1 teurer Agent"-Prinzip des Nutzers.
@@ -0,0 +1,83 @@
# GTFS-RAPTOR-Verbindungssuche: Praxis & Pitfalls (2026-08-19, urlaub.perfekt-ohne-en.de)
Echte Nahverkehrs-Verbindungs-Suche (RE/RB/S-Bahn, kein ICE) aus einer GTFS-Datei
(Nutzer lieferte `r.und.s.bahnen.zip`). Löst die Aufgabe "komplette Haltestellenliste
in einer PDF + konsistente Fahrzeiten für viele Zielverbindungen".
## Warum RAPTOR statt Dijkstra
Einfacher Dijkstra auf einem statischen Graphen ist bei großen Knoten **instabil**:
benachbarte Ziele ergaben absurde Differenzen (Mainz→Berlin 11h vs. Nachbar Wiesbaden
3h). **RAPTOR** (Round-Based Public Transit Optimized Route) erweitert die Reichweite
Runde um Runde über echte Trips und behandelt "Zug nehmen → aussteigen → umsteigen"
natürlich. Für Zeit-abhängiges Transit-Routing ist RAPTOR der Standard.
## GTFS-Import — Spaltenreihenfolge ist NICHT selbsterklärend!
Vor dem Import IMMER `head -1 <datei>.txt` lesen, nie annehmen:
- `stops.txt`: `stop_name, parent_station, stop_id, stop_lat, stop_lon, …`
**stop_id ist Spalte 3**, parent_station Spalte 2. Ein Bahnhof hat oft ZWEI Zeilen
(parent-'Hbf' + stop_id-Knoten); die `stop_id` ist fürs Joinen zu `stop_times` gedacht.
- `stop_times.txt`: `trip_id, arrival_time, departure_time, stop_id, stop_sequence, …`
**stop_id Spalte 4**.
- `trips.txt`: `route_id, service_id, trip_id` — **trip_id Spalte 3**, nicht 0.
Import-Bug hier: Spalte 0 (route_id) genommen → nur 964 statt 110.087 Trips.
- `stop_times.txt` ist riesig (82MB); als SQLite laden mit Index auf `(trip_id, seq)`.
## Bahnhof = mehrere stop_ids, nur 1 aktiv in `st`
Große Knoten haben MEHRERE stop_ids (Saarbrücken Hbf = 6, Köln Hbf = 2), aber nur
einige kommen in `stop_times` vor. `SELECT stop_id WHERE stop_name='Köln Hbf'` kann eine
"tote" ID mit 0 Trips liefern. **Lösung: Geo-Merge** — Stops <350m Abstand (Haversine)
in eine Gruppe fassen; Routing löst Start/Ziel über die GANZE Gruppe auf und nimmt MIN
über alle Ziel-IDs. Sonst schlägt die Verbindung fehl oder wird absurd.
## `resolve("Berlin")`-Substring-Falle
`name.lower() in stop_name.lower()` matcht ALLE Stops mit "berlin" im Namen — auch
kleine Orte (z.B. ein Ort "Berlin" unweit Trier → unter 5h statt quer durchs Land!).
Für mehrdeutige Städte IMMER eindeutigen Bahnhof angeben (`"Berlin Hbf"`) und die
Site-DB-`nach`-Werte darauf normieren. Umlaute: Name-basiertes Matching braucht
Umlaut-Rücktranskription (ue/oe/ae/ss ↔ ü/ö/ä/ß).
## RAPTOR: Dauer UND Pfad (für PDF-Haltestellenliste)
Nur Dauer reicht nicht — die PDF soll die komplette Haltestellenliste zeigen. RAPTOR
muss pro Stop den **parent** speichern (mit welchem trip + Einstieg-Stop erreicht) und
nach Ziel-Erreichen den Pfad rückwärts rekonstruieren: je Segment alle Stops von
Einstieg bis Ausstieg → echte GTFS-Haltefolge (Trier→Konz→Saarburg→…→Saarbrücken).
Für Lesbarkeit `[von] + halteliste + [nach]` als Rahmen.
## PDF-Route: Pfad-Cache statt Live-RAPTOR (sonst 504)
Live-RAPTOR bei jedem PDF-Aufruf ist zu langsam (bis 200s → nginx 504 Gateway Timeout).
Lösung: **Pfad-Cache vorab bauen** (`pfade_cache.json`: key `von|nach` → seg + dauer_h)
und die PDF-Route liest nur JSON → PDF <1s.
⚠️ Cache auf die Kern-Ebenen (1+2) begrenzen: alle Paare wären ~7h Bauzeit, die
Kernmenge ~35min.
## Start ohne Bahnhof (Radevormwald) → Remap + Bus-Vorsatz
Ein Startort ohne Schienenanschluss hat KEINEN Stop im Feed → Routing fehlt für die
wichtigste Ebene 1. Lösung: auf nächstgelegenen Bahnhof remappen (Radevormwald →
Remscheid-Lennep Bf) und einen Bus-Vorsatz (~20min) als erstes "Segment" + zur Dauer
addieren. Solche Fälle explizit als `START_REMAP = {ort: (bahnhof, bus_min)}` führen,
sonst fehlt die Ebene-1-PDF komplett.
## Unsichere/absurde Fernzeiten → "prüfen", nie falsch anzeigen
Auch RAPTOR liefert auf manchen komplexen Fernstrecken falsche Werte (z.B. >10h, wenn
Routing fehlschlug; real Regional 6–9h). Ehrlich handeln: plausible Werte behalten,
unsichere (>10h / 0.0h / leer) als **`'prüfen'` mit Kategorie `unsicher (vor Fahrt
prüfen)`** markieren, und das Template zeigt „vor Fahrt prüfen" statt einer Zahl. Das
Template muss jede unsichere Kategorie in seiner Gruppen-Map führen, sonst
verschwinden die Zeilen still.
## Empirische Referenz-Anker (Plausibilitäts-Check, Regional only)
- Trier→Saarbrücken ~55–60min (RE 1 direkt)
- Köln→Aachen ~35–50min (RE 1/RE 9)
- Köln→Frankfurt regional ~2,5–3h MIT Umstiegen (~1,5h wäre nur ICE = NICHT korrekt)
- Radevormwald→Frankfurt ~5,5h (Bus + Remscheid-Lennep + Regional)
- Verifizieren mit diesen Ankern, NIE dem Algorithmus blind vertrauen.
@@ -0,0 +1,53 @@
# GTFS-Verbindungs-PDF: Offline-Precompute statt Live-Routing (2026-08-19)
Bewährt für `urlaub.perfekt-ohne-en.de` (Flask + SQLite + GTFS-Zugdaten). Wenn eine
Einzel-Verbindungs-PDF eine **komplette Fahrplan-Verbindung inkl. Haltestellenliste**
zeigen soll, darf die Flask-Route den Router/RAPTOR/Dijkstra **nicht bei jedem Request**
ausführen.
## Symptom
`wkhtmltopdf` + RAPTOR auf großen Knoten (Köln, Berlin) lief 40–200 s je Aufruf →
**nginx 504 Gateway Timeout** (Gateway-Zeitlimit überschritten). Nutzer meldete
„Wenn ich die PDF anklicke … keine Haltestellenliste".
## Fix: einmaliger Offline-Backfill → JSON-Cache → schneller Cache-Read
1. **Precompute-Skript** (`gtfs_pfade_cache.py`): iteriert `SELECT DISTINCT von,nach`
aus `urlaub.db`, ruft für jedes Paar `router.path(von,nach)` (liefert Segmentliste
der Zwischenhalte), schreibt `pfade_cache.json`:
```json
{"<von>|<nach>": {"seg": [["Halt1","Halt2",...]], "dauer_h": 1.5}}
```
2. **Flask-Route** liest NUR `json.load(open('/opt/<app>/pfade_cache.json'))` mit
Key `f"{start}|{ziel}"` → Antwort < 1 s, kein Timeout. **Nie** den Router zur
Laufzeit importieren/rechnen.
3. Skript als **Hintergrund-Job** (background=true, notify_on_complete) laufen lassen,
nicht foreground.
## Teilmenge wählen, sonst quadratische Backfill-Zeit
Eindeutige `von|nach` über ALLE Ebenen waren **10 510 Paare** → ~2,4 s/Paar = **~7 h**.
Auf die **Ebenen 1+2** (`WHERE ebene IN (1,2)`, ~1 979 Paare) beschränkt = **~35 min**.
Ebene-3 (Kleinstädte) = reine Verzweigung ohne eigene PDFs → weglassen.
- Trefferquote ist KEINE 100 % (Radevormwald/Kleinstädte ohne Bahnhof im Feed).
Template ehrlich auf „Keine GTFS-Haltestellenliste verfügbar" zurückfallen, nie erfinden.
## Pfad-Rekonstruktion muss dasselbe Abfahrtsprofil wie die Dauer nutzen
Ein Precompute mit **fester Abfahrtszeit (09:00)** rekonstruierte nur ~34 % der Pfade,
weil viele Verbindungen erst zu späteren Tageszeiten existieren. Die Dauer-Profile-Suche
(06–22 Uhr) liefert mehr Treffer — die Pfad-Rekonstruktion muss **dasselbe Profil**
durchlaufen statt fest 09:00, sonst bleibt die Trefferquote konsistent niedrig.
## Verifikation
```bash
# Service nach Template-/Code-Änderung neu starten, dann:
curl -sk -o /dev/null -w "PDF: %{http_code} (%{time_total}s)\n" \
"https://<sub>/pdf/<von>/<nach>"
# Erwartung: 200 in <1 s (nicht 504), Haltestellenliste enthalten sobald Cache da.
```
Flask läuft mit `debug=False` (systemd) → **nach Änderung IMMER `systemctl restart`**
(wg. Template-/Code-Reload-Pitfall, siehe Haupt-SKILL).
@@ -0,0 +1,66 @@
# GTFS-Verbindungssuche: RAPTOR real erprobt (2026-08-19)
Kontext: Webseite urlaub.perfekt-ohne-en.de (Zugsuche, nur Nahverkehr/kein ICE).
Nutzer stellte echte GTFS-Datei bereit (Regional/S-Bahn) — Verarbeitung brachte
mehrere harte Lektionen.
## Lektion 1: RAPTOR verbessert, aber ist KEINE fertige Lösung
Einfacher **Dijkstra** auf GTFS ist instabil bei grossen Knoten: benachbarte Ziele
ergaben absurde Differenzen (Mainz→Berlin 11h vs. Nachbar Wiesbaden 3h — unmoeglich).
**RAPTOR** (Round-Based) ist der richtige Algorithmus und verbessert die Konsistenz
(Mainz & Wiesbaden → Berlin wurden konsistent ~8h22).
**ABER:** RAPTOR erzeugt weiterhin einzelne absurde Fernwerte (z.B. Mainz→Berlin
~22h, real Regional ~7-9h; Trefferquote ~66-95%). Ein Algorithmus mit solcher
Einzelfehlerquote darf NICHT als finale Fahrzeit-Quelle ausgegeben werden.
**Praxisfix (Nutzer-Entscheidung „3 dann 1"):**
- **Option 3 (sofort):** nur *team-verifizierte* Kernzeiten konkret anzeigen
(Plausibilitaets-Anker: Trier→Saarbruecken ~1h, Koeln→Aachen ~50min, vom Team
bestaetigt). Alle anderen Verbindungen → `fahrzeit_h='pruefen'` in SQLite; Template
rendert stattdessen orangen Hinweis „vor Fahrt prüfen". NIE absurde/inkonsistente
Zahl als real stehen lassen.
- **Option 1 (danach):** robuste Suche erst auf die Seite schreiben, wenn JEDER Wert
separat gegen einen Plausibilitaets-Anker verifiziert ist.
## Lektion 2: Konsistenz-Check = Nachbar-Ziele gegeneinander pruefen
Der schnellste Fehlerdetektor: benachbarte Ziele vergleichen. Mainz→Berlin darf
nicht dramatisch von Wiesbaden→Berlin abweichen (gleiche geographische Lage).
Erst wenn das Cluster konsistent ist, Werte freigeben.
## Lektion 3: PDF-Haltestellenliste via VORAB-PFAD-CACHE
Nutzerwunsch: PDF soll die komplette Verbindung (`von → zwischenhalte → nach`) zeigen,
nicht nur Start/Ziel. On-the-fly-Routing bei jedem PDF-Aufruf ist zu langsam
(RAPTOR ~40-200s → nginx **504 Gateway Timeout**).
**Lösung:** Vorab einmalig Pfad-Cache bauen (`pfade_cache.json`), NUR die Kern-Ebenen
1+2 (~1.900 eindeutige `von|nach`-Paare; Ebene 3 = Kleinstaedte braucht keine eigenen
PDFs). PDF-Route liest `json.load(cache)[f"{start}|{ziel}"]["seg"]` → Ladezeit von
504 auf **~0,4s**. Wiederverwendbares Muster: Wenn On-the-fly-Berechnung zu langsam
ist, vorab einen Cache bauen und die Route daraus lesen.
- Template ergaenzt Start-Stadt als ersten + Ziel-Stadt als letzten Eintrag, damit
die Liste immer den Rahmen `von → ... → nach` bildet.
## Lektion 4: GTFS-Datenmodell-Fallen
- `stops.txt` Spaltenreihenfolge ist NICHT `stop_id,stop_name,...` — sie ist
`stop_name,parent_station,stop_id,stop_lat,stop_lon,...`. Beim Import die echte
stop_id aus den richtigen Spalten ziehen (parent_station ist oft die nutzbare ID).
- Ein Bahnhof hat **mehrere stop_ids** (Saarbruecken Hbf hat 6, nur je 1 aktiv in `st`;
Koeln Hbf hat 2). Geo-Merging (<350m) gruppiert sie; sonst matcht die Suche auf
eine tote ID und liefert „keine Verbindung".
- Zeit-Spalten: `HH:MM` ODER `HH:MM:SS` — to_sec() muss beides handhaben.
- COROS/Efa-Text-APIs liefern oft `\\n` als literalen Backslash-n statt echten
Zeilenumbruchen → vor dem Parsen `text.replace('\\n','\n')`.
## Performance-Falle bei Gesamt-Neuberechnung
Pro-Profile-Query (33 Abfahrtszeiten) × pro Verbindung ist zu teuer fuer ~12.000
Verbindungen (huubai 18h). Effizienter: **EIN Dijkstra/RAPTOR je eindeutiger
Start-Bahnhof (Ausbreitung zu allen Zielen in einem Lauf)** → ~250 Starts statt
12.000 × viele → von Stunden auf ~10min. Cache die Ausbreitung je Start und lese
die Ziel-Ankunft daraus ab.
@@ -0,0 +1,60 @@
# GTFS Echt-Fahrplan-Routing (Nahverkehr, kein ICE)
Erfahrungen aus urlaub.perfekt-ohne-en.de (3-Ebenen-Zugsuche, Nutzer lieferte
`r.und.s.bahnen.zip` = Regional + S-Bahn, Deutschlandticket-tauglich).
Ziel war: echte Fahrzeiten statt Luftlinien-Schätzung, max ~1h Abweichung.
## Modellierung & GTFS-Gotchas
- **stops.txt kann Spalten in unerwarteter Reihenfolge/CSV haben.** Hier war der
Header: `stop_name,parent_station,stop_id,stop_lat,stop_lon,...` und `parent_station`
ist die **echte ID**, `stop_id` die Sekundär-ID. Immer per Header/Index lösen,
nie fest annehmen — die Spaltenreihenfolge weicht von der GTFS-Konvention ab.
- **Ein Bahnhof = viele stop_ids, nur EINE aktiv in stop_times.** Beispiel:
"Saarbrücken Hbf" hat 6 IDs, "Köln Hbf" 2, nur jeweils 1 davon taucht in `st`
(den Routen) auf. Löse Stops über Geo-Cluster (<350 m) zu einer Gruppe zusammen
(`parent_station` nutzen, wenn befüllt).
- **Orte ohne Bahnhof** (Radevormwald) haben keine Trips → über den nächstgelegenen
Bahnhof (Remscheid-Lennep) routen + Bus-Vorsatz (20 min) als eigenes Hinweis-Segment.
- **Namens-Matching ist fragil:** DB-Name "Freiburg im Breisgau" ≠ GTFS
"Freiburg Hauptbahnhof". Robust: kleinsten gemeinsamen Teilstring + von den Kandidaten
den mit den meisten Trips (größter Stop) wählen.
## Algorithmus: RAPTOR statt Dijkstra
Einfacher **zeit-basierter Dijkstra ist bei großen Knoten instabil** und liefert
inkonsistente Zeiten (Mainz→Berlin 11h vs. Nachbar Wiesbaden 3h). Standard-Lösung:
**RAPTOR** (Round-Based) — erweitert Reichweite Runde um Runde über echte Trips.
Kern-Ablauf RAPTOR:
1. `earliest[stop] = dep0` an allen Start-Stops (eigene Anfangszeit, KEIN Umstiegsbuffer am Einstieg).
2. Je Runde: von markierten Stops alle abfahrenden Trips nach `earliest` nehmen,
entlangfahren, `earliest[sid]` verbessern.
3. Ziel erreicht → abbrechen; sonst neue markierte Menge = neue Runde.
4. **Umstiegsbuffer** (4 min) nur bei Umstieg > dep0, nicht am Start.
### Performance
- Pro-Paar-Routing mit 17–33 Profil-Abfahrtszeiten ist langsam (Sekunden bis Minuten).
- **2x Beschleunigung:** ein einziger "Ausbreitungs"-Dijkstra/RAPTOR je **Start-Bahnhof**
erreicht alle Ziele zugleich (5000 Paare in Minuten statt Stunden).
- **Pfad-Cache:** einmalig berechnete `(von|nach) -> Segmente` als JSON cachen
(`pfade_cache.json`), damit die Web-Route/PDF nur noch liest (0,5 s statt 504-Timeout).
## Pfad-Rekonstruktion für Haltestellenliste
RAPTOR so ertüchtigen, dass er `parent[stop] = (trip_id, einstieg_stop)` merkt und
rückwärts den kompletten Weg (alle Zwischenhalte je Zugsegment) aufbaut. Benötigt,
um in der PDF die vollständige Haltestellenliste zu zeigen (Nutzerwunsch: nicht nur
Start/Ziel, sondern alle Halte aus der GTFS-Datei).
## Fallstricke & Lektionen
- **Kein Vertrauen in eine einzige Verifikation:** Zeiten immer gegen 2–3 bekannte
Referenzstrecken prüfen (Trier→Saarbrücken ~1 h, Köln→Aachen ~50 min, Köln→Frankfurt
Regional ~2,5–3 h). "Skurrile" Werte (11 h für Nachbarstädte) deuten auf einen
Routing-/Auflösungs-Fehler, nicht auf echte Fahrzeit.
- **Fehlende/fehlerhafte Werte nicht erfinden,** sondern ehrlich als
"vor Fahrt prüfen" markieren. Eine Webseite mit "prüfen"-Labels ist besser als eine
mit falschen Zahlen (Ehrlichkeits-Regel, siehe deutsche-bahn-timetable-data.md).
- **Batch-Schreibroutinen:** erst validieren/löschen, dann neu einfügen (idempotent);
DB vor jedem Lauf sichern (Backup-Regel).
@@ -0,0 +1,128 @@
# Multi-Site Pattern: Verlinkte Fitness/Calculator-Websites
Pattern aus Session 2026-07-12: Zwei zusammenhängende Single-Page-Apps auf Subdomains derselben Domain.
## Überblick
- **fitness.perfekt-ohne-en.de**: Makro-Rechner mit BMR, Aktivitätslevel, PDF-Export
- **kalorienrechner.perfekt-ohne-en.de**: Reiner Kalorienrechner mit Abnehm-Prognose
- Beide Sites per Cross-Link verbunden, **Daten werden beim Wechsel übergeben**
## Architektur
```
perfekt-ohne-en.de (Hauptdomain)
├── fitness.perfekt-ohne-en.de → /var/www/fitness.perfekt-ohne-en.de
└── kalorienrechner.perfekt-ohne-en.de → /var/www/kalorienrechner.perfekt-ohne-en.de
```
## Datenübergabe per URL-Parameter
Wenn der Nutzer auf Fitness Daten eingibt und dann zum Kalorienrechner wechselt, werden die Formularwerte als Query-String mitgegeben:
```javascript
// Auf der Quellseite:
function goToTarget(e) {
e.preventDefault();
const params = new URLSearchParams({
g: gender, // 'male'|'female'
w: weight.value, // kg
h: height.value, // cm
a: age.value,
act: activity.value, // activity factor
goal: goal.value // goal factor
});
window.location.href = 'https://target.example.de/?' + params.toString();
}
```
```html
<a href="#" onclick="goToTarget(event)">👉 Zur anderen Seite (mit Daten)</a>
```
Auf der Zielseite werden die Parameter beim Laden eingelesen und das Formular vorausgefüllt:
```javascript
(function(){
const p = new URLSearchParams(window.location.search);
if (p.get('w')) document.getElementById('weight').value = p.get('w');
if (p.get('h')) document.getElementById('height').value = p.get('h');
if (p.get('a')) document.getElementById('age').value = p.get('a');
if (p.get('g')) setGender(p.get('g'), ...);
if (p.get('act')) document.getElementById('activity').value = p.get('act');
if (p.get('goal')) document.getElementById('goal').value = p.get('goal');
// Auto-calculate if all data present
if (p.get('w') && p.get('h') && p.get('a')) calculate();
})();
```
**URL-Beispiel:**
```
https://kalorienrechner.perfekt-ohne-en.de/?g=male&w=80&h=178&a=30&act=1.375&goal=0.85
```
## PDF-Download (jsPDF)
Beide Sites nutzen jsPDF (CDN) für PDF-Export:
```html
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
```
**Minimale downloadPDF()-Funktion:**
```javascript
async function downloadPDF() {
if (typeof window.jspdf === 'undefined') return alert('PDF wird geladen...');
const { jsPDF } = window.jspdf;
const pdf = new jsPDF('p','mm','a4'), m = 20;
pdf.setFillColor(16,185,129); pdf.rect(0,0,210,15,'F');
pdf.setTextColor(255,255,255); pdf.setFontSize(14);
pdf.text('Titel', m, 12);
pdf.setTextColor(0,0,0); pdf.setFontSize(10);
// ... Daten einfügen ...
pdf.save('ergebnis.pdf');
}
```
**Pitfall:** `pdf.text()` existiert immer in jsPDF 2.x. Kein `typeof`-Check nötig!
## Sponsor-Coin (CSS-Animation)
Kleines drehendes Logo mit Link zur Sponsoren-Seite:
```css
.sponsor-coin { margin-bottom: 1rem; }
.coin-link { display: inline-flex; align-items: center; gap: 0.5rem; }
.coin-img {
width: 45px; height: 45px; border-radius: 50%;
animation: spinCoin 4s ease-in-out infinite;
filter: drop-shadow(0 2px 6px rgba(16,185,129,0.3));
}
.coin-link:hover .coin-img {
animation: spinCoin 0.6s ease-in-out infinite;
}
@keyframes spinCoin {
0% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
```
```html
<div class="sponsor-coin">
<a href="https://sponsor.example.de" class="coin-link">
<img src="/assets/logo.png" alt="Sponsor" class="coin-img">
<span class="coin-text">Gesponsert von</span>
</a>
</div>
```
## Deployment-Checkliste
1. `mkdir -p /var/www/sub.example.de/{html,logs,assets}`
2. nginx VHost in `/etc/nginx/sites-available/` → `ln -sf` → `sites-enabled/`
3. `nginx -t && systemctl reload nginx`
4. HTML-Dateien erstellen → **SOFORT**: `chown www-data:www-data && chmod 644`
5. Assets (Logos) kopieren → `chown www-data:www-data`
6. `certbot --nginx -d sub.example.de --non-interactive --agree-tos --email admin@... --redirect`
7. Lokal testen: `curl -s -H "Host: sub.example.de" http://localhost/ | grep '<title>'`
@@ -0,0 +1,39 @@
# Statische, datengetriebene Website mit PDF-Download je Eintrag (2026-08-08)
Muster für eine statische Status-/Listen-Seite auf einer Subdomain, die aus einem
JSON-Datensatz rendert und **pro Eintrag ein PDF** zum Download anbietet
(wkhtmltopdf, kein PHP/Python-Backend nötig). Beispiel: Zugverbindungs-Liste mit
je-Ziel-PDF (urlaub.perfekt-ohne-en.de).
## Bausteine
1. **Datenquelle**: eine `daten.json` im Webroot (Liste von Objekten mit den
anzuzeigenden Feldern + Fahrtzeit + Kategorie + Weg).
2. **`index.html`**: lädt `daten.json` per `fetch`, gruppiert nach Kategorie
(z.B. Kernziel / über-7h / Fernzone) und rendert Tabellen mit
`<a href="pdf/<slug>.pdf">⬇ PDF</a>`-Buttons.
Slug-Funktion: lowercase + NFD-normalisieren + Umlaute streifen + `[^a-z0-9]`→`-`.
3. **PDF-Generator** (`/usr/local/bin/<projekt>-pdf.sh`):
- Python-Schleife: liest `daten.json`, baut je Eintrag eine temporäre HTML-Datei
in `/tmp/<projekt>-html/`, schreibt nach `/var/www/<sub>/pdf/<slug>.pdf`.
- `wkhtmltopdf --quiet --enable-local-file-access "$html" "$pdf"`.
- Danach `chown -R www-data:www-data <webroot>` (write_file erzeugt root:root).
- Glob-Sicherheit bei leeren Verzeichnissen: `rm -rf /tmp/.../*` vorher oder
`shopt -s nullglob` (vgl. `set -euo pipefail`).
## Pitfalls
- **`write_file` weigert sich auf `/etc/nginx/...`** (sensitiver Systempfad).
Ersatz: Inhalt nach `/tmp/<datei>` schreiben, dann `cp /tmp/<datei> /etc/nginx/...`.
- **PDF nicht roh per `grep` prüfbar**: wkhtmltopdf komprimiert den Text. Statt
Text-Suche lieber (a) Größe (KB) + valides `%%EOF`-Ende prüfen, oder
(b) PDF im Browser öffnen (Browserbase rendert es im PDF-Viewer) als visueller Beweis.
## Verifikation (end-to-end)
```bash
for f in /var/www/<sub>/pdf/*.pdf; do
curl -sk -o /dev/null -w "%{http_code}\n" "https://<sub>.perfekt-ohne-en.de/pdf/$(basename "$f")"
done
# Alle 200 => PDFs OK. Zusätztlich index.html + daten.json auf 200 prüfen.
```