veroeffentliche_04.py: Upload mit rsync --delete und Nachpruefung

Der Anlass war messbar. Der Live-Stand vor dem Skript:

  /OR/                              200
  /OR/anhang-glossar-literatur.html 200   <- Leiche des vorigen Stands
  /OR/anhang-glossar.html           404
  /OR/notebooks.html                404
  /OR/Notebooks_04.zip              404
  /OR/Notebooks_04/                 403

scp -r ueberschreibt nur, es loescht nie - die alte Glossar-Seite bliebe
dauerhaft erreichbar. Das Zielverzeichnis vorher zu leeren waere die grobe
Antwort: Bricht die Uebertragung von 23 MB in 213 Dateien ab, ist die Website
tot. rsync --delete uebertraegt nur Geaendertes und entfernt genau das, was
verschwunden ist.

Der zweite Grund wiegt schwerer: Die Website hing wochenlang einen Bau
zurueck, ohne dass es auffiel. Vier Schritte:

1. Vorpruefung - ist eine Quelldatei neuer als die gebaute Website, Abbruch.
   Ebenso: PDF der Website byte-genau wie das gebaute, Verzeichnis-
   Weiterleitungen vorhanden (der Grund fuer den 403).
2. Probelauf - zaehlt Uebertragungen und Loeschungen. Mehr als 30 Loeschungen
   brechen ab; das ist der Schutz gegen einen falschen Zielpfad, mit dem
   --delete sonst ein fremdes Verzeichnis ausraeumt. --loeschgrenze hebt an.
3. Uebertragung (rsync -rltz --chmod=D755,F644 ueber ssh).
4. Nachpruefung - 14 Adressen per HTTP, Statuscode gegen Erwartung. Darunter
   die Probe, dass die entfernte Seite wirklich 404 liefert.

Anmeldung per SSH-Schluessel, nicht per Passwort in der .env: rsync und scp
haben keine Passwort-Option, ein Passwort braeuchte sshpass und staende
waehrend der Uebertragung in der Prozessliste. Die .env (in .gitignore,
Vorlage .env.beispiel) enthaelt nur Host, Benutzer, Zielpfad und URL.

Beim ersten Test lief die Pruefung in eine irrefuehrende Meldung ("kein rsync
installiert"), waehrend in Wahrheit schon die Anmeldung fehlschlug. Die
Pruefung unterscheidet die Faelle jetzt am ssh-Rueckgabewert 255 und nennt
ssh-copy-id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 16:28:22 +02:00
commit d566cf8dac
5 changed files with 450 additions and 2 deletions

20
.env.beispiel Normal file
View file

@ -0,0 +1,20 @@
# Vorlage fuer .env - kopieren, ausfuellen, absichern:
#
# cp .env.beispiel .env && chmod 600 .env
#
# Gelesen von veroeffentliche_04.py. Hier steht KEIN Geheimnis: rsync und scp
# haben keine Passwort-Option, die Anmeldung laeuft ueber den SSH-Schluessel.
# Einmalig einrichten mit
#
# ssh-copy-id dschlueter@jamulix.de
#
# Danach fragt nichts mehr nach einem Passwort.
OR_HOST=jamulix.de
OR_BENUTZER=dschlueter
OR_ZIEL=/var/www/jamulix.de/html/OR/
OR_URL=https://jamulix.de/OR/
# Optional - nur setzen, wenn vom Ueblichen abweichend:
# OR_SSH_PORT=22
# OR_SSH_KEY=~/.ssh/id_ed25519

5
.gitignore vendored
View file

@ -22,3 +22,8 @@ svg-inkscape/
Operations_Research_mit_Python_Version_04_Programme/output/
produktionsmix.xlsx
produktionsmix_ergebnis.xlsx
# Zugangsdaten fuers Veroeffentlichen (veroeffentliche_04.py). Enthaelt zwar
# kein Passwort - die Anmeldung laeuft ueber den SSH-Schluessel -, aber Host,
# Benutzer und Serverpfad gehen niemanden etwas an. Vorlage: .env.beispiel
.env

View file

@ -0,0 +1,342 @@
#!/usr/bin/env python3
# veroeffentliche_04.py
#
# Laedt OR_HTML_04/ auf den Webserver - mit rsync, nicht mit scp.
#
# Warum das der Unterschied ist: scp ueberschreibt nur, es loescht nie. Nach
# dem Teilen von Anhang E/F blieb 'anhang-glossar-literatur.html' online
# erreichbar, obwohl die Datei aus dem Buch verschwunden war - verlinkt von
# nirgendwo, aber auffindbar. Das Zielverzeichnis vorher zu leeren waere die
# grobe Antwort darauf: Bricht die Uebertragung von 23 MB in 213 Dateien ab,
# ist die Website tot. rsync --delete uebertraegt nur Geaendertes und entfernt
# genau das, was verschwunden ist.
#
# Der zweite Grund fuer dieses Skript wiegt schwerer: Die Website hing
# wochenlang einen Bau zurueck, ohne dass es auffiel. Deshalb prueft Schritt 1,
# ob der Stand ueberhaupt aktuell ist, und Schritt 4 prueft hinterher per HTTP
# nach, ob das Ergebnis stimmt.
#
# Anmeldung per SSH-Schluessel. rsync und scp haben keine Passwort-Option; ein
# Passwort braeuchte sshpass und staende waehrend der Uebertragung in der
# Prozessliste. Einmalig:
#
# ssh-copy-id dschlueter@jamulix.de
#
# Aufruf:
# python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
# ... --probelauf
#
# Konfiguration in Version_04/.env (chmod 600, nicht im Repository) - siehe
# .env.beispiel. Ein Geheimnis steht nicht darin.
import argparse
import os
import subprocess
import sys
import urllib.error
import urllib.request
HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
VERSION = "04"
HTML_NAME = f"OR_HTML_{VERSION}"
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
WEB_NAME = f"web_{VERSION}"
PDF_DATEINAME = f"Operations_Research_mit_Python_Version_{VERSION}.pdf"
ENV_DATEI = os.path.join(BASIS, ".env")
# Verzeichnisse, deren Aenderung einen neuen Bau noetig macht. Ist eine Datei
# darin neuer als die juengste erzeugte Seite, ist die Website veraltet.
QUELLEN = [
os.path.join(BASIS, f"Operations_Research_mit_Python_Version_{VERSION}"),
os.path.join(BASIS, f"bilder_{VERSION}"),
os.path.join(BASIS, WEB_NAME),
]
# Was nach dem Hochladen erreichbar sein MUSS bzw. NICHT MEHR erreichbar sein
# darf. Die 404-Zeile ist die eigentliche Probe darauf, dass --delete gewirkt
# hat: Sie war der Anlass fuer dieses Skript.
NACHPRUEFUNG = [
("", 200),
("index.html", 200),
("notebooks.html", 200),
("programme.html", 200),
("anhang-glossar.html", 200),
("anhang-literatur.html", 200),
("stichwortverzeichnis.html", 200),
("Notebooks_04.zip", 200),
("Notebooks_04/", 200),
("programme/", 200),
("assets/site.css", 200),
("katex/katex.min.js", 200),
(PDF_DATEINAME, 200),
("anhang-glossar-literatur.html", 404),
]
LOESCHGRENZE = 30
def lies_env() -> dict[str, str]:
"""Liest .env - Zeilen der Form SCHLUESSEL=wert, '#' leitet einen
Kommentar ein. Bewusst kein python-dotenv: fuenf Schluessel rechtfertigen
keine Abhaengigkeit."""
if not os.path.exists(ENV_DATEI):
raise SystemExit(
f"FEHLER: {ENV_DATEI} fehlt.\n"
f" Vorlage kopieren und ausfuellen:\n"
f" cp .env.beispiel .env && chmod 600 .env")
werte = {}
with open(ENV_DATEI, encoding="utf-8") as f:
for zeile in f:
zeile = zeile.split("#", 1)[0].strip()
if not zeile or "=" not in zeile:
continue
schluessel, _, wert = zeile.partition("=")
werte[schluessel.strip()] = wert.strip().strip('"').strip("'")
fehlend = [s for s in ("OR_HOST", "OR_BENUTZER", "OR_ZIEL", "OR_URL")
if not werte.get(s)]
if fehlend:
raise SystemExit(f"FEHLER: In .env fehlen: {', '.join(fehlend)}")
return werte
def juengste_datei(verzeichnisse: list[str]) -> tuple[float, str]:
"""Liefert (Zeitstempel, Pfad) der zuletzt geaenderten Datei.
Dieses Skript selbst zaehlt nicht mit: Es aendert die Website nicht, sein
Bearbeiten duerfte den Bau also nicht als veraltet erscheinen lassen."""
neuste, pfad = 0.0, ""
for verzeichnis in verzeichnisse:
for wurzel, verz, dateien in os.walk(verzeichnis):
verz[:] = [v for v in verz
if v != "__pycache__" and not v.startswith(".")]
for name in dateien:
voll = os.path.join(wurzel, name)
if os.path.abspath(voll) == os.path.abspath(__file__):
continue
try:
zeit = os.path.getmtime(voll)
except OSError:
continue
if zeit > neuste:
neuste, pfad = zeit, voll
return neuste, pfad
def vorpruefung() -> None:
"""Jede Verletzung bricht ab. Ein veralteter oder unvollstaendiger Stand
soll gar nicht erst auf den Server kommen."""
def scheitern(text: str) -> None:
raise SystemExit(f"FEHLER: {text}")
bau_aufruf = (f"python3 Operations_Research_mit_Python_Version_{VERSION}/"
f"build_version_{VERSION}.py --pdf --html")
if not os.path.exists(os.path.join(HTML_VERZ, "index.html")):
scheitern(f"{HTML_NAME}/index.html fehlt - erst bauen:\n"
f" {bau_aufruf}")
# Ist der Bau aktuell? Verglichen wird gegen die juengste erzeugte Seite,
# nicht gegen die aelteste: Der Bau schreibt alle Seiten in einem Zug.
quell_zeit, quell_datei = juengste_datei(QUELLEN)
bau_zeit, _ = juengste_datei([HTML_VERZ])
if quell_zeit > bau_zeit:
scheitern(
f"Die Quelle ist neuer als die Website:\n"
f" {os.path.relpath(quell_datei, BASIS)}\n"
f" Erst bauen, dann hochladen - sonst geht ein alter Stand "
f"online.\n"
f" (Bewusst trotzdem hochladen: --ohne-vorpruefung)")
# Das PDF wurde schon einmal aus der Wurzel kopiert, ohne dass die Kopie
# nachgezogen wurde - die Website bot ein aelteres PDF an als das Buch.
pdf_wurzel = os.path.join(BASIS, PDF_DATEINAME)
pdf_kopie = os.path.join(HTML_VERZ, PDF_DATEINAME)
if not os.path.exists(pdf_kopie):
scheitern(f"{HTML_NAME}/{PDF_DATEINAME} fehlt - mit --pdf --html bauen.")
if os.path.exists(pdf_wurzel):
gross_wurzel = os.path.getsize(pdf_wurzel)
gross_kopie = os.path.getsize(pdf_kopie)
if gross_wurzel != gross_kopie:
scheitern(
f"Das PDF der Website weicht vom gebauten ab "
f"({gross_kopie:n} statt {gross_wurzel:n} Bytes).\n"
f" Nochmal mit --html bauen, das kopiert es nach.")
# Ohne die beiden Weiterleitungen antwortet der Webserver auf die
# Verzeichnisse mit 403 - genau das war der Fehlerbericht.
pflicht = ["notebooks.html", "programme.html", f"Notebooks_{VERSION}.zip",
f"Notebooks_{VERSION}/index.html", "programme/index.html"]
fehlend = [d for d in pflicht
if not os.path.exists(os.path.join(HTML_VERZ, d))]
if fehlend:
scheitern(f"In {HTML_NAME}/ fehlen: {', '.join(fehlend)}\n"
f" Erst mit --html bauen.")
print("Vorpruefung bestanden: Bau ist aktuell und vollstaendig.")
def ssh_optionen(env: dict[str, str]) -> str:
teile = ["ssh"]
if env.get("OR_SSH_PORT"):
teile += ["-p", env["OR_SSH_PORT"]]
if env.get("OR_SSH_KEY"):
teile += ["-i", os.path.expanduser(env["OR_SSH_KEY"])]
return " ".join(teile)
def rsync_befehl(env: dict[str, str], probelauf: bool) -> list[str]:
# -rlt statt -a: Eigentuemer und Gruppe lassen sich als normaler Nutzer
# ohnehin nicht setzen, -a erzeugte dafuer nur Warnungen. --chmod sorgt
# dafuer, dass der Webserver alles lesen kann, unabhaengig von der lokalen
# umask.
befehl = ["rsync", "-rltz", "--delete", "--chmod=D755,F644",
"--skip-compress=pdf/zip/woff2/png/jpg/svg",
"--itemize-changes",
"-e", ssh_optionen(env)]
if probelauf:
befehl.append("--dry-run")
ziel = env["OR_ZIEL"]
befehl += [HTML_VERZ + os.sep,
f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{ziel}']
return befehl
def pruefe_ziel_erreichbar(env: dict[str, str]) -> None:
"""Ein Handschlag, bevor 23 MB losgeschickt werden - und er unterscheidet
die beiden Fehlerursachen, die sonst leicht verwechselt werden: Anmeldung
scheitert (ssh liefert 255) gegen rsync fehlt am Ziel."""
kennung = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}'
# BatchMode: keine Passwortabfrage, damit der Aufruf nicht haengt. Der
# ssh-agent funktioniert weiterhin. stderr laeuft absichtlich durch, damit
# ssh seine eigene Diagnose zeigen kann.
ergebnis = subprocess.run(
ssh_optionen(env).split() + ["-o", "BatchMode=yes", kennung,
"command -v rsync || echo KEIN_RSYNC"],
stdout=subprocess.PIPE, text=True)
if ergebnis.returncode == 255:
raise SystemExit(
f"FEHLER: Keine SSH-Verbindung zu {kennung} (siehe Meldung oben).\n"
f" Haeufigste Ursache: Der oeffentliche Schluessel liegt noch\n"
f" nicht auf dem Server. Einmalig:\n"
f" ssh-copy-id {kennung}\n"
f" Bei passwortgeschuetztem Schluessel: ssh-add einmal "
f"ausfuehren.")
if ergebnis.returncode != 0:
raise SystemExit(f"FEHLER: ssh {kennung} endete mit Code "
f"{ergebnis.returncode}.")
if "KEIN_RSYNC" in ergebnis.stdout or not ergebnis.stdout.strip():
raise SystemExit(
"FEHLER: Auf dem Server ist kein rsync installiert.\n"
" Ausweg ohne rsync - nach OR.neu/ hochladen und tauschen;\n"
" das entfernt ebenfalls Altes und ist fuer Besucher fast\n"
" unterbrechungsfrei:\n"
f" scp -r {HTML_NAME}/* {kennung}:{env['OR_ZIEL']}../OR.neu/\n"
f' ssh {kennung} '
f'"mv OR OR.alt && mv OR.neu OR && rm -rf OR.alt"')
def zaehle_aenderungen(ausgabe: str) -> tuple[list[str], list[str]]:
"""Trennt die --itemize-changes-Ausgabe in Uebertragungen und Loeschungen."""
uebertragen, geloescht = [], []
for zeile in ausgabe.splitlines():
if zeile.startswith("*deleting"):
geloescht.append(zeile.split(None, 1)[-1])
elif zeile[:1] in ("<", ">", "c", "h") and " " in zeile:
uebertragen.append(zeile.split(None, 1)[-1])
return uebertragen, geloescht
def nachpruefung(basis_url: str) -> bool:
"""Holt jede Adresse und vergleicht den Statuscode mit der Erwartung."""
print("\nNachpruefung:")
alles_gut = True
for pfad, erwartet in NACHPRUEFUNG:
url = basis_url.rstrip("/") + "/" + pfad
anfrage = urllib.request.Request(url, method="HEAD")
try:
with urllib.request.urlopen(anfrage, timeout=20) as antwort:
code = antwort.status
except urllib.error.HTTPError as fehler:
code = fehler.code
except OSError as fehler:
code = f"{type(fehler).__name__}"
passt = code == erwartet
alles_gut = alles_gut and passt
print(f" {'OK ' if passt else 'FEHLER'} {code!s:>6} "
f"(erwartet {erwartet}) /{pfad}")
return alles_gut
def main() -> int:
zerleger = argparse.ArgumentParser(
description="Laedt OR_HTML_04/ auf den Webserver (rsync ueber SSH).")
zerleger.add_argument("--probelauf", action="store_true",
help="alles ausser der echten Uebertragung")
zerleger.add_argument("--loeschgrenze", type=int, default=LOESCHGRENZE,
help=f"Abbruch, wenn mehr Dateien geloescht wuerden "
f"(Vorgabe: {LOESCHGRENZE})")
zerleger.add_argument("--ohne-vorpruefung", action="store_true",
help="Schritt 1 ueberspringen (bewusst alter Stand)")
zerleger.add_argument("--ohne-nachpruefung", action="store_true",
help="Schritt 4 ueberspringen (kein Netz zum Server)")
argumente = zerleger.parse_args()
env = lies_env()
ziel = f'{env["OR_BENUTZER"]}@{env["OR_HOST"]}:{env["OR_ZIEL"]}'
print(f"Ziel: {ziel}")
if not argumente.ohne_vorpruefung:
vorpruefung()
pruefe_ziel_erreichbar(env)
# Schritt 2: Immer erst zaehlen. Der Probelauf kostet Sekunden und ist die
# einzige Gelegenheit, einen falschen Zielpfad zu bemerken, BEVOR --delete
# eine fremde Website ausraeumt.
probe = subprocess.run(rsync_befehl(env, probelauf=True),
capture_output=True, text=True)
if probe.returncode != 0:
print(probe.stderr, file=sys.stderr)
return probe.returncode
uebertragen, geloescht = zaehle_aenderungen(probe.stdout)
print(f"\nZu uebertragen: {len(uebertragen)} Datei(en)")
for name in uebertragen[:15]:
print(f" + {name}")
if len(uebertragen) > 15:
print(f" ... und {len(uebertragen) - 15} weitere")
print(f"Zu loeschen: {len(geloescht)} Datei(en)")
for name in geloescht:
print(f" - {name}")
if len(geloescht) > argumente.loeschgrenze:
print(f"\nFEHLER: {len(geloescht)} Loeschungen ueberschreiten die Grenze "
f"von {argumente.loeschgrenze}.\n"
f" Das ist meist ein falscher Zielpfad oder ein halb "
f"gebautes {HTML_NAME}/.\n"
f" Wenn es stimmt: --loeschgrenze {len(geloescht)}",
file=sys.stderr)
return 1
if argumente.probelauf:
print("\nProbelauf - es wurde nichts uebertragen.")
return 0
if not uebertragen and not geloescht:
print("\nNichts zu tun - der Server ist auf dem Stand des Baus.")
else:
print("\nUebertrage ...")
lauf = subprocess.run(rsync_befehl(env, probelauf=False))
if lauf.returncode != 0:
return lauf.returncode
print(f"Fertig: {len(uebertragen)} uebertragen, "
f"{len(geloescht)} geloescht.")
if argumente.ohne_nachpruefung:
return 0
return 0 if nachpruefung(env["OR_URL"]) else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -2078,6 +2078,45 @@ Gegenprobe: drei Läufe mit `PYTHONHASHSEED=random` liefern dieselbe Prüfsumme
`3da5b9b2…`. Keine Hauptzeile trägt mehr einen doppelten Link; die 219 Fachbegriffe und 328
Indexmarken bleiben unverändert.
### ✅ `veroeffentliche_04.py` — der Upload wird nachprüfbar
Der Anlass war messbar. Der Live-Stand vor dem Skript:
```
/OR/ 200
/OR/anhang-glossar-literatur.html 200 <- Leiche des vorigen Stands
/OR/anhang-glossar.html 404
/OR/notebooks.html 404
/OR/Notebooks_04.zip 404
/OR/Notebooks_04/ 403
```
**`scp -r` überschreibt nur, es löscht nie.** Die alte Glossar-Seite bliebe nach einem
`scp`-Upload dauerhaft erreichbar. Das Zielverzeichnis vorher zu leeren wäre die grobe
Antwort: Bricht die Übertragung von 23 MB in 213 Dateien ab, ist die Website tot.
`rsync --delete` überträgt nur Geändertes und entfernt genau das, was verschwunden ist.
Der zweite Grund wiegt schwerer: **Die Website hing wochenlang einen Bau zurück**, ohne dass
es auffiel. Deshalb prüft das Skript vorher, ob eine Quelldatei neuer ist als die gebaute
Website, ob das PDF der Website byte-genau dem gebauten entspricht und ob die
Verzeichnis-Weiterleitungen vorhanden sind (der Grund für den 403). Und es prüft
**hinterher per HTTP** vierzehn Adressen nach — einschließlich der Probe, dass die entfernte
Seite wirklich 404 liefert.
Dazwischen ein Probelauf, der zählt: Mehr als 30 Löschungen brechen ab. Das ist der Schutz
gegen einen falschen Zielpfad — mit `--delete` räumt ein Tippfehler sonst ein fremdes
Verzeichnis aus.
**Anmeldung per SSH-Schlüssel, nicht per Passwort in der `.env`.** `rsync` und `scp` haben
keine Passwort-Option; ein Passwort bräuchte `sshpass` und stünde während der Übertragung in
der Prozessliste. Die `.env` (in `.gitignore`, Vorlage `.env.beispiel`) enthält deshalb nur
Host, Benutzer, Zielpfad und URL.
**Beim ersten Test lief die Prüfung in eine irreführende Meldung:** „Auf dem Server ist kein
rsync installiert" — tatsächlich schlug schon die Anmeldung fehl, weil der öffentliche
Schlüssel noch nicht auf `jamulix.de` liegt. Die Prüfung unterscheidet die Fälle jetzt am
ssh-Rückgabewert 255 und nennt `ssh-copy-id`.
---
## 8. Commit-Historie des V04-Strangs

View file

@ -115,6 +115,7 @@ pip install -r Operations_Research_mit_Python_Version_04_Programme/requirements.
| --- | --- |
| `Operations_Research_mit_Python_Version_04/` | **Quelle**: 37 Kapiteldateien (inkl. 5 Teil-Synthesen) + Build-Skripte |
| `bilder_04/` | **Quelle**: 33 Diagramme (SVG) + 16 `erzeuge_*.py`-Generatoren |
| `web_04/` | **Quelle**: `site.css`, `site.js`, `icons.svg`, `plotly.min.js`, `katex/` — die statischen Bestandteile der Website |
| `Kritik_und_Verbesserungsvorschlaege/` | **Quelle**: die Rezensionen und Vorschläge, die den Umbau ausgelöst haben |
| `pyproject.toml`, `Dockerfile`, `.dockerignore` | **Quelle**: Abhängigkeitsgruppen und Kurs-Image |
| `pandoc-defaults-*.yaml`, `pandoc/` | **Quelle**: Konfiguration des PDF-Baus |
@ -166,8 +167,9 @@ Prüfung hinterlassen.
| `inkscape` | SVG-Grafiken im PDF | LaTeX bricht bei `\includesvg` ab |
| `dot` (Graphviz) | 5 der 16 Bildgeneratoren | nur beim Neuerzeugen dieser Diagramme nötig |
**`OR_HTML_04/katex/`** ist externes Material und wird von keinem Skript erzeugt. Fehlt es,
bleiben die Formeln auf der Website ungesetzt.
**`web_04/katex/`** ist externes Material und wird von keinem Skript erzeugt — es liegt
deshalb als Quelle im Repository und wird bei jedem Bau nach `OR_HTML_04/katex/` kopiert.
Fehlt es, bleiben die Formeln auf der Website ungesetzt.
**Warum die PDF-Konfiguration doppelt vorliegt:** `pandoc-defaults-basis.yaml` ist eine Kopie
der Benutzerdatei `~/.config/pandoc/defaults.yaml` — mit **relativem** Pfad auf
@ -177,6 +179,46 @@ Benutzerdatei zurück, falls sie fehlt.
---
## Veröffentlichen
`OR_HTML_04/` ist selbstgenügsam: Der Ordner lässt sich direkt auf einen Webserver kopieren.
Für den regelmäßigen Fall gibt es ein Skript.
```bash
# einmalig: den eigenen SSH-Schlüssel auf den Server legen
ssh-copy-id benutzer@example.org
# einmalig: Ziel eintragen
cp .env.beispiel .env && chmod 600 .env # dann .env ausfüllen
# jedes Mal
python3 Operations_Research_mit_Python_Version_04/veroeffentliche_04.py
```
Das Skript benutzt **`rsync --delete`, nicht `scp`** — und das ist der entscheidende
Unterschied: `scp` überschreibt nur, es entfernt nie. Eine Seite, die aus dem Buch
verschwindet, bliebe sonst dauerhaft online erreichbar. `rsync` überträgt zudem nur, was sich
geändert hat; ein zweiter Lauf direkt danach überträgt nichts.
Vier Schritte, jeder mit einer eigenen Aufgabe:
1. **Vorprüfung.** Ist eine Quelldatei neuer als die gebaute Website, bricht das Skript ab —
ein alter Stand soll gar nicht erst hochgeladen werden. Ebenso geprüft: dass das PDF der
Website byte-genau dem gebauten entspricht und dass die Verzeichnis-Weiterleitungen da
sind.
2. **Probelauf.** Zählt und zeigt, was übertragen und was gelöscht würde. Über 30 Löschungen
ist der Lauf verdächtig (falscher Zielpfad, halb gebautes Verzeichnis) und bricht ab;
`--loeschgrenze N` hebt die Grenze an.
3. **Übertragung.**
4. **Nachprüfung per HTTP.** Vierzehn Adressen werden abgerufen und ihr Statuscode mit der
Erwartung verglichen — einschließlich der Probe, dass eine entfernte Seite wirklich 404
liefert.
`--probelauf` führt nur die Schritte 1 und 2 aus. In der `.env` steht **kein Geheimnis**:
`rsync` und `scp` haben keine Passwort-Option, die Anmeldung läuft über den SSH-Schlüssel.
---
## Notebooks und Google Colab
Jedes Kapitel hat ein Notebook in `Notebooks_04/`, und die Website verlinkt es zum