Initial: Mesh-Skills, Configs, Scripts
This commit is contained in:
@@ -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 Funktionspfade
|
||||
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.
|
||||
```
|
||||
Reference in New Issue
Block a user