diff --git a/article_server.py b/article_server.py
index 24728d8..7254732 100644
--- a/article_server.py
+++ b/article_server.py
@@ -30,90 +30,6 @@ ACCESS_TOKEN = "br3ttsp1el-n3ws-2026"
USERNAME = "DK-Adminchef"
PASSWORD = "Mcik7%vbdhXa_"
-# ── BSN Article CSS (aus Joomla bsn-custom.css) ───────────────────────────
-# Wird in Artikel-HTML injiziert, damit bsn-Klassen auch auf dem
-# Article-Server gerendert werden (nicht nur im Joomla-Template).
-BSN_ARTICLE_CSS = """
-/* ── Basis-Typografie (Article-Server) ── */
-body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #3B3B3C; background: #fff; max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem; -webkit-font-smoothing: antialiased; }
-article { display: block; }
-article h1, article h1.title { font-size: 2rem; font-weight: 800; color: #171D4F; margin: 0 0 0.5rem 0; line-height: 1.2; letter-spacing: -0.02em; }
-article h2 { font-size: 1.4rem; font-weight: 700; color: #171D4F; margin: 2rem 0 0.75rem; }
-article h3 { font-size: 1.15rem; font-weight: 700; color: #20228A; margin: 1.5rem 0 0.5rem; }
-article p { margin: 0 0 1rem 0; }
-article a { color: #20228A; text-decoration: underline; text-underline-offset: 2px; }
-article a:hover { color: #161e6e; }
-article ul, article ol { margin: 0.5rem 0 1rem 1.5rem; }
-article li { margin-bottom: 0.4rem; }
-article img { max-width: 100%; height: auto; border-radius: 8px; margin: 1.5rem 0; }
-article hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
-
-/* ── Meta / Dachzeile ── */
-.bsn-meta, .meta, p.meta { font-size: 0.85rem; color: #999; margin-bottom: 1.5rem; }
-.bsn-dachzeile, .dachzeile, p.dachzeile { font-size: 1.05rem; color: #555; margin-bottom: 1.5rem; font-weight: 500; }
-
-/* ── Infobox ── */
-.bsn-infobox { background: #f2f2f2; border: 1px solid #d0d8e0; border-left: 4px solid #20228A; padding: 1rem 1.5rem; margin: 1.5rem 0; border-radius: 16px; font-size: 0.95rem; }
-.bsn-infobox h3 { margin-top: 0; color: #20228A; font-family: Arial, Helvetica, sans-serif; font-size: 1.15rem; }
-.bsn-infobox table { width: 100%; border-collapse: collapse; }
-.bsn-infobox td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #dce4ec; }
-.bsn-infobox tr:last-child td { border-bottom: none; }
-.bsn-infobox td:first-child { font-weight: 600; color: #171D4F; width: 160px; white-space: nowrap; }
-
-/* ── Tabellen ── */
-.bsn-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
-.bsn-table td { padding: 0.5rem 0.8rem; border-bottom: 1px solid #e8e8f0; }
-.bsn-table tr:last-child td { border-bottom: none; }
-.bsn-table td:first-child { font-weight: 600; color: #171D4F; width: 30%; }
-
-/* ── Gamecard ── */
-.bsn-gamecard { background: #f2f2f2; border: 1px solid #e0e3e8; border-left: 4px solid #20228A; padding: 1rem 1.5rem; margin: 1.2rem 0; border-radius: 16px; transition: transform 0.2s ease, box-shadow 0.2s ease; }
-.bsn-gamecard h3 { margin-top: 0; color: #171D4F; font-family: Arial, Helvetica, sans-serif; font-size: 1.15rem; }
-.bsn-gamecard .game-meta { font-size: 0.85rem; color: #888; margin-bottom: 0.5rem; }
-@media (hover: hover) { .bsn-gamecard:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(32,34,138,0.12); border-color: #c8cde0; } }
-
-/* ── Quellen / Recherche ── */
-.bsn-quellen { margin-top: 2rem; font-size: 0.9rem; }
-.bsn-quellen h3 { color: #171D4F; font-family: Arial, Helvetica, sans-serif; font-size: 1.1rem; margin-bottom: 0.5rem; }
-.bsn-quellen ul { padding-left: 1.3rem; margin: 0; }
-.bsn-quellen li { margin-bottom: 0.3rem; }
-.bsn-recherche, .recherche, p.bsn-recherche { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #ddd; font-size: 0.9rem; color: #777; }
-
-/* ── Blockquote ── */
-blockquote { border-left: 4px solid #20228A; background: #f8f9ff; padding: 0.8rem 1.2rem; margin: 1.2rem 0; color: #3B3B3C; font-style: italic; border-radius: 0 8px 8px 0; }
-blockquote p { margin-bottom: 0.25rem; }
-
-/* ── System-Readmore (Joomla) ── */
-hr#system-readmore { border: none; border-top: 2px dashed #e0e0e0; margin: 1.5rem 0; }
-
-/* ── Quellen (alte Klasse) ── */
-.quellen { margin-top: 2rem; font-size: 0.9rem; }
-.quellen h3, .quellen strong { color: #171D4F; font-size: 1.1rem; display: block; margin-bottom: 0.5rem; }
-.quellen ol, .quellen ul { padding-left: 1.3rem; margin: 0.5rem 0; }
-.quellen li { margin-bottom: 0.3rem; }
-
-@media (prefers-color-scheme: dark) {
- body { background: #1a1a2e; color: #c8c8d8; }
- article h1, article h2 { color: #e0e0f0; }
- article h3 { color: #6b6bcc; }
- .bsn-infobox, .bsn-gamecard { background: #252530; border-color: #3a3a45; }
- .bsn-infobox td, .bsn-table td { border-bottom-color: #3a3a45; }
- .bsn-infobox td:first-child, .bsn-table td:first-child { color: #a0a0c0; }
- .bsn-infobox h3 { color: #6b6bcc; }
- .bsn-gamecard h3 { color: #c8c8d8; }
- .bsn-gamecard .game-meta { color: #888; }
- .bsn-quellen h3, .quellen h3, .quellen strong { color: #c8c8d8; }
- .bsn-quellen a, .quellen a { color: #6b6bcc; }
- .bsn-meta, .meta, p.meta { color: #777; }
- .bsn-recherche, .recherche, p.bsn-recherche { color: #888; border-top-color: #3a3a45; }
- .bsn-dachzeile, .dachzeile, p.dachzeile { color: #999; border-bottom-color: #3a3a45; }
- .bsn-gamecard:hover { box-shadow: 0 6px 20px rgba(100,100,200,0.15); border-color: #505068; }
- blockquote { background: #252530; border-left-color: #6b6bcc; color: #c0c0d0; }
- hr#system-readmore { border-top-color: #3a3a45; }
- hr { border-top-color: #3a3a45; }
-}
-"""
-
# ── Joomla API Configuration ──────────────────────────────────────────────
JOOMLA_API_URL = "https://www.brettspiel-news.de/api/index.php/v1/content/articles"
# JOOMLA_TOKEN is read from environment or from a token file
@@ -224,10 +140,7 @@ def _gemini_generate_image(
body = json.dumps({
"contents": [{"parts": parts}],
"generationConfig": {
- "responseModalities": ["image", "text"],
- "imageConfig": {
- "aspectRatio": aspect_ratio
- }
+ "responseModalities": ["image", "text"]
}
}).encode()
@@ -275,8 +188,8 @@ SKIP_PATTERNS = [
r'^envyborn', r'^brettspiel_news_newsletter', r'^boardgamewire',
r'^ddg_', r'^blocked', r'^error', r'^just.a.moment',
r'^duckduckgo', r'^update.regarding',
- r'kennerspiele_deals', r'kombi_deals', r'top_deals',
- r'kinderspiele_deals', r'amazon_primeday',
+ r'deals', r'kennerspiele', r'kombi_deals', r'top_deals',
+ r'kinderspiele_deals', r'amazon_deals', r'amazon_primeday',
]
@@ -335,9 +248,6 @@ def extract_meta(filepath: str) -> dict | None:
title = title_match.group(1)
date_match = re.search(r'
[^<]*?(\d{1,2}\.\s*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*\d{4})', content)
- if not date_match:
- # Fallback: HTML-Kommentar (Joomla-safe für Cronjob)
- date_match = re.search(r'', content)
if not date_match:
return None
date_str = date_match.group(1)
@@ -618,7 +528,7 @@ def build_index(articles: list[dict]) -> str:
@@ -1010,6 +920,12 @@ h1{color:#c0392b;}p{color:#888;}
self.wfile.write(html.encode("utf-8"))
elif path == "/nanobanana/articles":
self._handle_nanobanana_articles()
+ elif path == "/fehler":
+ html = self._build_fehler_page()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(html.encode("utf-8"))
elif path.startswith("/media/"):
rel = path[len("/"):]
filepath = os.path.normpath(os.path.join(WORKSPACE, rel))
@@ -1033,17 +949,6 @@ h1{color:#c0392b;}p{color:#888;}
if os.path.isfile(filepath) and filepath.startswith(WORKSPACE):
with open(filepath, "rb") as f:
data = f.read()
- if filepath.endswith(".html"):
- # BSN-CSS in Artikel injizieren (vor )
- html_str = data.decode("utf-8", errors="replace")
- css_block = f""
- if "" in html_str:
- html_str = html_str.replace("", f"{css_block}\n", 1)
- elif "\n{css_block}{html_str}"
- data = html_str.encode("utf-8")
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
if filepath.endswith(".html"):
@@ -1098,6 +1003,12 @@ h1{color:#c0392b;}p{color:#888;}
if not self._check_token():
return
self._handle_nanobanana_assign()
+ elif path == "/fehler":
+ html = self._build_fehler_page()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(html.encode("utf-8"))
elif path == "/delete":
if not self._check_auth():
return
@@ -1150,8 +1061,7 @@ h1{color:#c0392b;}p{color:#888;}
if 'name="prompt"' in headers_raw:
prompt = content.decode("utf-8", errors="ignore")
elif 'name="aspectRatio"' in headers_raw:
- # Ignoriert — wird auf "16:9" erzwungen (Daniels Vorgabe)
- pass
+ aspect_ratio = content.decode("utf-8", errors="ignore")
elif 'name="resolution"' in headers_raw:
resolution = content.decode("utf-8", errors="ignore")
elif 'name="image"' in headers_raw and len(content) > 100:
@@ -1187,13 +1097,13 @@ h1{color:#c0392b;}p{color:#888;}
with open(filepath, "wb") as f:
f.write(img_bytes)
- # PNG → JPEG konvertieren (nur Format + DPI, KEINE Größenänderung!)
+ # Optional: PIL-Konvertierung zu JPEG (1920x1080 @ 72 DPI)
try:
from PIL import Image
img = Image.open(filepath)
img = img.convert("RGB")
+ img = img.resize((1920, 1080), Image.LANCZOS)
jpg_path = os.path.join(month_dir, base_name + ".jpg")
- # Original-Abmessungen beibehalten, nur DPI setzen
img.save(jpg_path, "JPEG", dpi=(72, 72), quality=92)
filepath = jpg_path
ext = ".jpg"
@@ -1404,6 +1314,108 @@ h1{color:#c0392b;}p{color:#888;}
save_image_map(img_map)
self._json_reply(200, {"ok": True, "image": image_path, "article": article_file})
+ def _build_fehler_page(self) -> str:
+ """Baut die Fehler-Dashboard-Seite mit Daten vom Fehler-Server."""
+ stats = {"total": 0, "offen": 0, "behoben": 0, "verworfen": 0}
+ meldungen = []
+
+ try:
+ req = urllib.request.Request("http://127.0.0.1:8888/api/stats")
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ stats = json.loads(resp.read())
+ except Exception as e:
+ stats["error"] = str(e)
+
+ try:
+ req = urllib.request.Request("http://127.0.0.1:8888/api/meldungen")
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ meldungen = json.loads(resp.read())
+ except Exception:
+ pass
+
+ status_labels = {
+ "offen": '
offen',
+ "in_bearbeitung": '
in Bearbeitung',
+ "behoben": '
behoben',
+ "verworfen": '
verworfen',
+ }
+
+ rows = ""
+ for m in meldungen[:200]:
+ text = (m.get("fehler_text") or "")[:100]
+ rows += (
+ '
| {id} | '
+ '#{article_id} | '
+ '{name} | {text} | '
+ '{status} | {loesung} | '
+ '{date} |
'
+ ).format(
+ id=m.get("id"),
+ article_id=m.get("article_id"),
+ name=m.get("melder_name", ""),
+ text=text + ("…" if len(m.get("fehler_text") or "") > 100 else ""),
+ full=(m.get("fehler_text") or "").replace('"', """),
+ status=status_labels.get(m.get("status"), m.get("status", "")),
+ loesung=m.get("loesung") or "—",
+ date=(m.get("created_at") or "")[:10],
+ )
+
+ if not rows:
+ rows = '
| Keine Meldungen |
'
+
+ return """
+
+
+
+
+
Fehlermeldungen - BSN
+
+
+
+
+
+
+
+ | ID | Artikel | Name | Fehler |
+ Status | Lösung | Datum |
+
+ {rows}
+
+
+""".format(
+ ACCESS_TOKEN=ACCESS_TOKEN,
+ total=stats.get("total", 0),
+ offen=stats.get("offen", 0),
+ behoben=stats.get("behoben", 0),
+ verworfen=stats.get("verworfen", 0),
+ rows=rows,
+ )
+
def _handle_delete(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
@@ -2111,7 +2123,13 @@ textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; bo
-
16:9 — Full HD (fix)
+
@@ -2232,7 +2250,7 @@ async function startGeneration() {{
try {{
const formData = new FormData();
formData.append('prompt', prompt);
- formData.append('aspectRatio', '16:9'); // Fix: immer 16:9 (Daniels Vorgabe)
+ formData.append('aspectRatio', document.getElementById('aspectRatio').value);
formData.append('resolution', document.getElementById('resolution').value);
if (uploadedImageData) {{
formData.append('image', uploadedImageData);
diff --git a/fehler-server/fehler.db b/fehler-server/fehler.db
new file mode 100644
index 0000000..2bf4de3
Binary files /dev/null and b/fehler-server/fehler.db differ
diff --git a/fehler-server/server.py b/fehler-server/server.py
new file mode 100644
index 0000000..a09e524
--- /dev/null
+++ b/fehler-server/server.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python3
+"""Fehler-Melde-Server fuer brettspiel-news.de
+Mini-HTTP-Server auf Port 8888. Nur das Formular + API.
+Isoliert von allen anderen Diensten. Kein Flask, nur stdlib."""
+
+import http.server
+import json
+import sqlite3
+import os
+import urllib.parse
+import urllib.request
+from datetime import datetime
+
+PORT = 8888
+HOST = "127.0.0.1"
+DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fehler.db")
+
+TOKEN = os.environ.get("FEHLER_TOKEN", "br3ttsp1el-f3hl3r-2026")
+
+TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
+
+# ═══════════════════════════════════════════════════
+# SQLite
+# ═══════════════════════════════════════════════════
+
+def init_db():
+ conn = sqlite3.connect(DB_PATH)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS fehler (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ article_id TEXT NOT NULL,
+ melder_name TEXT NOT NULL,
+ melder_email TEXT DEFAULT '',
+ fehler_text TEXT NOT NULL,
+ status TEXT DEFAULT 'offen',
+ loesung TEXT DEFAULT '',
+ created_at TEXT DEFAULT (datetime('now','localtime')),
+ updated_at TEXT DEFAULT (datetime('now','localtime'))
+ )
+ """)
+ conn.commit()
+ conn.close()
+
+def save_report(article_id, name, email, text):
+ conn = sqlite3.connect(DB_PATH)
+ conn.execute(
+ "INSERT INTO fehler (article_id, melder_name, melder_email, fehler_text) VALUES (?,?,?,?)",
+ (str(article_id), name, email, text)
+ )
+ rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.commit()
+ conn.close()
+ return rid
+
+def get_stats():
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ total = conn.execute("SELECT COUNT(*) as c FROM fehler").fetchone()["c"]
+ offen = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='offen'").fetchone()["c"]
+ behoben = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='behoben'").fetchone()["c"]
+ verworfen = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='verworfen'").fetchone()["c"]
+ conn.close()
+ return {"total": total, "offen": offen, "behoben": behoben, "verworfen": verworfen}
+
+def get_meldungen(status_filter=None, limit=100):
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ if status_filter:
+ rows = conn.execute(
+ "SELECT * FROM fehler WHERE status=? ORDER BY created_at DESC LIMIT ?",
+ (status_filter, limit)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT * FROM fehler ORDER BY created_at DESC LIMIT ?",
+ (limit,)
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+def update_status(rid, status, loesung):
+ conn = sqlite3.connect(DB_PATH)
+ conn.execute(
+ "UPDATE fehler SET status=?, loesung=?, updated_at=datetime('now','localtime') WHERE id=?",
+ (status, loesung, rid)
+ )
+ conn.commit()
+ conn.close()
+
+# ═══════════════════════════════════════════════════
+# Telegram
+# ═══════════════════════════════════════════════════
+
+def notify_basti(report_id, article_id, name, text):
+ if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
+ return
+
+ preview = text[:300] + ("…" if len(text) > 300 else "")
+ msg = (
+ f"\U0001f41b *Neue Fehlermeldung* \\#{report_id}\n\n"
+ f"\U0001f4f0 *Artikel:* \\#{article_id}\n"
+ f"\U0001f464 *Von:* {name}\n\n"
+ f"\U0001f4dd *Fehler:*\n{preview}"
+ )
+
+ try:
+ payload = json.dumps({
+ "chat_id": TELEGRAM_CHAT_ID,
+ "text": msg,
+ "parse_mode": "Markdown"
+ }).encode()
+ req = urllib.request.Request(
+ f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
+ data=payload,
+ headers={"Content-Type": "application/json"}
+ )
+ urllib.request.urlopen(req, timeout=5)
+ except Exception as e:
+ print(f"[WARN] Telegram failed: {e}")
+
+# ═══════════════════════════════════════════════════
+# HTML-Formular (inline, self-contained)
+# ═══════════════════════════════════════════════════
+
+FORM_HTML = r"""
+
+
+
+
+
Fehler melden
+
+
+
+
+
+
+"""
+
+# ═══════════════════════════════════════════════════
+# JSON-Helfer
+# ═══════════════════════════════════════════════════
+
+def json_response(handler, status, data):
+ body = json.dumps(data, ensure_ascii=False).encode()
+ handler.send_response(status)
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
+ handler.send_header("Access-Control-Allow-Origin", "*")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.end_headers()
+ handler.wfile.write(body)
+
+# ═══════════════════════════════════════════════════
+# HTTP-Handler
+# ═══════════════════════════════════════════════════
+
+class FehlerHandler(http.server.BaseHTTPRequestHandler):
+
+ def do_GET(self):
+ p = urllib.parse.urlparse(self.path)
+
+ # Formular: / oder /form
+ if p.path in ("/", "/form"):
+ html = FORM_HTML.encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(html)))
+ self.end_headers()
+ self.wfile.write(html)
+ return
+
+ # Stats (nur localhost)
+ if p.path == "/api/stats":
+ if self.client_address[0] not in ("127.0.0.1", "::1"):
+ json_response(self, 403, {"error": "forbidden"})
+ return
+ json_response(self, 200, get_stats())
+ return
+
+ # Meldungen (nur localhost)
+ if p.path == "/api/meldungen":
+ if self.client_address[0] not in ("127.0.0.1", "::1"):
+ json_response(self, 403, {"error": "forbidden"})
+ return
+ qs = urllib.parse.parse_qs(p.query)
+ status = qs.get("status", [None])[0]
+ json_response(self, 200, get_meldungen(status))
+ return
+
+ self.send_error(404)
+
+ def do_POST(self):
+ # CORS-Preflight
+ if self.path == "/report":
+ length = int(self.headers.get("Content-Length", 0))
+ if length == 0:
+ json_response(self, 400, {"error": "Leerer Request"})
+ return
+
+ body = self.rfile.read(length)
+ try:
+ data = json.loads(body)
+ except json.JSONDecodeError:
+ json_response(self, 400, {"error": "Ungültiges JSON"})
+ return
+
+ article_id = data.get("article_id", "")
+ melder_name = data.get("melder_name", "").strip()
+ melder_email = data.get("melder_email", "").strip()
+ fehler_text = data.get("fehler_text", "").strip()
+
+ if not melder_name:
+ json_response(self, 400, {"error": "Name ist erforderlich"})
+ return
+ if not fehler_text:
+ json_response(self, 400, {"error": "Fehlerbeschreibung ist erforderlich"})
+ return
+
+ rid = save_report(article_id, melder_name, melder_email, fehler_text)
+ notify_basti(rid, article_id, melder_name, fehler_text)
+
+ json_response(self, 201, {"id": rid, "message": "Fehler gemeldet \u2014 danke!"})
+ return
+
+ # Status-Update (mit Token)
+ if self.path == "/api/update":
+ length = int(self.headers.get("Content-Length", 0))
+ data = json.loads(self.rfile.read(length))
+
+ if data.get("token") != TOKEN:
+ json_response(self, 403, {"error": "forbidden"})
+ return
+
+ update_status(data["id"], data["status"], data.get("loesung", ""))
+ json_response(self, 200, {"ok": True})
+ return
+
+ self.send_error(404)
+
+ def do_OPTIONS(self):
+ self.send_response(204)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.end_headers()
+
+ def log_message(self, format, *args):
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
+
+# ═══════════════════════════════════════════════════
+# Main
+# ═══════════════════════════════════════════════════
+
+if __name__ == "__main__":
+ init_db()
+ print(f"🐛 Fehler-Server auf http://{HOST}:{PORT}")
+ print(f" Formular: http://{HOST}:{PORT}/?article_id=123")
+ print(f" Stats: http://{HOST}:{PORT}/api/stats")
+ httpd = http.server.HTTPServer((HOST, PORT), FehlerHandler)
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ print("\n👋 Bye!")
+ httpd.server_close()