#!/usr/bin/env python3 """Article Index Server — with IDs, meta extraction, image upload, and multi-threading. v2: Added 'Markierte veröffentlichen' (Joomla API publish), article-image association, and automatic VG-Wort tracking pixel injection. v3: Added NanoBanana Image Generator (/nanobanana).""" import os import re import glob import json import base64 import hashlib import socket import urllib.parse import urllib.request import io import email.parser import tempfile import subprocess import time from http.server import HTTPServer, BaseHTTPRequestHandler from datetime import datetime from socketserver import ThreadingMixIn WORKSPACE = "/home/hermes/workspace" MEDIA_DIR = os.path.join(WORKSPACE, "media") os.makedirs(MEDIA_DIR, exist_ok=True) PORT = int(os.environ.get("ARTICLE_SERVER_PORT", "8890")) ACCESS_TOKEN = "br3ttsp1el-n3ws-2026" USERNAME = "DK-Adminchef" PASSWORD = "Mcik7%vbdhXa_" REVIEW_CONFIG_PATH = os.path.join(WORKSPACE, "review_config.json") REVIEW_CONFIG = {} if os.path.exists(REVIEW_CONFIG_PATH): with open(REVIEW_CONFIG_PATH, "r") as f: REVIEW_CONFIG = json.load(f) # ── 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 JOOMLA_TOKEN = os.environ.get("JOOMLA_API_TOKEN", "") if not JOOMLA_TOKEN: token_paths = [ "/tmp/alicia_skills/KEYS.txt", "/home/hermes/.hermes/joomla_token.txt", ] for tp in token_paths: if os.path.exists(tp): with open(tp, "r") as f: for line in f: line = line.strip() if line and not line.startswith("#"): JOOMLA_TOKEN = line break if JOOMLA_TOKEN: break # ── VG-Wort Configuration ───────────────────────────────────────────────── VG_WORT_COUNTER_ID = os.environ.get("VG_WORT_COUNTER_ID", "") VG_WORT_PIXEL_HTML = ( f'' ) if VG_WORT_COUNTER_ID else "" # ── Article-Image Mapping ───────────────────────────────────────────────── IMAGE_MAP_FILE = os.path.join(WORKSPACE, "article_images.json") MONTHS = { "Januar": 1, "Februar": 2, "März": 3, "April": 4, "Mai": 5, "Juni": 6, "Juli": 7, "August": 8, "September": 9, "Oktober": 10, "November": 11, "Dezember": 12 } # ── Gemini Flash Image API Configuration ──────────────────────────────────── GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image" GEMINI_API_URL = ( "https://generativelanguage.googleapis.com/v1beta/" "models/" + GEMINI_IMAGE_MODEL + ":generateContent" ) GEMINI_KEY_SECRET = "bsn/google-imagen-key" def _get_gemini_key() -> str: """Google Gemini API-Key aus bsn-secrets lesen.""" try: r = subprocess.run( ["/home/hermes/bin/bsn-secrets", "get", GEMINI_KEY_SECRET], capture_output=True, text=True, timeout=5 ) return r.stdout.strip() except Exception: return "" def _detect_image_mime(image_bytes: bytes) -> str: """MIME-Type anhand Magic-Bytes erkennen.""" if len(image_bytes) < 8: return "image/jpeg" # Fallback if image_bytes[:4] == b"\x89PNG": return "image/png" if image_bytes[:2] == b"\xff\xd8": return "image/jpeg" if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP": return "image/webp" if image_bytes[:4] in (b"ftyp", b"heic", b"heix", b"hevc", b"hevx"): return "image/heic" return "image/jpeg" # Fallback def _gemini_generate_image( prompt: str, image_base64: str | None, aspect_ratio: str, resolution: str ) -> dict: """Gemini Flash Image aufrufen — Text-to-Image und Image-to-Image. Args: prompt: Text-Prompt. image_base64: Optionales Referenzbild als Base64 (Image-to-Image). aspect_ratio: Seitenverhältnis (z.B. "16:9"). resolution: Behält Kompatibilität (von Gemini ignoriert). Returns: dict mit "image_base64" (PNG) oder "error". """ import base64 as b64 key = _get_gemini_key() if not key: return {"error": "Kein Gemini API-Key"} parts = [{"text": f"{prompt}\n\nAspect ratio: {aspect_ratio}."}] if image_base64: # MIME-Type aus den tatsächlichen Bilddaten erkennen raw_bytes = b64.b64decode(image_base64) mime_type = _detect_image_mime(raw_bytes) parts.append({ "inlineData": { "mimeType": mime_type, "data": image_base64 } }) body = json.dumps({ "contents": [{"parts": parts}], "generationConfig": { "responseModalities": ["image", "text"] } }).encode() req = urllib.request.Request(GEMINI_API_URL, data=body, method="POST") req.add_header("x-goog-api-key", key) req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=45) as resp: result = json.loads(resp.read()) candidates = result.get("candidates") or [] for cand in candidates: for part in cand.get("content", {}).get("parts", []): inline = part.get("inlineData") if inline and inline.get("data"): return {"image_base64": inline["data"]} return {"error": "Kein Bild in Gemini-Antwort"} except urllib.error.HTTPError as e: return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"} def _get_unpublished_articles() -> list[dict]: """Liste aller unveröffentlichten Artikel (ohne joomla-id).""" articles = [] for f in sorted(glob.glob(os.path.join(WORKSPACE, "*.html")), key=os.path.getmtime, reverse=True): name = os.path.basename(f) if any(re.search(p, name) for p in SKIP_PATTERNS): continue try: with open(f, "r") as fh: content = fh.read(5000) has_joomla_id = '(.*?)', content) title = title_match.group(1).strip() if title_match else name articles.append({"filename": name, "title": title}) except Exception: pass return articles[:50] # Max 50 SKIP_PATTERNS = [ r'^ddg\\d*\\.html', r'^google', r'^bgg_(search|thread|game|price)', r'^bk_', r'^backerkit', r'^brave_search', r'^fb_post', r'^reddit', r'^rp_mg', r'^rp_gladbach', r'^spiegel', r'^sportschau', r'^transfermarkt', r'^tm_', r'^kicker', r'^brettspielbox', r'^bsg_', r'^bbb_', r'^envyborn', r'^brettspiel_news_newsletter', r'^boardgamewire', r'^ddg_', r'^blocked', r'^error', r'^just.a.moment', r'^duckduckgo', r'^update.regarding', r'kinderspiele_deals', r'amazon_deals', r'amazon_primeday', ] # ── Image Mapping Helpers ───────────────────────────────────────────────── def get_image_map() -> dict[str, list[str]]: """Load article→image mapping from JSON.""" if os.path.exists(IMAGE_MAP_FILE): with open(IMAGE_MAP_FILE, "r") as f: return json.load(f) return {} def save_image_map(mapping: dict[str, list[str]]) -> None: """Save article→image mapping to JSON.""" with open(IMAGE_MAP_FILE, "w") as f: json.dump(mapping, f, indent=2) def find_orphan_images() -> list[str]: """Find images in workspace that are NOT in article_images.json.""" import glob as _glob existing = set() for paths in get_image_map().values(): for p in paths: existing.add(os.path.abspath(p)) orphans = [] for ext in ("*.jpg", "*.jpeg", "*.png", "*.webp", "*.gif"): for p in _glob.iglob(os.path.join(WORKSPACE, "**", ext), recursive=True): # Skip bsn-chatbot, media dir (already managed), __pycache__, .git, venv if "/bsn-chatbot/" in p or "/__pycache__/" in p or "/.git/" in p or "/venv/" in p: continue if "/image_cache/" in p: continue abspath = os.path.abspath(p) if abspath not in existing: orphans.append(abspath) return sorted(orphans) def is_article(filename: str) -> bool: for pattern in SKIP_PATTERNS: if re.search(pattern, filename, re.IGNORECASE): return False return filename.endswith(".html") def extract_meta(filepath: str) -> dict | None: """Extract title, date, meta description, keywords, and Joomla ID from an article.""" with open(filepath, "r", encoding="utf-8") as f: content = f.read() title_match = re.search(r'([^<]+)', content) if not title_match: return 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: return None date_str = date_match.group(1) try: parts = date_str.replace(".", "").split() day, month_name, year = int(parts[0]), parts[1], int(parts[2]) month = MONTHS.get(month_name, 1) sort_key = f"{year:04d}-{month:02d}-{day:02d}" display_date = f"{day}.{month_name} {year}" except (ValueError, IndexError): return None meta_desc = "" desc_match = re.search(r' list[dict]: articles = [] for f in glob.glob(os.path.join(WORKSPACE, "*.html")): filename = os.path.basename(f) if filename == "index.html": continue if not is_article(filename): continue meta = extract_meta(f) if meta: articles.append(meta) articles.sort(key=lambda a: (a["date_sort"], a["filename"]), reverse=True) for i, a in enumerate(articles, 1): a["local_id"] = i return articles def build_index(articles: list[dict]) -> str: by_date_sort: dict[str, list[dict]] = {} for a in articles: by_date_sort.setdefault(a["date_sort"], []).append(a) month_names = {"01":"Januar","02":"Februar","03":"März","04":"April","05":"Mai","06":"Juni", "07":"Juli","08":"August","09":"September","10":"Oktober","11":"November","12":"Dezember"} # Read image map for badge display img_map = get_image_map() items_html = "" for sort_key in sorted(by_date_sort.keys(), reverse=True): day_articles = by_date_sort[sort_key] try: y, m, d = sort_key.split("-") nice_date = f"{int(d)}. {month_names.get(m, m)} {y}" except ValueError: nice_date = day_articles[0]["date_display"] items_html += f'

\n' items_html += f'
{nice_date}{len(day_articles)}
\n' for a in day_articles: jid = a.get("joomla_id", "") local_id = a.get("local_id", "") id_str = f"#{jid}" if jid else f"#{local_id}" id_class = "id-joomla" if jid else "id-local" meta_desc = a.get("meta_desc", "") meta_tags = a.get("meta_tags", "") meta_preview = "" if meta_desc or meta_tags: meta_parts = [] if meta_desc: meta_parts.append(meta_desc[:120] + ("…" if len(meta_desc) > 120 else "")) if meta_tags: meta_parts.append(f'🏷 {meta_tags[:60]}{"…" if len(meta_tags) > 60 else ""}') meta_preview = '
' + " · ".join(meta_parts) + '
' # Image badge image_badge = "" image_ok = False if a["filename"] in img_map and img_map[a["filename"]]: img_count = len(img_map[a["filename"]]) image_badge = f'🖼{img_count}' image_ok = True # Readiness indicators meta_desc_ok = bool(meta_desc) meta_tags_ok = bool(meta_tags) all_ready = meta_desc_ok and meta_tags_ok and image_ok def ready_dot(ok: bool, title: str) -> str: cls = "ready-ok" if ok else "ready-missing" symbol = "●" if ok else "○" return f'{symbol}' readiness = ( ready_dot(meta_desc_ok, "Meta-Description") + ready_dot(meta_tags_ok, "Keywords") + ready_dot(image_ok, "Bild") ) items_html += f"""
{id_str} {readiness} {image_badge}
\n""" items_html += "
\n" count = len(articles) has_token = bool(JOOMLA_TOKEN) token_status = "🟢 API-Token vorhanden" if has_token else "🔴 Kein API-Token — Veröffentlichung deaktiviert" publish_disabled = "" if has_token else "disabled" publish_title = "" if has_token else ' title="Kein Joomla API-Token konfiguriert (JOOMLA_API_TOKEN env oder /tmp/alicia_skills/KEYS.txt)"' return f""" Artikel-Übersicht · brettspiel-news.de

Artikel-Übersicht

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

📂 Rubriken (Kategorien) — zum Nachschlagen · 📌 Öffentliche = 3 Tage Hauptartikel
61 Korrektur 🔒 Redaktion
9 Nachrichten
└ 64 Angebot
└ 65 Crowdfunding
└ 66 Branche
└ 67 Neuerscheinungen
└ 68 Ankündigungen
└ 69 Zubehör
8 Brettspieltest
11 Magazin
10 Video
55 Podcast
0 markiert

📷 Bild hochladen

Ziehe Bilder hierher oder klicke zum Auswählen.

Das Bild wird automatisch dem ersten markierten Artikel zugeordnet.
Oder nutze das 📷 Icon direkt neben dem gewünschten Artikel.

{items_html}
""" class ThreadingArticleServer(ThreadingMixIn, HTTPServer): """HTTPServer with threading support — handles multiple requests concurrently.""" daemon_threads = True allow_reuse_address = True class ArticleHandler(BaseHTTPRequestHandler): timeout = 30 def _check_auth(self) -> bool: auth_header = self.headers.get("Authorization", "") if not auth_header.startswith("Basic "): self._send_auth_required() return False try: creds = base64.b64decode(auth_header[6:]).decode("utf-8") username, password = creds.split(":", 1) except Exception: self._send_auth_required() return False if username == USERNAME and password == PASSWORD: return True self._send_auth_required() return False def _send_auth_required(self): self.send_response(401) self.send_header("WWW-Authenticate", 'Basic realm="Artikel-Archiv"') self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() html = """ Zugriff verweigert

Zugriff verweigert

Bitte einloggen, um die Artikel-Übersicht zu sehen.

""" self.wfile.write(html.encode("utf-8")) def _check_token(self) -> bool: parsed = urllib.parse.urlparse(self.path) params = urllib.parse.parse_qs(parsed.query) key = params.get("key", [None])[0] if key == ACCESS_TOKEN: return True self.send_response(403) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() html = "Zugriff verweigert

Zugriff verweigert

Diese Seite ist nicht öffentlich.

" self.wfile.write(html.encode("utf-8")) return False def do_GET(self): try: self._do_GET_impl() except (ConnectionResetError, BrokenPipeError, socket.timeout): pass except Exception as e: try: self.send_response(500) self.end_headers() self.wfile.write(b"Internal Server Error") except Exception: pass print(f"[ERROR] {self.client_address} - {e}", flush=True) def _do_GET_impl(self): if not self._check_token(): return parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/" or path == "/index.html": articles = get_articles() html = build_index(articles) 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 == "/nanobanana": html = self._build_nanobanana_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 == "/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 == "/review-builder": html = self._build_review_builder_form() 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)) if os.path.isfile(filepath) and filepath.startswith(WORKSPACE): with open(filepath, "rb") as f: data = f.read() self.send_response(200) self.send_header("Content-Length", str(len(data))) ext = os.path.splitext(filepath)[1].lower() mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml"} self.send_header("Content-Type", mime_map.get(ext, "application/octet-stream")) self.send_header("Cache-Control", "public, max-age=86400") self.end_headers() self.wfile.write(data) else: self.send_response(404) self.end_headers() elif path.startswith("/"): filepath = os.path.join(WORKSPACE, path.lstrip("/")) if os.path.isfile(filepath) and filepath.startswith(WORKSPACE): with open(filepath, "rb") as f: data = f.read() self.send_response(200) self.send_header("Content-Length", str(len(data))) if filepath.endswith(".html"): self.send_header("Content-Type", "text/html; charset=utf-8") elif filepath.endswith(".css"): self.send_header("Content-Type", "text/css") elif filepath.endswith(".js"): self.send_header("Content-Type", "application/javascript") else: self.send_header("Content-Type", "application/octet-stream") self.end_headers() self.wfile.write(data) else: self.send_response(404) self.end_headers() self.wfile.write(b"Not found") else: self.send_response(404) self.end_headers() def do_POST(self): try: self._do_POST_impl() except Exception as e: print(f"[POST ERROR] {e}", flush=True) try: self._json_reply(500, {"ok": False, "error": str(e)}) except Exception: pass def _do_POST_impl(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/upload": if not self._check_token(): return self._handle_upload() elif path == "/assign-image": if not self._check_token(): return self._handle_assign_image() elif path == "/nanobanana/generate": if not self._check_token(): return self._handle_image_generate() elif path == "/nanobanana/articles": if not self._check_token(): return self._handle_nanobanana_articles() elif path == "/nanobanana/assign": if not self._check_token(): return self._handle_nanobanana_assign() elif path == "/review-builder/build": if not self._check_token(): return self._handle_review_builder_build() 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 self._handle_delete() elif path == "/publish": if not self._check_auth(): return self._handle_publish() else: self.send_response(404) self.end_headers() # ═══════════════════════════════════════════════════════════════════ # Imagen Handler (Google Imagen — synchron, kein Polling) # ═══════════════════════════════════════════════════════════════════ def _handle_image_generate(self): """Bildgenerierung via Google Imagen (synchron, Base64→JPEG).""" import base64 as b64 content_type = self.headers.get("Content-Type", "") if "multipart/form-data" not in content_type: self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"}) return boundary = content_type.split("boundary=")[-1].strip() if not boundary: self._json_reply(400, {"ok": False, "error": "Kein Boundary"}) return length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) # Multipart manuell parsen (Python 3.13 hat kein cgi) parts = raw.split(b"--" + boundary.encode()) prompt = "" aspect_ratio = "16:9" resolution = "1K" image_base64 = None for part in parts: if b"Content-Disposition" not in part: continue headers_end = part.find(b"\r\n\r\n") if headers_end == -1: continue headers_raw = part[:headers_end].decode("utf-8", errors="ignore") content = part[headers_end + 4:].rstrip(b"\r\n") if 'name="prompt"' in headers_raw: prompt = content.decode("utf-8", errors="ignore") elif 'name="aspectRatio"' in headers_raw: 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: # Referenzbild als Base64 für Image-to-Image image_base64 = b64.b64encode(content).decode() if not prompt: self._json_reply(400, {"ok": False, "error": "Prompt fehlt"}) return # Gemini Flash Image aufrufen (synchron!) result = _gemini_generate_image(prompt, image_base64, aspect_ratio, resolution) if "error" in result: self._json_reply(500, {"ok": False, "error": result["error"]}) return # Base64-Bild (PNG) dekodieren b64_data = result.get("image_base64", "") if not b64_data: self._json_reply(500, {"ok": False, "error": "Leeres Bild von Gemini"}) return img_bytes = b64.b64decode(b64_data) # Im media-Ordner speichern now = datetime.now() month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m")) os.makedirs(month_dir, exist_ok=True) base_name = f"gemini_{now.strftime('%H%M%S')}" ext = ".png" # Gemini liefert PNG filepath = os.path.join(month_dir, base_name + ext) with open(filepath, "wb") as f: f.write(img_bytes) # 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") img.save(jpg_path, "JPEG", dpi=(72, 72), quality=92) filepath = jpg_path ext = ".jpg" except Exception: pass # PNG behalten wenn PIL fehlschlägt img_url = f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{base_name}{ext}?key={ACCESS_TOKEN}" self._json_reply(200, { "ok": True, "imageUrl": img_url, "filepath": filepath, }) def _handle_nanobanana_articles(self): """Liste unveröffentlichter Artikel als JSON.""" articles = _get_unpublished_articles() self._json_reply(200, {"ok": True, "articles": articles}) def _handle_nanobanana_assign(self): """Bild einem Artikel zuordnen.""" content_type = self.headers.get("Content-Type", "") if "application/json" not in content_type: self._json_reply(400, {"ok": False, "error": "Expected JSON"}) return length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) try: data = json.loads(raw) except json.JSONDecodeError: self._json_reply(400, {"ok": False, "error": "Invalid JSON"}) return article = data.get("article", "") image_url = data.get("imageUrl", "") if not article or not image_url: self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"}) return # Bild herunterladen oder lokal lesen try: if image_url.startswith("/media/"): # Lokales Bild — direkt von Platte lesen local_path = os.path.join(WORKSPACE, image_url.lstrip("/")) if not os.path.isfile(local_path): self._json_reply(500, {"ok": False, "error": f"Lokales Bild nicht gefunden: {local_path}"}) return with open(local_path, "rb") as lf: img_data = lf.read() else: req = urllib.request.Request(image_url) req.add_header("User-Agent", "Mozilla/5.0") with urllib.request.urlopen(req, timeout=30) as resp: img_data = resp.read() except Exception as e: self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"}) return # In media/YYYY/MM/ speichern now = datetime.now() month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m")) os.makedirs(month_dir, exist_ok=True) ts = now.strftime("%H%M%S") fname = f"nanobanana_{ts}.jpg" fpath = os.path.join(month_dir, fname) with open(fpath, "wb") as f: f.write(img_data) # article_images.json updaten img_map = get_image_map() if article not in img_map: img_map[article] = [] img_map[article].append(fpath) save_image_map(img_map) self._json_reply(200, {"ok": True, "path": fpath, "filename": fname}) def _build_nanobanana_page(self) -> str: """Baut die NanoBanana Editor HTML-Seite.""" articles = _get_unpublished_articles() article_options = "" for a in articles[:30]: article_options += f'\n' return NANOBANANA_HTML.format( key=ACCESS_TOKEN, article_options=article_options, ) # ═══════════════════════════════════════════════════════════════════ # End NanoBanana Handler # ═══════════════════════════════════════════════════════════════════ def _handle_upload(self): """Handle multipart file upload — now accepts 'article' field for association.""" content_type = self.headers.get("Content-Type", "") if "multipart/form-data" not in content_type: self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"}) return content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length) boundary = None for part in content_type.split(";"): part = part.strip() if part.startswith("boundary="): boundary = part[9:].strip('"').strip("'").strip() break if not boundary: self._json_reply(400, {"ok": False, "error": "Kein Boundary in Content-Type"}) return b_boundary = boundary.encode() parts = body.split(b"--" + b_boundary) file_data = None filename = None article_file = None for part in parts: if b"Content-Disposition" not in part: continue header_end = part.find(b"\r\n\r\n") if header_end == -1: continue headers = part[:header_end].decode("utf-8", errors="replace") if b"filename=" in part: fn_match = re.search(r'filename="([^"]*)"', headers) if fn_match: filename = os.path.basename(fn_match.group(1)) file_data = part[header_end + 4:] if file_data: file_data = file_data.rstrip(b"\r\n").rstrip(b"-") elif 'name="article"' in headers: article_file = part[header_end + 4:].decode("utf-8", errors="replace").strip() if article_file: article_file = article_file.rstrip("\r\n").rstrip("-") if not file_data or not filename: self._json_reply(400, {"ok": False, "error": "Keine Datei gefunden"}) return ext = os.path.splitext(filename)[1].lower() if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"): self._json_reply(400, {"ok": False, "error": f"Nicht erlaubtes Format: {ext}. Erlaubt: jpg, png, webp, gif, svg"}) return now = datetime.now() month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m")) os.makedirs(month_dir, exist_ok=True) base, ext = os.path.splitext(filename) safe_base = re.sub(r'[^a-zA-Z0-9_-]', '_', base) unique_name = f"{safe_base}_{now.strftime('%H%M%S')}{ext}" filepath = os.path.join(month_dir, unique_name) with open(filepath, "wb") as f: f.write(file_data) # ── Article-Image Association ────────────────────────────────────── association_msg = "" if article_file: img_map = get_image_map() if article_file not in img_map: img_map[article_file] = [] img_map[article_file].append(filepath) save_image_map(img_map) association_msg = f" → zugeordnet zu: {article_file}" rel_path = os.path.relpath(filepath, WORKSPACE) self._json_reply(200, { "ok": True, "filename": unique_name, "path": filepath, "url": f"MEDIA:{filepath}", "html_src": f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{unique_name}", "article": article_file, }) def _handle_assign_image(self): """Associate an existing image file with an article.""" length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) try: data = json.loads(body) image_path = data.get("image", "") article_file = data.get("article", "") except json.JSONDecodeError: self._json_reply(400, {"ok": False, "error": "Invalid JSON"}) return if not image_path or not os.path.exists(image_path): self._json_reply(400, {"ok": False, "error": f"Bild nicht gefunden: {image_path}"}) return if not article_file: self._json_reply(400, {"ok": False, "error": "Kein Artikel angegeben"}) return img_map = get_image_map() if article_file not in img_map: img_map[article_file] = [] if image_path not in img_map[article_file]: img_map[article_file].append(image_path) 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}
IDArtikelNameFehler StatusLö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) try: data = json.loads(body) files = data.get("files", []) except json.JSONDecodeError: self._json_reply(400, {"ok": False, "error": "Invalid JSON"}) return deleted = 0 errors = [] for fname in files: fname = os.path.basename(fname) if not fname.endswith(".html"): errors.append(f"{fname}: not an HTML file") continue filepath = os.path.join(WORKSPACE, fname) if not os.path.exists(filepath): errors.append(f"{fname}: not found") continue try: os.remove(filepath) deleted += 1 except OSError as e: errors.append(f"{fname}: {e}") self._json_reply(200, {"ok": True, "deleted": deleted, "errors": errors}) def _handle_publish(self): """Publish selected articles via Joomla API with VG-Wort pixel injection and automatic cleanup of associated images. SAFETY GATE (Daniel 18.06.): Ohne 'live': true im Request-Payload gehen ALLE Artikel auf Korrektur (catid=61, state=0) — niemals live. """ if not JOOMLA_TOKEN: self._json_reply(400, { "ok": False, "error": "Kein Joomla API-Token konfiguriert. " "Bitte JOOMLA_API_TOKEN Umgebungsvariable setzen " "oder /tmp/alicia_skills/KEYS.txt anlegen." }) return length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) try: data = json.loads(body) files = data.get("files", []) allow_live = data.get("live", False) # SAFETY GATE: Nur wenn explizit true rubric = data.get("rubric", 0) # 0 = nicht gesetzt except json.JSONDecodeError: self._json_reply(400, {"ok": False, "error": "Invalid JSON"}) return published = 0 published_ids = [] images_deleted_total = 0 errors = [] img_map = get_image_map() # Safety log mode = "🔴 LIVE (state=1)" if allow_live else "🟡 KORREKTUR (state=0, Redaktion-only)" print(f"[PUBLISH] {len(files)} Artikel, Modus: {mode}", flush=True) # Try importing requests try: import requests except ImportError: self._json_reply(500, { "ok": False, "error": "Python 'requests' Modul nicht installiert. " "Bitte ausführen: /home/hermes/venv/bin/pip install requests" }) return for fname in files: fname = os.path.basename(fname) if not fname.endswith(".html"): errors.append(f"{fname}: not an HTML file") continue filepath = os.path.join(WORKSPACE, fname) if not os.path.exists(filepath): errors.append(f"{fname}: not found") continue try: with open(filepath, "r", encoding="utf-8") as f: html_content = f.read() # ── Extract article metadata ─────────────────────────────── title_match = re.search(r'([^<]+)', html_content) if not title_match: errors.append(f"{fname}: kein gefunden") continue title = title_match.group(1) # ── Readiness-Warnung (soft, blockiert nicht) ─────────── readiness_warnings = [] meta_desc_match = re.search( r'<meta\s+name="description"\s+content="([^"]+)"', html_content, re.IGNORECASE ) if not (meta_desc_match and meta_desc_match.group(1).strip()): readiness_warnings.append("keine Meta-Description") meta_key_match = re.search( r'<meta\s+name="keywords"\s+content="([^"]+)"', html_content, re.IGNORECASE ) if not (meta_key_match and meta_key_match.group(1).strip()): readiness_warnings.append("keine Meta-Keywords") if fname not in img_map or not img_map[fname]: readiness_warnings.append("kein Bild zugewiesen") if readiness_warnings: # Soft warning — Publizieren trotzdem möglich errors.append(f"{fname}: ⚠️ {', '.join(readiness_warnings)} (trotzdem publiziert)") # Extract the article body (content inside <article> or <body>) body_match = re.search(r'<article[^>]*>(.*?)</article>', html_content, re.DOTALL) if not body_match: # Fallback: use everything between </header> and <footer> or end body_match = re.search(r'(?:</header>|</head>)(.*?)(?:<footer|<div class="quellen"|</body)', html_content, re.DOTALL) article_body = body_match.group(1) if body_match else html_content # ── Strip internal markers from article body ────────────── # These are editorial metadata, not part of the published article. # Applied AFTER extraction but BEFORE read-more split. article_body = re.sub(r'<h1[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL) article_body = re.sub(r'<p\s+class="meta"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL) article_body = re.sub(r'<p\s+class="qc"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL) # ── Fallback: strip any remaining h1 or meta (edge cases) ── article_body = re.sub(r'<h1\b[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL | re.IGNORECASE) article_body = re.sub(r'<p\b[^>]*\bclass\s*=\s*"[^"]*\bmeta\b[^"]*"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL | re.IGNORECASE) # ── Umbruch vor Überschriften (Daniel 19.06.: bessere Lesbarkeit) ── article_body = re.sub(r'(</(?:p|/div|/blockquote)>)\s*(<h[234])', r'\1\n<br>\n\2', article_body) # ── Remove <br> after read-more (muss NACH Umbruch-Insertion laufen!) ── article_body = re.sub( r'(<hr\s+id="system-readmore"\s*/?>)\s*<br\s*/?>\s*', r'\1\n', article_body, flags=re.IGNORECASE ) # ── Strip trailing whitespace/newlines before closing </article> ── article_body = re.sub(r'\s*</article>', '</article>', article_body) # Extract Joomla ID if present (for updates) jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+|XXXXX)"', html_content, re.IGNORECASE) joomla_id = jid_match.group(1) if jid_match else None # ── Kategorie: Rubrik vom Dropdown oder meta ────────── catid = 61 # Default: Korrektur if allow_live and rubric > 0: catid = rubric elif allow_live: cat_match = re.search(r'<meta\s+name="category-id"\s+content="(\d+)"', html_content, re.IGNORECASE) if cat_match: catid = int(cat_match.group(1)) # Korrektur (61) → state=0 (unpublished/Redaktion-only) # Alles andere → state=1 (published/public) – NUR bei allow_live state = 0 if catid == 61 else 1 access = 6 if catid == 61 else 1 # 6=Redaktion, 1=Public # ── Featured (Hauptartikel) ───────────────────────── featured = 0 featured_up = None featured_down = None if allow_live and catid != 61: # Default: 3 Tage featured, via <meta name="featured-days"> anpassbar featured_days = 3 fd_match = re.search( r'<meta\s+name="featured-days"\s+content="(\d+)"', html_content, re.IGNORECASE ) if fd_match: featured_days = int(fd_match.group(1)) from datetime import datetime as dt, timedelta now = dt.now() featured = 1 featured_up = now.strftime("%Y-%m-%d %H:%M:%S") featured_down = (now + timedelta(days=featured_days)).strftime("%Y-%m-%d %H:%M:%S") # Generate alias from title alias = re.sub(r'[^a-z0-9]+', '-', title.lower().strip())[:100] # ── VG-Wort Zählmarke IMMER einbauen (auch bei Korrektur) ── # Daniel 19.06.: Pixel muss bei JEDEM Publish rein, weil er # später manuell live stellt und sonst kein Geld bekommt. vgwort_pixel_html = "" try: import subprocess result = subprocess.run( ["python3", os.path.expanduser("~/.hermes/vgwort_pool.py"), "get"], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: pixel_data = json.loads(result.stdout) if pixel_data.get("ok"): vgwort_pixel_html = pixel_data.get("pixel_html", "") print(f"[VG-Wort] Zählmarke {pixel_data.get('public_id')} eingebaut, " f"Pool: {pixel_data.get('pool_remaining')} übrig", flush=True) else: print(f"[VG-Wort] FEHLER: {pixel_data.get('error', 'unbekannt')}", flush=True) else: print(f"[VG-Wort] Pool-Script fehlgeschlagen: {result.stderr}", flush=True) except Exception as e: print(f"[VG-Wort] Ausnahme: {e}", flush=True) if vgwort_pixel_html: if '</article>' in article_body: article_body = article_body.replace('</article>', f'{vgwort_pixel_html}\n</article>') else: article_body = article_body.rstrip() + f'\n{vgwort_pixel_html}\n' # ── Joomla API call ───────────────────────────────────────── headers = { "Accept": "application/vnd.api+json", "Content-Type": "application/json", "X-Joomla-Token": JOOMLA_TOKEN, } # Joomla 5/6 PATCH-Fix: articletext wird ignoriert, muss in introtext + fulltext aufgeteilt werden introtext = "" fulltext = article_body.strip() sr_match = re.search(r'<hr\s+id="system-readmore"\s*/?>', article_body, re.IGNORECASE) if sr_match: split_idx = sr_match.start() introtext = article_body[:split_idx].strip() fulltext = article_body[split_idx:].strip() # Remove the <hr id="system-readmore" /> from fulltext start # (Joomla inserts its own read-more between introtext and fulltext) fulltext = re.sub(r'^<hr\s+id="system-readmore"\s*/?>\s*', '', fulltext) # ── CSS aus <head> in fulltext injizieren ────────────────── # Problem: <style> liegt außerhalb <article>, wird beim # Body-Extract nicht mitgenommen → Tabellen-Styling fehlt. style_match = re.search(r'<style>(.*?)</style>', html_content, re.DOTALL) if style_match: style_css = style_match.group(1).strip() fulltext = f'<style>\n{style_css}\n</style>\n\n{fulltext}' # ── target="_blank" für externe Links erzwingen ────── # Daniel 24.06.: Alle externen Links müssen _blank sein, # damit brettspiel-news.de nicht verlassen wird. def add_target_blank(m): href = m.group(1) rest = m.group(2) if 'brettspiel-news.de' in href: return m.group(0) # interne Links unverändert if 'target=' in rest: return m.group(0) # hat bereits target return f'<a href="{href}" target="_blank"{rest}>' fulltext = re.sub(r'<a\s+href="([^"]+)"([^>]*)>', add_target_blank, fulltext) introtext = re.sub(r'<a\s+href="([^"]+)"([^>]*)>', add_target_blank, introtext) article_payload = { "title": title, "alias": alias, "introtext": introtext, "fulltext": fulltext, "catid": catid, "language": "*", "state": state, "access": access, "featured": featured, "created_by": 560, # Daniel Krause } # ── Meta-Description & Keywords ───────────────────────── if meta_desc_match and meta_desc_match.group(1).strip(): article_payload["metadesc"] = meta_desc_match.group(1).strip() if meta_key_match and meta_key_match.group(1).strip(): article_payload["metakey"] = meta_key_match.group(1).strip() # ── Image upload to Joomla (Joomla 5/6: Base64 + json, nicht multipart) ── if fname in img_map and img_map[fname]: image_path = img_map[fname][0] # First image if os.path.exists(image_path): try: import base64 as b64 from datetime import datetime as dt img_filename = os.path.basename(image_path) year = dt.now().strftime("%Y") j_img_path = f"Spiele/{year}/{img_filename}" # OHNE images/ prefix (local-images root = images/) with open(image_path, "rb") as img_f: img_content = b64.b64encode(img_f.read()).decode() img_resp = requests.post( "https://www.brettspiel-news.de/api/index.php/v1/media/files", headers={ "X-Joomla-Token": JOOMLA_TOKEN, "Accept": "application/vnd.api+json", "Content-Type": "application/json", }, json={"path": j_img_path, "content": img_content}, timeout=30 ) if img_resp.status_code == 400 and "exists" in img_resp.text: # Überschreiben existierender Datei print(f"[Image] {img_filename} existiert — überschreibe via PATCH", flush=True) img_resp = requests.patch( f"https://www.brettspiel-news.de/api/index.php/v1/media/files/local-images:/{j_img_path}", headers={ "X-Joomla-Token": JOOMLA_TOKEN, "Accept": "application/vnd.api+json", "Content-Type": "application/json", }, json={"path": j_img_path, "content": img_content}, timeout=30 ) if img_resp.status_code in (200, 201): j_img_url = f"https://www.brettspiel-news.de/images/{j_img_path}" # Web-URL braucht images/ Prefix # Joomla requires relative + joomlaImage pseudo-protocol # Format: images/Spiele/2026/file.jpg#joomlaImage://local-images/Spiele/2026/file.jpg # image_intro braucht images/ prefix, joomlaImage NICHT # ── Bild-Dimensionen erkennen (Joomla 6 braucht ?width=&height=) ── try: from PIL import Image img_pil = Image.open(image_path) iw, ih = img_pil.size img_pil.close() except: iw, ih = 1920, 1080 # Fallback: Standard-BSN-Bildmaße image_intro_value = f"images/{j_img_path}#joomlaImage://local-images/{j_img_path}?width={iw}&height={ih}" article_payload["images"] = { "image_intro": image_intro_value, "image_intro_alt": title, "image_fulltext": image_intro_value, "image_fulltext_alt": title, } print(f"[Image] Uploaded {img_filename} → {j_img_path}", flush=True) else: print(f"[Image] Upload failed HTTP {img_resp.status_code}: {img_resp.text[:200]}", flush=True) except Exception as e: print(f"[Image] Upload error: {e}", flush=True) if featured_up: article_payload["featured_up"] = featured_up if featured_down: article_payload["featured_down"] = featured_down if joomla_id: # Update existing article resp = requests.patch( f"{JOOMLA_API_URL}/{joomla_id}", headers=headers, json=article_payload, timeout=30 ) else: # Create new article resp = requests.post( JOOMLA_API_URL, headers=headers, json=article_payload, timeout=30 ) if resp.status_code in (200, 201): published += 1 # ── Write back Joomla ID to HTML ──────────────────────── resp_data = resp.json() new_jid = None if "data" in resp_data and "id" in resp_data["data"]: new_jid = str(resp_data["data"]["id"]) elif "data" in resp_data and "attributes" in resp_data["data"]: # Some responses nest the id differently pass if new_jid: published_ids.append(new_jid) if new_jid: # Immer Joomla-ID in die HTML-Datei schreiben (auch bei Updates) if '<meta name="joomla-id"' not in html_content: jid_meta = f'\n<meta name="joomla-id" content="{new_jid}">' html_content = re.sub( r'(<meta[^>]*>)', lambda m: m.group(1) + jid_meta, html_content, count=1 ) else: html_content = re.sub( r'<meta\s+name="joomla-id"\s+content="([^"]*)"', f'<meta name="joomla-id" content="{new_jid}"', html_content ) with open(filepath, "w", encoding="utf-8") as f: f.write(html_content) # ── Image mapping cleanup: remove association but keep files ── # Daniel 19.06.: Bilder NICHT löschen — sie werden für # spätere Live-Schaltungen und Referenz benötigt. if fname in img_map: del img_map[fname] save_image_map(img_map) else: error_msg = f"HTTP {resp.status_code}" try: err_data = resp.json() if "errors" in err_data: error_msg += ": " + str(err_data["errors"][0].get("title", err_data["errors"])) except Exception: error_msg += ": " + resp.text[:200] errors.append(f"{fname}: {error_msg}") except Exception as e: errors.append(f"{fname}: {e}") self._json_reply(200 if published > 0 else 500, { "ok": published > 0, "error": None if published > 0 else (errors[0] if errors else "Unbekannter Fehler beim Veröffentlichen"), "published": published, "published_ids": published_ids, "live_mode": allow_live, "images_deleted": images_deleted_total, "errors": errors, "vg_wort_enabled": not allow_live or True, # immer enabled, aber nur bei live genutzt }) # ── Review Builder ────────────────────────────────────────────────── _REVIEW_BUILDER_HTML = """<!DOCTYPE html> <html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>BSN Review Builder
Zuruck zur Artikel-Ubersicht

Review Builder

Word-Datei + Bilder hochladen. Inhalte werden automatisch zugeordnet.

1. Dateien hochladen

Excel mit Fakten
(.xlsx)
    Word mit Artikeltext
    (.docx)

      2. Bilder hochladen

      Bilder hier ablegen oder klicken

        Auto-Resize: 1920x1080 @ 72dpi

        3. Metadaten

        """ def _build_review_builder_form(self): """Render the Review Builder upload form.""" cfg = REVIEW_CONFIG authors = cfg.get("authors", {}) author_opts = "".join(f'' for k, v in authors.items()) rubrics = cfg.get("rubrics", []) rubric_opts = "".join(f'' for r in rubrics) disclosures = cfg.get("disclosures", []) disclosure_opts = "".join(f'' for d in disclosures) komplex_opts = "".join(f'' for k in cfg.get("komplexitaet", [])) return self._REVIEW_BUILDER_HTML.format( access_token=ACCESS_TOKEN, author_opts=author_opts, rubric_opts=rubric_opts, disclosure_opts=disclosure_opts, komplex_opts=komplex_opts, ) def _handle_review_builder_build(self): """POST: Excel parsen, HTML bauen.""" ctype = self.headers.get("Content-Type", "") if "multipart/form-data" not in ctype: self._json_reply(400, {"ok": False, "error": "Nur multipart/form-data"}) return clen = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(clen) boundary = ctype.split("boundary=")[1].strip() if boundary.startswith('"') and boundary.endswith('"'): boundary = boundary[1:-1] parts = _parse_multipart(raw, boundary) excel_data = parts.get("excel") word_data = parts.get("word") image_datas = [v for k, v in parts.items() if k.startswith("images_")] meta = {k: v for k, v in parts.items() if k not in ("excel", "word") and not k.startswith("images_")} # Excel parsen (Fakten, Wertung, Pro/Kontra, Tags) xl = {} if excel_data: try: import openpyxl wb = openpyxl.load_workbook(io.BytesIO(excel_data), data_only=True) ws = wb["EINGABE"] for row in ws.iter_rows(min_row=1, values_only=True): key = str(row[0]).strip() if row[0] else "" val = str(row[1]).strip() if row[1] else "" if key and val: xl[key] = val except Exception as e: self._json_reply(400, {"ok": False, "error": f"Excel-Fehler: {e}"}) return # Word parsen (Artikeltext) word_text = "" if word_data: try: from docx import Document doc = Document(io.BytesIO(word_data)) word_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip()) except Exception as e: self._json_reply(400, {"ok": False, "error": f"Word-Fehler: {e}"}) return spielname = xl.get("Spieltitel", meta.get("spielname", "Unbekannt")).strip() zusatz = xl.get("Zusatztitel", "").strip() tester = xl.get("Test von", "").strip() # Autor-Mapping: "Jan Maguna" → autor_key (immer aus Excel, Form nur Fallback) autor_key = meta.get("autor", "basti").strip() cfg = REVIEW_CONFIG if tester: for k, v in cfg.get("authors", {}).items(): if v["name"].lower() == tester.lower(): autor_key = k break author_info = cfg.get("authors", {}).get(autor_key, {"name": tester or autor_key, "banner": "sebastian_meine_meinung_uberschrift.png"}) rubrik_id = int(meta.get("rubrik", "8")) do_publish = meta.get("publish", "0") == "1" fantasywelt_url = meta.get("fantasywelt_url", "").strip() so_url = meta.get("so_url", "").strip() amazon_url = meta.get("amazon_url", "").strip() allgames4you_url = meta.get("allgames4you_url", "").strip() # Excel-Felder komplex = xl.get("Komplexität des Spiels", "").strip() autoren = xl.get("Autor:innen", "").strip() illustratoren = xl.get("Illustrator:innen", "").strip() verlag = xl.get("Verlag/Vertrieb", "").strip() personen = xl.get("Personenzahl", "").strip() spielzeit = xl.get("Spielzeit", "").strip() jahr = xl.get("Erscheinungsjahr", "").strip() preis = xl.get("UVP/Ø-Preis", "").strip() app = xl.get("App-Unterstützung", "").strip() alter = xl.get("empfohlenes Alter", "").strip() wertung = xl.get("WERTUNG (1-100)", "").strip() sprache = xl.get("Sprache", "Deutsch").strip() # Mechanismen mech = [] for i in range(1, 5): m = xl.get(f"Mechanismus {i}", "").strip() if m: mech.append(m) # Pro/Kontra pros = [] for i in range(1, 5): p = xl.get(f"Pro {i}", "").strip() if p: pros.append(p) kontras = [] for i in range(1, 5): k = xl.get(f"Kontra {i}", "").strip() if k: kontras.append(k) # Tags tags = [] for i in range(1, 11): t = xl.get(f"Tag {i}", "").strip() if t: tags.append(t) # Disclosure aus Verlag verlag_name = meta.get("verlag", verlag).strip() disclosure_id = meta.get("disclosure", "verlag_gestellt").strip() disclosure_text = "" for d in cfg.get("disclosures", []): if d["id"] == disclosure_id: disclosure_text = d["text"].replace("{VERLAG}", verlag_name or "der Verlag").replace("{SPIEL}", spielname) break # Bilder speichern erster = spielname[0].lower() if spielname else "x" ordner = spielname.lower().replace(" ", "") base_img = f"images/Bilderverzeichnis/01/{erster}/{ordner}" wd = os.path.join(WORKSPACE, base_img, "wertung") os.makedirs(wd, exist_ok=True) for idx, img_data in enumerate(image_datas): try: _resize_image(img_data, os.path.join(wd, f"bild_{idx+1}.jpg")) except Exception as e: print(f"[review-builder] Bild {idx} fehlgeschlagen: {e}", flush=True) # HTML bauen datum = datetime.now().strftime("%d. %B %Y") autor_name = author_info["name"] autor_banner = author_info["banner"] # FAKTEN ZUM SPIEL — 3-Spalten-Grid (CSS: .bsn-fakten) fakten_items = [] if autoren: fakten_items.append(('Autor:innen', autoren)) if illustratoren: fakten_items.append(('Illustrator:innen', illustratoren)) if verlag: fakten_items.append(('Verlag/Vertrieb', verlag)) if personen: fakten_items.append(('Personenzahl', personen)) if spielzeit: fakten_items.append(('Spielzeit', spielzeit)) if jahr: fakten_items.append(('Erscheinungsjahr', jahr)) if preis: fakten_items.append(('ungefährer Preis', preis)) if app: fakten_items.append(('App', app)) if alter: fakten_items.append(('empfohlenes Alter', alter)) fakten_html = "" if fakten_items: items = "".join(f'
        {v}{k}
        ' for k, v in fakten_items) fakten_html = f'

        FAKTEN ZUM SPIEL

        {items}
        ' # BEWERTUNGSKARTE — dunkelblauer Header + 3 Spalten + Footer (CSS: .bsn-rating-card) mech_items = "".join(f'{m}' for m in mech) if mech else "" pro_items = "".join(f'{p}' for p in pros) if pros else "" kontra_items = "".join(f'{k}' for k in kontras) if kontras else "" rating_html = "" if wertung: rating_html = f"""
        {spielname}Komplexität: {komplex or '—'}Sprachen: {sprache}

        MECHANISMEN

        {mech_items or ''}

        PRO

        {pro_items or ''}

        KONTRA

        {kontra_items or ''}
        """ # Artikeltext aus Word splitten erklarung_html = "" fazit_html = "" if word_text: import re as re2 split_marker = re2.search(r'(?i)^Fazit\s*$', word_text, re2.MULTILINE) if split_marker: erkl = word_text[:split_marker.start()].strip() faz = word_text[split_marker.end():].strip() elif "Fazit" in word_text: parts = word_text.split("Fazit", 1) erkl = parts[0].strip() faz = parts[1].strip() else: erkl = word_text faz = "" if erkl and "<" not in erkl[:100]: erkl = f"

        {erkl.replace(chr(10)+chr(10), '

        ')}

        " if faz and "<" not in faz[:100]: faz = f"

        {faz.replace(chr(10)+chr(10), '

        ')}

        " erklarung_html = erkl fazit_html = faz disc_html = "" if disclosure_text: disc_html = f'

        {disclosure_text}
        Dies hat keinen Einfluss auf unsere Bewertung!

        ' aff_html = "" if fantasywelt_url: aff_html += f'

        Bei Fantasywelt kaufen

        \n' if so_url: aff_html += f'

        Bei Spiele-Offensive kaufen

        \n' if amazon_url: aff_html += f'

        Bei Amazon kaufen

        \n' if allgames4you_url: aff_html += f'

        Bei AllGames4You kaufen

        \n' gal_path = f"01/{erster}/{ordner}" tags_str = ", ".join(tags) if tags else f"{spielname}, Brettspiel, Review, Bewertung, {autor_name}" title_full = f"{spielname} {zusatz}".strip() display_autor = autoren or verlag or meta.get("verlag", "").strip() or "unbekannt" body = f"""

        {spielname} — {komplex or 'Brettspiel'} von {display_autor}


        {fakten_html} {disc_html} {erklarung_html}

        Meine Meinung

        {fazit_html} {rating_html}

         

        {aff_html}

        Bilder vom Spiel

        {{gallery}}{gal_path}{{/gallery}}

        """ datei = datetime.now().strftime("%Y-%m-%d") safe = spielname.lower().replace(" ", "-").replace(":", "").replace("/", "-") filename = f"artikel_{safe}_{datei}.html" filepath = os.path.join(WORKSPACE, filename) full = f""" {title_full} — Review von {autor_name} | Brettspiel-News.de

        {datum} | von {autor_name}

        {body}
        """ with open(filepath, "w", encoding="utf-8") as f: f.write(full) result = {"ok": True, "title": f"Review '{spielname}' erstellt", "filename": filename, "published": False, "article_url": "", "preview_url": f"/{filename}?key={ACCESS_TOKEN}"} if do_publish and JOOMLA_TOKEN: try: pub = _publish_to_joomla(filepath, rubrik_id) if pub.get("ok"): result["published"] = True result["article_url"] = f"https://www.brettspiel-news.de/index.php/de/?view=article&id={pub.get('id','')}" else: result["title"] = f"Gespeichert, Publish fehlgeschlagen: {pub.get('error','')}" except Exception as e: result["title"] = f"Gespeichert, Publish fehlgeschlagen: {e}" self._json_reply(200, result) def _json_reply(self, status, data): self.send_response(status) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(data).encode("utf-8")) # ═══════════════════════════════════════════════════════════════════════════ # NanoBanana Editor HTML # ═══════════════════════════════════════════════════════════════════════════ NANOBANANA_HTML = """ BSN Bild-Generator — Google Imagen
        🎨 BSN Bild-Generator ← Zurück zur Artikelübersicht

        📷 Referenzbild (optional)

        🖼️ Foto hier ablegen oder klicken zum Auswählen

        ✍️ Prompt

        0 Zeichen
        Generiere Bild…

        ✅ Ergebnis

        Generiertes Bild
        """ # ── Module-Level Review Builder Helpers ───────────────────────────────── def _parse_multipart(raw, boundary): """Manuelles Multipart-Parsing (Python 3.13 kein cgi-Modul).""" parts = {} b = boundary.encode() sections = raw.split(b"--" + b) for section in sections: if not section.strip() or section.startswith(b"--"): continue if b"\r\n\r\n" not in section: continue header_part, body = section.split(b"\r\n\r\n", 1) if body.endswith(b"\r\n"): body = body[:-2] if b"\r\n--" in body: body = body[:body.rfind(b"\r\n--")] header_str = header_part.decode(errors="replace") name_match = re.search(r'name="([^"]+)"', header_str) fname_match = re.search(r'filename="([^"]+)"', header_str) field_name = name_match.group(1) if name_match else "unknown" if fname_match: if field_name == "images": cnt = sum(1 for k in parts if k.startswith("images_")) parts[f"images_{cnt}"] = body else: parts[field_name] = body else: parts[field_name] = body.decode(errors="replace") return parts def _resize_image(data, output_path): """Bild auf 1920x1080 @ 72dpi croppen als JPEG.""" from PIL import Image img = Image.open(io.BytesIO(data)) if img.mode in ("RGBA", "P"): img = img.convert("RGB") tw, th = 1920, 1080 iw, ih = img.size scale = max(tw / iw, th / ih) nw, nh = int(iw * scale), int(ih * scale) img = img.resize((nw, nh), Image.LANCZOS) left = (nw - tw) // 2 top = (nh - th) // 2 img = img.crop((left, top, left + tw, top + th)) img.save(output_path, "JPEG", quality=85, dpi=(72, 72)) def _publish_to_joomla(filepath, rubrik_id, allow_live=False): """Artikel via Joomla-API veroffentlichen. Nutzt das gleiche flache Payload-Format wie _handle_publish() (Joomla 5/6 API akzeptiert NICHT den JSON:API-Wrapper bei POST). """ import requests with open(filepath, encoding="utf-8") as f: html = f.read() # ── Metadaten extrahieren ── title_match = re.search(r"(.*?)", html) title = title_match.group(1) if title_match else "Unbekannt" alias = re.sub(r'[^a-z0-9]+', '-', title.lower().strip())[:100] body_match = re.search(r"
        (.*?)
        ", html, re.DOTALL) article_body = body_match.group(1).strip() if body_match else html # ── Interne Marker entfernen (wie _handle_publish) ── article_body = re.sub(r']*>.*?\s*', '', article_body, flags=re.DOTALL) article_body = re.sub(r']*>.*?

        \s*', '', article_body, flags=re.DOTALL) # ── Read-More-Split für introtext/fulltext ── introtext = "" fulltext = article_body.strip() sr_match = re.search(r'', article_body, re.IGNORECASE) if sr_match: split_idx = sr_match.start() introtext = article_body[:split_idx].strip() fulltext = article_body[split_idx:].strip() fulltext = re.sub(r'^\s*', '', fulltext) # ── Meta-Description & Keywords ── meta_desc_match = re.search(r'' html = html.replace('