Stand 16.10.2026. Enthält das Marketing-Dashboard, die Daten in daten/, die Produktionsvorgaben, Reels und das Webinar mit Skript, Sprechzettel und Präsentation (HTML, PowerPoint, PDF). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
14 KiB
Python
352 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Lokaler Server für das Marketing-Dashboard.
|
|
|
|
Liefert den ganzen Hub-Ordner aus und dazu ein paar kleine Auskünfte:
|
|
|
|
GET /api/dokumente alle Markdown-Dateien im Hub, mit Titel
|
|
GET /api/stand Änderungszeiten, damit das Dashboard merkt, wenn sich
|
|
Daten oder Dokumente geändert haben
|
|
GET /api/suche?q= Volltextsuche über alle Markdown-Dateien
|
|
POST /api/status Status eines Slots oder Roadmap-Schritts setzen
|
|
POST /api/export Redaktionsplan für einen Zeitraum exportieren
|
|
(PDF, HTML oder Markdown), landet in ~/Downloads
|
|
POST /api/oeffnen einen Export öffnen oder im Finder zeigen
|
|
|
|
Der Server läuft nur auf 127.0.0.1 und ist kein Auslieferungsserver.
|
|
Gestartet wird er mit ./serve.sh.
|
|
"""
|
|
|
|
import http.server
|
|
import json
|
|
import re
|
|
import socketserver
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import unicodedata
|
|
import urllib.parse
|
|
import webbrowser
|
|
from pathlib import Path
|
|
|
|
PORT = 4322
|
|
HUB = Path(__file__).resolve().parent
|
|
DATEN = HUB / "daten"
|
|
BAUEN = ["node", str(HUB / "werkzeuge" / "bauen.mjs")]
|
|
# Exporte landen dort, wo man sie zum Verschicken greift. Wie beim
|
|
# Post-Generator: der Server schreibt die Datei, nicht der Browser.
|
|
DOWNLOADS = Path.home() / "Downloads"
|
|
EXPORT_PRAEFIX = "HiveGrid Content-Plan "
|
|
FORMATE = {"pdf": "pdf", "html": "html", "md": "md"}
|
|
ISO_DATUM = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
# Ordner, die keine Dokumente enthalten, sondern das Werkzeug selbst oder
|
|
# erzeugte Kopien.
|
|
# "Übersicht-Marketing-Strategie" ist das erste Paket, das Noah bekommen hat.
|
|
NICHT_DURCHSUCHEN = {"Dashboard", "Übersicht-Marketing-Strategie", "werkzeuge", "daten", "node_modules"}
|
|
|
|
STATUS = {
|
|
"redaktionsplan": {"geplant", "fertig", "erschienen", "ausgefallen"},
|
|
"roadmap": {"offen", "läuft", "erledigt", "später", "entfällt"},
|
|
}
|
|
SICHERE_ID = re.compile(r"^[A-Za-z0-9.]{1,12}$")
|
|
|
|
# Status setzen schreibt Dateien, ein Export startet Chrome. Je eins zur
|
|
# selben Zeit, damit sich nichts in die Quere kommt und nie zwei Chromes laufen.
|
|
SCHREIBSPERRE = threading.Lock()
|
|
EXPORTSPERRE = threading.Lock()
|
|
|
|
|
|
def nfc(text):
|
|
# macOS liefert Umlaute in Dateinamen zerlegt (U und Trema einzeln).
|
|
# Ohne Angleichen wäre "Übersicht" auf der Platte nicht "Übersicht" im Code.
|
|
return unicodedata.normalize("NFC", text)
|
|
|
|
|
|
def markdown_dateien():
|
|
for p in sorted(HUB.rglob("*.md")):
|
|
teile = [nfc(t) for t in p.relative_to(HUB).parts]
|
|
if any(t in NICHT_DURCHSUCHEN or t.startswith(".") for t in teile[:-1]):
|
|
continue
|
|
yield p
|
|
|
|
|
|
def titel_von(p):
|
|
try:
|
|
with p.open(encoding="utf-8") as f:
|
|
for zeile in f:
|
|
if zeile.startswith("# "):
|
|
return zeile[2:].strip()
|
|
except OSError:
|
|
pass
|
|
return p.stem
|
|
|
|
|
|
def mtime(p):
|
|
try:
|
|
return p.stat().st_mtime
|
|
except OSError:
|
|
return 0.0
|
|
|
|
|
|
def status_setzen(datei, kennung, status):
|
|
"""Setzt den Status in genau einer Zeile der Datendatei.
|
|
|
|
Die Dateien stehen mit einem Eintrag pro Zeile. So bleibt das Format,
|
|
das Claude und git lesen, beim Umschalten im Dashboard unberührt. Ein
|
|
komplettes json.dump würde jede Datei auf hunderte Zeilen aufblähen.
|
|
"""
|
|
pfad = DATEN / (datei + ".json")
|
|
text = pfad.read_text(encoding="utf-8")
|
|
zeilen = text.split("\n")
|
|
marke = '"id": "%s",' % kennung
|
|
treffer = [i for i, z in enumerate(zeilen) if marke in z]
|
|
if len(treffer) != 1:
|
|
raise ValueError("Eintrag %s nicht eindeutig gefunden" % kennung)
|
|
i = treffer[0]
|
|
neu, n = re.subn(r'"status": "[^"]*"', '"status": "%s"' % status, zeilen[i], count=1)
|
|
if n != 1:
|
|
raise ValueError("Eintrag %s hat kein Statusfeld" % kennung)
|
|
zeilen[i] = neu
|
|
neuer_text = "\n".join(zeilen)
|
|
|
|
# Gegenprobe: gültiges JSON, und der Eintrag hat jetzt den neuen Status.
|
|
daten = json.loads(neuer_text)
|
|
eintraege = daten.get("slots", []) + [s for p in daten.get("pakete", []) for s in p["schritte"]]
|
|
if not any(e.get("id") == kennung and e.get("status") == status for e in eintraege):
|
|
raise ValueError("Gegenprobe fehlgeschlagen")
|
|
|
|
tmp = pfad.with_suffix(".json.tmp")
|
|
tmp.write_text(neuer_text, encoding="utf-8")
|
|
tmp.replace(pfad)
|
|
|
|
|
|
def bauen(*optionen, zeit=240):
|
|
lauf = subprocess.run(BAUEN + list(optionen), cwd=str(HUB), capture_output=True,
|
|
text=True, timeout=zeit)
|
|
return lauf.returncode, (lauf.stdout + lauf.stderr).strip()
|
|
|
|
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=str(HUB), **kw)
|
|
|
|
# -------------------------------------------------------------- GET
|
|
|
|
def do_GET(self):
|
|
teile = urllib.parse.urlparse(self.path)
|
|
if teile.path in ("/", "/index.html"):
|
|
self.path = "/Dashboard/index.html"
|
|
return super().do_GET()
|
|
if teile.path == "/api/dokumente":
|
|
return self._dokumente()
|
|
if teile.path == "/api/stand":
|
|
return self._stand()
|
|
if teile.path == "/api/suche":
|
|
q = urllib.parse.parse_qs(teile.query).get("q", [""])[0]
|
|
return self._suche(q)
|
|
return super().do_GET()
|
|
|
|
def _dokumente(self):
|
|
liste = []
|
|
for p in markdown_dateien():
|
|
rel = nfc(p.relative_to(HUB).as_posix())
|
|
liste.append({"pfad": rel, "titel": titel_von(p),
|
|
"ordner": str(Path(rel).parent) if "/" in rel else "",
|
|
"zeit": mtime(p)})
|
|
self._json(200, liste)
|
|
|
|
def _stand(self):
|
|
daten = max([mtime(p) for p in DATEN.glob("*.json")] or [0.0])
|
|
dokumente = max([mtime(p) for p in markdown_dateien()] or [0.0])
|
|
self._json(200, {"daten": daten, "dokumente": dokumente, "aenderung": max(daten, dokumente)})
|
|
|
|
def _suche(self, q):
|
|
q = q.strip()
|
|
if len(q) < 2:
|
|
return self._json(200, [])
|
|
muster = re.compile(re.escape(q), re.IGNORECASE)
|
|
treffer = []
|
|
for p in markdown_dateien():
|
|
rel = nfc(p.relative_to(HUB).as_posix())
|
|
ueberschrift = ""
|
|
try:
|
|
zeilen = p.read_text(encoding="utf-8").split("\n")
|
|
except OSError:
|
|
continue
|
|
for nr, zeile in enumerate(zeilen, 1):
|
|
if zeile.startswith("#"):
|
|
ueberschrift = zeile.lstrip("#").strip()
|
|
m = muster.search(zeile)
|
|
if not m:
|
|
continue
|
|
a = max(0, m.start() - 70)
|
|
ausschnitt = ("…" if a else "") + zeile[a:m.end() + 90].strip()
|
|
treffer.append({"pfad": rel, "titel": titel_von(p), "zeile": nr,
|
|
"ueberschrift": ueberschrift, "text": ausschnitt})
|
|
if len(treffer) >= 300:
|
|
return self._json(200, treffer)
|
|
self._json(200, treffer)
|
|
|
|
# -------------------------------------------------------------- POST
|
|
|
|
def do_POST(self):
|
|
pfad = urllib.parse.urlparse(self.path).path
|
|
if pfad == "/api/status":
|
|
return self._status()
|
|
if pfad == "/api/export":
|
|
return self._export()
|
|
if pfad == "/api/oeffnen":
|
|
return self._oeffnen()
|
|
self.send_error(404)
|
|
|
|
def _lies_json(self):
|
|
laenge = int(self.headers.get("Content-Length", 0))
|
|
if laenge <= 0 or laenge > 4096:
|
|
raise ValueError("ungültige Größe")
|
|
return json.loads(self.rfile.read(laenge))
|
|
|
|
def _status(self):
|
|
try:
|
|
wunsch = self._lies_json()
|
|
datei, kennung, status = wunsch["datei"], str(wunsch["id"]), wunsch["status"]
|
|
except Exception:
|
|
return self._json(400, {"fehler": "ungültige Anfrage"})
|
|
if datei not in STATUS or status not in STATUS[datei] or not SICHERE_ID.match(kennung):
|
|
return self._json(400, {"fehler": "unzulässiger Wert"})
|
|
with SCHREIBSPERRE:
|
|
try:
|
|
status_setzen(datei, kennung, status)
|
|
except Exception as e:
|
|
return self._json(409, {"fehler": str(e)})
|
|
# Die Markdown-Tabellen sofort nachziehen, damit Datei und Dashboard
|
|
# nie auseinanderlaufen.
|
|
code, ausgabe = bauen(zeit=30)
|
|
print("Status %s/%s: %s" % (datei, kennung, status))
|
|
self._json(200 if code == 0 else 500, {"ok": code == 0, "ausgabe": ausgabe})
|
|
|
|
def _export(self):
|
|
try:
|
|
wunsch = self._lies_json()
|
|
von, bis, format_ = str(wunsch["von"]), str(wunsch["bis"]), str(wunsch["format"])
|
|
ideen = bool(wunsch.get("ideen"))
|
|
except Exception:
|
|
return self._json(400, {"fehler": "ungültige Anfrage"})
|
|
if not ISO_DATUM.match(von) or not ISO_DATUM.match(bis) or von > bis or format_ not in FORMATE:
|
|
return self._json(400, {"fehler": "Zeitraum oder Format ungültig"})
|
|
# "HiveGrid Content-Plan 19.10.-01.11.2026.pdf". Nichts überschreiben:
|
|
# eine Datei, die vielleicht schon verschickt ist, bleibt, wie sie ist.
|
|
stamm = "%s%s.%s.-%s.%s.%s" % (EXPORT_PRAEFIX, von[8:10], von[5:7], bis[8:10], bis[5:7], bis[:4])
|
|
if ideen:
|
|
stamm += " mit Ideen"
|
|
DOWNLOADS.mkdir(parents=True, exist_ok=True)
|
|
ziel = DOWNLOADS / ("%s.%s" % (stamm, FORMATE[format_]))
|
|
n = 2
|
|
while ziel.exists():
|
|
ziel = DOWNLOADS / ("%s (%d).%s" % (stamm, n, FORMATE[format_]))
|
|
n += 1
|
|
if not EXPORTSPERRE.acquire(blocking=False):
|
|
return self._json(409, {"fehler": "Ein Export läuft schon"})
|
|
try:
|
|
optionen = ["--von=" + von, "--bis=" + bis, "--format=" + format_, "--ziel=" + str(ziel)]
|
|
if ideen:
|
|
optionen.append("--ideen")
|
|
code, ausgabe = bauen(*optionen, zeit=90)
|
|
except subprocess.TimeoutExpired:
|
|
return self._json(500, {"fehler": "Zeitüberschreitung beim Export"})
|
|
except FileNotFoundError:
|
|
return self._json(500, {"fehler": "node nicht gefunden"})
|
|
finally:
|
|
EXPORTSPERRE.release()
|
|
if code != 0 or not ziel.exists():
|
|
return self._json(500, {"fehler": ausgabe or "Export fehlgeschlagen"})
|
|
print("Export: %s" % ziel)
|
|
self._json(200, {"ok": True, "name": ziel.name, "pfad": str(ziel), "info": ausgabe.splitlines()[-1] if ausgabe else ""})
|
|
|
|
def _oeffnen(self):
|
|
"""Öffnet einen Export (PDF in Vorschau, HTML im Browser) oder zeigt
|
|
ihn im Finder. Nur Exporte aus dem Downloads-Ordner, sonst nichts."""
|
|
try:
|
|
wunsch = self._lies_json()
|
|
ziel = Path(str(wunsch["pfad"])).resolve()
|
|
zeigen = bool(wunsch.get("zeigen"))
|
|
except Exception:
|
|
return self._json(400, {"fehler": "ungültige Anfrage"})
|
|
if ziel.parent != DOWNLOADS.resolve() or not nfc(ziel.name).startswith(EXPORT_PRAEFIX) or not ziel.is_file():
|
|
return self._json(400, {"fehler": "unzulässige Datei"})
|
|
subprocess.run(["open", "-R", str(ziel)] if zeigen else ["open", str(ziel)], check=False, timeout=10)
|
|
self._json(200, {"ok": True})
|
|
|
|
# -------------------------------------------------------------- Hilfen
|
|
|
|
def _json(self, code, daten):
|
|
roh = json.dumps(daten, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(roh)))
|
|
self.end_headers()
|
|
self.wfile.write(roh)
|
|
|
|
def guess_type(self, path):
|
|
if str(path).endswith(".md"):
|
|
return "text/markdown; charset=utf-8"
|
|
typ = super().guess_type(path)
|
|
if typ in ("text/html", "text/css", "application/javascript", "text/javascript", "application/json"):
|
|
return typ + "; charset=utf-8"
|
|
return typ
|
|
|
|
def end_headers(self):
|
|
# Wie bei posts: Schriften und Bibliotheken darf der Browser behalten,
|
|
# muss aber nachfragen. Alles andere kommt immer frisch, sonst sieht
|
|
# Dejan nach einer Änderung den alten Plan.
|
|
pfad = urllib.parse.urlparse(self.path).path
|
|
if pfad.startswith(("/Dashboard/assets/", "/Dashboard/vendor/")):
|
|
self.send_header("Cache-Control", "no-cache")
|
|
else:
|
|
self.send_header("Cache-Control", "no-store, must-revalidate")
|
|
super().end_headers()
|
|
|
|
def log_message(self, fmt, *args):
|
|
erste = str(args[0]) if args else ""
|
|
# Nur Fehler und Schreibzugriffe zeigen, sonst rauscht das Fenster.
|
|
if not erste.startswith(("GET", "HEAD", "POST")):
|
|
super().log_message(fmt, *args)
|
|
|
|
|
|
class Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
|
|
def freier_server(wunsch):
|
|
"""Der erste freie Port ab dem Wunschport. Läuft schon etwas auf 4322,
|
|
zum Beispiel ein zweites Dashboard, startet dieses eben daneben."""
|
|
for port in range(wunsch, wunsch + 20):
|
|
try:
|
|
return Server(("127.0.0.1", port), Handler), port
|
|
except OSError:
|
|
continue
|
|
return None, None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
wunsch = PORT
|
|
for arg in sys.argv[1:]:
|
|
if arg.startswith("--port="):
|
|
wunsch = int(arg.split("=", 1)[1])
|
|
server, port = freier_server(wunsch)
|
|
if server is None:
|
|
print("Kein freier Port zwischen %d und %d gefunden." % (wunsch, wunsch + 19), flush=True)
|
|
sys.exit(1)
|
|
adresse = "http://127.0.0.1:%d/" % port
|
|
if port != wunsch:
|
|
print("Port %d ist belegt, das Dashboard läuft deshalb auf %d." % (wunsch, port), flush=True)
|
|
print("Marketing-Dashboard: " + adresse, flush=True)
|
|
print("Beenden mit Ctrl+C", flush=True)
|
|
if "--oeffnen" in sys.argv:
|
|
threading.Timer(0.6, lambda: webbrowser.open(adresse)).start()
|
|
with server:
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|