Files
dejanandClaude Opus 5.5 686ee36b4f HiveGrid Marketing: Hub mit Strategie, Redaktionsplan, Dashboard, Produktionsvorgaben und Webinar
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>
2026-10-16 18:09:07 +02:00

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