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:

Artikel-Übersicht

-

{count} Artikel im Archiv · Aktualisieren · 🎨 Bild-Generator{token_status}

+

{count} Artikel im Archiv · Aktualisieren · 🎨 Bild-Generator · 🐛 Fehler{token_status}

@@ -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 + + + +
+

🐛 Fehlermeldungen

+ ← Zurück zur Artikel-Übersicht +
+
+
{total}
Gesamt
+
{offen}
Offen
+
{behoben}
Behoben
+
{verworfen}
Verworfen
+
+ + + + + + {rows} +
IDArtikelNameFehlerStatusLösungDatum
+ +""".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()