Notebook-Uebersichtsseite - das fehlende Gegenstueck zu programme.html

Der 403 auf /OR/Notebooks_04/ hatte eine andere Ursache als vermutet: Beim
Eintragen der Website-Adresse ins README hatte ich in der Lesen-Tabelle das
VERZEICHNIS verlinkt. Keine Seite der Website tut das - die Kapitelseiten
zeigen direkt auf Notebooks_04/<name>.ipynb, und das funktioniert. Der
Webserver verweigert Directory-Listing, zu Recht.

Die Frage "gibt es so etwas nicht schon?" war aber berechtigt und hat die
eigentliche Luecke sichtbar gemacht: baue_programme_seite() erzeugt seit
jeher programme.html, eine gestaltete Uebersicht der 76 Programme. Fuer die
25 Notebooks gab es kein Gegenstueck - sie wurden ausgeliefert, aber keine
Seite listete sie.

Neu ist baue_notebooks_seite(), nach demselben Muster und mit denselben
Bausteinen: die Zuordnung Seite -> Notebook kommt fertig aus
baue_notebooks(), Rahmen und Navigation aus baue_seiten_schablone() und
baue_sidebar_html(). Gruppiert nach Vorspann, Kapiteln und Anhaengen; der
einleitende Absatz nennt die drei Wege, ein Notebook auszufuehren.

Dazu ein ZIP mit allen 25 Notebooks (208 KB), byteidentisch ueber zwei
Laeufe. Ein ZIP speichert je Eintrag die Aenderungszeit; ohne festen Wert
entstuende bei jedem Bau eine andere Datei, und da OR_HTML_04/ versioniert
ist, wuechse das Repository bei jedem Lauf. Die Eintraege werden deshalb
sortiert und mit ZipInfo(date_time=(1980,1,1,0,0,0)) geschrieben - dieselbe
Sorgfalt wie svg.hashsalt bei den Diagrammen.

Gegen kuenftige 403 schreiben spiegle_notebooks() und baue_programme_seite()
je eine dreizeilige index.html mit meta refresh in ihr Downloadverzeichnis.
Das behebt zugleich denselben latenten Fall bei programme/.

Ein eigener Fehler, zum zweiten Mal derselbe: Das .replace(",", ".") fuer
deutsche Tausenderpunkte hing am Ende eines mehrzeiligen f-Strings - und in
Python bindet die Methode an die GESAMTE zusammengesetzte Zeichenkette. Aus
"Drei Wege, sie auszufuehren" wurde "Drei Wege. sie auszufuehren". Exakt der
Fehler, vor dem ich in erzeuge_wirkung.py selbst einen Kommentar hinterlassen
hatte. Die Zahl wird jetzt getrennt formatiert.

Geprueft: 25 gelistete Notebooks, 0 tote Links auf der Seite, ZIP mit 25
Eintraegen und identischen Zeitstempeln, Seitenleisteneintrag auf allen 40
Seiten mit Seitenleiste, --check fehlerfrei, PDF unveraendert 759 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 15:20:06 +02:00
commit 5c49725d5c
49 changed files with 311 additions and 41 deletions

View file

@ -161,6 +161,12 @@ Im PDF steht stattdessen ein Hinweis auf die Website.
Das Buch steht online auf <https://jamulix.de/OR/>, das Repository oeffentlich auf Das Buch steht online auf <https://jamulix.de/OR/>, das Repository oeffentlich auf
<https://kitux.de/forgejo/dschlueter/operations_research>. <https://kitux.de/forgejo/dschlueter/operations_research>.
Die Website hat vier Übersichtsseiten: `index.html` (Landing), `programme.html`,
`notebooks.html` und `stichwortverzeichnis.html`. Die beiden Downloadverzeichnisse
`programme/` und `Notebooks_04/` bekommen je eine `index.html`, die auf ihre Übersicht
weiterleitet — sonst antwortet der Webserver mit 403, weil Directory-Listing abgeschaltet
ist.
**Nach jedem `--pdf --html` muss `OR_HTML_04/` neu auf den Webserver kopiert werden** — sonst **Nach jedem `--pdf --html` muss `OR_HTML_04/` neu auf den Webserver kopiert werden** — sonst
laeuft die veroeffentlichte Fassung dem Repository hinterher. Woran man das merkt: Ein laeuft die veroeffentlichte Fassung dem Repository hinterher. Woran man das merkt: Ein
`curl -s https://jamulix.de/OR/lp.html | grep -c colab.research` muss **0** ergeben, und die `curl -s https://jamulix.de/OR/lp.html | grep -c colab.research` muss **0** ergeben, und die

BIN
OR_HTML_04/Notebooks_04.zip generated Normal file

Binary file not shown.

4
OR_HTML_04/Notebooks_04/index.html generated Normal file
View file

@ -0,0 +1,4 @@
<!doctype html><meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=../notebooks.html">
<title>Weiterleitung</title>
<p>Weiter zur <a href="../notebooks.html">Übersicht</a>.</p>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
OR_HTML_04/cpsat.html generated

File diff suppressed because one or more lines are too long

2
OR_HTML_04/cvar.html generated

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
OR_HTML_04/index.html generated

File diff suppressed because one or more lines are too long

2
OR_HTML_04/lp.html generated

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
OR_HTML_04/milp.html generated

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

116
OR_HTML_04/notebooks.html generated Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
OR_HTML_04/programme/index.html generated Normal file
View file

@ -0,0 +1,4 @@
<!doctype html><meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=../programme.html">
<title>Weiterleitung</title>
<p>Weiter zur <a href="../programme.html">Übersicht</a>.</p>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -34,6 +34,7 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import zipfile
HIER = os.path.dirname(os.path.abspath(__file__)) HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER) BASIS = os.path.dirname(HIER)
@ -1541,6 +1542,7 @@ def baue_sidebar_html(struktur: list[dict], aktuelle_seite: str, pdf_verfuegbar:
+ "".join(teile_html) + "".join(teile_html)
+ '<ul class="sidebar-extra">' + '<ul class="sidebar-extra">'
+ '<li><a href="programme.html">' + icon("book") + ' Beispielprogramme</a></li>' + '<li><a href="programme.html">' + icon("book") + ' Beispielprogramme</a></li>'
+ '<li><a href="notebooks.html">' + icon("book") + ' Notebooks</a></li>'
+ '<li><a href="stichwortverzeichnis.html">' + icon("search") + '<li><a href="stichwortverzeichnis.html">' + icon("search")
+ ' Stichwortverzeichnis</a></li>' + ' Stichwortverzeichnis</a></li>'
+ '<li><a href="gesamtdokument.html">' + icon("external-link") + '<li><a href="gesamtdokument.html">' + icon("external-link")
@ -1855,6 +1857,21 @@ def baue_notebooks(struktur: list[dict]) -> dict[str, str]:
return notizbuecher return notizbuecher
def schreibe_verzeichnis_weiterleitung(verzeichnis: str, ziel: str) -> None:
"""Legt in einem Downloadverzeichnis eine index.html an, die auf die
zugehoerige Uebersichtsseite weiterleitet.
Keine Seite der Website verlinkt diese Verzeichnisse - verlinkt werden
immer die Einzeldateien. Wer die URL aber raet oder aus einer aelteren
Fassung uebernimmt, bekommt vom Webserver ein 403 (Directory-Listing ist
zu Recht abgeschaltet). Drei Zeilen machen daraus etwas Brauchbares."""
with open(os.path.join(verzeichnis, "index.html"), "w", encoding="utf-8") as f:
f.write('<!doctype html><meta charset="utf-8">\n'
f'<meta http-equiv="refresh" content="0; url={ziel}">\n'
f'<title>Weiterleitung</title>\n'
f'<p>Weiter zur <a href="{ziel}">Übersicht</a>.</p>\n')
def spiegle_notebooks(html_verz: str, notizbuecher: dict[str, str]) -> None: def spiegle_notebooks(html_verz: str, notizbuecher: dict[str, str]) -> None:
"""Legt die Notebooks neben die Website, damit der Download-Link in der """Legt die Notebooks neben die Website, damit der Download-Link in der
Kapitelkarte auch dann funktioniert, wenn nur OR_HTML_04/ auf einen Kapitelkarte auch dann funktioniert, wenn nur OR_HTML_04/ auf einen
@ -1866,6 +1883,7 @@ def spiegle_notebooks(html_verz: str, notizbuecher: dict[str, str]) -> None:
inhalt = f.read() inhalt = f.read()
with open(os.path.join(ziel_verz, name), "w", encoding="utf-8") as f: with open(os.path.join(ziel_verz, name), "w", encoding="utf-8") as f:
f.write(inhalt) f.write(inhalt)
schreibe_verzeichnis_weiterleitung(ziel_verz, "../notebooks.html")
# Letzte Zeile der Karte 'Kapitel auf einen Blick' - davor wird der # Letzte Zeile der Karte 'Kapitel auf einen Blick' - davor wird der
@ -2131,9 +2149,89 @@ def baue_programme_seite(html_verz: str, struktur: list[dict], pdf_verfuegbar: b
inhalt_html=inhalt) inhalt_html=inhalt)
with open(os.path.join(html_verz, "programme.html"), "w", encoding="utf-8") as f: with open(os.path.join(html_verz, "programme.html"), "w", encoding="utf-8") as f:
f.write(html) f.write(html)
schreibe_verzeichnis_weiterleitung(ziel_verz, "../programme.html")
print(f"Programme-Seite erzeugt: {kopiert} Dateien nach {ziel_verz} kopiert.") print(f"Programme-Seite erzeugt: {kopiert} Dateien nach {ziel_verz} kopiert.")
# Der ZIP-Nullpunkt. Ein ZIP speichert je Eintrag eine Aenderungszeit - ohne
# festen Wert entstuende bei jedem Bau eine andere Datei. Da OR_HTML_04/
# versioniert ist, wuerde das Repository sonst bei jedem Lauf um die Groesse
# des Archivs wachsen. Dieselbe Ueberlegung wie svg.hashsalt und
# metadata={"Date": None} bei den Diagrammgeneratoren.
ZIP_ZEITSTEMPEL = (1980, 1, 1, 0, 0, 0)
def baue_notebooks_seite(html_verz: str, struktur: list[dict],
notizbuecher: dict[str, str], pdf_verfuegbar: bool) -> None:
"""Uebersicht ueber alle Notebooks - das Gegenstueck zu
baue_programme_seite() fuer die andere Sorte Download.
Die Zuordnung Seite -> Notebookname kommt fertig aus baue_notebooks();
hier wird nichts neu hergeleitet. Gruppiert wird nach Typ, damit Vorwort
und Anhang nicht zwischen den Kapiteln stehen.
"""
gruppen = {"sonder": ("Vorspann", []), "kapitel": ("Kapitel", []),
"anhang": ("Anhänge", [])}
for e in struktur:
name = notizbuecher.get(e["seite"])
if not name:
continue
beschriftung = (f'{e["nummer"]}: {e["titel"]}' if e.get("nummer")
else e["titel"])
gruppen.get(e["typ"], gruppen["kapitel"])[1].append(
f'<li><a href="{e["seite"]}">{beschriftung}</a> '
f'<a class="download-link" href="{NOTEBOOK_NAME}/{name}" download>'
f'{icon("download", "icon")} {name}</a></li>')
anzahl = sum(len(eintraege) for _, eintraege in gruppen.values())
# --- Alle Notebooks in einem Archiv ---------------------------------
archiv_name = f"{NOTEBOOK_NAME}.zip"
archiv_pfad = os.path.join(html_verz, archiv_name)
with zipfile.ZipFile(archiv_pfad, "w", zipfile.ZIP_DEFLATED) as archiv:
for datei in sorted(set(notizbuecher.values())):
with open(os.path.join(NOTEBOOK_VERZ, datei), encoding="utf-8") as f:
inhalt = f.read()
eintrag = zipfile.ZipInfo(f"{NOTEBOOK_NAME}/{datei}", ZIP_ZEITSTEMPEL)
eintrag.compress_type = zipfile.ZIP_DEFLATED
eintrag.external_attr = 0o644 << 16
archiv.writestr(eintrag, inhalt)
archiv_kb = os.path.getsize(archiv_pfad) / 1024
# Deutsche Tausenderpunkte NUR auf der Zahl. Ein .replace(",", ".") am Ende
# eines mehrzeiligen f-Strings trifft die gesamte zusammengesetzte
# Zeichenkette - also auch jedes Satzkomma davor.
archiv_groesse = f"{archiv_kb:,.0f}".replace(",", ".")
gruppen_html = "".join(
f'<section class="programmgruppe"><h2>{titel}</h2>'
f'<ul class="programmliste">{"".join(eintraege)}</ul></section>'
for titel, eintraege in gruppen.values() if eintraege)
inhalt = (
f"<h1>Notebooks</h1>"
f"<p>Zu jedem Kapitel gehört ein Jupyter-Notebook mit allen Codeblöcken "
f"in Lesereihenfolge — insgesamt {anzahl}. Drei Wege, sie auszuführen: "
f"in einer lokalen <strong>Jupyter</strong>-Installation öffnen, in "
f"<strong>Google&nbsp;Colab</strong> hochladen, oder mit dem "
f"<strong>Kurs-Image</strong> starten, das alle Solver mitbringt:</p>"
f'<pre><code>docker run --rm -p 127.0.0.1:8888:8888 or-mit-python</code></pre>'
f'<p class="notebook-archiv">'
f'<a class="download-link" href="{archiv_name}" download>'
f'{icon("download", "icon")} Alle {anzahl} Notebooks als ZIP '
f'({archiv_groesse}&nbsp;KB)</a></p>'
+ gruppen_html)
html = baue_seiten_schablone(
titel="Notebooks", aktuelle_seite="notebooks.html",
sidebar_html=baue_sidebar_html(struktur, "notebooks.html", pdf_verfuegbar),
inhalt_html=inhalt)
with open(os.path.join(html_verz, "notebooks.html"), "w", encoding="utf-8") as f:
f.write(html)
print(f"Notebook-Seite erzeugt: {anzahl} Notebooks, "
f"Archiv {archiv_name} ({archiv_kb:.0f} KB).")
_UMLAUT_TABELLE = str.maketrans( _UMLAUT_TABELLE = str.maketrans(
{"ä": "a", "ö": "o", "ü": "u", "ß": "s", "Ä": "a", "Ö": "o", "Ü": "u"}) {"ä": "a", "ö": "o", "ü": "u", "ß": "s", "Ä": "a", "Ö": "o", "Ü": "u"})
@ -2249,6 +2347,7 @@ def baue_html_website() -> None:
suchindex, pdf_verfuegbar, notizbuecher) suchindex, pdf_verfuegbar, notizbuecher)
baue_landingpage(html_verz, struktur, labels, label_seite, datei_seite, pdf_verfuegbar) baue_landingpage(html_verz, struktur, labels, label_seite, datei_seite, pdf_verfuegbar)
baue_programme_seite(html_verz, struktur, pdf_verfuegbar) baue_programme_seite(html_verz, struktur, pdf_verfuegbar)
baue_notebooks_seite(html_verz, struktur, notizbuecher, pdf_verfuegbar)
baue_stichwortverzeichnis(html_verz, struktur, stichwortregister, pdf_verfuegbar) baue_stichwortverzeichnis(html_verz, struktur, stichwortregister, pdf_verfuegbar)
erzeuge_suchindex(html_verz, suchindex) erzeuge_suchindex(html_verz, suchindex)
baue_gesamtdokument(html_verz) baue_gesamtdokument(html_verz)

View file

@ -1952,6 +1952,47 @@ aus der Wurzel legt dort **null** Dateien ab.
**Und `git gc`:** 653 lose Objekte gepackt, `.git` von 71 MB auf **28 MB** — reines **Und `git gc`:** 653 lose Objekte gepackt, `.git` von 71 MB auf **28 MB** — reines
Repacken, kein Inhalt berührt. Repacken, kein Inhalt berührt.
### ✅ Notebook-Übersichtsseite — und ein 403, dessen Ursache woanders lag
`https://jamulix.de/OR/Notebooks_04/` lieferte 403. **Die Ursache war ein Link, den ich
selbst gesetzt hatte:** Beim Eintragen der Website-Adresse ins README hatte ich in der
Lesen-Tabelle das *Verzeichnis* verlinkt. Keine Seite der Website tut das — die
Kapitelseiten zeigen direkt auf `Notebooks_04/<name>.ipynb`, und das funktioniert. Der
Webserver verweigert Directory-Listing, zu Recht.
**Die Frage „gibt es so etwas nicht schon?" war aber berechtigt** — und hat die eigentliche
Lücke sichtbar gemacht: `baue_programme_seite()` erzeugt seit jeher `programme.html`, eine
gestaltete Übersicht der 76 Programme. Für die 25 Notebooks gab es kein Gegenstück; sie
wurden ausgeliefert, aber keine Seite listete sie.
Neu ist deshalb `baue_notebooks_seite()`, gebaut nach demselben Muster und mit denselben
Bausteinen: die Zuordnung Seite → Notebook kommt fertig aus `baue_notebooks()`, Rahmen und
Navigation aus `baue_seiten_schablone()` und `baue_sidebar_html()`. Gruppiert nach Vorspann,
Kapiteln und Anhängen; der einleitende Absatz nennt die drei Wege, ein Notebook auszuführen
(Jupyter, Colab-Upload, Kurs-Image).
**Dazu ein ZIP mit allen 25 Notebooks** — 208 KB, und **byteidentisch über zwei Läufe**. Ein
ZIP speichert je Eintrag die Änderungszeit; ohne festen Wert entstünde bei jedem Bau eine
andere Datei, und da `OR_HTML_04/` versioniert ist, wüchse das Repository bei jedem Lauf.
Die Einträge werden deshalb sortiert und mit `ZipInfo(date_time=(1980,1,1,0,0,0))`
geschrieben. Dieselbe Sorgfalt wie `svg.hashsalt` bei den Diagrammen.
**Gegen künftige 403** schreiben `spiegle_notebooks()` und `baue_programme_seite()` je eine
dreizeilige `index.html` mit `meta refresh` in ihr Downloadverzeichnis. Wer die URL rät oder
einen alten Link hat, landet auf der Übersicht statt auf einem Fehler — das behebt zugleich
denselben latenten Fall bei `programme/`.
**Ein eigener Fehler, zum zweiten Mal derselbe:** Das `.replace(",", ".")` für deutsche
Tausenderpunkte hing am Ende eines mehrzeiligen f-Strings — und in Python bindet die
Methode an die **gesamte** zusammengesetzte Zeichenkette. Aus „Drei Wege, sie auszuführen"
wurde „Drei Wege. sie auszuführen". Exakt der Fehler, vor dem ich in `erzeuge_wirkung.py`
selbst einen Kommentar hinterlassen hatte. Die Zahl wird jetzt getrennt formatiert, mit
demselben Warnhinweis daneben.
Geprüft: 25 gelistete Notebooks, 0 tote Links auf der Seite, ZIP mit 25 Einträgen und
identischen Zeitstempeln, Seitenleisteneintrag auf allen 40 Seiten mit Seitenleiste
(`gesamtdokument.html` hat konstruktionsbedingt keine).
--- ---
## 8. Commit-Historie des V04-Strangs ## 8. Commit-Historie des V04-Strangs

View file

@ -26,7 +26,7 @@ Autor: **Dieter Schlüter** · Lizenz: MIT (Code) und CC BY-SA 4.0 (Text)
| **Website** — eine Seite je Kapitel, mit Volltextsuche, Stichwortverzeichnis und vier interaktiven Plotly-Figuren | **<https://jamulix.de/OR/>** — oder lokal `OR_HTML_04/index.html` | | **Website** — eine Seite je Kapitel, mit Volltextsuche, Stichwortverzeichnis und vier interaktiven Plotly-Figuren | **<https://jamulix.de/OR/>** — oder lokal `OR_HTML_04/index.html` |
| **PDF**, 759 Seiten | <https://jamulix.de/OR/Operations_Research_mit_Python_Version_04.pdf> — oder lokal [`…_Version_04.pdf`](Operations_Research_mit_Python_Version_04.pdf) | | **PDF**, 759 Seiten | <https://jamulix.de/OR/Operations_Research_mit_Python_Version_04.pdf> — oder lokal [`…_Version_04.pdf`](Operations_Research_mit_Python_Version_04.pdf) |
| **Ein einziges HTML-Dokument** | <https://jamulix.de/OR/gesamtdokument.html> | | **Ein einziges HTML-Dokument** | <https://jamulix.de/OR/gesamtdokument.html> |
| **Notebooks**, eines je Kapitel | <https://jamulix.de/OR/Notebooks_04/> — oder lokal `Notebooks_04/` | | **Notebooks**, eines je Kapitel, einzeln oder als ZIP | <https://jamulix.de/OR/notebooks.html> — oder lokal `Notebooks_04/` |
Die Website ist selbstgenügsam — alle Verweise zeigen auf Unterverzeichnisse (`assets/`, Die Website ist selbstgenügsam — alle Verweise zeigen auf Unterverzeichnisse (`assets/`,
`bilder_04/`, `katex/`, `Notebooks_04/`). Der Ordner lässt sich unverändert auf einen `bilder_04/`, `katex/`, `Notebooks_04/`). Der Ordner lässt sich unverändert auf einen